Agora

Production

The operational checklist — webhook secrets that must not be optional, choosing a dispatcher, responding fast, sandbox flags, reconciliation, what to watch, and the deploy-day failure modes.

Everything in the quickstart runs against a sandbox with an in-process dispatcher. This page is the gap between that and money moving.

Set every webhook credential

The app will not boot without them, which is the change most likely to bite an upgrade.

Making verification optional for the gateways where signing is optional (Asaas, AbacatePay, Woovi) would buy a local development convenience at the price of an unauthenticated endpoint in production: anyone who could reach the URL could POST a payload claiming a payment happened, and the built-in sync would mark it paid.

Every driver now declares webhookVerification, and a driver that can verify with nothing configured refuses at boot — the same failing-closed the dashboard already does on a missing session secret.

config/payments.ts
providers: {
  asaas: payments.asaas({
    apiKey: env.get('ASAAS_API_KEY'),
    webhookToken: env.get('ASAAS_WEBHOOK_TOKEN'),        // ← not optional in production
  }),
  woovi: payments.woovi({
    appId: env.get('WOOVI_APP_ID'),
    webhookPublicKey: env.get('WOOVI_WEBHOOK_PUBLIC_KEY'),
  }),
  stripe: payments.stripe({
    apiKey: env.get('STRIPE_KEY'),
    currency: 'brl',                                     // required — no default
    webhookSecret: env.get('STRIPE_WEBHOOK_SECRET'),     // Stripe always requires it
  }),
}

Making them required in your env validation is still worth doing — it fails earlier, and with a message about the variable rather than about the provider:

start/env.ts
ASAAS_WEBHOOK_TOKEN: Env.schema.string(),
STRIPE_WEBHOOK_SECRET: Env.schema.string(),

For Woovi, prefer webhookPublicKey (RSA-SHA256) over the deprecated per-webhook HMAC. When both are set the public key wins and the HMAC is ignored. See Webhooks → Validation.

Upgrading: this is a breaking change

An app that has been running with a webhook credential unset will not come up. The boot error names the provider and the route it would have mounted. Two ways out, in order of preference:

  1. Set the credential. Each provider page names the env var.

  2. Declare the exemption — only when verification genuinely happens upstream (mutual TLS at the edge, an API gateway that checks the signature before forwarding):

    config/payments.ts
    allowUnverifiedWebhooks: ['efi'],   // or `true` for all, which is almost never right

Efí and InfinitePay never trip it: their gateways sign nothing, so they report 'unsupported' and there is no credential to forget. Deploy the credentials before the version that requires them.

Turn off sandbox deliberately

sandbox on the Asaas drivers defaults to NODE_ENV !== 'production'. That is convenient and it is implicit — a staging environment running with NODE_ENV=production talks to the real gateway. Be explicit where it matters:

asaas: payments.asaas({
  apiKey: env.get('ASAAS_API_KEY'),
  sandbox: env.get('PAYMENTS_SANDBOX', false),
}),

Use different API keys per environment regardless. A sandbox key pointed at production fails loudly; a production key pointed at staging charges real cards.

Choose a dispatcher on purpose

billing.dispatcher decides how a validated webhook is processed:

ValueBehaviour
'auto' (default)durable when its provider is registered; otherwise in-process
'durable'a durable workflow run — throws at boot when durable is missing
'in-process'inline, with exponential-backoff retries in the background

'auto' is the friendly default and it is also silent: an app that expected durable and forgot to register its provider degrades to in-process and never says so. In production, name the backend you actually run:

config/payments.ts
billing: { dispatcher: 'durable' }

Then a missing durable provider is a boot error, which is where you want to find out.

What in-process retries do and do not survive

The in-process dispatcher retries in the background with exponential backoff — 5 attempts, 500 ms base, capped at 30 s. That covers a transient database or gateway failure. It does not survive a process restart: a deploy in the middle of the backoff window drops the pending retries. Durable does. Choose accordingly for money paths.

Splitting api and worker

billing.role splits the deployment in two. The api half receives webhooks, validates and hands them off; the worker half processes them:

