Agora
Patterns

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 inOn throwReach for it when
Convention folderapp/payment_handlers/<event>.tsledger failedthe gateway redelivers (see below)the default; one file per event
billing.handlersconfig/payments.tsledger failedthe gateway redelivers (see below)you want the wiring visible in one place
Diagnostics subscriberstart/payments.tsswalloweddecoupled reactions you can afford to lose
Durable workflowapp/workflows/checkpointed → resumedthe 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.dispatcherOn a handler throwWhat retries it
'in-process'route answers 500both: 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 enqueuesdurable's retries, in durable:work
'auto' (default)whichever of the two above it resolved tothe 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.succeeded
app/payment_handlers/payment_succeeded.ts
import { 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:

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

Without 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 it

A 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.disputed and payment.dispute_closed are 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.

On this page