Webhooks
How the mounted webhook route validates the signature, de-duplicates redeliveries, syncs the billing tables, and runs your business logic — folder handlers, billing.handlers, diagnostics events, and durable dispatch.
A webhook is the gateway telling your app "money moved". It is the only thing that can
turn a PENDING charge into revenue — so it must be verified (anyone can POST a fake
payload), idempotent (gateways redeliver), and it must run your logic at the right
moment.
The provider mounts a single route — POST /payments/webhook/:provider — and routes the
request to the matching driver. The driver:
- validates the signature (or throws — a forged callback never touches your data),
- normalizes the gateway's event onto a canonical type (
payment.succeeded,subscription.canceled, …) with a stableid.
Then the billing layer takes over.
Registering endpoints
node ace payments:webhookprints the exact URL and the event list per configured provider (filtered by the driver's declared capabilities — a Woovi provider never advertises refund events, for example). Paste them into each gateway's dashboard.
Stripe is the one gateway whose dashboard lets you create the endpoint over the API — the
command can do it for you (needs STRIPE_KEY; the signing secret is echoed to put in
.env):
node ace payments:webhook --createValidation per provider
Each gateway signs webhooks its own way. Configure the credential in the driver config
(or the env fallback) and the driver enforces it — a request without a valid signature
returns 400 and is never processed.
| Provider | Mechanism | Header | Config / env |
|---|---|---|---|
| Stripe | HMAC via constructEvent | stripe-signature | webhookSecret / STRIPE_WEBHOOK_SECRET |
| AbacatePay | HMAC-SHA256 (base64) | x-webhook-signature | webhookSecret or publicKey / ABACATE_PUBLIC_KEY |
| Asaas | Shared token | asaas-access-token | webhookToken / ASAAS_WEBHOOK_ACCESS_TOKEN |
| Woovi | RSA-SHA256 (recommended) | x-webhook-signature | webhookPublicKey / WOOVI_WEBHOOK_PUBLIC_KEY |
| Woovi | Per-webhook HMAC-SHA1 (deprecated) | X-OpenPix-Signature | webhookSecret / WOOVI_WEBHOOK_SECRET |
| PagBank | SHA-256 of <token>-<body> — not an HMAC, read why | x-authenticity-token | webhookToken / PAGBANK_WEBHOOK_TOKEN (defaults to the API token) |
| Efí | None — mTLS or an hmac query param, both enforced outside the driver | — | — |
| InfinitePay | None — the gateway publishes no signature | — | — |
The other eleven drivers each sign their own way; the provider page for each one names the header and the env var.
export default defineConfig({
providers: {
asaas: payments.asaas({ apiKey: env.get('ASAAS_API_KEY'), webhookToken: env.get('ASAAS_WEBHOOK_TOKEN') }),
woovi: payments.woovi({ appId: env.get('WOOVI_APP_ID'), webhookPublicKey: env.get('WOOVI_PUBLIC_KEY') }),
},
})Enforcement policy
Every driver declares one of three states, and the app refuses to boot on the middle one:
webhookVerification | Meaning | At boot |
|---|---|---|
'configured' | it can verify, and the credential is set | mounts the route, strict |
'unconfigured' | it can verify and nothing was configured | throws |
'unsupported' | the gateway signs nothing at all — Efí and InfinitePay | mounts the route |
Strict means what it says: requests missing the header, with a wrong signature, or with a tampered body are rejected, and all comparisons are timing-safe.
Woovi's recommended x-webhook-signature takes precedence over the deprecated HMAC:
when webhookPublicKey is set it is enforced, and webhookSecret (if also set) is
ignored. The public key accepts PEM or the base64-encoded PEM shown in the dashboard.
A provider without its webhook credential does not boot
A driver that can verify a delivery and has nothing configured refuses to start, naming the provider and the route it would have mounted. The alternative is an endpoint that accepts any body anyone posts to it, including one that marks a payment paid, with nothing to log and nothing to warn about.
Set the credential (each provider page names the env var), or say explicitly that verification happens upstream:
export default defineConfig({
providers: { /* ... */ },
// Only for a deployment that really does terminate verification at the edge — mutual TLS,
// or an API gateway that checks the signature before forwarding.
allowUnverifiedWebhooks: ['efi'],
})allowUnverifiedWebhooks: true opts every provider out at once, which is almost never what you
want. Efí and InfinitePay never trip this: they report 'unsupported', because their gateways sign
nothing and there is no credential to forget.
The lifecycle of a webhook
Once validated, an event flows through the processor in a strict order:
- Ledgered first — the event id is recorded in
billing_webhook_eventsbefore any processing, together with the raw payload and the normalized event (thenormalizedcolumn, which is what the dashboard's retry replays). If that id was already processed, the event is a redelivery and processing stops immediately (falsereturned). This is the idempotency guarantee: a gateway retry can never double-grant, because the check happens before the work. - Synced — the normalized event (
payment.succeeded,subscription.canceled, …) is upserted into the billing tables (billing_payments,billing_subscriptions), including theexternalReferenceit carried. - Your business logic runs — the handler for that event type (see below).
- Marked processed — only after the handler returns. A throwing handler marks the
event
failed(with the error), the dispatcher retries it, and the route answers500so the gateway redelivers too — the in-process retry dies with the process, the gateway's does not. Afailedrow is claimable again, which is what lets either retry re-run it. - Published — the normalized business event goes out on the
@adonis-agora/diagnosticsbus (agora:payments:payment.succeeded, …) and the lifecycle events (webhook.received/processed/failed) are emitted.
Because step 1 happens before step 3, the "already processed" guard is the outer defense and your own idempotency checks are the inner one — a redelivery never even reaches your handler.
One delivery, several events
Most gateways POST one event per request. Two do not: Adyen's envelope is a
notificationItems array and Efí's Pix notification is a pix array. So
parseWebhook may return WebhookEvent[] as well as a single WebhookEvent, and the
mounted route loops.
Nothing about the lifecycle above changes — it just runs once per event:
- A ledger row each. Idempotency is keyed on the gateway event id, so four events in one delivery are four rows. A redelivery of that batch where three were already processed runs only the fourth; the other three short-circuit on the ledger before reaching a handler.
- A
webhook.received/processed/failedtriple each. A delivery carrying four events never looks like one event on the diagnostics bus. - One
traceIdfor the whole delivery. The trace answers "what happened to this HTTP request" — the body that arrived, the signature check over it, the response the gateway got — and those are request-scoped facts. Every event in the batch shares it, and each lifecycle event still carries its ownid, so you can see both that these four arrived together and what became of each one.
When one event in a batch fails
Two things have to be true at once, and only one of them is obvious:
- The siblings are still attempted. Event 2 throwing says nothing about events 3 and 4 — they are different payments, and refusing them because a neighbour failed loses money for a reason unrelated to them. The route processes every event and collects the failures.
- The gateway is told to retry. A
2xxis a promise that it never has to send this again; answering it over an event that failed is how a payment is lost for good. So a delivery with any failed event answers500:
{ "received": true, "processed": 3, "failed": ["evt_2"], "error": "..." }The redelivery is cheap and safe: the three that succeeded are processed in the ledger and
skip, and only the failed one — whose row is failed, which is claimable again — actually
re-runs. Adyen queues a non-2xx for up to 30 days of retries; Efí makes up to 9 attempts.
A rejected delivery is still a 400, not a 500
A throw out of parseWebhook — a bad signature, an unparsable body, an unknown provider — is
a rejected delivery and keeps answering 400. It would fail identically on every retry,
and a forged payload must not be answered with an invitation to send it again. 500 is
reserved for a delivery that was authentic and failed to process.
A rejection is refused before an event exists, so it never becomes a ledger row. It writes a
webhook.rejected row into billing_audit_events instead — the only trace it leaves anywhere,
and what the rejected_deliveries health check counts. The write is best-effort: the delivery is
already being refused, and failing to file the note must not change the status the gateway sees.
Signatures in a batch
Where a gateway signs per event, every event is verified before any of them is mapped.
Adyen is the case that matters: the HMAC lives inside each item's own
additionalData.hmacSignature and nothing signs the envelope, so verifying the first item
and trusting the rest would let an attacker append anything beside one genuine notification.
One bad signature rejects the whole delivery.
Efí signs nothing at all — its authenticity comes from mutual TLS or an hmac query
parameter, both enforced outside the driver —
so a batch there is exactly as trusted as the transport that carried it, and no more.
Writing a driver? One event is still the normal answer
The contract is WebhookEvent | WebhookEvent[], a widening rather than a migration. If your
gateway sends one event per request, keep returning one — an array of length one adds nothing
and reads as if batching were possible. If it can send several, give each returned event an
id of its own: reusing one id across the batch makes the second event look like a
redelivery of the first, and it is silently skipped.
Running your business logic — four options
The mounted route guarantees security + sync, but your logic (grant credits, activate a
subscription) needs a home. All four options run the same handler contract
(event) => void | Promise<void>; they differ in where it's declared and what happens
when it throws:
| Option | Declared where | Throw → | Best for |
|---|---|---|---|
Folder app/payment_handlers/ | a file per event | marks failed + retries | the default; drop-in, discoverable |
billing.handlers | config/payments.ts | marks failed + retries | explicit wiring, DI services |
| Diagnostics events | onDiagnostic(...) | swallowed (fire-and-forget) | decoupled reactions, observability |
| Own controller | your route | you control it | full response-shape control |
Each is worked through — with per-gateway code and a durable workflow that subscribes itself via
@OnDiagnostic — in Reacting to payments.
1. Convention folder app/payment_handlers/ (default)
Drop a file per event; the provider auto-discovers it. Scaffold:
node ace make:webhook-handler payment.succeededimport { inject } from '@adonisjs/core'
import type { WebhookEventFor } from '@adonis-agora/payments'
@inject()
export default class PaymentSucceededHandler {
static readonly eventType = 'payment.succeeded'
async handle(event: WebhookEventFor<'payment.succeeded'>): Promise<void> {
// dispatch to a durable workflow / queue — never do heavy work inline
}
}Or as a function, with the type inferred from the first argument:
import { defineWebhookHandler } from '@adonis-agora/payments'
export default defineWebhookHandler('payment.disputed', async (event) => {
event.data.actionableUntil // DisputeWebhookData
event.data.paidAt // compile error: that is a payment payload
})Nothing else to wire. The provider discovers the folder at boot and registers what it finds.
Discovery is durable-style: node ace add registers an Assembler init hook that generates a barrel
of the folder at build and dev time (the file watcher re-runs it), and the provider falls back to a
runtime scan when no barrel exists — so the convention works either way, and the hook only saves
the scan.
A service class form (static eventType + handle) is resolved through the container, so
constructor injection just works.
2. billing.handlers in the config
The same handler, declared in config/payments.ts — a DI service class (the lib calls its
.handle(event)) or a plain function:
import PaymentSucceededHandler from '#payment_handlers/payment_succeeded'
billing: {
handlers: {
'payment.succeeded': PaymentSucceededHandler, // DI service (lib calls .handle)
'payment.refunded': (event) => { /* ... */ },
},
}Handlers here run inside WebhookProcessor.process() — a throw marks the event
failed in the ledger and the dispatcher retries, exactly like the folder form.
A defineWebhookHandler(...) value works as an entry here too: it is callable and carries
type/handle, so one definition serves both wiring styles.
Typing event.data
WebhookEvent.data is unknown until something narrows it. Three exports do:
| Export | What it gives you |
|---|---|
WebhookEventDataMap | which payload each canonical type carries |
WebhookEventFor<T> | a WebhookEvent narrowed to one type, payload included |
TypedWebhookHandler<T> | the handler signature for one type |
That map is the reason the old const data = event.data as PaymentWebhookData line is worth
deleting rather than keeping. On payment.disputed it was simply wrong: a dispute carries a
DisputeWebhookData, whose amount and currency are optional — a Stripe early fraud warning
has neither — so the cast promised two fields that are routinely undefined.
A passthrough type keeps data: unknown. Nothing normalized it, and typing it would be the
same lie one level down.
A typo in eventType does not boot
'payment.suceeded' would register a handler nothing ever calls: the processor looks the type up,
misses, and skips — while the ledger still records the delivery as processed and the route still
answers 200, which tells the gateway it never has to send that event again. The grant simply never
happens, and nothing anywhere says so. It throws at boot instead.
The provider now refuses at boot. The rule is namespace-based and stated in the error:
payment.*/subscription.*is the library's namespace, so a type there must be one of the tenWEBHOOK_EVENT_TYPES.- A gateway event a driver could not map arrives lowercased as the gateway spells it —
Asaas'
PAYMENT_ANTICIPATEDbecomespayment_anticipated, which has no dot and is accepted as-is. - Two handlers claiming the same type also throw, naming both files. Registration is a map keyed by event type, so without the check the second would silently replace the first.
For the rare gateway that really does spell a passthrough event with a dot inside that namespace, declare it:
billing: {
passthroughEvents: ['payment.anticipated'],
}WEBHOOK_EVENT_TYPES (the array), WebhookEventType (the union) and isWebhookEventType (the
guard) are exported from the package root — they existed from the start and were not, which is why
apps typed their handler keys as a bare string.
3. Subscribe to the diagnostics events
The processor publishes the normalized business events on the @adonis-agora/diagnostics
bus — agora:payments:payment.succeeded, payment.failed, subscription.canceled, plus
the webhook lifecycle. Subscribe with onDiagnostic (framework-agnostic — works in HTTP,
workers, and ace commands):
import { onDiagnostic } from '@adonis-agora/diagnostics'
onDiagnostic('payments', 'payment.succeeded', ({ payload }) => {
// payload.externalReference routes to your local record
})The same events show up in Telescope as payments entries (via
@adonis-agora/payments/telescope).
Events are fire-and-forget
A throwing onDiagnostic subscriber does not fail the webhook — the event was already
ledgered by the time it fires. For anything that must not be lost, use the folder/config
handlers (which mark failed + retry) or dispatch a durable workflow.
4. Your own controller
Build your own route with new WebhookProcessor({ store, driver, handlers }) for full
control over the response shape and flow — the escape hatch when the mounted route's
{ received: true } contract doesn't fit.
The money-flow pattern: handlers dispatch to durable
Webhooks must respond fast (200) — holding the connection while a payment grant runs
blocks retries and times out. And money work must survive crashes. So the handler's only
job is to dispatch a durable workflow: durable owns retry + exactly-once, and the
webhook responds in milliseconds.
With durable's event triggers the workflow subscribes itself — no handler file at all:
@OnDiagnostic({ lib: 'payments', event: 'payment.succeeded' })
export class ProcessPaymentWorkflow extends BaseWorkflow {
static workflow = { name: 'process-payment' }
async run(ctx, input) {
// input is the payment.succeeded payload — durable owns retry + exactly-once
}
}Without that release, a plain handler dispatches via engine.start (fire-and-forget):
@inject()
export default class PaymentSucceededHandler {
static readonly eventType = 'payment.succeeded'
constructor(private engine: WorkflowEngine) {}
async handle(event: WebhookEventFor<'payment.succeeded'>): Promise<void> {
const { externalReference } = event.data
if (!externalReference) return
await this.engine.start(ProcessPaymentWorkflow, { externalReference }, randomUUID())
}
}Routing payments back to your records (externalReference)
A webhook carries the gateway's ids — but your business logic needs your id (a Payment
row, a subscription). externalReference is the bridge: set it on the charge or
subscription, the gateway echoes it back, and the library surfaces it on the normalized
event.data.externalReference and the payment.succeeded diagnostics payload — so you
never dig into event.raw. It is also stored, on
billing_payments.external_reference (indexed), so you can ask the store for the payment
behind one of your ids at any time, not only while handling the webhook:
const payment = await Payment.create({ ... })
await driver.charge({
customerId,
amount,
externalReference: payment.id, // echoed back on webhooks
})onDiagnostic('payments', 'payment.succeeded', async ({ payload }) => {
const payment = await Payment.find(payload.externalReference)
// grant the purchased credits...
})// ...or, later, from anywhere: the reference is a stored, indexed column.
const row = await store.findPaymentByExternalReference(payment.id)Rows written before the column existed stay null
external_reference arrived after the first release, and there is nothing to run for it: the
library owns its schema and adds the column as a
guarded ALTER on the next boot, whether the install is fresh or is upgrading. (There is no
add_billing_external_reference migration to run — a page telling you to run one is out of date.)
What does not happen is a backfill. Every payment row written before the column was there
keeps external_reference null: findPaymentByExternalReference answers null for those ids,
and the browser status endpoint falls back to reading your reference as a gateway id. A reference
that was never stored cannot be recovered from the payload.
How each gateway stores it:
- Asaas →
externalReferencefield — and propagated to every installment of a subscription's charges, so the 3rd monthly webhook still routes to your subscription record. Prefer it overidempotencyKey(which still doubles as the reference whenexternalReferenceis absent). - Woovi →
correlationID. - Stripe →
metadata.external_reference. - PagBank → the order's
reference_id, echoed on every notification about that order. - Efí → the txid, and only when your reference fits its charset (26–35
alphanumerics); Efí's Pix notification carries no other field of yours. A reference that
does not fit is not mangled — Efí generates the txid and you route on the returned
gatewayId.
Webhook events
The normalized event types the processor understands — ten of them:
payment.succeeded/payment.failed/payment.refunded/payment.updatedpayment.dispute_warning/payment.disputed/payment.dispute_closedsubscription.created/subscription.updated/subscription.canceled
Each gateway maps onto these — Stripe invoice.payment_succeeded, Asaas
PAYMENT_RECEIVED, AbacatePay checkout.completed and Woovi
PIX_AUTOMATIC_COBR_COMPLETED all become payment.succeeded. An unknown event type is
passed through unprocessed — the processor syncs what it understands and runs a handler if
one is registered.
payment.updated — the one that carries its outcome on the payload
Every other payment event states its outcome in its type. An update says only "this payment
changed", so the new state travels on event.data: status, paidAt and refundedAmount are all
optional there, and all three are read.
It is the event a partial refund arrives as. Routing one to payment.refunded is not an
option — that handler writes the whole charge off, so a R$10 refund on a R$100 charge would erase
R$90 of revenue. So the update keeps the row current instead: amount stays the charge,
refunded_amount records the part that went back, and the net is amount - refunded_amount.
Three things it deliberately does not do:
- It never creates a row. An update about a charge this install never recorded is not a charge.
payment.succeededandpayments:synccreate rows; this one only updates. - It never moves a row out of
disputed. A gateway's payment resource often goes on reporting a charged-back charge as received. Onlypayment.dispute_closed, which carries an outcome, resolves a dispute. - It moves nothing when the driver sends no
status. Most drivers normalize none; inventing one is how a paid row becomes pending.
Until this handler existed the event reached a branch that did nothing at all, so a partial refund was recorded nowhere and revenue stayed overstated by the refunded part, permanently.
The three dispute events
They are separate types, not shades of one, because they answer different questions about where the money is. That is the question every driver's mapping was checked against: has the gateway taken it yet.
| Event | What happened | What the payment row does |
|---|---|---|
payment.dispute_warning | a pre-chargeback alert — Stripe's early fraud warning, Adyen's NOTIFICATION_OF_FRAUD. No money has moved. | nothing. It still says paid, because it still is. |
payment.disputed | the funds have been withdrawn | moves to disputed |
payment.dispute_closed | resolved, carrying outcome | won puts it back to paid; lost, expired and canceled leave it alone |
All three also write a billing_disputes row,
which is where the deadline lives.
A warning is the one worth building on: nothing has been pulled back yet, and refunding inside the window stops the chargeback from ever being filed — which matters beyond the one sale, because a chargeback counts against the ratio that puts a merchant into a card network's monitoring programme. Subscribe to it and put it in front of somebody.
canceled deliberately does not restore revenue. It means the cardholder withdrew, but
on Stripe a withdrawn dispute still has to be closed in your favour with evidence, so
booking it would count money the acquirer has not returned. Understating is the safe
direction.
A close that carries no outcome throws rather than defaulting. A driver that cannot read
the result emits payment.updated instead, and the processor must not undo that by guessing
on the other side.
Several gateways have no pre-dispute vocabulary at all
Asaas, PagBank, InfinitePay, Lemon Squeezy, Polar and AbacatePay send no funds-untouched
alert, and neither does Paddle — its chargeback_warning is not a warning, since the
disputed amount is already refunded when one is raised, so it maps to payment.disputed.
Each provider page says which of the three its gateway can actually emit. A gateway missing
from your alerting is not necessarily misconfigured.
Dodo Payments
Merchant-of-record billing for SaaS — cards worldwide plus Pix in Brazil, with every charge bound to a product you created in Dodo.
Client polling
The browser-facing status endpoint and the React hook that polls it — waiting for a Pix or boleto to settle without hand-writing the loop, and without handing one customer's payment to another.