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

> ## Agent Instructions
> These are the customer docs for kinematic, a name written in lowercase except at the start of a sentence. Kinematic provides provably fair casino games, served to a casino's site by a game server: a Cloudflare Worker that the kinematic dashboard deploys into the casino's own Cloudflare account.
> Never put SESSION_SECRET or WALLET_SECRET in code, prompts, logs, commits or chat. Read them from environment variables and ask the person you work with to set them. The dashboard's Integration page shows each secret once.
> Values such as lucky-star, luckystar.example, og-rgs-lucky-star.luckystar.workers.dev, games.luckystar.example and player_48213 are examples. Use the casino's own casino ID, game server origin, element tag, session issuer and currencies: the Instructions for your coding agent on the dashboard's Integration page hold them, or ask the person you work with.
> To debug, start at https://docs.kinematic.run/troubleshooting and https://docs.kinematic.run/reference/errors. The wallet contract is at https://docs.kinematic.run/api/wallet.openapi.yaml.
> Sandbox bets are recorded on Solana devnet. Live game servers record nothing on Solana until the audited mainnet program is deployed.

# Wallet

> Implement the debit, credit and balance endpoints your game server calls in live mode: signatures, atomic idempotency, receipts, retries and the optional credit check.

In live mode your game server calls your wallet to take stakes and pay wins. Your wallet stays the only place a player's balance changes, including for your other products. The game server never holds funds.

