Reacting to payments
Where the business logic lives — the convention folder, billing.handlers in the config, a diagnostics subscriber, or a durable workflow that subscribes itself — what each does when it throws, and how to make the grant safe to run twice.
The mounted webhook route validates the signature, ledgers the event and syncs the billing tables. What it cannot do is grant the thing the customer bought — that is yours, and it has four homes.
They all run the same contract, (event) => void | Promise<void>. They differ in where the code
lives and, much more importantly, what happens when it throws.
| Declared in | On throw | Reach for it when | |
|---|---|---|---|
| Convention folder | app/payment_handlers/<event>.ts | ledger failed → the gateway redelivers (see below) | the default; one file per event |
billing.handlers | config/payments.ts | ledger failed → the gateway redelivers (see below) | you want the wiring visible in one place |
| Diagnostics subscriber | start/payments.ts | swallowed | decoupled reactions you can afford to lose |
| Durable workflow | app/workflows/ | checkpointed → resumed | the grant is multi-step, slow, or must survive a deploy |
Only three of the four are retried
A throwing onDiagnostic subscriber does not fail the webhook. By the time the event is
published it is already ledgered and the route has moved on, so nothing retries it. Use events for
analytics, notifications and side reactions — never for the grant itself.
What actually retries a failed handler
"Retried" is not one thing, and which mechanism catches your throw depends entirely on
billing.dispatcher. Worth getting straight, because only one of them survives the process dying.
The route answers 500 when a handler threw. A 2xx promises the gateway it never has to send
that delivery again, which over a failed event is the payment lost for good. A rejected delivery — bad signature, unparsable body — stays 400, because
redelivering it would fail identically.
billing.dispatcher | On a handler throw | What retries it |
|---|---|---|
'in-process' | route answers 500 | both: an in-process background retry (5 attempts, exponential backoff, dies with the process) and the gateway's own redelivery |
'durable' | route already answered 200 — dispatch only enqueues | durable's retries, in durable:work |
'auto' (default) | whichever of the two above it resolved to | the same |
Adyen retries a non-2xx for up to 30 days and Efí makes nine attempts; neither starts on a 2xx. The
redelivery is cheap, because the ledger claim means only the failed event re-runs — the events in
the same delivery that succeeded are already processed and skip.
Anyone alerting on 5xx from the webhook route will now see it fire
That is the honest signal. It was firing at the gateway all along, invisibly, as a delivery the gateway believed had succeeded.
One delivery can carry several events
Adyen's notificationItems and Efí's pix are lists. They run sequentially — two events in one
envelope routinely touch the same row — and one failing does not cancel its siblings. A delivery
with any failed event is a failed delivery, so the 500 covers the batch and the response body
names which events failed and how many landed.
The four, side by side
The same job — a Pix payment confirmed, credits granted — written four ways.
Scaffold a file per event; the provider discovers it:
node ace make:webhook-handler payment.succeededimport { inject } from '@adonisjs/core'
import type { WebhookEventFor } from '@adonis-agora/payments'
import { DateTime } from 'luxon'
import Order from '#models/order'
import GrantService from '#services/grant_service'
@inject()
export default class PaymentSucceededHandler {
static readonly eventType = 'payment.succeeded'
constructor(private grants: GrantService) {}
async handle(event: WebhookEventFor<'payment.succeeded'>): Promise<void> {
// Typed from the event type — a `PaymentWebhookData`, no cast.
const orderId = event.data.externalReference
if (!orderId) return
// The inner idempotency guard — see below.
const updated = await Order.query()
.where('id', orderId)
.andWhere('status', 'awaiting_payment')
.update({ status: 'paid', paidAt: DateTime.now() })
if (updated[0] === 0) return
await this.grants.grantFor(orderId)
}
}That is the whole setup. The provider scans app/payment_handlers/ at boot and registers what it
finds — no import, no config entry, no adonisrc.ts edit. The class form (static eventType +
handle) is resolved through the container, so constructor injection just works.
eventType is checked at boot. A type in the library's own payment.*/subscription.* namespace
that is not one of the ten WEBHOOK_EVENT_TYPES throws, naming the file — otherwise 'payment.suceeded'
registers a handler nothing ever calls, while the ledger records the delivery as processed and the
route answers 200. Two files claiming the same type throw too, naming both: registration is a map
keyed by type, so the second would silently replace the first.
How discovery actually runs
node ace add registers an Assembler init hook that generates a barrel of the folder at build and
dev time, so boot imports a generated module instead of reading the directory. If that hook is not
registered — an older install, or a hand-built adonisrc.ts — the provider falls back to a
runtime scan of the folder, and everything still works. The hook is a boot-time optimization,
not a requirement.
Throwing marks the event failed in the ledger and makes the route answer 500, so the gateway
redelivers. See What actually retries.
The dispatcher is a separate decision
Which of the four you use decides where the code lives. billing.dispatcher decides what
carries the webhook — and therefore whether a retry survives a restart:
billing: { dispatcher: 'durable' }| Survives a restart | |
|---|---|
'auto' (default) | depends on what it picked — durable when its provider is registered, else in-process |
'durable' | yes |
'in-process' | no — 5 retries with backoff, lost on deploy |
'auto' degrades silently: an app that meant to run durable and forgot to register its provider
falls back to in-process and nothing says so. Name the backend in production. See
Production.
There is no `'queue'` dispatcher
billing.dispatcher: 'queue' is refused at boot with an error naming the supported set, rather than
type-checking and then silently running durable-or-in-process. The three values are 'auto', 'durable' and 'in-process' — use 'durable' when
you need a restart-surviving channel.
durable needs its worker running
There is no payments:listen — webhooks arrive over HTTP into the route the provider already mounts,
so there is nothing to start for the receiving side. But with dispatcher: 'durable', the
processing happens in durable's worker:
node ace durable:workWithout it, webhooks are accepted, ledgered and enqueued — and never processed. The endpoint answers
200 the whole time (dispatch succeeded; the work has not run), so nothing looks wrong until
someone notices nobody got what they paid for. payments:health is the check that catches it: its
stuck_webhooks count is events claimed in the ledger and never finished.
dispatcher: 'in-process' is the one that needs no worker, because it runs inside the web process.
The inner idempotency guard
The ledger already stops a redelivery of the same event. It does not make your grant idempotent — two different events can legitimately describe the same fact, a reconcile can replay, a second code path can exist.
The guard belongs at the database, as a conditional write:
const updated = await Order.query()
.where('id', orderId)
.andWhere('status', 'awaiting_payment') // ← only possible once
.update({ status: 'paid', paidAt: DateTime.now() })
if (updated[0] === 0) return // someone else already claimed itA read-then-write races itself under concurrent delivery:
// Wrong: two deliveries can both read 'awaiting_payment' before either writes.
const order = await Order.find(orderId)
if (order.status === 'paid') return
order.status = 'paid'
await order.save()A unique index on the grant works just as well — (order_id, kind) on a credits table, an upsert,
anything whose second execution is a no-op at the database.
Choosing
- Grant credits, activate a subscription, anything the customer paid for → folder or
billing.handlers, ideally dispatching to a durable workflow. Retried on failure. - Multi-step, slow, or must survive a deploy → a durable workflow with
@OnDiagnostic. Each step checkpointed. - Receipts, metrics, Slack pings, analytics → a diagnostics subscriber. Cheap, decoupled, and losing one is survivable.
- A chargeback → a handler, and read the deadline off it.
payment.dispute_warning,payment.disputedandpayment.dispute_closedare ordinary handler events, but the window they carry is on a clock nothing else here runs on. See Disputes. - A response shape the mounted route cannot produce → your own controller with
new WebhookProcessor({ store, driver, handlers }). See Webhooks.
Whatever you pick, keep the handler fast. Webhooks that hold the connection time out, and a timeout means a redelivery — safe, thanks to the ledger, but slow to converge.
Subscriptions
Recurring billing per gateway — tokenized cards on Asaas and Stripe, Pix Automático on Woovi, what each one needs to actually start charging, plus trials, upgrades and cancellation.
Metered billing
Record consumption as it happens, roll it up per meter for a period, price it against per-meter rates with an included allowance, and charge the overage exactly once.