Skip to content
Sente
All articles
  • engineering
  • idempotency
  • distributed-systems

Idempotency Keys: Why 'Send Once' Is Harder Than It Sounds

By Sente Team · 2 October 2026 · 6 min read

Every payment system promises the same thing: when you press Send, the money moves exactly once. Not zero times, not twice. Once.

That promise is much harder to keep than it sounds, and the reason has nothing to do with money. It comes from a basic property of networks: when you send a request and don't get an answer, you cannot tell whether the request was lost on the way there, or the answer was lost on the way back.

This article explains the problem, the standard tool for solving it — the idempotency key — and the details that make the difference between an idempotency scheme that works in a demo and one that holds up when real money is on the line.

The two-generals problem, at the checkout

Picture a customer paying a merchant from the Sente app on a crowded bus. The app sends the payment request. On the server, everything goes right: the payment is checked, the ledger is updated, the merchant is credited. The server sends back "success".

Then the bus goes into a tunnel. The response never arrives.

From the app's point of view, the request simply timed out. Did the payment happen? It has no way to know. It has two choices:

  1. Give up and show an error. If the payment did go through, the customer believes it failed, may pay again by another method, and has now paid twice.
  2. Retry. If the first payment went through, a naive server processes the retry as a brand-new payment. The customer is charged twice.

Neither is acceptable. What we want is a third option: retry safely, so that if the first attempt succeeded, the retry returns the same result instead of doing it again.

That property is called idempotency: doing something twice has the same effect as doing it once.

The idempotency key

The solution is simple to describe. Before sending a payment, the client generates a unique key — at Sente, a random UUID version 4 — and sends it with the request in an Idempotency-Key header. If it needs to retry, it sends the same key again.

POST /v1/checkout-sessions
Idempotency-Key: 6f1c2a1e-6c1a-4f43-9d55-6d3f0e7b2c91
Content-Type: application/json

The server remembers what it did for each key. If a request arrives with a key it has seen before, it does not process it again. It returns the stored result of the first request.

Three rules make the key work:

  • The client generates it, not the server, because only the client knows that two requests are the same intent.
  • One key per intent. A new payment gets a new key. A retry of the same payment reuses the old one.
  • The key is generated before the first attempt and kept until the client knows the outcome — in memory, or better, on disk so it survives the app being closed.

Where it gets hard

That is the whole idea. The difficulty is entirely in the details, and each detail corresponds to a real way of losing money.

Concurrent duplicates

A retry doesn't always arrive after the first request has finished. A client with an aggressive timeout, or a load balancer that retries on its own, can deliver two copies of the same request at the same moment, to two different servers. If both check "have I seen this key?" before either has recorded it, both answer "no" — and both process the payment.

At Sente, the first thing a server does with an idempotency key is take a distributed lock on it in Redis. Only one request at a time can hold the lock for a given key. The second copy cannot run alongside the first: once the first has finished, any copy of it gets the stored result back instead of a second payment.

Remembering the result atomically

The server has to store the result of a request in a way that can never disagree with what actually happened. Suppose it processes the payment, commits it, and then crashes before writing "key X → success". The retry arrives, finds nothing recorded, and charges again.

So the result is written to a Postgres idempotency store in the same database transaction as the payment itself. Either both are committed — the payment and the record that it happened — or neither is. There is no window in which the money has moved but the key has no result.

The same key with a different request

What should the server do if a request arrives with a key it has seen, but a different body — a different amount, or a different recipient?

It could ignore the new body and return the old result. But that hides a bug: the client may have reused a key by mistake, or retried with data that changed in between. Silently returning a result for a different payment is dangerous.

At Sente, the server stores a fingerprint of the original request with the key. If a later request reuses the key with a different body, it is rejected with 409 IDEMPOTENCY_CONFLICT. The client learns immediately that something is wrong, instead of believing a payment happened that didn't.

How long to remember

Keys can't be kept forever, but they must be kept long enough to cover any realistic retry: a phone that was offline overnight, a batch job that resumes the next morning. Sente keeps idempotency results for 24 hours. A key reused after that is treated as new — which is why clients should never reuse keys deliberately, only for genuine retries.

Keys all the way down

A payment in a real system is rarely one step. A transfer to a mobile-money wallet at Sente runs as a small saga: place a hold on the sender's funds, call the mobile-money provider, then either post the payment or release the hold. Each step can fail and be retried independently — and each must also happen exactly once.

The provider call is the most delicate. If we call the provider, it pays out, and our process crashes before recording the response, a retry must not trigger a second payout. So every step gets its own idempotency key, derived deterministically from the original payment's key and the name of the step. The retry of "call the provider" always carries the same key the first attempt did, and that key is passed to the provider wherever its API accepts one, so the provider can recognise the repeat too.

The result is exactly-once behaviour not just at the edge of the system, but at every hop where money can move.

What clients should do

If you are building on the Sente API, the rules are short:

  1. Generate a fresh UUIDv4 for every new payment or other state-changing request.
  2. Store it with your record of the intent — the order, the invoice, the payout — before you send the first request.
  3. On a timeout, network error or 5xx response, retry with the same key, with exponential backoff.
  4. Treat 409 IDEMPOTENCY_CONFLICT as a bug to investigate, never as something to retry blindly.
  5. Never generate a new key for a retry. That is the one mistake that turns a safe retry into a double charge.

The developer guide shows the header in every language we have examples for, and the platform rejects any money-moving request without a valid key — a missing key is a 422, not a payment.

Why we care this much

Idempotency is one of those problems that sounds trivial until you are responsible for other people's money. Then it becomes the most important thirty lines of code in your system.

On a stable office network, you can go a long time without noticing that a payment system lacks it. On mobile networks across Africa — where timeouts are common and people reasonably press the button again when nothing seems to happen — you will notice in the first week. We would rather our customers never have to.