# Wallet API

Your server implements five endpoints under one base URL. We call them,
signed, for every session and round. Balances never leave your platform.

| Endpoint                | When we call it                                          | You return                          |
| ----------------------- | -------------------------------------------------------- | ----------------------------------- |
| `POST /authenticate`    | The game client loads                                    | The player, balance and client seed |
| `POST /debit`           | A spin, before the result is served                      | The balance after the stake         |
| `POST /credit`          | The round ends                                           | The payout and the new balance      |
| `POST /credit-rational` | Instead of `credit`, for exact rational payouts (opt-in) | The payout and the new balance      |
| `POST /rollback`        | A debit that never became a round                        | The balance after the refund        |

## Verify every request

Each request carries two headers:

- `x-munja-timestamp`: milliseconds since the epoch
- `x-munja-signature`: `hex(HMAC-SHA256(secret, "<timestamp>.<raw body>"))`

The secret is your wallet signing secret, shown once when the wallet is
configured. Verify over the **raw body bytes**, before parsing. Refuse a
timestamp more than five minutes away from your clock.

```ts
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verify(
  secret: string,
  timestamp: string,
  rawBody: string,
  signature: string,
) {
  if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60_000) return false;
  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(signature, 'hex');
  return a.length === b.length && timingSafeEqual(a, b);
}
```

## Amounts are micro-units

`1,000,000` is one unit of the session currency: `2,500,000` USD is $2.50.
Amounts are integers. We never compute on them.

## Settlement: debit, credit, rollback

A round is a small state machine on your side, keyed by `roundId`:

- `debited → credited`: the normal path
- `debited → rolled back`: the failure path
- nothing else: a credit after a rollback, or a rollback after a credit, is a
  refusal

**`debit`** takes `amount` and `currency`. Refuse with `ERR_IPB` when the
balance does not cover it.

**`credit`** sends no amount. It sends `payoutMultiplier` (hundredths of the
**base** stake) and `costX100` (the mode's cost in hundredths). You compute:

```text
payout = debited amount × payoutMultiplier / costX100   (floored)
```

The debited amount is the one you recorded under the same `roundId`. For a
bonus buy with cost 100× the debit is 100 base stakes, and this expression
pays the win on the base stake, as the game shows it.

**`rollback`** refunds the full debit. Answer `ERR_BNF` when you have no debit
for that `roundId`.

## Idempotency

Every call of a round carries the same `roundId`. A repeated call must return
the result you already settled, never move money twice. A unique constraint
on `(roundId, kind)` in your ledger is the simplest way to guarantee it.

## Refusals

Answer a refusal with a non-2xx status and `{ "code": "ERR_…", "message": "…" }`:

| Code      | Meaning                                         |
| --------- | ----------------------------------------------- |
| `ERR_IPB` | Insufficient player balance                     |
| `ERR_IS`  | Invalid session token, or the session timed out |
| `ERR_ATE` | Authentication failed, or the token expired     |
| `ERR_GLE` | Gambling limits exceeded                        |
| `ERR_BNF` | Bet not found                                   |
| `ERR_BE`  | The player already has an active bet            |
| `ERR_UE`  | General error; a rollback is expected           |
| `ERR_GE`  | General error; no rollback                      |

The full request and response shapes are in the [API reference](/docs/reference#wallet).
