The first serious bug most teams hit against a payments API is not a failed request. It is a successful one that the client never saw. A timeout fires, the connection drops, a container restarts mid flight, and your code does the reasonable thing: it tries again. If the server treats that second attempt as a new instruction, somebody just got charged twice.
This is not an edge case. On mobile networks it is a weekly occurrence at any real volume. So the question is not whether your integration will retry, it is whether the platform gives you a safe way to do it.
What an idempotency key actually promises
An idempotency key is a value you generate, attach to a request, and reuse for every retry of that same logical operation. The server stores the result against that key. If it sees the key again, it returns the original outcome instead of performing the work a second time.
Every purchase endpoint on the Ureh API accepts one:
{
"client_id": "URH-8F2C91A4D6B0E37F1A",
"biller": "MTN",
"recipient": "08012345678",
"amount": 500,
"idempotency_key": "5c2f9e2a-6b7a-4b31-9d3a-df2b6c9a4e10"
}
The promise is narrow and worth stating precisely. It means one key produces at most one transaction. It does not mean the request always succeeds, and it does not mean you can change the amount and reuse the key.
Generate the key where the intent is formed
The most common mistake is generating the key immediately before the HTTP call. That is too late. If the retry happens one layer up, in a job runner or a queue consumer, the new attempt generates a new key and the protection is gone.
Generate the key at the point where the business intent is created. When a user taps "buy airtime", that tap is one intent, and it deserves one key that travels with the job through every retry, redelivery and restart. A UUID stored alongside the order row is the usual shape.
Retries need a schedule, not a loop
A safe key makes retrying possible. It does not make an immediate retry loop a good idea. A tight loop against a struggling upstream is indistinguishable from an attack, and our rate limiter will treat it accordingly: limits apply per IP, per API key, per client and per endpoint.
Back off exponentially, add jitter so your whole fleet does not retry in lockstep, and cap the total attempts. Three attempts over about thirty seconds resolves the overwhelming majority of transient failures. Beyond that you are no longer handling a blip, you are queueing work for a system that is genuinely down, and it belongs in a dead letter queue where a human can look at it.
Reconcile what you cannot resolve
Some requests end genuinely unknown. The connection died after the server committed but before the response left. Retrying with the same key answers this for you: you get the original result back.
If you did not send a key, the only remaining tool is a status lookup by reference. That works, but it is a slower and less certain path, and it requires you to have stored a reference you may never have received. The key is the better answer, which is the argument for sending one on every purchase rather than only the ones you expect to matter.