How to Prevent Duplicate Payment Confirmation Issues (Idempotency Patterns)
VyaparGateway Team
Payments Editorial
Duplicate payment confirmation is the bug that hurts twice — once when your customer is double-charged or double-shipped, and again when your support team spends hours untangling the mess. The root cause is almost always the same: at-least-once webhook delivery met code that didn't expect to see the same event twice. This guide walks through the four layers where duplicate confirmations slip through, and the concrete patterns that prevent them at each layer.
Why duplicates happen
Every payment gateway delivers webhooks with at-least-once semantics. That means a single payment event can be delivered to your endpoint two, three, or even ten times if your server has transient failures or slow responses. This is by design — it's the only way to guarantee delivery in an unreliable network. The corollary is that your handler must be designed to receive the same event multiple times and produce the same result.
The four most common sources of duplicate processing:
- Webhook retries after a 5xx or timeout from your endpoint.
- Customer paying twice because they didn't see confirmation the first time.
- Frontend double-submit (button clicked twice in rapid succession).
- Manual replay from the gateway dashboard during debugging.
Layer 1: Database constraints (the safety net)
Your last line of defence against duplicates is a unique constraint in your database. If everything else fails, the database will refuse to insert the second record and your application can catch the constraint violation gracefully.
- Add a UNIQUE constraint on the gateway's payment intent ID column in your payments table.
- Optionally, also a UNIQUE constraint on the UTR field (which comes from NPCI, not the gateway).
- Wrap the 'insert payment + fulfil order' transaction inside a single DB transaction so partial state is impossible.
- Catch the unique-constraint exception specifically and treat it as 'already processed' — return 200 to the gateway without re-fulfilling.
PostgreSQL example: ALTER TABLE payments ADD CONSTRAINT unique_intent UNIQUE (gateway_intent_id);
Layer 2: State machine in your application
Above the database, your order should have an explicit state machine that makes invalid transitions a no-op. The states for a typical order:
created → awaiting_payment → paid → fulfilled → (optionally: refund_initiated → refunded)
The rules:
- Transitioning from awaiting_payment to paid is allowed and fires fulfilment.
- Transitioning from paid to paid is a no-op — return 200 immediately, log for observability, don't run fulfilment.
- Transitioning from fulfilled to paid is a no-op (already further along the pipeline).
- Any unexpected transition triggers an alert for human review.
"An idempotent webhook handler isn't just one that handles duplicates without crashing — it's one that produces the same end state regardless of how many times it's called. The first call does the work; subsequent calls do nothing but return success."
Layer 3: Idempotency keys in queue workers
Many teams process webhooks by adding them to a queue and processing asynchronously. This is good practice (keeps webhook latency low) but introduces a new place duplicates can sneak in: the queue itself might retry, or two workers might pick up the same job. The fix is an idempotency key:
- Use the webhook event ID (evt_xxxxxxx) as the idempotency key when enqueuing.
- Maintain a 'processed events' table with the event ID as the primary key.
- Before processing a job, check 'have I seen this event ID before?' — if yes, skip.
- After successful processing, write the event ID to the processed-events table.
- Wrap the check + work + write in a single transaction.
Layer 4: Frontend double-submit prevention
Customers sometimes click the 'Pay' button twice in quick succession, or refresh the checkout page and start a new payment attempt while the original one is in flight. Prevent these at the UI:
- Disable the pay button as soon as it's clicked, until the response returns.
- Generate a client-side idempotency key (e.g. UUID v4) when the checkout page loads, and send it with every create-intent call. If the customer refreshes the page, generate a new key — otherwise reuse.
- Show a clear 'payment in progress' state if the customer refreshes mid-flow, so they know not to retry.
- On the success page, show order details from the DB lookup — don't rely on URL parameters that can be manipulated.
Customer-side duplicates: the trickiest case
Sometimes the duplicate isn't from your code at all — the customer literally pays twice because they didn't see the first confirmation. The customer's UPI app shows two separate successful debits; your bank shows two credits; your gateway fires two webhooks (different intents, same order). This is the hardest case because the duplicate is genuine money movement, not a software bug.
Defense:
- Single intent per order: once an intent is created for an order, reuse it on retries (within the expiry window) instead of creating a new one. The QR can only be paid once.
- After fulfilment, hide or disable the pay-again button on the customer's order page.
- Build an automatic refund job: when you detect a second successful payment for an already-paid order, refund it within minutes and notify the customer.
- Alert your support team so they can proactively reach out before the customer files a chargeback.
Putting it all together
A bullet-proof anti-duplicate setup combines all four layers: unique DB constraints catch anything the rest misses, the state machine makes re-processing a no-op, queue workers check idempotency keys before doing work, and the frontend stops most duplicates from being generated in the first place. With these in place, duplicate webhook delivery becomes a non-event in your application logs instead of a customer support fire. VyaparGateway helps you issue dynamic UPI QR codes, verify payments, and notify your stack via webhooks—without charging a per-transaction platform fee on top of your plan.
Frequently asked questions
- What is an idempotency key in payment processing?
- An idempotency key is a unique identifier (often the gateway's event ID or your own UUID) attached to an operation, used to ensure the operation produces the same result whether it's executed once or many times. In webhook handling, the idempotency key lets your code detect 'I've seen this event before' and skip re-processing without losing data.
- Why use a database unique constraint instead of just checking in code?
- Race conditions. Two webhook deliveries arriving milliseconds apart can both pass an 'is this already processed?' check before either has committed its write. A UNIQUE constraint in the database is enforced at the storage layer and physically cannot allow two rows with the same key, regardless of how concurrent requests interleave.
- Should my webhook handler return 200 OK if it detects a duplicate?
- Yes. Returning 200 OK tells the gateway 'delivery successful, stop retrying' — which is what you want for a duplicate, because re-processing it wouldn't add anything. Returning an error code would just trigger more retries, making the duplicate problem worse.
- How do I handle a customer who genuinely paid twice?
- Once your idempotency logic detects a second successful payment for an already-paid order, kick off an automatic refund for the duplicate and notify the customer. The earlier you can do this (ideally within minutes), the less likely the customer will escalate to a support ticket or chargeback.
Free tools for Indian merchants
No sign-up, no ads, no data selling
Use our free, browser-only tools whenever you need them. We don't store the values you enter or track you across the web.
📱 UPI QR Code Generator
Create UPI / URL / WhatsApp / WiFi QRs. Export as SVG or PNG.
🧮 GST Calculator (CGST + SGST + IGST)
Add or remove GST across 5%, 12%, 18%, 28% slabs.