Skip to main content

Integration pattern

Reviewed 14 October 2026

The same webhook twice should book one payment.idempotent webhook handler

Gateways send a webhook again when they do not hear back in time. If your handler books a payment each time, the ledger doubles.

No live demo shows a webhook yet. The sandbox below is the demo.

Use it when

  • A webhook creates or changes money, stock or a document.
  • The sender retries, as Razorpay, Stripe, Shopify and Frappe all do.
  • Your handler can fail after doing part of its work.

Skip it when

  • The handler only sets a value, such as "status = paid". Repeating it changes nothing, so a key table adds cost, not safety.
  • The webhook only sends a notification where a repeat is harmless, such as a chat message.
  • Only a few events arrive a month and a person checks each one against the bank statement.

Try it

Duplicate-delivery sandbox

Fire one payment event at a handler more than once. Change the handler and fire it again. Sample data. Not a measurement.

From the gateway to your handler
payment.captured
Payment id
pay_SAMPLE1
Amount
₹12,000
For invoice
SINV-SAMPLE, ₹12,000

Why it arrives again

A Frappe Webhook waits 5 seconds by default, then sends again at once. Razorpay names the server that "accepts the event but fails to respond in 5 seconds". Shopify allows five seconds for the whole request. Stripe asks for a 2xx before any complex logic.

Frappe webhook.py, v15Razorpay best practicesShopify duplicate webhooksStripe webhooks

Times it arrives

Your handler

Only matters for handlers that claim an id.

A bare receipt has no invoice rows, so ERPNext's allocation check never runs. When on, the handler allocates the full amount from the event each time.

What the handler did

  1. Arrival 1

    Booked a receipt

  2. Arrival 2

    Booked a receipt

  3. Arrival 3

    Booked a receipt

Receipts booked
3
Cash received per the books
₹36,000
What the gateway paid
₹12,000
Difference
+₹24,000
Unallocated receipts
₹36,000
3 receipts booked. The books show ₹36,000 in; the gateway paid ₹12,000. ₹24,000 sits unallocated.

Sample data. Not a measurement. A model of the pattern, not of any one gateway.

The four handlers at a glance

The four handlers at a glance: three arrivals, one after another, a bare receipt
HandlerWhat it doesReceipts
Books a receipt every time3 receipts booked. The books show ₹36,000 in; the gateway paid ₹12,000. ₹24,000 sits unallocated.3
Looks for an existing receipt, then booksOne receipt. The first arrival books; later ones find it and stop.1
Claims the event id, then booksOne receipt. Later arrivals find the id and stop.1
Claims the id and books in one transactionOne receipt. If the server stops, the id and the work roll back together, and the retry books once.1

Three arrivals, one after another, a bare receipt. Sample data.

What you give up, and how it fails.

What you give up

What you get and what it costs
What you getWhat it costs
Any number of repeats books once.A table of event ids to keep, with a unique rule.
Retries become safe, so you can reply fast and work later.Ids must outlive the sender's longest retry window, and any manual resend.
A stop midway leaves nothing half done.The id row and the books must share one database, or you need a second safeguard.

How it fails

How the pattern fails, why, and the evidence
What happensWhyEvidence
A slow handler causes the duplicate.A Frappe Webhook waits 5 seconds by default and sends again at once after a timeout. Razorpay and Stripe say the same thing differently: reply fast, work later.
The retry window ends and the event is gone.Frappe stops after three attempts, Shopify after eight tries over four hours, Razorpay after 24 hours, Stripe after three days in live mode. A person can resend from Stripe's dashboard for 15 days.
Events arrive out of order.Stripe does not guarantee order, and Razorpay says you may not always receive webhooks in order. A key stops repeats, not reordering: fetch the current record.
A retry carries a new key, so the key protects nothing.Medusa issue 16292: a payment-capture retry sends a different idempotency key. Closed as not planned on 27 September 2026.
Claiming the id first can lose the event.If the id is saved before the work and the server stops, the retry sees the id and skips. Save the id and the work together. The sandbox shows it.
Follows from the design

Each failure is Documented, with the maker's own page or code, or it Follows from the design. None is a result from a MithTech client project.

In ERPNext and Frappe.

Read from Frappe v15.123.0 and v16.52.0, ERPNext v15.122.0 and Frappe's payments app at commit 9885a6e5 on 14 October 2026, with the file and line. Other systems' names and settings were not checked here.

Where each part lives

The pattern in ERPNext and Frappe: feature, where it lives, and what it does
FeatureWhereWhat it does
Webhook (when Frappe sends)Integrations › Webhook
Sends in a background job after the save commits. Three attempts. Timeout 5 seconds unless you set one. After a read timeout the next attempt starts at once; after other errors it waits 1 second, then 4. One Webhook Request Log row per attempt. No delivery id in the request.Frappe webhook.py, v15
What it sendsWebhook › Webhook Data, or the JSON template
Add name and modified so the receiver has a key. Repeated triggers for the same document and webhook inside one transaction are merged.Frappe webhook.py, v15
Signatureheader when Enable Security is on
Base64 of an HMAC-SHA256 of the body. Check it before anything else.Frappe webhook.py, v15
Payment Entry referencefield , labelled "Cheque/Reference No"
Plain text with no unique rule. Put the gateway's payment id here and look it up before you book.ERPNext Payment Entry, v15
Allocation checkPayment Entry against an invoice
A receipt that allocates to an invoice is checked against the invoice's latest outstanding. A repeat is refused with "has already been fully paid" when nothing is left, and "has already been partly paid" when the outstanding has changed but is not zero. A receipt with no invoice rows skips the check.ERPNext Payment Entry, v15
A table keyed by event ida new doctype with Auto Name
Inserting a second row with the same id raises Duplicate Entry.Frappe base_document.py, v15
One transactiona whitelisted POST request
Frappe commits the request when it ends cleanly and rolls it back if it raises. Put the id row and the receipt in the same request.Frappe app.py, v15
Gateway callbacksFrappe Payments app, Integration Request
Logs each Razorpay payment as an Integration Request and moves it through Queued, Authorized and Completed.Frappe Payments Razorpay, v15

How to build it

  1. Reply first. Return a 2xx as soon as the event is saved or queued; do the work after.

  2. Use the key the sender gives you: Razorpay's x-razorpay-event-id, Shopify's X-Shopify-Webhook-Id, Stripe's event id. If the sender gives none, as a Frappe Webhook does not, add the document name and modified time to the payload.

  3. Store the key with a unique rule, in the same transaction as the work. Let the insert decide, not a check before it.

  4. Make the work itself safe to repeat where you can: set a status, look up before you create.

  5. Keep keys at least as long as the sender can resend. Then reconcile for what never arrived.

Questions about idempotent webhooks