API Idempotency and Webhooks When Real Money Moves
Every explanation of idempotency uses a counter or a shopping cart. That is fine until the operation debits a wallet and buys gold, at which point “the retry is harmless” stops being an assertion and starts being something you have to actually build. This is what it looks like in a live money API, with the source references, so you can compare it against yours.
Short version: an idempotency key is necessary and it is nowhere near sufficient.
| Key accepted as | Idempotency-Key header, or idempotency_key in the body. Header wins |
| Replay key | (partner_id, mode, endpoint, idempotency_key) |
| Replay response | The original body, plus Idempotent-Replayed: true |
| Validity | At least 24 hours |
| Second line of defence | The quote row is SELECT ... FOR UPDATE |
| Third | A consumed quote fails loudly, it does not reprice |
| Webhook retries | 5, at 30s, 2m, 10m, 1h, 6h. Total span 432.5 minutes |
| Signature | HMAC-SHA256 over ${timestamp}.${rawBody} |
Why the toy examples do not transfer
The standard example is PUT /counter/5. Run it twice, same result, and the lesson is that idempotent operations are safe to retry.
A buy is not that. It has a price attached, that price expires, it moves money out of one balance and an asset into another, and the two sides settle in different systems. “Same result” is doing a lot of work in that sentence. If the retry arrives after the quote expired, is the right answer to reprice, or to fail? If two retries arrive at the same instant, what stops both from passing the balance check before either debits?
Those questions do not come up with a counter. They are the whole problem here.
Four layers, not one

The idempotency store. Keyed on partner, mode, endpoint and the key itself. A repeat returns the stored response body and sets Idempotent-Replayed: true, so the caller can tell a replay from a fresh success. Nothing re-executes.
The detail worth stealing is the mode in that key. Sandbox and live are separate namespaces, so a key you used while testing cannot silently match a live call later. Without mode in the key, a partner who replays their test suite against production gets a cached sandbox response for a real order, which is a genuinely nasty failure.
The quote row lock. The confirm path reads its quote with SELECT ... WHERE quote_id = ? AND partner_id = ? FOR UPDATE. Two concurrent confirms of the same quote do not race; the second waits at the database. This is the layer that handles simultaneous retries, which the idempotency store on its own does not, because both requests can miss the cache before either writes to it.
Consumed means consumed. An already-settled quote returns QUOTE_ALREADY_CONSUMED rather than quietly issuing a new one at the current price. The error text says what to do about it: “This quote has already been consumed. Use idempotency_key to retry safely.”
That choice is worth dwelling on. Silently repricing would look friendlier and would be much worse: the partner believes they transacted at the quoted rate and did not. On a money API, failing loudly is a feature.
The wallet debit lock. The balance row takes its own FOR UPDATE and throws on insufficient funds or a limit breach. Belt and braces, deliberately.
None of these four is the answer on its own. That is the actual lesson, and it is the one the tutorials cannot teach with a counter.
Using it
The key is accepted either way, and the route reads req.get('Idempotency-Key') || req.body?.idempotency_key, so the header takes precedence if you send both.
curl -X POST https://api.oropocket.com/partner/buy/confirm \
-H "Authorization: Bearer oro_test_..." \
-H "Idempotency-Key: 8f14e45f-ea0c-4b3d-9f22-2b1c7a6d5e40" \
-H "Content-Type: application/json" \
-d '{"quote_id":"qt_..."}'
Retry the same call and you get the original body back with an extra header:
HTTP/1.1 200 OK
Idempotent-Replayed: true
Two practical notes. Generate the key before the first attempt and reuse it across every retry of that logical operation, including retries triggered by your own timeout handler. A key generated inside the retry loop is not an idempotency key, it is a new request each time. And scope it to the operation, not the session.
Webhooks: the numbers you actually need

Five retries after a failed delivery, at 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours. The gaps grow roughly fourfold, which is what backoff is for: a momentary blip gets retried almost immediately, and a sustained outage is not hammered.
The number to plan against is the total: 432.5 minutes, a little over 7 hours, from the first failed delivery to the last retry. If your endpoint is down longer than that, those deliveries are gone and you need to reconcile by polling rather than waiting.
That is the honest way to read a retry schedule, and it is why “we retry with exponential backoff” is not an answer on its own. Ask any provider for the array.
Verifying the signature
Deliveries carry X-OroPocket-Signature: sha256=<hex>, computed as HMAC-SHA256 over ${timestamp}.${rawBody} using your signing secret, alongside X-OroPocket-Timestamp, -Event and -Event-Id.
Three things people get wrong:
const crypto = require("crypto");
function verify(rawBody, timestamp, signatureHeader, secret) {
// 1. Sign the RAW body. Not JSON.parse'd and re-stringified: key order
// and whitespace change, and the digest changes with them.
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
// 2. Constant-time compare. `===` leaks timing.
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader || "");
if (a.length !== b.length) return false;
if (!crypto.timingSafeEqual(a, b)) return false;
// 3. Reject stale timestamps, or a captured delivery can be replayed at
// you indefinitely. The timestamp is inside the signed payload for
// exactly this reason.
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
return ageSeconds < 300;
}
In Express, rawBody means mounting the raw parser on the webhook route specifically, because a global express.json() will have consumed and discarded it before your handler runs.
Be idempotent on the receiving side too
Retries mean your endpoint will see the same event more than once, by design. X-OroPocket-Event-Id is there to be stored and checked. A 2xx acknowledges receipt, not processing: acknowledge fast, queue the work, and do not hold the connection open while you settle something downstream.
What this costs you to get wrong
A double-settled buy is not a duplicate row. It is metal bought twice, a wallet debited twice, and a reconciliation conversation. A missed webhook is an order your system never learned about, sitting settled on our side and pending on yours. If you are weighing whether to embed this at all, the reason apps do it is usually retention rather than the asset.
Neither is caught by tests that only exercise the happy path. The useful test is the ugly one: fire two confirms at the same quote concurrently and assert that exactly one settles and the other returns QUOTE_ALREADY_CONSUMED.
A neighbouring case worth testing is paying people in metal instead of points, where the same double-settlement risk applies to a reward credit.
You can run that against the sandbox, which is stateful, priced off the same live feed, and fires real signed webhooks stamped as sandbox. Sandbox access is immediate, so the whole failure-path test suite can be written before any commercial conversation. We wrote up how the integration fits together separately, and if all you want is rates rather than transactions, GET https://api.oropocket.com/public/prices is unauthenticated and needs no key at all.
The short version
An idempotency key stops the same request being processed twice. It does not stop two requests arriving at once, it does not decide what an expired quote should do, and it does not protect a balance. Those need a row lock, an explicit consumed state that fails loudly, and a lock on the balance itself.
And when you evaluate any money API, ask for two things: the retry array, and what happens on a second confirm of a settled quote. The answers tell you more than the documentation will.
Test the failure paths first
The sandbox is stateful, priced off the live feed, and fires real signed webhooks stamped as sandbox, so you can fire two concurrent confirms at one quote and watch exactly one settle. Sandbox access is immediate.
Put this into practice on OroPocket
Buy 24K digital gold from ₹1. Earn Bitcoin cashback on every purchase.
GET THE APP
Join the Conversation
Be the first to share your thoughts.