config/payments.ts — the api pods
billing: { dispatcher: 'durable', role: 'api' }
config/payments.ts — the worker pods
billing: { dispatcher: 'durable', role: 'worker' }

A 'worker' process does not mount /payments/webhook/:provider — it consumes what the api half enqueued, so nothing can deliver to it directly. An 'api' process skips resolving your handlers entirely, since they run on the other side.

Splitting needs a dispatcher with a channel

'in-process' calls the processor inline: an 'api' process would quietly process everything itself and a 'worker' would sit idle. 'auto' can silently resolve to it. Both are refused at boot when role is set, so a split deployment cannot come up half-wired:

[payments] billing.role is "api", which splits receiving from processing — but
billing.dispatcher is "auto", which has no channel between the two halves.
Set dispatcher to "durable", or drop billing.role.

Leaving role unset keeps one process doing both, which is the right shape until webhook volume or deploy independence actually forces the split.

The worker the dispatcher depends on

'durable' moves processing out of the web process, which means something has to be consuming it:

node ace durable:work   # dispatcher: 'durable'

This is the deployment mistake that hides best: the webhook endpoint keeps answering 200, the ledger keeps filling up, and no handler ever runs. Add the worker to your process manager alongside the web process, and alert on events that were claimed and never finished:

node ace payments:health

A non-zero stuck count means exactly that — something claimed the event and nothing consumed it. See Health checks below.

Respond fast

A webhook endpoint that holds the connection while a grant runs is a webhook endpoint that times out, and a timeout means the gateway redelivers — which is safe, thanks to the ledger, but wasteful and slow to converge.

The handler's job is to hand off:

async handle(event: WebhookEvent) {
  await ProcessPaymentWorkflow.dispatch({ externalReference: ref }, { runId: `pay:${event.id}` })
}

Keep out of the handler: sending email, calling third-party APIs, generating PDFs, anything that takes seconds. Those belong in the workflow or job the handler dispatches.

The webhook route answers 500 when a handler fails

A 2xx tells the gateway it never has to send that delivery again, which over a failed event is the payment lost for good — and an in-process retry meant to cover it dies with the next deploy. Adyen retries a non-2xx for up to 30 days and Efí makes nine attempts, and neither starts on a 2xx.

