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 webhooksTimes 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
Arrival 1
Booked a receipt
Arrival 2
Booked a receipt
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
Sample data. Not a measurement. A model of the pattern, not of any one gateway.
The four handlers at a glance
| Handler | What it does | Receipts |
|---|---|---|
| Books a receipt every time | 3 receipts booked. The books show ₹36,000 in; the gateway paid ₹12,000. ₹24,000 sits unallocated. | 3 |
| Looks for an existing receipt, then books | One receipt. The first arrival books; later ones find it and stop. | 1 |
| Claims the event id, then books | One receipt. Later arrivals find the id and stop. | 1 |
| Claims the id and books in one transaction | One 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 | What 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
| What happens | Why | Evidence |
|---|---|---|
| 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. | DocumentedStripe webhooksRazorpay best practices |
| 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. | DocumentedMedusa issue 16292 |
| 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
| Feature | Where | What 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 sends | Webhook › 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 |
| Signature | header when Enable Security is on | Base64 of an HMAC-SHA256 of the body. Check it before anything else.Frappe webhook.py, v15 |
| Payment Entry reference | field , 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 check | Payment 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 id | a new doctype with Auto Name | Inserting a second row with the same id raises Duplicate Entry.Frappe base_document.py, v15 |
| One transaction | a 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 callbacks | Frappe 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
Reply first. Return a 2xx as soon as the event is saved or queued; do the work after.
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.
Store the key with a unique rule, in the same transaction as the work. Let the insert decide, not a check before it.
Make the work itself safe to repeat where you can: set a status, look up before you create.
Keep keys at least as long as the sender can resend. Then reconcile for what never arrived.