The payment lifecycle
From charge to revenue — what a PENDING payment actually is, the six things the webhook route does in order, the ten normalized event types, and how externalReference routes a confirmation back to your row.
you open a charge
payment.status = 'pending' — the record exists, but nothing has been paid. Never grant anything here.
the customer acts
The customer scans the Pix QR, or approves the card — outside your process, on their own clock. Your app is not in this step and cannot observe it.
the webhook confirms it
One request, five ordered steps — each one is a guarantee:
- 1validate the signatureforged callbacks rejected
- 2ledger the eventredeliveries stop here
- 3sync the billing tablesbilling_payments / billing_subscriptions
- 4run your handlergrant credits, activate the subscription
- 5publish diagnosticsagora:payments:payment.succeeded
charge() does not move money
It creates a charge at the gateway and returns a Payment whose status is 'pending', carrying
whatever the customer needs in order to pay:
const payment = await getPayments().driver('pix').charge({
customerId: customer.id,
amount: 1990,
externalReference: order.id,
})
payment.status // 'pending'
payment.pixCode // the copy-paste BR code to show
payment.pixQrCodeImage // the QR image (base64 PNG)
payment.hostedUrl // a hosted page, for gateways that use oneEverything after that is out of your process. The customer opens their bank app, or does not. The card is approved, or declined. You find out when the gateway tells you.
A PENDING charge is not revenue
Granting access on the return value of charge() gives away the product to anyone who starts a
checkout and walks away. It is the most expensive mistake available in this domain, and it is easy
to make because the code reads as if it worked. Grant on payment.succeeded, from the webhook.
What the webhook route does, in order
The provider mounts POST /payments/webhook/:provider and hands the request to the matching driver.
The order below is the whole design, and each step depends on the one before it:
- Validate the signature. The driver verifies the gateway's scheme — HMAC, RSA, or a shared token, always timing-safe — and throws on failure. A forged callback never reaches your data. A driver that can verify and has no credential configured refuses to boot, so this step is never silently skipped.
- Normalize. The gateway's own event (
PAYMENT_RECEIVED,checkout.session.completed,PIX_AUTOMATIC_COBR_COMPLETED) becomes a canonicalpayment.succeededwith a stableid. One delivery can normalize into several events — Adyen'snotificationItemsand Efí'spixare arrays — and steps 3 to 5 then run once per event, in the order the gateway sent them. - Ledger it — before any work. The event id is written to
billing_webhook_events. Already there and not failed? This is a redelivery; processing stops and returnsfalse. See Idempotency. - Sync. The normalized event is upserted into
billing_payments,billing_subscriptionsandbilling_disputes, so your database mirrors the gateway without you writing a sync query. - Run your handler, then mark processed. Your business logic runs, and the ledger row is marked
processedonly after it returns. A throw marks itfailedand the dispatcher retries. - Answer the gateway, and the status is an instruction. A clean delivery is
200. A delivery where any event threw is500, because a 2xx promises the gateway it never has to send that delivery again — over a failed event, that is the payment lost, while the in-process retry meant to cover it dies with the process. A delivery rejected in step 1 stays400: redelivering a bad signature would fail identically.
Step 3 happening before step 5 is the guarantee: a redelivery never reaches your handler at all —
provided the driver declares a real event id, since that is what the ledger keys on. Step 6 is
why that redelivery is cheap — the events that succeeded are already processed and skip
the claim, so only the failed one runs again.
A failed webhook shows up in your 5xx rate
A delivery whose handler threw answers 500. If you alert on 5xx from the webhook route, it will
fire — and that is the honest signal; the gateway's own delivery log, which nobody reads, would be
the only other place it shows. See
Webhooks → When one event in a batch fails.
The normalized event types
There are ten. Every driver maps its own gateway's vocabulary onto them, and the processor's built-in sync switches on nothing else:
| Type | Means | What the built-in sync does |
|---|---|---|
payment.succeeded | the money moved | the payment row becomes paid |
payment.failed | the attempt failed | the payment row becomes failed |
payment.refunded | a full refund completed | a known payment row becomes refunded, with refunded_amount set to the whole amount |
payment.disputed | a chargeback — the money is already going back | a known payment row becomes disputed, and a dispute row is opened |
payment.dispute_warning | an alert before any chargeback exists | the payment row is left alone; a dispute row is written with status warning |
payment.dispute_closed | the dispute reached an outcome | won puts a disputed row back to paid; lost moves a row that never went to disputed; the dispute row records the outcome |
payment.updated | a payment changed in some other way — a partial refund is this | a known payment row is kept current: status, amount, refunded_amount and paid_at, each only when the event states it |
subscription.created | a recurring subscription now exists | upserted |
subscription.updated | its status or period changed | upserted |
subscription.canceled | it ended | a known subscription becomes canceled |
The three dispute events
payment.disputed is the one that takes revenue away. A chargeback that passes through as an
unknown type leaves the payment row saying paid while the money is already being pulled back, and
the app finds out from its bank statement.
payment.dispute_warning is the moment before that, and it is the one worth acting on: a Stripe
inquiry or early fraud warning, Adyen's notification of chargeback, a Razorpay retrieval phase,
Square's INQUIRY_* states. No money has moved, so the payment row is not touched — a row saying
paid is telling the truth. What the event carries is actionableUntil, the deadline past which
the dispute is lost by default rather than on the merits, and refunding inside that window stops the
chargeback from being filed at all. That is worth doing even on a dispute you would win, because a
chargeback counts against the ratio that puts a merchant into a network monitoring programme.
payment.dispute_closed carries outcome — won, lost, canceled or expired — and only won
and lost move a payment row. won matters for a reason that is easy to miss: revenue() sums
rows that are paid, so a dispute you won left money written off permanently until the row went
back. canceled and expired deliberately move nothing; on Stripe a dispute the cardholder
withdrew still has to be closed in your favour before the acquirer returns anything, and
understating is the safe direction. A close that carries no outcome is refused: the processor
throws rather than invent a result the gateway never sent, which is why a driver that cannot read
one emits payment.updated instead.
Not every gateway speaks all three. Eleven drivers report an outcome and seven relay a pre-chargeback alert; several genuinely have no dispute vocabulary at all, and their pages say so rather than implying one exists. Which gateway event maps onto which type is on each provider page — that, and the deadline each one does or does not send, are the details worth reading before you build a dispute workflow.
An event type the processor does not recognize passes through: it is ledgered, no built-in sync runs, and a handler runs if one is registered. Unknown does not mean dropped.
externalReference — how a confirmation finds your row
A webhook carries the gateway's ids. Your business logic needs yours. externalReference is
the string you choose, the gateway echoes back, and the library surfaces on the normalized event:
const order = await Order.create({ total: 1990, status: 'awaiting_payment' })
await getPayments().driver('pix').charge({
customerId,
amount: order.total,
externalReference: order.id, // ← comes back on every webhook for this charge
})async handle(event: WebhookEventFor<'payment.succeeded'>) {
const orderId = event.data.externalReference // typed — no cast
if (!orderId) return
await grantAccessFor(orderId)
}Each gateway stores it in its own field, and the driver normalizes both directions. A few, to show how far apart the fields are:
| Gateway | Field |
|---|---|
| Asaas | externalReference — propagated to every installment of a subscription's charges |
| Woovi | correlationID |
| Stripe | metadata.external_reference |
| Mercado Pago | external_reference |
| PagBank | the order's reference_id |
| Efí | the Pix txid itself, when your reference fits its charset |
| Adyen | merchantReference — required, not optional |
Every provider page names its own field, and the differences matter more
than they look: Adyen refuses a charge without one, Efí squeezes it into a txid with a restricted
charset, and Razorpay hides it in notes. You never touch any of that — you set
externalReference and read event.data.externalReference — but when you are staring at a raw
payload in the gateway's console, knowing which field to look at is the difference between a minute
and an afternoon.
The Asaas propagation is the one that saves real work: the webhook for the third monthly charge of a subscription still carries the reference you set when you created it, so it routes back to your subscription row without you parsing anything gateway-specific.
externalReference is not idempotencyKey
They look similar and solve opposite problems. idempotencyKey protects the outbound call — reusing
it must not create a second charge at the gateway. externalReference is for inbound routing —
it is how a webhook finds your record. Set both; they are not substitutes.
Where your business logic goes
The route guarantees security and sync. Granting the thing the customer bought is yours, and it has
four homes with genuinely different failure behaviour — a file in app/payment_handlers/, an entry
in billing.handlers, a diagnostics subscriber, or your own controller. The difference that matters
is what happens when it throws. See
Webhooks → Running your business logic.
The shape to aim for either way: the handler dispatches durable work and returns. Webhooks must answer fast, and money work must survive a crash — so the handler's job is to hand off, not to grant inline.
Routing
How a payment method becomes a gateway — the driver contract, the manager's three resolution rules, the capability checks that fail early, and why the SDKs load lazily.
Idempotency
Why gateways redeliver, what the ledger guarantees and what it does not, why the event is recorded before any work runs, how a failed event is retried, and where your own inner guard belongs.