Integrations
Duplicate provider callbacks: designing an idempotent seamless wallet
Game providers retry callbacks, reorder them and send rollbacks for bets you never saw. How to build a seamless wallet that applies every transaction exactly once.
- Author
- Gamicy Engineering
- Published
- Reading time
- 3 min
In a seamless wallet integration the player's balance stays in your platform. The game provider calls your API to debit a bet, credit a win, or roll back a transaction. You don't control when those calls arrive, how many times, or in what order.
Providers retry when they don't receive a response in time. That's correct behaviour on their side — but if your system processed the first call and only the response was lost, the retry is a duplicate. Applied twice, it's a double debit or, worse, a double win.
What actually arrives at your endpoint
- Retries of a bet or win you already processed, because your response timed out.
- Concurrent duplicates — the same callback arriving twice at the same time on two different servers.
- Rollbacks for unknown transactions, when the provider's bet request never reached you but its rollback did.
- Wins for rounds you have no bet for, for example free rounds or provider-side promotions.
- Out-of-order delivery, such as a win arriving before the bet it settles.
The provider's transaction ID is your idempotency key
Every provider sends a unique transaction identifier with each wallet call. Scoped by provider, that identifier is the natural idempotency key. Store it under a unique constraint, in the same database transaction as the balance change and the ledger entry.
CREATE TABLE provider_transactions (
provider TEXT NOT NULL,
provider_tx_id TEXT NOT NULL,
kind TEXT NOT NULL, -- bet | win | rollback
round_id TEXT,
amount_minor NUMERIC(78) NOT NULL,
currency TEXT NOT NULL,
response JSONB NOT NULL, -- exactly what we returned the first time
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (provider, provider_tx_id)
);When a duplicate arrives, the insert fails on the primary key. Instead of an error, the handler loads the stored row and returns the original response. To the provider, the retry looks exactly like the first call succeeded — which it did.
async function handleBet(req: BetRequest): Promise<WalletResponse> {
return db.transaction(async (tx) => {
const existing = await tx.providerTx.find(req.provider, req.txId);
if (existing) return existing.response; // retry: replay
if (await tx.rollbacks.exists(req.provider, req.txId)) {
return reject("TRANSACTION_ROLLED_BACK"); // rollback arrived first
}
const balance = await tx.balances.debitIfSufficient(req.playerId, req.amount);
if (!balance) return reject("INSUFFICIENT_FUNDS");
const response = ok(balance);
await tx.ledger.post(betEntries(req));
await tx.providerTx.insert({ ...req, kind: "bet", response }); // unique key
return response;
});
// A concurrent duplicate fails on the unique key, the transaction rolls back,
// and the handler retries once — now taking the "replay" branch.
}Rollbacks need tombstones
If a rollback arrives for a bet you never saw, record the rollback anyway and return success. That record acts as a tombstone: when the original bet arrives late, the handler sees that it has already been rolled back and rejects it. Without the tombstone, the late bet would debit a player for a round the provider considers cancelled.
Follow each provider's specification precisely
Providers differ in the details: which error codes trigger a retry, whether a win without a bet is valid, whether rollbacks can be partial, how long they retry for, and how signatures are computed. Those rules belong in a provider-specific adapter, with the idempotent core shared across all of them.
Reconcile against provider reports
Providers publish daily or per-period reports of rounds and transactions. Compare them with your provider_transactions table: totals per currency, counts per kind, and any transaction present on one side only. A daily diff that is always empty is the strongest evidence your wallet is correct.
This is the core of our game provider integration work, and part of every casino platform we build.
Related services
- Game provider integrations
Aggregator and direct provider integrations with seamless wallet callbacks that stay consistent under retries.
- Casino platform development
Player accounts, back office, bonus logic and reporting — built as one system rather than a pile of plugins.