Agora

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:

  1. validates the signature (or throws — a forged callback never touches your data),
  2. normalizes the gateway's event onto a canonical type (payment.succeeded, subscription.canceled, …) with a stable id.

Then the billing layer takes over.

Registering endpoints

node ace payments:webhook

prints 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 --create

Validation 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.

ProviderMechanismHeaderConfig / env
StripeHMAC via constructEventstripe-signaturewebhookSecret / STRIPE_WEBHOOK_SECRET
AbacatePayHMAC-SHA256 (base64)x-webhook-signaturewebhookSecret or publicKey / ABACATE_PUBLIC_KEY
AsaasShared tokenasaas-access-tokenwebhookToken / ASAAS_WEBHOOK_ACCESS_TOKEN
WooviRSA-SHA256 (recommended)x-webhook-signaturewebhookPublicKey / WOOVI_WEBHOOK_PUBLIC_KEY
WooviPer-webhook HMAC-SHA1 (deprecated)X-OpenPix-SignaturewebhookSecret / WOOVI_WEBHOOK_SECRET
PagBankSHA-256 of <token>-<body>not an HMAC, read whyx-authenticity-tokenwebhookToken / PAGBANK_WEBHOOK_TOKEN (defaults to the API token)
EfíNone — mTLS or an hmac query param, both enforced outside the driver
InfinitePayNone — 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.

config/payments.ts
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:

webhookVerificationMeaningAt boot
'configured'it can verify, and the credential is setmounts the route, strict
'unconfigured'it can verify and nothing was configuredthrows
'unsupported'the gateway signs nothing at all — Efí and InfinitePaymounts 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:

config/payments.ts
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:

  1. Ledgered first — the event id is recorded in billing_webhook_events before any processing, together with the raw payload and the normalized event (the normalized column, which is what the dashboard's retry replays). If that id was already processed, the event is a redelivery and processing stops immediately (false returned). This is the idempotency guarantee: a gateway retry can never double-grant, because the check happens before the work.
  2. Synced — the normalized event (payment.succeeded, subscription.canceled, …) is upserted into the billing tables (billing_payments, billing_subscriptions), including the externalReference it carried.
  3. Your business logic runs — the handler for that event type (see below).
  4. Marked processed — only after the handler returns. A throwing handler marks the event failed (with the error), the dispatcher retries it, and the route answers 500 so the gateway redelivers too — the in-process retry dies with the process, the gateway's does not. A failed row is claimable again, which is what lets either retry re-run it.
  5. Published — the normalized business event goes out on the @adonis-agora/diagnostics bus (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/failed triple each. A delivery carrying four events never looks like one event on the diagnostics bus.
  • One traceId for 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 own id, 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:

  1. 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.
  2. The gateway is told to retry. A 2xx is 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 answers 500:
{ "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:

OptionDeclared whereThrow →Best for
Folder app/payment_handlers/a file per eventmarks failed + retriesthe default; drop-in, discoverable
billing.handlersconfig/payments.tsmarks failed + retriesexplicit wiring, DI services
Diagnostics eventsonDiagnostic(...)swallowed (fire-and-forget)decoupled reactions, observability
Own controlleryour routeyou control itfull 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.succeeded
app/payment_handlers/payment_succeeded.ts
import { 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:

app/payment_handlers/payment_disputed.ts
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:

config/payments.ts
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:

ExportWhat it gives you
WebhookEventDataMapwhich 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 ten WEBHOOK_EVENT_TYPES.
  • A gateway event a driver could not map arrives lowercased as the gateway spells it — Asaas' PAYMENT_ANTICIPATED becomes payment_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:

config/payments.ts
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):

start/payments.ts (preloaded)
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:

app/workflows/process_payment_workflow.ts
@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):

app/payment_handlers/payment_succeeded.ts
@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:

app/services/checkout_service.ts
const payment = await Payment.create({ ... })
await driver.charge({
  customerId,
  amount,
  externalReference: payment.id, // echoed back on webhooks
})
start/payments.ts
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:

  • AsaasexternalReference field — and propagated to every installment of a subscription's charges, so the 3rd monthly webhook still routes to your subscription record. Prefer it over idempotencyKey (which still doubles as the reference when externalReference is absent).
  • WoovicorrelationID.
  • Stripemetadata.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.updated
  • payment.dispute_warning / payment.disputed / payment.dispute_closed
  • subscription.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.succeeded and payments:sync create 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. Only payment.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.

EventWhat happenedWhat the payment row does
payment.dispute_warninga 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.disputedthe funds have been withdrawnmoves to disputed
payment.dispute_closedresolved, carrying outcomewon 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.

On this page