Idempotency keys pointed the other way
A payment API's idempotency key and a gate's anti-replay check are the same primitive with opposite second answers. Seen as one mechanism, the design collapses to two questions.
A payment API and a ticket gate have the same problem and give opposite answers to it. Both receive a request carrying an identifier the client chose. Both must do something sensible when the same identifier arrives twice. The payment API returns the first response again, so a retried charge does not become two charges. The gate refuses, so a scanned ticket does not admit two people. Same table, same unique constraint, same first request. Only the second request differs, and the difference is a single branch. Once I saw that, the anti-replay design for the QR ticketing system stopped being a security feature and became an idempotency key pointed the other way.
One primitive, two second answers
Stripe's idempotent requests documentation describes the payment side. The client sends a key it generated; the server stores the key with the result of the first request; a repeat with the same key gets the stored result back instead of a second execution. Keys are pruned once they are at least 24 hours old, after which a reused key is treated as a new request. Adyen's API idempotency page does the same thing with a longer memory: keys are valid for 7 to 14 days after first submission.
The gate side is the mirror. The key is the ticket identifier, chosen by the server at issue time rather than by the client, but presented by the client at the gate. The first presentation records the spend. A repeat with the same identifier is refused, and the refusal cites the first-seen time and place. Nobody calls this an idempotency key, but the storage is identical: a key, a first-seen time, and a first result.
The reason the two systems answer differently is not that one is about money and one is about security. It is that they mean different things by "again". A retried payment request is the same intent arriving twice because a network was unreliable, and the right thing is to make the second arrival invisible. A rescanned ticket is a different intent, a second person trying to use one admission, and the right thing is to make the second arrival fail loudly. Echo or refuse. That is the only branch.
The mirror rule
So the rule I use now is this. For each endpoint that accepts a client-supplied identifier, decide whether a repeat is echoed or refused, write that decision down, and then store exactly the same three columns either way: the key, the time it was first seen, and the result of the first request. The branch is data, not code. The same table, the same insert, and one flag on the endpoint that says which way the second request goes.
INSERT INTO seen (key, first_seen, result)
VALUES ($1, now(), $2)
ON CONFLICT (key) DO NOTHING
RETURNING key;
If the insert returns a row, this is the first request: execute, store the result, reply. If it returns nothing, the key was seen before: read the stored row, then either echo its result or refuse with its first-seen time. The unique constraint does the work of deciding which request was first, under any concurrency, without a lock the application has to manage. Two requests that arrive in the same millisecond are serialised by the index, one wins, and the other reads the winner's row.
There is one subtlety the payment side handles and the gate side must copy. Between the insert and the stored result, there is a window where the first request has claimed the key but has not finished executing. A second request arriving in that window finds the row but no result. The payment API's answer is to reply with a conflict status and let the client retry after a moment; Stripe documents exactly this case. The gate's answer is the same: refuse with "in progress", which at a gate is a half-second wait, not a problem.
Two gates, 200 milliseconds apart
The concurrency case is what makes the unique constraint the right home for the decision, and it is worth drawing.
For a payment endpoint the diagram is identical except for the last arrow, which would carry the stored result to gate B instead of a refusal. That is the whole point. The infrastructure that makes retried charges safe is the infrastructure that makes replayed tickets fail, and a team that has built one has built the other.
What the key identifies
The second question is what the key should be, and the two sides answer it the same way once you ask it properly: the key identifies the intent, not the request. A payment client generates a fresh key per attempt at a charge, and reuses it across retries of that same attempt; a new charge gets a new key. If the client instead derived the key from the request body, two legitimately separate charges of the same amount would collide, and the second customer would silently get the first customer's receipt. That failure is quiet, which is what makes it expensive: nothing errors, one payment is simply missing, and the reconciliation happens weeks later in a spreadsheet.
The gate has it easier, because the intent is the admission and the identifier was minted by the server when the ticket was issued. But the same mistake is available: a gate that keys on the phone, or the person, or the booking rather than the ticket admits two tickets from one booking as one, or refuses a family of four on the second child. The key is the ticket, because the ticket is the unit of admission, and everything else is a lookup after the fact.
How long to remember
The final question is retention, and the mirror rule gives it a clean shape. An echoing endpoint remembers for as long as a client might reasonably retry, which is why Stripe's floor is a day and Adyen's is a week; beyond that, forgetting is safe because the retry that arrives after the window is, by then, a new intent. A refusing endpoint remembers for as long as a replay would still be harmful, and for a ticket that is the life of the event, because a screenshot does not expire on a schedule the attacker knows about. Forgetting early on a refusing endpoint reopens the replay. Remembering forever on an echoing endpoint is merely a storage bill.
Webhook receivers sit in the bottom-right cell for a reason worth stating. They refuse repeats, but only within the window their signature's timestamp allows, because outside the window the signature check already rejects the message. Their memory can be short because another mechanism takes over. A ticket has no such mechanism; the row is all there is, and that is why its memory is measured in the life of the event rather than in hours.
What this bought me
When I built the QR system, the anti-replay requirement arrived as a security feature with a security vocabulary: nonces, replay windows, freshness. Building it as an idempotency key pointed the other way meant it arrived as one table I already knew how to run, one unique constraint the database already knew how to enforce, and two decisions written next to the endpoint: refuse, and remember for the event. The same table now backs the echoing endpoints too, with the flag flipped. Two questions, one primitive, and no separate security subsystem to keep honest.
Get new posts by email
Occasional essays on engineering, AI, and building for the people technology leaves behind.
Subscribe with RSS