> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zerodash.studio/llms.txt
> Use this file to discover all available pages before exploring further.

# Idempotency

> The same transaction can arrive twice. It must only ever apply once.

<Card title="The requirement" icon="circle-exclamation" horizontal>
  All transaction endpoints must be **idempotent**. If the same `transactionId` arrives again, do not apply the operation a second time — return `200 OK` with valid data.
</Card>

This is not a theoretical safeguard. Duplicates are a normal part of operating the integration: a response times out on the network while your server processed it fine, and the retry lands on a transaction you already committed.

```mermaid theme={null}
sequenceDiagram
    participant Z as Zero-Dash
    participant O as Your wallet

    Z->>O: POST /debit (transactionId: tx-1112)
    O->>O: Balance 100 → 95, committed
    O--xZ: 200 OK (lost in transit)
    Note over Z: No response. Retry.
    Z->>O: POST /debit (transactionId: tx-1112)
    O->>O: Already seen — do not reapply
    O-->>Z: 200 OK, balance 95
    Note over Z,O: Correct. Without idempotency: balance 90.
```

## The key

`transactionId` is your idempotency key. It is generated by Zero-Dash, unique per transaction, and present on every money-moving callback.

| Callback    | Idempotency key | Also carries                            |
| ----------- | --------------- | --------------------------------------- |
| `/debit`    | `transactionId` | `gameRoundId`                           |
| `/credit`   | `transactionId` | `referenceTransactionId`, `gameRoundId` |
| `/rollback` | `transactionId` | `referenceTransactionId`, `gameRoundId` |

<Warning>
  Key on `transactionId`, not on `gameRoundId`. A single round produces **at least two** transactions — the debit and its settlement — and they share a round ID. Deduplicating by round would drop the settlement.
</Warning>

## Implementing it

The database does the work. A unique constraint plus one atomic transaction is both correct and fast; application-level "check then write" is a race waiting to happen.

<Steps>
  <Step title="Constrain the ledger" icon="database">
    ```sql theme={null}
    CREATE TABLE zd_transactions (
      transaction_id   TEXT PRIMARY KEY,          -- Zero-Dash transactionId
      account_id       TEXT NOT NULL,
      game_round_id    TEXT NOT NULL,
      kind             TEXT NOT NULL,             -- debit | credit | rollback
      amount           NUMERIC(30, 8) NOT NULL,
      currency         TEXT NOT NULL,
      operator_tx_id   TEXT NOT NULL,
      balance_after    NUMERIC(30, 8) NOT NULL,
      created_at       TIMESTAMPTZ NOT NULL DEFAULT now()
    );

    CREATE INDEX ON zd_transactions (account_id, game_round_id);
    ```

    `transaction_id` as the primary key makes a duplicate physically impossible.
  </Step>

  <Step title="Insert and update atomically" icon="lock">
    ```sql theme={null}
    BEGIN;
      INSERT INTO zd_transactions (…) VALUES (…);  -- fails on duplicate
      UPDATE wallets
         SET balance = balance - :stake
       WHERE account_id = :account_id
         AND balance >= :stake;                    -- 0 rows = insufficient funds
    COMMIT;
    ```

    One transaction. If either statement fails, neither takes effect.
  </Step>

  <Step title="Replay the stored result on conflict" icon="rotate-left">
    ```sql theme={null}
    SELECT operator_tx_id, balance_after, currency, created_at
      FROM zd_transactions
     WHERE transaction_id = :transaction_id;
    ```

    Return that row as a normal `200 OK`. Same shape, same values, no second movement.
  </Step>
</Steps>

<Warning>
  Return the balance **stored with the original transaction**, not the player's current balance. Later rounds may have changed it, and replaying a duplicate should reproduce the original answer.
</Warning>

## A reference handler

