Payment systems fail in a particular way. The money moves correctly, but your application credits the customer twice, ships an order twice, or marks a paid order as failed. These bugs usually come from three sources: duplicate webhooks, retried API calls and concurrent code paths racing on the same order.
This article covers the patterns that prevent them. They apply to any gateway. Razorpay's documented behaviour is used as the concrete example.
Why duplicates are normal, not exceptional
Webhook delivery is at least once. Razorpay's webhook best practices are explicit about what that means in practice:
- The same event can be delivered more than once, and this is expected.
- Any non-2xx response is treated as a failure and retried.
- If your server does not respond within 5 seconds, the delivery is marked as a timeout and sent again, even if you actually processed it.
- Retries follow exponential backoff for 24 hours.
- Events may arrive out of order.
So your handler must produce the same end state whether it sees an event once, three times, or after a later event has already arrived.
Layer 1: Deduplicate by event ID
Most gateways attach a unique identifier to each event. Razorpay sends it in the x-razorpay-event-id header. Record it with a unique constraint, inside the same transaction as the work it triggers:
CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
async function handleEvent(eventId, event) {
await db.tx(async (t) => {
const inserted = await t.result(
`INSERT INTO processed_events (event_id, event_type)
VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[eventId, event.event]
);
if (inserted.rowCount === 0) return; // already handled
await applyEvent(t, event);
});
}
Do not check with a SELECT and then insert. Two deliveries arriving together can both pass the SELECT. Let the database constraint decide.
Layer 2: Make the business effect idempotent
Event-ID deduplication is necessary but not sufficient. Different events can describe the same business fact. For example, Razorpay fires both payment.captured and order.paid when a payment on an order is captured. If both handlers fulfil the order, you ship twice.
Key every side effect by the business entity, not the event:
- One fulfilment per order: a unique constraint on
fulfilments(order_id). - One ledger credit per payment: a unique constraint on
ledger_entries(payment_id, entry_type). - One refund per refund request: your own refund request ID, stored before calling the gateway.
With these constraints in place, a duplicate that slips through still cannot create a second row.
Layer 3: Idempotent outbound calls
Retries also happen in the other direction. Your server calls the gateway, the network times out, and you do not know whether the request succeeded.
Some APIs accept an idempotency key header for this. Where an API does not, look for a field that serves the same purpose. Razorpay's create refund API accepts a receipt value, and the documented errors include "Duplicate receipt found for this refund request", so passing your own refund request ID as the receipt protects against double refunds on retry.
import requests
def refund(payment_id, request_id, amount_paise):
resp = requests.post(
f"https://api.razorpay.com/v1/payments/{payment_id}/refund",
auth=(KEY_ID, KEY_SECRET),
json={"amount": amount_paise, "receipt": request_id},
timeout=30,
)
return resp.json() # retry with the same request_id on timeout
For order creation, store the gateway order ID against your internal order the first time, and reuse it on retry rather than creating a new one.
Order and payment state machines
Orders and payments are separate entities with separate lifecycles. Conflating them is a common source of state bugs.
Razorpay's orders documentation describes order states of created, attempted and paid. An order can have several payment attempts, but only one successful payment. Payments move through created, authorized, captured, refunded and failed.
Model your own order as a state machine with allowed transitions, and apply updates only when they move forward:
const NEXT = {
pending: ['paid', 'failed'],
failed: ['paid'], // late success is possible
paid: ['refunded', 'partially_refunded'],
partially_refunded: ['refunded'],
};
function canMove(from, to) {
return (NEXT[from] || []).includes(to);
}
Note the transition from failed to paid. Razorpay documents late authorisation: if the bank does not respond in time, a payment can be marked failed and later move to authorized when the bank confirms. Razorpay polls the bank for up to 3 days, and uncaptured payments where money was debited are refunded automatically. If your state machine treats failed as terminal, you can end up with a customer who paid but never received the order.
Apply transitions with a conditional update so the database enforces them:
UPDATE orders
SET status = 'paid', payment_id = $2
WHERE id = $1 AND status IN ('pending', 'failed');
If zero rows are updated, someone else already moved the order. That is not an error; log it and return.
The callback versus webhook race
On a successful checkout, two things usually happen almost at the same time. The browser returns to your app with the payment details, and the gateway sends a webhook to your server. Either can arrive first. Sometimes the browser never returns at all, because the user closed the tab.
The fix is to route both paths through one function that is safe to run twice:
- Verify the input. For Razorpay's checkout response, that means checking the payment signature; for the webhook, checking the webhook signature.
- Lock or conditionally update the order row.
- Apply the transition and side effects only if the order was not already paid.
- Return the current state to the caller.
async function markPaid(orderId, paymentId) {
return db.tx(async (t) => {
const order = await t.one(
'SELECT * FROM orders WHERE id = $1 FOR UPDATE', [orderId]);
if (order.status === 'paid') return order;
await t.none(
"UPDATE orders SET status='paid', payment_id=$2 WHERE id=$1",
[orderId, paymentId]);
await t.none(
'INSERT INTO fulfilments (order_id) VALUES ($1) ON CONFLICT DO NOTHING',
[orderId]);
return { ...order, status: 'paid' };
});
}
Treat the browser callback as a user experience signal and the webhook (or a server-side fetch of the payment) as the authoritative one.
Respond fast, process later
Given the 5 second timeout, do minimal work in the webhook request: verify, record the event, enqueue, return 200. Run fulfilment, emails and ledger writes in a worker. The worker reuses the same idempotent functions, so a retried job is harmless.
Add a reconciliation sweep
Even with all of this, events can be missed, for example if your endpoint was down beyond the retry window. Schedule a job that finds orders stuck in pending or failed for longer than expected, fetches their payments from the gateway API, and feeds the result into the same markPaid function.
Summary
- Deduplicate events with a database constraint, not a read-then-write check.
- Key side effects by business IDs: order, payment, refund request.
- Use idempotency keys or equivalent fields on outbound calls.
- Model orders and payments separately, and allow late success.
- Funnel callback, webhook and sweep through one idempotent function.
Disclosure: The author works with the Razorpay team on content.
Top comments (0)