Platební poskytovatelé doručují události přes HTTP a doručování přes HTTP selhává běžnými způsoby: vypršením časového limitu, nasazením, které přeruší spojení, nebo opakováním po pomalé odpovědi. Přijímač webhooků, který předpokládá, že každá událost dorazí právě jednou, včas a ve správném pořadí, dříve nebo později poškodí stav systému. Tento článek popisuje návrh přijímače, který s duplicitami, zpožděním a změnou pořadí počítá jako s normálním stavem, nikoli jako s výjimkou. Konkrétní tvrzení opírá o veřejnou dokumentaci Stripe, Adyen a PostgreSQL a upozorňuje na místa, kde zdroje neříkají dost na to, abychom si mohli být jistí.
Proč musí handlery webhooků počítat s duplicitami
Webhooky existují proto, že mnoho platebních toků je asynchronních. Dokumentace Stripe k webhookům (https://docs.stripe.com/webhooks) popisuje odesílání dat v reálném čase na váš HTTPS endpoint, když nastanou události, například když banka zákazníka potvrdí platbu, zákazník zahájí spor nebo uspěje opakovaná platba. Dokumentace Adyen (https://docs.adyen.com/development-resources/webhooks/) uvádí totéž: konečný stav zdroje často není znám hned po API požadavku, a proto vás poskytovatel upozorní, až bude výsledek k dispozici. Webhooky navíc přenášejí události, které nevznikají z vašich vlastních požadavků, například chargebacky nebo dostupnost reportů.
Všimněte si, co z těchto popisů vyplývá. Události vám třetí strana posílá podle vlastního harmonogramu. Prověřené úryvky nespecifikují přesné harmonogramy opakování ani záruky pořadí, takže bezpečný návrh nesmí záviset na žádném konkrétním chování. Pokud tyto podrobnosti potřebujete pro konkrétního poskytovatele, přečtěte si jeho aktuální dokumentaci k doručování a opakování přímo, místo abyste je odvozovali z obecného návodu, jako je tento.
Návrh určují tři způsoby selhání:
- Duplicity: stejná událost je doručena víckrát, například proto, že vaše potvrzení se ztratilo nebo přišlo pozdě. Bez ochrany můžete odeslat objednávku dvakrát nebo připsat peníze na účet dvakrát.
- Zpoždění: událost dorazí dlouho po stavu, který popisuje. Váš systém se mohl mezitím posunout dál.
- Doručení mimo pořadí: pozdější událost dorazí dřív než dřívější. Pokud handlery slepě přepisují stav tím, co přišlo naposledy, starý stav může nahradit novější.
Idempotence, tedy vlastnost, kdy zpracování stejného vstupu dvakrát má stejný účinek jako zpracování jednou, je základní vlastnost, která neutralizuje první selhání a pomáhá i u zbylých dvou.
Ověřte podpis dříve, než payloadu uvěříte
Endpoint webhooku je veřejná URL. Stripe vyžaduje, aby registrované endpointy byly veřejně dostupné HTTPS adresy (https://docs.stripe.com/webhooks), což znamená, že kdokoli, kdo adresu objeví, na ni může posílat požadavky. Poskytovatel proto nabízí způsob, jak prokázat autenticitu. V postupu nastavení u Stripe vytvoření cíle webhooku vrátí podpisové tajemství začínající whsec_, které použijete při sestavování handleru. Dokumentace Adyen obsahuje samostatné stránky o zabezpečení webhooků a ověřování HMAC podpisů (https://docs.adyen.com/development-resources/webhooks/).
Praktická pravidla, která z toho plynou:
1. Ověřujte dřív, než začnete zpracovávat obchodní logiku. Podpis kontrolujte proti surovému tělu požadavku přesně v podobě, v jaké dorazilo. Opětovná serializace naparsovaného JSON může změnit bajty a ověření rozbít. 2. Při selhání zamítněte. U požadavků, jejichž podpis neprojde, vraťte stavový kód 4xx a nezařazujte je do fronty. 3. Tajemství nepatří do kódu ani do logů. Podpisové tajemství uložte ve správci tajemství a rotujte ho podle postupu svého poskytovatele. 4. Nejdřív ověřit, potom deduplikovat. Nikdy nezapisujte do deduplikační tabulky ID neověřené události; útočník by ji jinak mohl otrávit a způsobit, že skutečné události budou zahozeny jako duplicity.
Přesný ověřovací algoritmus a tolerance časových oken jsou specifické pro poskytovatele. Kde existuje oficiální knihovna, použijte ji místo ručního psaní porovnání.
Potvrďte rychle, zpracujte později
Doporučení Adyen je přímé: přijměte webhook s HTTP stavovým kódem 2xx, zprávu uložte a teprve potom zpracujte její obsah (https://docs.adyen.com/development-resources/webhooks/). Tato posloupnost je páteří odolného přijímače. HTTP požadavek má dělat co nejméně: ověřit podpis, trvale uložit surovou událost a vrátit úspěch. Vše náročné se děje později ve workeru.
Toto rozdělení je důležité, protože pomalé synchronní zpracování samo vytváří duplicity. Pokud váš handler před odpovědí volá tři navazující služby a jedna z nich se zasekne, poskytovatel může doručení považovat za neúspěšné a poslat událost znovu, zatímco váš první pokus ještě běží. Dvě kopie téže události pak běží souběžně. Potvrzení až po trvalém uložení většinu tohoto okna odstraní. Prověřené úryvky neuvádějí hodnoty časových limitů poskytovatele, takže přesnou hranici berte jako něco, co je třeba ověřit v dokumentaci vašeho poskytovatele.
Rozumný tvar přijímače:
1. Přečíst surové tělo. 2. Ověřit podpis; při selhání vrátit 4xx. 3. Vložit událost do trvalé tabulky (podrobnosti v další části). 4. Vrátit 2xx. 5. Samostatný worker si vyzvedává nezpracované řádky a provádí obchodní logiku.
Pokud krok 3 selže, protože vaše databáze neběží, vraťte 5xx, aby poskytovatel zopakoval doručení. Vrátit 2xx dřív, než je událost bezpečně uložena, smysl celého postupu maří.
Deduplikace pomocí unikátního omezení v PostgreSQL
Nejspolehlivější obranou proti duplicitám je taková, kterou vynucuje databáze. Vytvořte tabulku událostí s unikátním omezením na ID události od poskytovatele:
CREATE TABLE webhook_events ( id bigserial PRIMARY KEY, provider text NOT NULL, event_id text NOT NULL, payload jsonb NOT NULL, received_at timestamptz NOT NULL DEFAULT now(), processed_at timestamptz, UNIQUE (provider, event_id) );
Přijímač pak události ukládá takto:
INSERT INTO webhook_events (provider, event_id, payload) VALUES ($1, $2, $3) ON CONFLICT (provider, event_id) DO NOTHING RETURNING id;
Dokumentace PostgreSQL k příkazu INSERT (https://www.postgresql.org/docs/current/sql-insert.html) vysvětluje, proč to funguje. ON CONFLICT určuje alternativní akci místo vyvolání chyby porušení unikátního omezení a DO NOTHING je jednou z dostupných akcí při konfliktu. Klauzule RETURNING vrací hodnoty jen pro řádky, které byly skutečně vloženy, případně aktualizovány, pokud se použije DO UPDATE. Pokud tedy RETURNING vrátí řádek, je toto doručení nové; pokud nevrátí nic, kopie už existuje a můžete přesto potvrdit stavem 2xx. Protože kontrolu unikátnosti vynucuje index uvnitř databáze, dvě souběžná doručení téže události nemohou obě uspět při vložení, což u aplikační kombinace SELECT a následného INSERT zaručit nelze.
Několik návrhových rozhodnutí zaslouží vysvětlení:
- Proč zahrnout provider do klíče? ID událostí mají smysl jen v rámci jmenného prostoru jednoho poskytovatele. Omezení rozsahu zabrání nechtěným kolizím, pokud integrujete více poskytovatelů.
- Proč DO NOTHING a ne DO UPDATE? Opakované doručení by nemělo přepisovat uložený payload ani resetovat stav zpracování. Dokumentace poznamenává, že DO UPDATE navíc vyžaduje oprávnění UPDATE a SELECT na sloupcích, které čte, takže DO NOTHING také zjednodušuje oprávnění.
- Proč ukládat surový payload? Umožní vám zpracování po opravě chyby zopakovat a poskytne auditní stopu.
Jedno upozornění ze stránky PostgreSQL: vložení do tabulek s unikátními indexy může být blokováno, pokud souběžné relace zamykají nebo mění řádky se stejnými unikátními hodnotami. U tabulky webhooků je to krátké, ale stojí za to o tom vědět, pokud při nárazovém doručování duplicit uvidíte čekání na zámky.
Deduplikace na úrovni události vás chrání před dvojím doručením téhož. Sama o sobě ale nezajišťuje bezpečnost vaší obchodní logiky: worker může spadnout poté, co provedl vedlejší efekt, ale dřív, než nastavil processed_at, a opakování pak efekt zopakuje. Tady přichází na řadu idempotence odchozích volání.
Idempotency klíče u odchozích API volání
Workery často volají poskytovatele zpět, například aby vytvořily refundaci, zachytily platbu nebo vytvořily objekt. Pokud uprostřed volání dojde k chybě spojení, nemůžete vědět, zda se operace provedla. Reference Stripe k idempotentním požadavkům (https://docs.stripe.com/api/idempotent_requests) řeší přesně tento problém: dodáte idempotency klíč, aby bylo možné požadavek zopakovat, aniž by se táž operace provedla dvakrát.
Podrobnosti z této stránky, na které se vyplatí návrh postavit:
- Klíč generuje klient. Stripe doporučuje UUID verze 4 nebo jiný náhodný řetězec s dostatečnou entropií, aby se předešlo kolizím, o délce nejvýše 255 znaků, a odrazuje od citlivých dat, například e-mailových adres.
- U API v1 Stripe uloží stavový kód a tělo prvního požadavku provedeného s daným klíčem, jakmile začne vykonávání na endpointu, a opakování se stejným klíčem vrací uloženou odpověď, včetně chyb 500.
- Příchozí parametry se porovnávají s původním požadavkem a neshoda vyvolá chybu, což chrání před náhodným znovupoužitím klíče.
- Výsledky se ukládají až po zahájení vykonávání; požadavky, které selžou při validaci nebo kolidují se souběžně probíhajícím požadavkem, se neukládají a lze je zopakovat.
- U API v1 lze klíče odstranit, jakmile jsou staré alespoň 24 hodin, a poté jejich znovupoužití vytvoří nový požadavek.
- Idempotency klíče přijímají všechny požadavky POST; GET a DELETE jsou idempotentní z definice a klíč na ně nemá vliv.
- API v2 se chová jinak: opakování neopakují úspěšné operace a Stripe se v případě selhání nebo částečného provedení snaží práci dokončit, kde je to možné.
Klíčovým návrhovým rozhodnutím je odvozovat klíč deterministicky z vlastního stavu, místo aby se při každém pokusu generovala nová náhodná hodnota. Nový náhodný klíč při každém opakování mechanismus ruší. Klíč místo toho uložte společně s pracovní položkou nebo ho odvoďte z něčeho stabilního, například z ID řádku události a názvu akce (třeba refund pro událost 4812). Táž logická operace pak vždy předloží týž klíč, i přes pády workerů a opakovaná doručení. S ohledem na poznámku o odstraňování klíčů po 24 hodinách u v1 se nespoléhejte na to, že klíč ochrání opakování, které proběhne po několika dnech; dokončení zaznamenejte také do vlastní databáze.
Adyen rovněž dokumentuje idempotenci API ve svém seznamu zdrojů (https://docs.adyen.com/development-resources/webhooks/ na ni odkazuje), ale prověřený úryvek její chování nepopisuje, takže si příslušnou stránku přečtěte dřív, než předpokládáte, že odpovídá Stripe.
Zpožděné události a události mimo pořadí
Deduplikace odpovídá na otázku, zda jste událost už viděli; neodpovídá na otázku, zda je událost stále relevantní. Adyen uvádí, že zprávy webhooků obsahují snímek zdroje, aby váš systém mohl podle něj aktualizovat svůj stav (https://docs.adyen.com/development-resources/webhooks/). Snímky jsou pohodlné, ale nebezpečné při změně pořadí: použití staršího snímku po novějším váš stav vrátí zpět.
Strategie od nejjednodušší po nejrobustnější:
1. Monotónní stavové automaty. Definujte povolené přechody (například čeká na platbu, zaplaceno, refundováno) a ignorujte každou událost, která by stav posunula zpět. Událost mimo pořadí je pak neškodná operace bez účinku. 2. Porovnávejte časová razítka nebo pole verze, kde je poskytovatel dodává. Událost aplikujte, jen pokud je novější než to, co máte uloženo. V schématu poskytovatele ověřte, které pole je autoritativní; prověřené úryvky žádné neurčují. 3. Znovu načtěte aktuální objekt. Když událost dorazí, berte ji jako signál a před aktualizací si přečtěte nejnovější stav z API poskytovatele. Za extra volání získáte odolnost proti zastaralým payloadům a GET je idempotentní svou podstatou. 4. Řazení po entitách ve workeru. Události téže platby nebo téhož zákazníka zpracovávejte sériově, aby se dva workery nikdy nepřely o jeden objekt.
Volte podle rizika. U přechodů, které pohybují penězi, preferujte stavový automat spolu s opětovným načtením; u oznámení s nízkými sázkami stačí jednoduchá ochrana.
Sledujte backlog událostí
Jakmile je potvrzení oddělené od zpracování, objeví se nový způsob selhání: poskytovatel vidí úspěch, zatímco váš worker tiše zaostává. Monitoring proto musí pokrývat i vaši stranu pipeline.
Užitečné signály, všechny spočitatelné z výše uvedené tabulky událostí:
- Hloubka backlogu: počet řádků, kde je processed_at null.
- Stáří nejstarší nezpracované události: now() minus nejstarší received_at mezi nezpracovanými řádky. Bývá to nejlepší signál pro alarm, protože měří zpoždění viditelné uživatelům.
- Počty selhání a opakování na událost, aby se otrávená událost, která worker pokaždé shodí, dostala do karantény a neblokovala frontu.
- Míra selhání podpisů: nárůst může naznačovat rotované tajemství, špatně nakonfigurovaný endpoint nebo zkoumání ze strany třetích osob.
- Míra duplicit: jak často ON CONFLICT potlačí vložení. Náhlý skok může znamenat, že vaše potvrzení jsou pomalá nebo selhávají.
- Přehled doručování od poskytovatele: dokumentace Adyen uvádí protokoly událostí webhooků a sekci řešení problémů při selhání doručení (https://docs.adyen.com/development-resources/webhooks/), takže při rozporu v číslech porovnejte záznamy o doručení na straně poskytovatele s vlastními počty přijatých událostí.
Prahové hodnoty alarmů nastavte podle vlastních provozních vzorců; zdroje žádná univerzální čísla neposkytují a jejich vymýšlení by vám nepomohlo.
Kontrolní seznam implementace
- Endpoint je HTTPS a veřejně dostupný; tajemství jsou bezpečně uložena.
- Pro ověření podpisu se používá surové tělo; neplatné podpisy dostanou 4xx a nikdy se neukládají.
- Událost se vkládá do trvalé tabulky s unikátním omezením (provider, event_id) pomocí ON CONFLICT DO NOTHING.
- 2xx se vrací až po trvalém uložení; selhání databáze vrací 5xx.
- Duplicitní doručení se potvrzují, nikoli hlásí jako chyba.
- Workery si řádky vyzvedávají bezpečně a označují dokončení; pády nechávají řádky znovu zpracovatelné.
- Odchozí volání POST používají stabilní, deterministické idempotency klíče navázané na vaše vlastní pracovní položky.
- Dokončení vedlejších efektů se zaznamenává ve vaší databázi, nejen v cache klíčů poskytovatele.
- Přechody stavů jsou monotónní nebo chráněné kontrolou verze; kritické cesty znovu načítají aktuální stav.
- Hloubka backlogu, stáří nejstarší události, počty selhání a míra duplicit jsou zobrazeny v grafech a hlídány alarmy.
- Existuje postup přehrání pro opětovné zpracování uložených payloadů po opravě.
- Chování poskytovatele ohledně opakování, časových limitů a pořadí bylo přečteno z aktuální oficiální dokumentace.
Omezení
Tento článek vychází z omezené sady úryvků dokumentace. Několik z nich bylo zkrácených, proto nepokrývá ukázky kódu handlerů u Stripe, podrobněji ani rozdíl mezi tenkými a snímkovými formáty payloadů, ani kroky ověřování HMAC u Adyen. Neuvádí harmonogramy opakování poskytovatelů, hodnoty časových limitů ani záruky pořadí, protože prověřený materiál je nespecifikoval. Sémantika idempotence se mezi poskytovateli a verzemi API liší, proto chování ověřte pro přesné endpointy, které používáte. Uvedené SQL je výchozí vzor; schéma, indexování a vyzvedávání řádků workery přizpůsobte své zátěži a provozním standardům a před nasazením návrhu do produkce otestujte scénáře selhání, jako je souběžné duplicitní doručení a pád uprostřed zpracování.