<CodeGroup>
  ```javascript Node.js theme={null}
  async function handleDebit(req) {
    const { transactionId, playerId, gameRoundId, stake, reason } = req.body;

    // 1. Replay if we already processed this transaction.
    const existing = await db.transactions.findByPk(transactionId);
    if (existing) return toResponse(existing);

    // 2. Free rounds move no money, but still create a transaction.
    const amount = reason === 'freeround' ? ZERO : decimal(stake.amount);

    try {
      // 3. One atomic unit: insert the row, move the balance.
      const tx = await db.transaction(async (t) => {
        const wallet = await debitWallet(playerId, amount, { transaction: t });
        return db.transactions.create(
          {
            transactionId,
            accountId: playerId,
            gameRoundId,
            kind: 'debit',
            amount,
            currency: stake.currency,
            operatorTxId: newOperatorTxId(),
            balanceAfter: wallet.balance,
          },
          { transaction: t },
        );
      });
      return toResponse(tx);
    } catch (err) {
      // 4. Lost the race with a concurrent duplicate — replay its result.
      if (err instanceof UniqueConstraintError) {
        return toResponse(await db.transactions.findByPk(transactionId));
      }
      if (err instanceof InsufficientFunds) throw new HttpError(409, 'insufficient balance');
      throw err; // 422 or 500 — see /wallet/errors-and-retries
    }
  }
  ```

  ```go Go theme={null}
  func (h *Wallet) Debit(ctx context.Context, req DebitRequest) (TxResponse, error) {
  	// 1. Replay if we already processed this transaction.
  	if tx, err := h.repo.FindTransaction(ctx, req.TransactionID); err == nil {
  		return toResponse(tx), nil
  	} else if !errors.Is(err, ErrNotFound) {
  		return TxResponse{}, err
  	}

  	// 2. Free rounds move no money, but still create a transaction.
  	amount := req.Stake.Amount
  	if req.Reason == ReasonFreeRound {
  		amount = decimal.Zero
  	}

  	// 3. One atomic unit: insert the row, move the balance.
  	tx, err := h.repo.ApplyDebit(ctx, req.TransactionID, req.PlayerID, amount, req.Stake.Currency)
  	switch {
  	case errors.Is(err, ErrDuplicateTransaction):
  		// 4. Lost the race with a concurrent duplicate — replay its result.
  		stored, ferr := h.repo.FindTransaction(ctx, req.TransactionID)
  		if ferr != nil {
  			return TxResponse{}, ferr
  		}
  		return toResponse(stored), nil
  	case errors.Is(err, ErrInsufficientFunds):
  		return TxResponse{}, ErrInsufficientFunds // → 409
  	case err != nil:
  		return TxResponse{}, err // → 422 or 500
  	}

  	return toResponse(tx), nil
  }
  ```

  ```java Java theme={null}
  public final class DebitHandler {

      public TxResponse handleDebit(DebitRequest req) throws Exception {
          // 1. Replay if we already processed this transaction.
          Tx existing = repo.findTransaction(req.transactionId);
          if (existing != null) return toResponse(existing);

          // 2. Free rounds move no money, but still create a transaction.
          BigDecimal amount = req.stake.amount;
          if (req.reason == Reason.FREE_ROUND) {
              amount = BigDecimal.ZERO;
          }

          // 3. One atomic unit: insert the row, move the balance.
          try {
              Tx tx = repo.applyDebit(req.transactionId, req.playerId, amount, req.stake.currency);
              return toResponse(tx);
          } catch (DuplicateTransactionException e) {
              // 4. Lost the race with a concurrent duplicate — replay its result.
              return toResponse(repo.findTransaction(req.transactionId));
          } catch (InsufficientFundsException e) {
              throw new HttpException(409, "insufficient balance"); // → 409
          }
      }
  }
  ```

  ```php PHP theme={null}
  function handleDebit(array $req): array {
      // 1. Replay if we already processed this transaction.
      $existing = $db->transactions->find($req['transactionId']);
      if ($existing !== null) return toResponse($existing);

      // 2. Free rounds move no money, but still create a transaction.
      $amount = $req['reason'] === 'freeround' ? '0' : $req['stake']['amount'];

      // 3. One atomic unit: insert the row, move the balance.
      try {
          $tx = $db->applyDebit(
              $req['transactionId'],
              $req['playerId'],
              $amount,
              $req['stake']['currency']
          );
          return toResponse($tx);
      } catch (DuplicateTransactionException $e) {
          // 4. Lost the race with a concurrent duplicate — replay its result.
          return toResponse($db->transactions->find($req['transactionId']));
      } catch (InsufficientFundsException $e) {
          throw new HttpError(409, 'insufficient balance'); // → 409
      }
  }
  ```
</CodeGroup>

## Idempotency and rollback

A rollback is only valid against a bet that exists.

| Situation                                              | Response                           |
| ------------------------------------------------------ | ---------------------------------- |
| Bet exists, not yet reverted                           | Return the stake, `200 OK`         |
| Bet exists, already reverted with this `transactionId` | Replay the stored result, `200 OK` |
| Bet was never created                                  | `404` — nothing to revert          |

<Warning>
  Never let a rollback create a credit out of thin air. If `referenceTransactionId` does not exist in your ledger, return `404`. Blindly crediting a stake for an unknown bet is how a duplicate-delivery bug becomes a money leak.
</Warning>

## Testing it

<AccordionGroup>
  <Accordion title="Same request twice in a row" icon="repeat">
    Send an identical debit twice. The balance must move once, and both responses must be identical `200 OK`s.
  </Accordion>

  <Accordion title="Same request concurrently" icon="bolt">
    Fire both at the same instant, on different connections. This is what the unique constraint is for — an application-level check-then-write will apply both.
  </Accordion>

  <Accordion title="Duplicate after a later round" icon="clock-rotate-left">
    Debit, settle, play another round, then replay the first debit. The response must carry the **original** `balanceAfter`, not the current balance.
  </Accordion>

  <Accordion title="Duplicate credit for a zero payout" icon="circle-half-stroke">
    A losing round credits `0`. Replaying it must still be a no-op and still return `200 OK`.
  </Accordion>
</AccordionGroup>
