Agora
Concepts

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 processed

Because 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-op

That 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:

StatusMeaningA new attempt
receivedclaimed, in flightis a redelivery — refused
processedfinished successfullyis a redelivery — refused
failedthe previous attempt threwclaims 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_RECEIVED and later PAYMENT_CONFIRMED for 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 second PAYMENT_UPDATED about 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 the Math.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 += 100 safe.

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:

WhereOn what
ChargeInput.idempotencyKeycharge()
CheckoutInput.idempotencyKeycreateCheckout()
CreateCustomerInput.idempotencyKeycreateCustomer()
CreateSubscriptionInput.idempotencyKeycreateSubscription()
UpdateSubscriptionInput.idempotencyKeyupdateSubscription()
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 it X-Idempotency-Key, Pagar.me Idempotency-key, PagBank x-idempotency-key, PayPal PayPal-Request-Id, and Razorpay — on refunds and nothing else — X-Refund-Idempotency.
  • A body field. Square's idempotency_key is in the payload, not the headers, and it is mandatory on the create calls.
  • The id in the URL. An Efí refund is a PUT to /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-grant

See Testing.

On this page