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.
Every gateway redelivers webhooks. Not as an error path — as normal operation:
- your endpoint timed out, or answered non-2xx;
- a deploy dropped the connection mid-request;
- someone hit "resend" in the dashboard;
- an ops script replayed a day of events after an outage.
So "trust only the webhook" is only a workable rule if receiving the same webhook twice is safe. That is what the ledger is for.
The ordering is the guarantee
billing_webhook_events is keyed by the gateway's event id, and it is written before any work
runs:
event arrives
└─ recordWebhookEvent(event.id)
├─ already there, not failed? → stop. return false. nothing else happens.
└─ claimed
├─ built-in sync
├─ your handler
└─ mark processedBecause the claim happens first, a redelivery never reaches the sync and never reaches your handler. The idempotency is a property of the order, not of anything the handler does.
const first = await processor.process(event) // true — processed
const second = await processor.process(event) // false — redelivery, no-opThat boolean is the whole contract, and it is worth asserting in your tests.
A failed event is claimable again
There are three ledger states, and the difference between two of them is what makes retries work:
| Status | Meaning | A new attempt |
|---|---|---|
received | claimed, in flight | is a redelivery — refused |
processed | finished successfully | is a redelivery — refused |
failed | the previous attempt threw | claims it again and re-runs |
Without that last row, the first failure would seal the ledger and every retry — in-process or durable — would short-circuit on the claim and silently do nothing, which looks exactly like a webhook that was never delivered.
So a throwing handler is not a lost event: it marks the ledger failed, and the dispatcher's retry
picks it back up. The route also answers 500, so the gateway redelivers as well — and that
redelivery is cheap for exactly this reason: the events that already succeeded answer "processed"
at the claim and skip, and only the failed row is claimed again. Errors surface instead of being
swallowed.
What the ledger does not guarantee
It is the outer defence, and it is not the only one you need.
- It does not protect against two different event ids for the same fact. A gateway that sends
PAYMENT_RECEIVEDand laterPAYMENT_CONFIRMEDfor one payment sends two distinct ids, and both are legitimately new. - It is only as good as the id the driver declares. The key has to be an event identity. The
Asaas driver reads Asaas' own top-level
id: synthesizing${event}-${paymentId}would be a (payment, event-type) identity, so the secondPAYMENT_UPDATEDabout a payment would be discarded as a replay of the first — and a partial refund arrives as exactly that type. It falls back to a deterministic hash of the raw body (a redelivery of the same notification hashes to the same key; two different notifications do not) rather than to theMath.random()that had been disabling deduplication outright for any payload naming neither a payment nor a subscription. Writing a driver? Give every event an id of its own, from the gateway where there is one. - It does not protect a handler with side effects outside your database. An email sent before a later step throws is sent again on the retry.
- It does not make your grant idempotent. It makes the event processed once, which is not the
same as making
credits += 100safe.
The inner defence is a guard in your own logic, and it should be a database constraint rather than a read-then-write:
// Outer: the ledger already stopped the redelivery.
// Inner: the transition itself is only possible once.
const updated = await Order.query()
.where('id', orderId)
.andWhere('status', 'awaiting_payment') // ← the guard
.update({ status: 'paid' })
if (updated[0] === 0) return // already paid; nothing to grant
await grantCredits(orderId)A conditional update, a unique index on (order_id, kind) for the grant, an upsert — anything whose
second execution is a no-op at the database. Defence in depth, not redundancy: the two layers fail
in different ways.
Diagnostics subscribers get no ledger protection at all
By the time agora:payments:payment.succeeded is published, the event is already ledgered and the
route has moved on. A throwing onDiagnostic subscriber does not fail the webhook and is not
retried. Use events for reactions you can afford to lose — and handlers, or a durable workflow, for
anything you cannot. See Diagnostics.
The outbound half — idempotencyKey
The ledger protects what comes in. idempotencyKey protects what goes out: reusing it on a
charge must not create a second charge at the gateway.
await driver.charge({
customerId,
amount: order.total,
idempotencyKey: `order:${order.id}:attempt:1`,
externalReference: order.id,
})Derive it from something stable, not from a timestamp or a random value — a key that changes on every
retry protects nothing. And keep it distinct from externalReference: one guards against duplicate
charges, the other routes webhooks back to your record. See
The payment lifecycle.
It rides on six operations, which is every driver call that creates something:
| Where | On what |
|---|---|
ChargeInput.idempotencyKey | charge() |
CheckoutInput.idempotencyKey | createCheckout() |
CreateCustomerInput.idempotencyKey | createCustomer() |
CreateSubscriptionInput.idempotencyKey | createSubscription() |
UpdateSubscriptionInput.idempotencyKey | updateSubscription() |
refund(id, amount?, { idempotencyKey }) | refund() |
A driver either honours it or refuses it
There is no third behaviour, and that is the design. Accepting a key and dropping it is the worst option available: the caller believes their retry is safe, and the retry is a second charge, a second refund or a second subscription. So a driver whose gateway documents no deduplication for an operation throws, naming the endpoint and telling you to deduplicate on your side.
Where it is honoured, it goes wherever that gateway put it — and no two agree:
- A request header. Stripe, Adyen, Mollie and Polar take
Idempotency-Key; Mercado Pago spells itX-Idempotency-Key, Pagar.meIdempotency-key, PagBankx-idempotency-key, PayPalPayPal-Request-Id, and Razorpay — on refunds and nothing else —X-Refund-Idempotency. - A body field. Square's
idempotency_keyis in the payload, not the headers, and it is mandatory on the create calls. - The id in the URL. An Efí refund is a
PUTto/v2/pix/{e2eid}/devolucao/{id}, so the id you choose is the deduplication. Omit the key and the driver mints a random one, which protects a retry inside one call and nothing across a restart.
The gateway's own limits are enforced here rather than at the gateway: Adyen caps the key's length, Square caps it, Razorpay requires at least ten characters of a restricted charset, and Efí requires BACEN's 1–35 alphanumerics. Each throws with the limit in the message, because a key the gateway rejects is a key that does not deduplicate.
Partial support is common and is refused per operation, not per driver. Mercado Pago documents the
header for payments and refunds only; Pagar.me for order creation only; Razorpay for refunds only;
Mollie for POST only, so updateSubscription — a PATCH — refuses; Square has no
idempotency_key on a subscription update, so that one refuses too. AbacatePay, Dodo, Lemon Squeezy
and Paddle document no mechanism anywhere and refuse on every operation they implement.
Asaas charge() — the lookup, done for you
Asaas documents no idempotency header and no idempotency body field on any endpoint; it tells you to
deduplicate on your side. So AsaasDriver.charge() does that lookup, and it is the one place in
this package where the third behaviour would have been the wrong call.
The key travels as the charge's externalReference — the only handle Asaas echoes back and
accepts as a query filter — and a GET /payments scoped to that reference and that customer runs
before the POST. A hit returns the charge that already exists, enriched with its Pix code so
the caller gets a usable payment, and re-runs neither side effect: no second fiscal invoice (an
NFS-e is a legal document, not a retryable write) and no charge.created diagnostic for a charge
that was not created.
The guarantee is at most one Asaas charge per key, scoped to the customer. What it is not is a lock: two concurrent calls with the same key can both miss the lookup and both create, and Asaas offers nothing to prevent that. It closes the retry case, which is the one that actually happens.
Every other method on the Asaas driver still refuses loudly — an accepted-and-dropped key on
refund() is a second refund, and there is no equivalent lookup to invent.
Three places the key is still not deduplication
On AbacatePay, Woovi and Efí, a charge() with no externalReference uses the idempotencyKey
as the reference — legacy behaviour, kept because removing it would silently stop routing
webhooks for anyone relying on it. It is routing, not deduplication. (Efí's refund() is different
and genuinely deduplicates: the key becomes the devolução id in the URL.)
InfinitePay is the one gateway that neither honours nor refuses it. Its only creating operation is
createCheckout, its API documents no idempotency at all, and the key is used there as the last
fallback for order_nsu — the field it echoes back. A repeated call makes a second payment link.
Deduplicate before you call it.
Testing it
This is the property most worth a test, and it is one line:
const processor = new WebhookProcessor({ store, driver, handlers })
expect(await processor.process(event)).toBe(true)
expect(await processor.process(event)).toBe(false) // redelivery
expect(granted).toHaveLength(1) // and it did not double-grantSee Testing.
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.
Configuration
The config/payments.ts reference — named providers built with lazy factories, method routing with capability checks, the invoice section, and the billing layer (who owns the schema, how a webhook is processed, where business handlers live).