Two consequences for a running install. Anything alerting on 5xx from /payments/webhook/* will now fire — that is the honest signal, and it was firing at the gateway all along, invisibly. And the redelivery is cheap: the events that succeeded are already processed in the ledger, so only the failed one is claimable again. A delivery rejected before processing — bad signature, unparsable body — still answers 400, because redelivering it would fail identically.

Watch the right events

Every milestone is on the diagnostics bus, which is where alerting belongs. Three are worth paging on:

start/payments.ts
import { onDiagnostic } from '@adonis-agora/diagnostics'

// A handler threw. The ledger says failed and the dispatcher is retrying.
onDiagnostic('payments', 'webhook.failed', ({ payload }) => {
  logger.error({ event: payload }, 'payments webhook failed')
})

// Payments failing in a cluster means a gateway incident, not a customer problem.
onDiagnostic('payments', 'payment.failed', ({ payload }) => {
  metrics.increment('payments.failed', { provider: payload.provider })
})

The signal that is hardest to see and most worth building: charges created without a matching confirmation. A charge.created rate that stays healthy while payment.succeeded falls off a cliff is a broken webhook endpoint, and nothing else in the system will tell you. Alert on the ratio, not on either number.

Health checks

Six failures in a billing install are silent — nothing throws, and the first person to notice is a customer. payments:health asks about all six:

node ace payments:health
CheckWhat a non-zero count means
stuck_webhooks — claimed over 15m ago, never finishedNothing is consuming the dispatcher. The worker is not running.
failed_webhooks — dispatcher gave up, last 24hHandlers threw and retries ran out; those events never took effect.
unconfirmed_payments — charges created over 2h ago, still pendingCharges are being created and never confirmed — a webhook endpoint that stopped being reachable.
disputes_due — an open chargeback whose evidence window closes within 72hNothing is broken. The money goes anyway if nobody answers in time.
open_disputes — any dispute in warning, open or under_reviewA chargeback is open and the money is already out of the account. No threshold, no flag.
rejected_deliveries — deliveries the endpoint refused, last 24hA signature that did not verify, a body it could not parse, a provider nobody configured. Usually a rotated webhook secret that never reached the deployment.

The last three are different in kind and that is why they are on this page rather than only in the CLI reference. The first three are things that broke; these are money.

disputes_due alerts on a clock: every part of the system is working, and past the deadline the dispute is lost by default rather than on the merits, with no appeal. A window that has already closed keeps being counted — it is still open and still unanswered, and going quiet the moment it expires would read as resolved.

open_disputes exists because disputes_due is structurally blind on most installs: it requires evidence_due_by, and only a gateway that publishes a deadline can fill it. On Asaas that comes from chargeback.deadlineToSendDisputeDocuments, which no published webhook example even contains — so the deadline check answers zero forever while a chargeback sits open with the money already pulled back. This one takes no deadline and cannot be silenced: an open chargeback is money out of the account, and there is no horizon at which that stops mattering. billingHealth() returns openDisputes alongside it, up to twenty rows, oldest first — with no deadline to sort on, age is the only priority left.

rejected_deliveries is the only visibility a refused delivery has anywhere. It is answered 400 before an event exists, so it never becomes a ledger row: without this check a rotated webhook secret looks exactly like a quiet week — zero events, zero failures, every check green — while every refund, chargeback and dispute closure is dropped on the floor. unconfirmed_payments eventually notices, but only for charges the app itself created. Each rejection writes a webhook.rejected row into billing_audit_events; read them with listAuditEvents({ action: 'webhook.rejected' }).

See Disputes for what to do when a dispute check fires.

It exits non-zero when anything is wrong, so a cron entry or a container healthcheck can page on it without parsing output. Thresholds are flags — --stuck-after=5 (minutes), --unconfirmed-after=30 (minutes), --window=48 (hours), --dispute-window=168 (hours) — and --json prints the machine-readable form for a metrics shipper. There is no flag for the two new checks: open_disputes has no threshold at all, and rejected_deliveries uses a fixed 24 h window from the command (billingHealth(store, { rejectedWithin }) takes one in code):

node ace payments:health --window=48 --json

The same report is available in code, so a scheduled job can ship it wherever your alerts live:

app/jobs/billing_health_job.ts
import { inject } from '@adonisjs/core'
import { billingHealth, LucidBillingStore } from '@adonis-agora/payments'

@inject()
export default class BillingHealthJob {
  constructor(private store: LucidBillingStore) {}

  async handle() {
    const report = await billingHealth(this.store)
    if (report.healthy) return

    for (const check of report.checks.filter((check) => !check.healthy)) {
      logger.error({ check: check.key, count: check.count }, check.hint)
    }
    // report.failures groups the failed events by provider and type, worst first.
    // report.deadlines names the closing dispute windows, soonest first — the dispute id,
    // the payment it is against, and evidenceDueBy. A count sends nobody anywhere.
    for (const dispute of report.deadlines) {
      logger.warn({ dispute: dispute.gatewayId, dueBy: dispute.evidenceDueBy }, 'dispute window closing')
    }
    // report.openDisputes names the unanswered ones, oldest first — including every dispute
    // whose gateway published no deadline, which report.deadlines can never contain.
    for (const dispute of report.openDisputes) {
      logger.warn({ dispute: dispute.gatewayId, since: dispute.createdAt }, 'dispute unanswered')
    }
  }
}

report.deadlines and report.openDisputes are each capped at twenty; the counts are not. A report can name twenty of fifty without pretending it named all of them. The two lists overlap on purpose: a dispute that has both a deadline and no answer belongs in both, and hiding it from one to keep them disjoint would make the deadline-free check incomplete on exactly the install it exists for.

To read the failures themselves — the handler's error message survives nowhere else — page the ledger:

const failures = await store.listWebhookEvents({ status: 'failed', size: 20 })
// [{ gatewayEventId, provider, type, error, createdAt }, ...]

Reconcile on a schedule

Webhooks are reliable, not infallible: an outage window, a dashboard edit, a gateway incident. payments:sync pages every invoice the gateway holds and reconciles the billing tables in both directions — a local row saying paid while the gateway says refunded is corrected, not skipped:

node ace payments:sync --all

Run it nightly. Remember what it does not do: it writes billing rows, it does not run your handlers, so it will not grant anything. Two rows it will not touch either — a local disputed row (the gateway usually still reports a charged-back payment as received, and reconciling that to paid re-counts money the bank pulled back) and a paid_at that is already recorded. After a large reconcile, compare against your own tables — or replay the events from the gateway dashboard so the normal path runs. See CLI.

The schema and the ledger

The billing layer owns billing_customers, billing_subscriptions, billing_payments, billing_disputes, billing_webhook_events, billing_usage_events and billing_audit_events, and creates them itself on first use. Columns added in later versions are carried to a database that already has the tables, so an upgrade is a deploy rather than a deploy plus a migration — and if your schema is managed elsewhere, billing.autoCreateSchema: false plus the published migration is the same DDL under your control. Nothing is ever backfilled: a column that arrives today is null on every row written before it.

Turning it off means owning the upgrades too

createBillingTables is idempotent and also applies the columns added after a table shipped — but a migration you already ran does not run again. So with autoCreateSchema: false, an upgrade needs a new migration calling createBillingTables once more, in the same deploy as the code. Skipping that does not always fail loudly: the store probes for billing_payments.external_reference, billing_payments.refunded_amount, billing_webhook_events.normalized and the billing_disputes and billing_audit_events tables before using them, and skips what is not there — so references stop being stored, partial refunds stop being recorded, disputes and rejected deliveries go unwritten, and payments:health reports a healthy zero over a table that does not exist. The probe answers once per process and is cached, so restart after running the DDL.

autoCreateSchema: false does reach a store you built yourself with billing.store (including billingStores.lucid({ models })): the provider turns auto-creation off on it after your factory returns. So billing: { autoCreateSchema: false, store: () => billingStores.lucid({ models }) } is enough — no DDL runs against the database the flag was set to protect.

It only ever turns the flag off, never on. A store you constructed with autoCreateSchema: false of your own is left that way even when the config says true, and a store that is not a LucidBillingStore is left alone entirely — the provider has no way to reach into a persistence layer it did not build.

billing_webhook_events is the one with an operational profile: it grows with every event, forever, and it is what makes redeliveries safe. Do not prune it aggressively. A gateway can redeliver an event days later, and a pruned id is an id that will be processed a second time. Ninety days is a reasonable floor; longer if your gateway's replay window is longer.

Secrets

  • Gateway API keys — full account access. Environment-injected, never in the repo, rotated on a schedule.
  • Webhook secrets — whoever holds one can forge a confirmed payment. Same treatment.
  • Rotation — most dashboards let you keep two signing secrets briefly. Roll them one at a time: add the new one, deploy, remove the old.

Before you ship

  • Every configured provider has its webhook credential set (the app refuses to boot otherwise), and env validation requires it
  • allowUnverifiedWebhooks names only providers whose verification really happens upstream — and is not true
  • sandbox is explicit, and keys differ per environment
  • Webhook endpoints registered in each dashboard (node ace payments:webhook)
  • billing.dispatcher names the backend you actually run — not 'auto'
  • durable:work is running if the dispatcher is 'durable'
  • If the deployment is split, billing.role is set per process and the dispatcher has a channel
  • If billing.autoCreateSchema is off, this deploy's migration ran; billing_webhook_events has a retention floor, not an aggressive prune
  • Handlers dispatch rather than grant inline
  • Every grant has an inner idempotency guard at the database
  • Alerting on webhook.failed, on 5xx from the webhook route, and on the charge.created ÷ payment.succeeded ratio
  • payments:health on a cron — the dispute window closes on a clock, not on a failure, and rejected_deliveries is the only place a rotated secret shows up
  • Someone is named as the person who answers an open_disputes count, and closes it in the console afterwards
  • payments:sync --all scheduled
  • Someone has watched an end-to-end payment succeed in production

On this page