Webhooks
This page is the code side of webhooks, verifying, parsing, and handling events. Registering the endpoint URL and obtaining its signing secret happens in each PSP's dashboard — that step lives in the PSP's own set-up guide, indexed from Payment providers.
Both ingress patterns are supported, the output is always one normalized UnifiedWebhookEvent, whichever PSP sent it.
// 1. Recommended: one endpoint per adapter
const stripeHook = createAdapterWebhookHandler(stripe, { onEvent });
// 2. Single shared URL (tries each adapter's signature verification; logs which matched)
const unifiedHook = createUnifiedWebhookHandler([stripe, paysafe], { onEvent, log: console.log });⚠ Raw body required
Signature verification hashes the exact raw request bytes. express.json(), Next.js default body parsing, and most middlewares destroy them:
// Express, register BEFORE express.json():
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), async (req, res) => {
const result = await stripeHook({ rawBody: req.body.toString("utf8"), headers: req.headers });
res.status(result.status).end();
});
// Next.js App Router:
export async function POST(req: Request) {
const result = await stripeHook({
rawBody: await req.text(), // BEFORE any json() call
headers: Object.fromEntries(req.headers),
});
return new Response(null, { status: result.status });
}
// Fastify: addContentTypeParser("application/json", { parseAs: "string" }, (_r, body, done) => done(null, body))
// then pass request.body (the raw string) as rawBody.The conformance suite enforces this
There is a test that fails any adapter which re-serializes a parsed body before verifying (same JSON value, different bytes ⇒ must reject). It applies to every adapter declaring webhookSignatureScope: "raw-bytes", the default and the common case. A provider that signs values extracted from the payload rather than its bytes declares "field-values" instead, and its set-up guide says so: a re-encoded body still verifies there, so the adapter authenticates the delivery channel by other means, and fields outside the signed set are never trusted. Pass the raw bytes either way, they cost nothing and the handlers take them regardless of scope.
Ack fast, process async
The handler verifies, parses, hands the event to your onEvent, and expects a 2xx immediately, onEvent must enqueue, not process. Redelivery is not unlimited: Paysafe, for one, counts only a 200 or 202 as received and stops after three attempts with no alert, so an event whose handler failed all three is gone. A retrievePayment re-read recovers a payment's outcome, but not a bank-debit return: Paysafe documents those only through the return webhook and its Merchant Back Office return reports, so reconcile bank debits against those reports, and never let a read that says succeeded override a return you received.
- Dedupe is yours:
event.idis a stable key; keep the seen-set in your store. Worldline can give two genuine refund events one id, so re-read its refund-type events before dropping one as a duplicate, as step 8 of the Worldline guide describes. Paysafe sends no event id, so its adapter derives one that every redelivery shares and that two genuine events can also share; re-read a Paysafe event withretrievePaymentorretrieveRefundbefore dropping it as a duplicate, as the Paysafe guide's event ids and correlation section describes. Adyen duplicates can differ outsideeventCodeandpspReference, and Adyen asks you to use the latest one, so upsert byevent.idand keep the latest delivery's details rather than dropping the repeat, as step 8 of the Adyen guide describes. - Ordering is not guaranteed by any PSP, treat events as unordered facts. When sequence matters, reconcile with
retrievePaymentwhere the adapter declaressupportsPaymentRetrieval; a push-only PSP such as Adyen has no payment read, so apply its events inoccurredAtorder, except that a final dispute stage is never overridden by a non-final one whatever the dates, as the Adyen guide's duplicates and ordering section describes. - Batched deliveries (GoCardless): the unified handlers process one event per delivery — a batched GoCardless webhook (up to 250 events under one signature) makes
parseWebhookEventthrow instead of dropping events. Route GoCardless deliveries toparseGoCardlessWebhookEvents(verify once, fan out per event) as shown in the GoCardless guide. - Money facts ride the event where the PSP payload carries them:
event.amount/event.currency(integer minor units) andevent.refundIdon refund-shaped events — most handlers never need aretrievePaymentround-trip just to learn how much apayment.refundedrefunded.
Operational concerns
Secret rotation without cutover: the adapters accept an array of signing secrets/HMAC keys (Worldline's as { keyId, secretKey } pairs matched by the webhook's key id; Adyen's as hex HMAC keys plus an array of { username, password } endpoint credentials, since its signature covers extracted values and the basic-auth pair is what authenticates the caller; PayPal verifies by postback, so there is nothing to rotate locally), register the new one, keep the old until the PSP switches, then drop it.
Missed-webhook recovery: payments.fetchEvents("stripe", { since, cursor }) replays recent events as the same normalized UnifiedWebhookEvents (same ids, your dedupe makes replays no-ops). Paysafe has no public events API (supportsEventPolling: false), so its fallback stays retrievePayment per order.
The events you'll see
- Refund outcomes are first-class: async refunds that later fail arrive as
payment.refund_failed, never a misleadingpayment.refunded. Where the adapter declaressupportsRefundRetrieval,retrieveRefund(refundId)polls any"pending"refund to its terminal state; on a push-only PSP such as Adyen the refund events are the only outcome, and apayment.refundedthere can still be followed bypayment.refund_failed. - Async rails signal progress: SEPA/ACH-style methods emit
payment.processing(underway, not final) before their terminal event days later. - Disputes:
payment.chargebackwhen a dispute opens, thenpayment.chargeback_wonorpayment.chargeback_lostwhen the PSP reports how it ended. Not every PSP reports both, and a reported outcome is not always final: GoCardless never emitspayment.chargeback_lost, since the chargeback itself is the loss, and Adyen reports a reversed chargeback aspayment.chargeback_wonalthough a later loss can override it. Each PSP's set-up guide lists which of its events map to which type.
Next
- Server usage, where
fetchEventsandretrievePaymentlive. - Conformance, how the raw-body guarantee stays true for every adapter.