You implement three endpoints at the wallet URL you set under **Mode** in [Settings](https://docs.kinematic.run/dashboard/settings.md#mode), for example `https://wallet.luckystar.example/kinematic/`:

| Call | Does |
| - | - |
| `POST <wallet URL>/debit` | Takes the stakes of one or more bets. |
| `POST <wallet URL>/credit` | Pays one round's gross payout, or refunds a stake. |
| `POST <wallet URL>/balance` | Reads the player's balance. Changes nothing. |

The [Wallet API reference](https://docs.kinematic.run/reference/wallet-api.md) has every field. This page explains the rules your implementation must keep. In the dashboard, the **Connect your wallet** step on **Integration** sums up the three calls with your wallet URL, and the page's **Wallet contract** tab has the contract to read.

> [!NOTE]
>
> Sandbox casinos need no wallet: the game server keeps test balances. Build and test your wallet against a sandbox casino first, then switch to live. See [Go live](https://docs.kinematic.run/dashboard/go-live.md).

## Signatures

Every call is a `POST` with a JSON body and these headers:

| Header | Value |
| - | - |
| `Idempotency-Key` | The body's `id`. |
| `X-Kinematic-Timestamp` | When the game server signed this attempt, in Unix milliseconds. |
| `X-Kinematic-Signature` | Lowercase hex HMAC-SHA256 of `timestamp + "." + body`, keyed with your `WALLET_SECRET`. |

Check every call before you act on it:

1. Compute the HMAC over the exact bytes you received, before you parse them, and compare it with the header in constant time.
2. Refuse a timestamp more than 60 seconds from your clock.
3. Check that `Idempotency-Key` equals the body's `id`, and that the path's endpoint matches the body's `kind`.

Refuse a call that fails any check with a status other than 200, such as 401. A retry of the same call arrives with a fresh timestamp and signature.

```js verify.mjs theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

// True when the call is your game server's: `body` is the raw request body as a string.
export function signedByGameServer(headers, body) {
  const timestamp = headers["x-kinematic-timestamp"] ?? "";
  const signature = headers["x-kinematic-signature"] ?? "";
  if (!/^[0-9]{13}$/.test(timestamp) || Math.abs(Date.now() - Number(timestamp)) > 60_000) return false;
  if (!/^[0-9a-f]{64}$/.test(signature)) return false;
  const expected = createHmac("sha256", process.env.WALLET_SECRET).update(`${timestamp}.${body}`).digest();
  return timingSafeEqual(expected, Buffer.from(signature, "hex"));
}
```

Serve the endpoints over https, and answer them directly. The game server never follows a redirect: it treats one as a failed call and retries.

## Receipts

Answer every call you accept with HTTP 200 and a receipt:

```json theme={null}
{"id":"batch_3a787ce18225e69054f0bfbe34e909d7","status":"applied","balance":"92500","revision":"41","currency":"USD"}
```

* `id` and `currency` repeat the request's.
* `status` is `applied`, or `declined` for a debit the player cannot cover. Credits and balance reads are always `applied`.
* `balance` is the player's balance after the operation, in the currency's minor units.
* `revision` is your wallet-wide revision after the operation. It increases with every change to the account, including deposits, withdrawals and your other products. Two different balances never share a revision.
* `amount`, `balance` and `revision` are canonical integer strings: digits only, no decimals, no signs, no leading zeros.
* The receipt is a JSON object of at most 16 KiB.

## Idempotency

Implement atomic, persistent idempotency, not only the endpoints:

* Store each ID with a fingerprint of its request and the exact receipt you answered, in the same database transaction as the balance change.
* A repeated ID with the same body gets the original receipt, even if the balance has changed since.
* A repeated ID with a different body is a conflict. Refuse it with a status other than 200, such as 409. The game server never sends one.
* Never expire IDs while the game server may still retry them.

The game server keeps every call it has not seen answered and sends it again, with the same ID and the same body, after 1, 2, 4 and up to 30 seconds. A timeout, an HTTP error, a redirect, a body that is not a receipt, or a receipt with another `id` or `currency` all count as unanswered. None of them counts as a declined debit, so a plain `400` never releases a bet.

## Debits

```json theme={null}
{"id":"batch_3a787ce18225e69054f0bfbe34e909d7","player":"player_48213","currency":"USD","amount":"7500","kind":"debit","rounds":["1eff8f3759107f88d5d63eaf980a9459","d5963bfae4ad9081315e9159548f8541"]}
```

* A debit can pay for several bets at once: `rounds` lists their bet IDs, and their stakes add up to `amount`.
* Apply the whole amount, or durably record `status: "declined"` and apply nothing. Never debit part of a batch. A declined ID stays declined if funds arrive later.
* Lock and check the same account that your other products spend from.
* Record which rounds each applied debit named.
* A Blackjack double, split or insurance arrives as its own debit with an ID that starts with `stake_`. Its `rounds` holds the round's bet ID and `amount` is the extra stake.

No outcome exists when a debit arrives. The bet carries only the hash of a seed that stays in the player's browser until your debit has applied and the game server has signed its acceptance. So nobody can know a bet's outcome before your wallet takes the stake, and no bet is refused because it would win.

A declined debit refuses its bets with `insufficient_funds`. No money moved.

## Credits

```json theme={null}
{
  "id": "payout_091aaed882b1fb6a076c877ff78bb307",
  "player": "player_48213",
  "currency": "USD",
  "amount": "9900",
  "kind": "credit",
  "rounds": ["1eff8f3759107f88d5d63eaf980a9459"],
  "settlements": [
    {
      "round": "1eff8f3759107f88d5d63eaf980a9459",
      "amount": "9900",
      "bet": {
        "type": "bet",
        "id": "1eff8f3759107f88d5d63eaf980a9459",
        "wager": { "stake": "5000", "bet": { "game": "dice", "target": 5000, "over": true } },
        "seedHash": "e28489e5d5c71d154a9def37a5369339f566e62267ac3222f28c0fc35ac7c0c8",
        "authorization": { "payload": { "format": "og.proof.v3", "kind": "authorization", "…": "…" }, "publicKey": "…", "signature": "…" }
      },
      "vrf": {
        "keyId": "62516d82119a9f4eb688da80f3f41548",
        "seed": "e9ca18e3c557e52e3e6b2feb062e7ab68a8a822114c4de4eabeb0ee1db79214c",
        "proof": "20864c99b79c5037ce8be2768b475757253c431f46d8165b73ed189c4831adec",
        "output": "bafc2f548ebacb0be2767181ac9824cd917b5c3f387c1edaf1ecb4bd8082fa4c"
      },
      "steps": []
    }
  ]
}
```

* The game server credits each round that pays on its own, as soon as the round ends. A losing round has no credit.
* A credit is the gross payout: it includes the returned stake, because the debit took the whole stake.
* Never decline a credit for funds. Credit availability is your liability. Answer `applied`.
* Each credit carries one settlement per round: the player's signed bet, the revealed seed, the VRF proof and output, and the round's steps. A refund carries `refund` instead.
* A payout of a bet at a return to player other than 99% also carries `manifest`, the payload of the signed math manifest the bet was placed under (about 7 KB). Its SHA-256 is the `math` the player's authorization signs, and it names the house edge the round paid at.

| ID prefix | `refund` | Meaning |
| - | - | - |
| `payout_` | | A round that pays. |
| `refund_` | `abandoned` | The bet was accepted and its seed never arrived within 24 hours. The stake comes back. |
| `refund_` | `engine_failure` | The engine could not evaluate the revealed round. The stake comes back. |
| `refund_` | `key_retired` | The debit applied only after a VRF key rotation, so the bet was never accepted. The stake comes back. |
| `refund_` | `stake` | A Blackjack double, split or insurance the engine could not apply. Its extra stake comes back. |

## Balance reads

```json theme={null}
{"id":"balance_ace41cf2254913a32f4cb1d7f45eb476","player":"player_48213","currency":"USD","kind":"balance","amount":"0","rounds":[]}
```

Answer the player's current balance and your current revision. The game server reads a player's balance at most once every 5 seconds, however many tabs the player has open. Every debit and credit receipt already carries the new balance.

## Several currencies

Every call names its `currency`, and a player's currencies are separate accounts. Answer with that currency's balance in its minor units, under the same `currency`. One revision counter across a player's currencies works if it increases with every change it reports.

## Balance changes outside the games

When a player deposits, withdraws or spends in another product, tell the game server. It reads the balance again after any settlement in progress and pushes it to every tab the player has open:

```js wallet-events.mjs theme={null}
import { createHmac } from "node:crypto";

const GAME_SERVER = "https://games.luckystar.example";

// A deposit, a withdrawal or a bet elsewhere changed the player's balance in this currency.
export async function balanceChanged(player, currency) {
  const body = JSON.stringify({ player, currency });
  const timestamp = String(Date.now());
  const signature = createHmac("sha256", process.env.WALLET_SECRET)
    .update(`${timestamp}.${body}`)
    .digest("hex");
  const response = await fetch(`${GAME_SERVER}/v1/wallet-events`, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "x-kinematic-timestamp": timestamp,
      "x-kinematic-signature": signature,
    },
    body,
  });
  if (!response.ok) throw new Error(`wallet event refused: HTTP ${response.status} ${await response.text()}`);
}
```

The hint is never a balance itself: the game server always reads your `/balance`. Without hints, an open game refreshes its balance when the page becomes visible and every 30 seconds while it stays idle.

## A reference wallet

This server keeps the contract with balances and receipts in memory. Use it to compare your implementation's answers, never as storage:

> [accordion] reference-wallet.mjs
>
> ```js reference-wallet.mjs theme={null}
> // A wallet that follows the contract, for tests: balances and receipts live in memory.
> // A production wallet stores each receipt in the same database transaction as the balance change.
> import { createHash, createHmac, timingSafeEqual } from "node:crypto";
> import { createServer } from "node:http";
>
> const WALLET_SECRET = process.env.WALLET_SECRET;
> const accounts = new Map(); // "player:currency" -> { balance, revision }
> const receipts = new Map(); // id -> { fingerprint, receipt }
>
> function account(player, currency) {
>   const key = `${player}:${currency}`;
>   if (!accounts.has(key)) accounts.set(key, { balance: 1_000_000n, revision: 0n });
>   return accounts.get(key);
> }
>
> // The game server signs `timestamp + "." + body` with WALLET_SECRET.
> function signed(headers, body) {
>   const timestamp = headers["x-kinematic-timestamp"] ?? "";
>   const signature = headers["x-kinematic-signature"] ?? "";
>   if (!/^[0-9]{13}$/.test(timestamp) || Math.abs(Date.now() - Number(timestamp)) > 60_000) return false;
>   if (!/^[0-9a-f]{64}$/.test(signature)) return false;
>   const expected = createHmac("sha256", WALLET_SECRET).update(`${timestamp}.${body}`).digest();
>   return timingSafeEqual(expected, Buffer.from(signature, "hex"));
> }
>
> function apply(request, body) {
>   const a = account(request.player, request.currency);
>   const receipt = (status) => ({
>     id: request.id,
>     status,
>     balance: String(a.balance),
>     revision: String(a.revision),
>     currency: request.currency,
>   });
>   if (request.kind === "balance") return receipt("applied");
>   // A retry repeats the ID with the same body: answer the first receipt again.
>   const fingerprint = createHash("sha256").update(body).digest("hex");
>   const earlier = receipts.get(request.id);
>   if (earlier) return earlier.fingerprint === fingerprint ? earlier.receipt : null;
>   const amount = BigInt(request.amount);
>   let status = "applied";
>   if (request.kind === "debit" && a.balance < amount) status = "declined"; // the whole batch or nothing
>   else {
>     a.balance += request.kind === "debit" ? -amount : amount; // credits are gross, never declined
>     a.revision += 1n;
>   }
>   const result = receipt(status);
>   receipts.set(request.id, { fingerprint, receipt: result });
>   return result;
> }
>
> createServer((req, res) => {
>   let body = "";
>   req.on("data", (chunk) => (body += chunk));
>   req.on("end", () => {
>     const send = (status, json) =>
>       res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(json));
>     const kind = new URL(req.url, "https://wallet.invalid").pathname.split("/").pop();
>     if (req.method !== "POST" || !["debit", "credit", "balance"].includes(kind)) return send(404, { error: "not_found" });
>     if (!signed(req.headers, body)) return send(401, { error: "unauthorized" });
>     let request;
>     try {
>       request = JSON.parse(body);
>     } catch {
>       return send(400, { error: "invalid_json" });
>     }
>     if (request.kind !== kind || req.headers["idempotency-key"] !== request.id) return send(400, { error: "invalid_request" });
>     const receipt = apply(request, body);
>     return receipt ? send(200, receipt) : send(409, { error: "idempotency_conflict" });
>   });
> }).listen(Number(process.env.PORT ?? 8080));
> ```

## Checking credits before they apply

Every payout credit carries what your wallet needs to check it: the player's own signed bet, the seed it committed to, and the VRF proof under your casino's key. A wallet that checks a credit applies no payout the engine does not compute from the proof of a round you debited. The check is optional. Kinematic's verifier library implements it as `checkCredit`; write to [hello@kinematic.run](mailto:hello@kinematic.run) to get it.

For each settlement, the check confirms, in order:

| Reason when it fails | What it checks |
| - | - |
| `malformed` | One settlement per round of `rounds`, in order, each with its signed bet; a payout has `vrf` and `steps`. |
| `amount` | Every amount is a canonical integer string, and the settlements add up to the credit. |
| `authorization` | The player's signature on the bet, or on the refunded action, verifies over those bytes. |
| `wallet` | The signed wallet scope names the credit's player and currency. |
| `no_debit` | An applied debit of yours named the round. |
| `duplicate` | No applied credit settled the round, or for a `stake` refund that action, already. |
| `refund` | A refund returns exactly the bet's stake, or the staked action's `stake`. |
| `key` | The acceptance's VRF key is one your game server publishes, and the key list is well formed. |
| `seed` | The seed hashes to the bet's `seedHash`. |
| `proof` | The VRF proof verifies for the bet's ID, binding and seed, and the output derives from it. |
| `manifest` | A payout that carries `manifest` hashes it to the `math` the player's authorization signs, and it names a house edge of 100, 200, 300 or 400 basis points. |
| `engine` | The engine you run is the build the game server's signed manifest names. |
| `outcome` | The engine, given the output, the bet and the steps, at the bet's house edge (99% return to player without `manifest`), ends the round with exactly `amount`. |

Your game server publishes its VRF keys, retired ones included, at `GET <game server>/v1/catalog` under `vrf.keys`. A bet always settles under the key its acceptance named, even after a rotation.

Refuse a credit that fails the check with HTTP 409 and change nothing:

```json theme={null}
{"error":"credit_unverified","reason":"proof"}
```

The game server keeps the credit under the same ID and body, retries it, and the round stays owed. A refusal means your wallet and your game server disagree about a payout: find out why before anything else, and never edit or drop the credit.

The check proves that a payout is the outcome of a round you debited. It does not prove that every winning round was credited: a losing round has no credit, and a winning round's credit can be late. It does not check an abandoned refund beyond its amount.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.