# Idempotency > Why every money endpoint is idempotent on your transaction_id, and how to write a retry loop that cannot double-debit. Source: https://casinoapipro.com/docs/idempotency Every endpoint that moves money is idempotent on an identifier you supply. Send the same `transaction_id` twice and the money moves once. This is the single most important contract in the API, and the one worth testing hardest. ## How it works A transaction is claimed by a unique database index on `(operator_id, transaction_id)`. The first request to arrive wins the claim and proceeds to your wallet. Any later request carrying the same id finds the existing record and returns its stored result — it never reaches your wallet a second time. - The guarantee is enforced by the database, not by application code, so it holds under concurrency. - It holds across processes and across restarts. - It is scoped to your operator account — your ids cannot collide with another operator's. ## Choosing an id Mint it yourself, make it unique, and make it _derivable_ — you must be able to produce the same id again when you retry. A UUID stored alongside the bet in your own database is the usual answer. ```js // Good: generated once, persisted with the bet, reused on every retry. const transactionId = bet.id; // your own primary key await placeBet({ transaction_id: transactionId, amount: "10.00" }); // Bad: a fresh id per attempt turns one retry into two debits. await placeBet({ transaction_id: crypto.randomUUID(), amount: "10.00" }); ``` ## What a replay returns A replayed request returns the original outcome with `200 OK` — the same balance, the same round, the same everything. It is indistinguishable from the first call except that nothing moved. ```json { "transaction_id": "bet_9f21c4", "status": "COMPLETED", "balance": "489.50", "currency": "GHS", "replayed": true } ``` ## Same id, different body Reusing an id with a different amount, player or currency is a bug on your side, and we treat it as one: the request is rejected with `409 IDEMPOTENCY_CONFLICT` and nothing moves. We will not guess which of the two you meant. ## Timeouts are the whole point > **A 504 is not a failure** > > If a call to your wallet times out, we do not know whether it applied. We leave the transaction `PENDING` and answer `504 WALLET_TIMEOUT`, which is marked retryable. Retry with the same id. The retry resolves the pending record — it does not create a second one. This is why a fresh id per attempt is dangerous: it converts a recoverable ambiguity into a double debit that only reconciliation will find. ```js async function settle(bet) { for (let attempt = 0; attempt < 5; attempt++) { try { // Same id every time — that is what makes this safe. return await api.post("/v1/transactions/bet", { transaction_id: bet.id, round_id: bet.roundId, player_id: bet.playerId, amount: bet.amount, currency: bet.currency, }); } catch (err) { if (!err.retryable) throw err; await sleep(2 ** attempt * 250); } } throw new Error("wallet did not settle; leave for reconciliation"); } ``` ## What is not idempotent Reads are naturally safe to repeat. Session creation is not idempotent — calling it twice mints two sessions, which is harmless but wasteful. See [Sessions](/docs/sessions).