Get started
Idempotency
Why every money endpoint is idempotent on your transaction_id, and how to write a retry loop that cannot double-debit.
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.
// 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.
{
"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
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.
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.