Idempotency Keys: Preventing Duplicate Payments in the Philippines
This article is not about webhook delivery, and it is not about traffic spikes. For safely retrying a notification your server receives, read our webhook retry design for failed payment notifications piece instead. If your problem is many requests arriving at once and your API falling over, that is a throttling problem, covered in our piece on payment gateway API rate limiting and concurrency design. This article covers a narrower problem: the outbound call your own client makes to create a payment, and what to do when you genuinely do not know whether that call succeeded.
The Bug That Taught Me to Stop Trusting "Did It Go Through?"
I run the backend for a mid-sized e-commerce operation settling through SMARTPAY using GCash and QRPH as the main collection channels. During a flash promotion, our order volume spiked and our outbound HTTP client to the payment API started timing out intermittently — not because SMARTPAY's side was failing, but because our own connection pool was saturated and requests sat past our client timeout before a response came back.
Our checkout code did the obvious, naive thing: if the create-payment call throws a timeout error, retry it. That is reasonable advice almost everywhere else. It is catastrophic advice for a payment-creation call, because a timeout on the client side tells you nothing about what happened on the server side. The request may never have reached the gateway. It may have reached the gateway and failed. Or — the dangerous case — it may have been processed completely, with the response simply never making it back before our client gave up waiting. In that third case, our retry logic fired a second, fully independent request moments later. The gateway had no way of knowing the second request was "the same" payment as the first, because nothing linked them. Two charges went through for one order. We found out from a support ticket, not from our own monitoring — its own lesson.
The fix was not "add more retries" or "make the timeout longer." It was to stop asking the server to guess whether two requests were the same logical operation, and instead tell it explicitly, every time, with an idempotency key.
What Idempotency Keys Actually Prevent (and What They Don't)
An idempotency key is a unique token the client generates for one logical operation — in our case, one checkout attempt for one order — and attaches to the request, usually in a header. The server's job: if it sees a request carrying a key it has already processed, it must not process the underlying operation again, and instead returns the same result it returned the first time.
This solves exactly one problem: retry-under-uncertainty. It does not solve:
- Webhook notifications arriving more than once — that's a receiving-side dedup problem, with its own failure modes around delivery order and signature checks.
- Too many requests overwhelming your API — that's about throttling and backpressure, not about whether a single request got double-processed.
- A user clicking "pay" twice on purpose across two separate tabs — a UX/business-logic problem, not something a single request's key fixes by itself, unless the key is also tied to order-level state.
Keeping these separate matters, because the fix for each differs — conflating them is how teams end up with a retry layer that "handles idempotency" but still produces duplicate charges under load.
How an Idempotency Key Works, Step by Step
The mechanism is not complicated, which is part of why it's easy to underestimate:
- Before the client makes its first attempt at a logical operation, it generates a unique key and holds onto it.
- Every request related to that same logical attempt — the original call and any retries of it — carries that exact same key, usually as an
Idempotency-Keyheader. - When the server receives a request with a key, it checks whether it has seen that key before.
- If it has not seen the key, it processes the payment normally, records the key alongside the outcome, and returns the result.
- If it has seen the key and the operation already completed, it returns the stored result directly — no second charge, no second call to the underlying channel (GCash, QRPH, or whichever rail is in play).
- If it has seen the key and the operation is still in progress (a genuine race, not a retry after timeout), it returns a conflict response rather than racing two attempts against each other.
The part people skip is step 6. Most implementations only handle the "already finished" case and forget that two requests with the same key can arrive almost simultaneously — one from the original call still in flight, one from a retry fired slightly too early. Without a lock on the key while processing is underway, you can still get two charges, just through a narrower window.
Generating Idempotency Keys: The Part Most Teams Get Wrong
The single most common mistake is generating a new key on every retry instead of reusing the key from the original attempt. Call uuid() inside your retry loop and you have defeated the entire mechanism — the server sees three "first-time" requests instead of one attempt and two retries, and happily processes all three. The key has to be generated once, before the first network call, and persisted somewhere the retry logic can read it back from (a local order record, a queue message, a client-side cache) so every retry of that attempt reuses it.
The second common mistake is scope: using something coarse like a customer ID, which collides across unrelated orders. The key should map one-to-one with a single logical payment attempt — your own internal order reference is a reasonable seed, combined with enough randomness that nobody can predict or forge another merchant's key.
Here is roughly what our client-side retry logic looks like now. The key point is that the key is generated and saved before the first attempt, outside the retry loop, and the loop reuses it on every subsequent try:
// client.js — generate ONE idempotency key per logical payment attempt,
// then reuse that same key on every retry of that attempt
const crypto = require("crypto");
const axios = require("axios");
async function createPayment(order) {
// Generate the key once, outside the retry loop, and persist it
// alongside the order record BEFORE the first network call is made.
const idempotencyKey = order.idempotencyKey || crypto.randomUUID();
order.idempotencyKey = idempotencyKey; // save to local DB/order record now
const maxAttempts = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const res = await axios.post(
"https://api.paysmartph.com/v1/payments",
{
merchant_order_no: order.orderNo,
amount: order.amount,
currency: order.currency,
channel: "gcash",
},
{
headers: {
"Idempotency-Key": idempotencyKey,
"Content-Type": "application/json",
},
timeout: 8000,
}
);
return res.data;
} catch (err) {
const isTimeoutOrNetworkError =
err.code === "ECONNABORTED" || !err.response;
if (isTimeoutOrNetworkError && attempt < maxAttempts) {
await new Promise((r) => setTimeout(r, attempt * 500));
continue; // retry with the SAME idempotencyKey, never a new one
}
throw err;
}
}
}
Notice what is not in this code: no logic guessing whether the previous attempt succeeded by calling a separate status endpoint first. That guessing game is exactly what idempotency keys let you stop playing — you retry blindly and let the server sort out new work from a repeat.
Server-Side Dedup Logic: What Should Happen on the Second Request
On the server side, the logic needs to cover four cases: new key, completed key with a matching payload, completed key with a different payload (reject it — this usually signals a client bug), and a key that's still mid-flight. A simplified version behind a payment-create endpoint:
# server dedup logic — simplified, runs inside the payment-create handler
def handle_create_payment(request):
key = request.headers.get("Idempotency-Key")
if not key:
return error_response(400, "Idempotency-Key header is required")
payload_hash = sha256(request.body)
existing = idempotency_store.get(key) # backed by Redis or a dedicated table
if existing:
if existing.payload_hash != payload_hash:
# same key, different request body — reject, never silently process
return error_response(409, "Idempotency key reused with a different payload")
if existing.status == "processing":
# an earlier attempt with this exact key is still in flight
return error_response(409, "A request with this key is already processing")
# already completed — return the ORIGINAL result, do not charge again
return existing.response_body, existing.response_status
# first time we've seen this key — lock it before touching the channel
lock_acquired = idempotency_store.set_if_not_exists(
key, {"status": "processing", "payload_hash": payload_hash}, ttl_seconds=86400
)
if not lock_acquired:
return error_response(409, "A concurrent request with this key is already processing")
result = charge_via_channel(request.body) # actual call to GCash/QRPH processor
idempotency_store.update(key, {
"status": "completed",
"response_body": result.body,
"response_status": result.status,
})
return result.body, result.status
The set_if_not_exists step closes the race window from the earlier section — it has to be atomic at the storage layer (a conditional write in Redis, or a unique constraint in a table), not a read-then-write in application code, or two near-simultaneous requests can both pass the check before either writes its lock.
Because SMARTPAY operates under BSP OPS and EMI authorization rather than as an unlicensed intermediary, our dedup and reconciliation logic on the processing side is built to the same standard we expect merchants to hold their own client-side idempotency logic to — a duplicate charge is a reconciliation and compliance headache for a licensed operator, not just a support ticket.
Idempotency Key Expiry: How Long Is Long Enough to Remember a Key?
Storing every key forever is not free, and it is not necessary. The practical question is how long a legitimate retry could plausibly arrive after the original attempt. A mobile client that loses connectivity mid-checkout and retries once the user reopens the app later is realistic; a retry arriving weeks later usually means something else is wrong and should not be treated as a safe duplicate anymore.
Most teams land somewhere between a day and a few days of retention, stored in something with a built-in expiry (a Redis TTL, or a scheduled cleanup job against a table), and treat an expired key as "unknown" — if the same key resurfaces after expiry, it is processed as new rather than rejected outright. The goal of the key is to cover the uncertainty window right after a timeout, not to serve as a permanent ledger. Your actual transaction records and reconciliation logs remain the long-term source of truth; the key store is a short-lived safety net on top of them.
Idempotency Key Patterns: Right vs Wrong
Most of the bugs I've seen — in our own code and others' — trace back to a handful of repeated mistakes. Laid out side by side:
| Scenario | Wrong pattern | Correct pattern | Why it matters |
|---|---|---|---|
| Key generation timing | Generate a new key inside the retry loop, every attempt | Generate one key before the first attempt, reuse it on every retry | A fresh key per retry looks like three separate payments to the server, not one retried attempt |
| Key scope | One key reused across multiple different orders | One key per logical payment attempt, tied to a single order | Reusing a key across orders causes the server to return the wrong order's result for a new order |
| Key format | Sequential counter or predictable string | UUID v4 or another cryptographically random, high-entropy string | Predictable keys can be guessed or collide; randomness avoids accidental or adversarial reuse |
| Same key, different payload | Server silently processes the new payload as if nothing changed | Server rejects with a conflict error, flags for investigation | A changed payload under the same key usually signals a client bug, not a legitimate retry |
| Concurrent requests, same key | Both requests read "not found" and both proceed to charge | Second request blocked by an atomic lock while the first is still processing | Read-then-write checks without a lock leave a race window wide enough to double-charge |
| Key persistence | Key only held in memory inside the retry loop | Key persisted to the order record before the first network call | If the process crashes between attempts, an in-memory-only key is lost and the next retry generates a new one |
If you only fix one row, fix the first one. In nearly every double-charge investigation I've been part of, the root cause traces back to a key getting regenerated somewhere in the retry path instead of carried through from the original attempt.
Testing This Before It Costs You a Refund
Idempotency logic looks correct in a demo and fails under real network conditions, because a demo rarely simulates a timeout that happens after the server has committed the charge but before the response leaves it. To test it properly, send a create-payment request, simulate the response being dropped past your client timeout, then fire a genuine retry with the same key and confirm you get back the original result instead of a second charge. Run this kind of scenario in a sandbox, not production, and treat it as its own test case rather than folding it into a happy-path integration test.
Frequently Asked Questions
Is an idempotency key the same thing as my order ID or transaction reference?
No, though they're related. Your order ID identifies the business object, which can persist across many interactions. The idempotency key identifies one specific attempt to create a payment for that order. Many teams derive the key from the order ID plus some randomness, so a genuinely new checkout attempt on the same order can get a fresh key instead of being blocked by the old one.
What should happen if the same key arrives with a different request body?
Reject it. A mismatched payload under a previously used key almost never represents a legitimate retry — it usually means a client-side bug mutated the request between attempts. Silently processing the new payload under an old key hides the bug and produces confusing, hard-to-reconcile results later.
Do GET or read-only requests need idempotency keys?
Not in the same sense. Requests that only read data are naturally idempotent already — calling them twice creates no second side effect. Idempotency keys matter for operations that create or mutate something with a real-world consequence, like creating a payment, where calling the same logical operation twice would otherwise produce two side effects instead of one.
What happens if two requests with the same key arrive at virtually the same moment?
This is the race condition covered in the server-side section above. The fix is an atomic "set if not already present" lock the moment the key is first seen, so the second request backs off with a conflict response instead of proceeding in parallel. A plain read-then-write check in application code leaves a timing window where both requests pass before either records the key.
How is this different from the webhook retry problem?
Webhook retries are about the notification the gateway sends to your server after a transaction resolves, and whether that notification arrives more than once. Idempotency keys for payment creation are about the request your own client sends outward to create that transaction in the first place. They're complementary — a mature integration needs dedup logic on both sides — but they protect against different failure points.
Does an idempotency key help if my API is just overloaded and slow, not actually failing?
It helps with the symptom — duplicate charges caused by retries during slowness — but it doesn't fix the underlying capacity problem. If your real issue is sustained high concurrency causing timeouts in normal operation, you also need proper throttling and backpressure handling, covered separately in our API rate limiting and concurrency piece. Idempotency keys are a safety net for when a retry happens, not a substitute for not needing to retry.
Can I reuse an idempotency key after it has expired from the server's store?
Generally yes — this is expected given how expiry is meant to work. Once a key ages out, the server treats a request carrying it as new, since it has no record of having seen it. That's fine as long as the expiry window comfortably covers any realistic retry delay; don't rely on key expiry as a long-term duplicate-prevention mechanism for requests that might resurface far outside that window. For that, add business-level checks, like order status.
Should I trust my own idempotency handling, or rely on the gateway to catch duplicates too?
Both, deliberately. Client-side key generation prevents your own retries from creating duplicates. Server-side dedup and reconciliation is a second layer that catches what your client-side logic missed — a bug, a race condition, a key lost between restarts. Good payment API error handling practices and solid signature verification on every request round out a defensive integration; idempotency keys are one layer among several.
Idempotency keys are a small piece of protocol discipline that pays for itself the first time a network blip would otherwise have cost you a double charge and a refund conversation. If you're integrating payment creation against SMARTPAY's API and want a second pair of eyes on your retry and key-generation logic — including how it interacts with our BSP OPS and EMI licensing and transaction-record handling — reach out directly: @LouisSMARTPAY on Telegram, or by email at [email protected].