Name the operation, not the attempt
Give every logical operation a name, and you have the idempotency key pattern.
Your client generates a unique identifier and sends it with the request, conventionally in a header. That key belongs to the operation, not to the attempt: the first try and its retry carry the same key, and that is exactly what lets your server connect them.
Mint a fresh key for a deliberate second order, though, because your user really does want two.
Follow four steps on the server: check, reserve, execute, record. Look the key up, and if a stored result exists, return it without doing any work at all. If not, record the key, run the operation, then store the response against that key.
Copy the design one payment company popularised, keeping keys for 24 hours, long enough to cover any sane retry horizon.
Make the check-and-reserve step atomic, the part people get wrong. A set-if-absent, or a uniqueness constraint. Two concurrent duplicates will otherwise both pass the check and both execute, and the one that loses the race should either wait for the winner's result or refuse outright.
Where the key must live
Watch for the subtler consistency trap. If your key lives in a cache while the operation commits to your database, a crash between the two leaves them disagreeing: the cache says done, the database says nothing happened, and the legitimate retry gets swallowed.
Put the key in the same database as the operation for anything involving money, inside the same transaction, on a unique index. Your reservation and your work then commit or roll back together.
Budget the overhead honestly: one extra lookup and one extra write per request. For anything that moves money or changes state that matters, that is not a cost worth debating.
Worked example
After the double-charge weekend, Grace gets two days to fix the charge endpoint. She skips the cache entirely and adds a charge_requests table in the same relational database, with a unique index on idempotency_key and a column holding the serialized response. The handler inserts the key and the charge row in one transaction; on a unique violation it reads and returns the stored response instead. The mobile team ships a client that generates a UUID per checkout and reuses it across retries. Load tests fire the same request 50 times concurrently: one charge row, 49 replayed responses, every time. In the first full month duplicate charges go from 1,400 in a bad weekend to zero, and the whole change is about 60 lines.