Duplicate Payment Webhooks: Make the Handler Idempotent
At-least-once delivery is the contract every payment provider actually offers, whatever the documentation implies, so a webhook handler should be designed for repetition rather than defended against it.
- Payments
- Webhooks
- Node.js
- Idempotency
- Ecommerce
Why does the same payment webhook arrive twice?
A payment provider's webhook is not a promise that it will call your endpoint once. Every major gateway, whatever the marketing language on its documentation page implies, offers at-least-once delivery: it will keep trying until it receives a success response, and it reserves the right to try again even after it has already received one. Treat that as the actual contract rather than an edge case, and a large category of payment bugs disappears before a line of business logic gets written.
There are several ordinary ways the same event reaches your handler twice. Your handler can time out on the provider's side while the write it triggered still commits on yours, so the provider records a failure and retries a request you already completed. You can return a non-2xx status after the work is done, because something threw further down the call stack after the write succeeded. The provider's own retry schedule can fire before your 200 response makes it back across the network, particularly under load. Or the network can drop the acknowledgement between the moment you send 200 and the moment the provider's side receives it. None of these need a defect on your end. Most of them are the network behaving exactly as it is built to behave.
The framing worth carrying through everything below is that a duplicate delivery is not an error condition. It is normal traffic, produced by a system working as designed. A handler written to treat a repeat as something exceptional is a handler that will corrupt a booking on a slow Tuesday, not because anything failed, but because the retry meant to make the system more reliable found a state it was never taught to expect.
What are the three cases, and what does each one do?
Once a duplicate delivery is treated as normal traffic rather than as an exception, there are exactly three cases a handler needs to cover, and only one of them needs any real engineering. This is the shape I built booking confirmation around on a car rental platform carrying four payment integrations, and it has held up better than any amount of defensive checking in application code would have.
- 1
The first delivery has already completed
Read the booking, see the payment is already confirmed, and return 200 with no further side effect.
- 2
The first delivery is still in flight
Two updates race against each other. The write itself decides which one wins, and the loser becomes a no-op rather than a second confirmation.
- 3
Both reach the database
The effect is identical either way, because confirming a confirmed booking is the same state as confirming it once.
The first case is the one that reads, on a first pass, like it should be treated as an error. The booking is already confirmed and a webhook has just arrived instructing you to confirm it. Returning 200 with no further action is deliberate rather than careless: a non-2xx status is a message to the provider that says send this again, and the one message you never want sent again is the one that already applied correctly. Treating an already-processed event as a failure is how a handler turns a harmless retry into an endless one.
The second case is where the actual engineering sits. A booking's payment-confirmation write is built as an upsert whose filter includes the status condition, so it can only match a booking that is not yet confirmed. Two deliveries can land close enough together that both reach the database while the first is still being applied. The first write matches the filter and confirms the booking. The second arrives a moment later, finds nothing left to match because the status has already moved on, and becomes a no-op. Neither request needs to know the other exists, and nothing needs a lock, a queue or any coordination between them: the database decides which delivery wins, because that is the only place in the system where the decision can be made safely. Deciding it in application code instead, by reading the status and then deciding whether to write, reopens exactly the race the database was closing.
The third case is the one that makes the other two survivable. If both deliveries genuinely do reach the database in a way that lets both attempt the same transition, the effect is identical regardless of which one goes first, because confirming an already confirmed booking is the same state as confirming it once. The operation is idempotent, so repetition stops being a correctness problem and becomes, at worst, a handful of wasted writes.
The conditional write is the whole mechanism
The conditional write is the whole mechanism, and everything else in this post is either a refinement built on top of it or a reason it needs to exist in the first place. What makes it safe is that the check and the write happen as a single operation the database will not let interleave: nothing can read the status, find it unconfirmed, and then lose a write to a concurrent request that got there first, because there is no separate read step in which to lose that race.
// Illustrative shape only: a MongoDB-style conditional upsert.
async function confirmBookingPayment(bookingId: string, paymentRef: string) {
const result = await bookings.updateOne(
{
_id: bookingId,
status: {$ne: 'confirmed'} // the whole mechanism lives in this line
},
{
$set: {
status: 'confirmed',
paymentRef,
confirmedAt: new Date()
}
}
);
// matchedCount is 0 when the booking was already confirmed: a no-op,
// not an error, and nothing further needs to run.
return result.matchedCount > 0;
}The pattern generalises past a single field. Whatever state a booking, order or subscription needs to move through, the filter that performs the move should carry the condition that makes the move valid, and the write should run as one call rather than as a read, a decision, and a separate write. Splitting it into three steps in application code opens a window between the read and the write, and a second delivery landing inside that window sees the same pre-write state the first delivery saw, decides it is also allowed to write, and now two deliveries both believe they are the one confirming the booking. That race almost never shows up in ordinary testing, because it needs two deliveries close enough together to land inside a window that is usually a few milliseconds wide, which is precisely the shape of traffic a provider's retry burst produces.
How do you stop processing the same event twice at all?
The conditional upsert protects the booking's state: it makes confirming an already confirmed booking harmless no matter how many times it happens. It does not stop a duplicate event from reaching your application code at all, and for a handler that only ever touches one field, that is often a reasonable place to stop. The refinement worth adding, once a handler does more than one thing behind the same event, is to store the provider's event ID with a unique index on it.
// Illustrative shape only. eventId is the provider's own
// identifier for this specific webhook delivery.
await db.collection('processedEvents').createIndex(
{eventId: 1},
{unique: true}
);
async function recordEvent(eventId: string, payload: unknown) {
try {
await db.collection('processedEvents').insertOne({
eventId,
payload,
receivedAt: new Date()
});
return true; // first time this event has been seen
} catch (err: any) {
if (err?.code === 11000) {
return false; // duplicate key: already recorded, return 200 and stop
}
throw err;
}
}The two mechanisms protect different things, and the distinction is worth keeping precise. The conditional upsert protects the state: whatever happens, the booking ends up confirmed exactly once. The unique index protects the processing: it stops the handler doing any work at all for an event it has already recorded, rejected by the database before a single line of business logic runs. That difference stops being academic the moment a webhook does more than one thing, for instance writing the booking state and also triggering a confirmation email or a downstream notification. Without the unique index, a duplicate that reaches the handler after the state write has already happened can still fire every side effect sitting downstream of it a second time, even though the booking itself never moves again. The audit record is close to free once the index exists: every delivery a provider ever sends becomes a row you can query later, not only the ones that changed something.
The browser must not be the thing that tells you a payment succeeded
Most checkout flows have a moment where the browser calls the backend to say a payment has just succeeded, usually straight after the payment provider redirects the customer back from its own hosted page. That call is not evidence of anything, and the post-payment callback from the browser to the backend is a design worth removing wherever it exists. The browser is not a party the payment happened between. It is a passenger reporting on a conversation it was not part of, and a report from a passenger can be replayed, forged, or simply never sent if the customer closes the tab a moment early.
The gateway's own server-to-server webhook is signed and is the only source of truth for whether a payment actually succeeded. Everything that follows it, the booking status, the confirmation email, whatever else sits queued behind it, should key off that webhook and nothing else. If a UI callback is kept at all, its only honest role is a hint that it is worth checking sooner: a nudge to poll, never a fact to act on. Treating it as confirmation is a correctness problem and a security problem occupying the same line of code, because a request the browser can send is a request anyone who controls that browser can also send.
- Verify the signature against the raw request body before you parse it as JSON, so an unsigned or forged payload never reaches business logic at all.
- Read the amount and currency the booking should charge from your own record, not from the incoming payload: the payload is exactly what an attacker forging a request controls.
- Treat any browser-triggered callback as a prompt to check the booking status, never as the check itself.
// Illustrative shape only: verify before you trust the body.
export async function POST(req: Request) {
const rawBody = await req.text();
const signature = req.headers.get('x-gateway-signature');
if (!verifySignature(rawBody, signature, WEBHOOK_SECRET)) {
return new Response('invalid signature', {status: 400});
}
const event = JSON.parse(rawBody); // only now is the payload trusted
return handleEvent(event);
}Does any of this change per gateway?
None of the three cases above are specific to one payment gateway, and it is worth being precise about what does and does not change across them.
| Brand | Gateway |
|---|---|
| Thrifty (UAE) | N-Genius (Network International) |
| Dollar (UAE) | Adyen |
| Dollar (Oman) | Bank of Muscat |
Alongside the three gateways above, PointsPay handles redemption of loyalty points at checkout, which is a payment method in the same sense the others are, even though no card network sees a transaction.
What changes between four integrations is the payload shape, the signature scheme and the retry schedule the provider actually runs. What does not change is the three cases from earlier in this post. A conditional upsert keyed on booking status, and ideally a unique index on the provider's own event ID, is the same fix whether the signature is a standard HMAC over the raw body or something proprietary to the gateway, and whether the retry burst arrives within seconds or is spread across hours.
Each gateway carries its own identifier, and which identifiers a brand is permitted to use are configured per environment rather than decided by a conditional anywhere in the code. A resolver reads the brand, looks up the gateway identifiers that brand is allowed to use in that environment, and constructs the payment URL from there. Because the allow list is environmental rather than an if statement checking which brand is asking, a brand cannot reach a gateway it was never configured for. There is no code path to misconfigure into existing, only an environment variable to get wrong, which is a considerably smaller and more visible surface to review.
I have integrated payments through N-Genius, Adyen, Bank of Muscat and PointsPay, across the three brands above. Telr, PayTabs and Saudi Arabia's Mada are not on that list, and it would be wrong to present them as though they were. They are the same integration pattern again, run against a different provider's API: verify the signature, read state from your own record rather than the payload, and treat every delivery as though it might arrive more than once. That is scoped here as new work, not described as work already shipped, because it has not been.
What to test before you call it idempotent
Idempotent is a claim, not a description, and the only way to trust it is a test suite that tries to break it rather than one that exercises the happy path once and moves on.
- Replay the identical event and assert exactly one state change happens and exactly one 200 is returned, on both the first delivery and the replay.
- Fire two deliveries for the same event concurrently and assert the same outcome: one state change, not a race that occasionally produces two.
- Send an event for a booking that does not exist and assert nothing is created, rather than the handler quietly building a record to attach it to.
- Send an event with a tampered signature and assert it is rejected before it reaches any business logic at all.
- Assert that a non-2xx response is returned only when the event genuinely has not been processed yet, because a non-2xx is a request to be sent it again, and sending that request against an event that already succeeded is the failure mode the rest of this post exists to prevent.
The short version
A payment webhook will be delivered more than once, on a schedule you do not control, for reasons that have nothing to do with a bug in your code. The handler that survives that is not the one built to prevent a second delivery. It is the one built so a second delivery, or a fifth, changes nothing beyond what the first one already changed. A conditional write keyed on the state that actually matters. An event ID behind a unique index once the handler does more than one thing. A webhook that is the only thing your backend trusts about whether a payment happened. A test suite that fires the same event twice on purpose, because that is the only way to know the claim of idempotent is true rather than assumed.
Getting this right the first time, across a real checkout rather than a demo, is ecommerce development work, and the platform this pattern was built for is part of the platform work.
What should you know about how I work on this?
Because the alternative is worse. A non-2xx response tells the provider the delivery failed and to send it again, and the one webhook you never want resent is the one that already applied correctly. Returning 200 for an event you recognise as already processed, with no further side effect, tells the provider its job is done and stops the retries it would otherwise keep sending.
Deduplication stops the same event being processed twice at all, typically with a unique index on the provider's event ID so a repeat is rejected before business logic runs. Idempotency is a property of the operation itself: running it once or five times produces the same state. You want both. Deduplication is the cheaper win, and idempotency is what keeps you safe on the deliveries deduplication misses, such as two different event IDs that end up describing the same underlying payment.
The event ID, not the payment ID. A single payment can legitimately generate more than one genuine event, for example an authorisation followed later by a capture, and a unique index on the payment ID would reject the second real event as though it were a duplicate. The event ID is what the provider treats as unique per delivery; the payment ID is what your business logic groups those deliveries by.
Long enough to outlast the provider's own retry window with margin, which for most gateways means weeks rather than days. After that point the record's value shifts from deduplication toward audit history, so it is reasonable to move older entries to cheaper storage rather than delete them outright: a paid booking's payment trail is exactly the kind of record worth being able to produce later.
A webhook can be dropped by a network failure, a provider outage or a misconfigured endpoint, and no amount of idempotency at your end fixes an event that never arrived in the first place. The general answer is reconciliation: poll the provider's API on an interval for payments sitting in a pending state and compare them against your own bookings, so a missing webhook becomes a delayed confirmation rather than a lost one. The webhook is the fast path; reconciliation is the path with no single point of failure.