# ghostagg API

ghostagg is a slot game aggregator with a remote game server. You run the
casino — players, balances, payments. We run the games: maths, rounds,
fairness proofs and the game clients. The two meet over two small APIs.

| Direction       | What                                                                             | Auth                         |
| --------------- | -------------------------------------------------------------------------------- | ---------------------------- |
| **You call us** | The _operator API_: list the catalogue, mint a session for a player              | `Authorization: Bearer mk_…` |
| **We call you** | The _wallet API_: authenticate a session, debit a stake, credit a win, roll back | HMAC-SHA256 signature        |

## How a round flows

1. Your lobby lists games from `GET /operator/games`.
2. A player opens a game. Your server calls `POST /operator/sessions` with
   your own session token and a player reference. You get our `sessionID`
   and the launch parameters.
3. You load the game client with those parameters. The client talks to our
   server; your token never reaches the browser.
4. On load we call your wallet's `authenticate`. On every spin we call
   `debit` before the result is served, and `credit` when the round ends.
5. Every round is recorded with its game version, seed and nonce before it
   is served, so any round can be proved afterwards.

## Principles

- **Your money, your arithmetic.** Amounts are opaque _micro-units_
  (`1,000,000` = one currency unit). We never compute on them; for a win we
  send the multiplier and you compute the payout from the stake you recorded.
- **Idempotent by `roundId`.** Every wallet call of a round carries the same
  `roundId`. A retry must return the settled result, never post twice.
- **Stake Engine compatible.** The game-client API follows Stake Engine's
  paths, shapes and error codes, so existing clients run unchanged.
- **Provably fair.** Each session commits to a server seed hash before the
  first round; the round record and the verifier make every result checkable.

## Next

- [Quickstart](/docs/quickstart): from API key to a running game in five steps.
- [Wallet API](/docs/wallet): the five callbacks your server implements.
- [API reference](/docs/reference): every endpoint, field and response.


---

# Quickstart

From an API key to a game running against your wallet, in five steps.

## 1. Get an API key

Sign in to the [portal](/portal) and create a key under **API keys**. It is
shown once; store it in your secret store. Keys look like `mk_…`.

```bash
export GHOSTAGG_API="https://<your API base URL>"   # shown in the portal
export GHOSTAGG_KEY="mk_…"
```

## 2. List the catalogue

```bash
curl -s "$GHOSTAGG_API/operator/games" \
  -H "Authorization: Bearer $GHOSTAGG_KEY"
```

Each game has an id, a version, a title, its provider and its modes with the
declared RTP and cost multiplier (`1` for a base spin, for example `100` for a
bonus buy).

## 3. Implement your wallet

Expose the five [wallet callbacks](/docs/wallet) on an HTTPS URL of your
server and send us that URL. We configure it and hand you the wallet signing
secret, once. We sign every call; verify the signature before you parse the
body.

## 4. Mint a session when a player opens a game

From your server — never from the browser:

```bash
curl -s "$GHOSTAGG_API/operator/sessions" \
  -H "Authorization: Bearer $GHOSTAGG_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "gameId": "bigger-bass-bonanza",
    "token": "your-own-session-token",
    "playerRef": "player-4711",
    "currency": "USD",
    "device": "desktop"
  }'
```

The answer carries our `sessionID`, the fairness commitment and `launch`, the
query string the game client expects.

## 5. Launch the game

Open the game client with the launch parameters and the game server host:

```text
https://<game host>/<gameId>/?<launch>&rgs_url=<game server host>
```

The client calls our server, we call your wallet: `authenticate` on load,
`debit` and `credit` on every round. That is the whole integration.

## Try it without code

The [games tester](/portal/games) in the portal plays every title against our
demo wallet and shows each server call as it happens. The
[API playground](/docs/playground) sends real requests with your key.


---

# 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).


---

# Fairness

Every result comes from one weighted draw, decided before the player sees it
and provable afterwards.

## The commitment

When a session is minted, its answer carries `fairness.serverSeedHash`: the
SHA-256 of a server seed we keep secret for the life of the session. Show it
to the player before the first round if you like. Together with the player's
`clientSeed` (fixed per session, passed at minting) and a nonce per round, it
fixes every outcome in advance.

## The draw

A round resolves one weighted selection from the game's published book set
with HMAC-SHA256 over the server seed, client seed and nonce. Nothing else in
the round path draws randomness.

## The record

Every round is recorded before it is served: game, `gameVersion`, book set,
seed and nonce. When the session's seed is rotated, the old server seed is
revealed, and anyone can recompute each round with the verifier.

## Versions

`gameVersion` travels with every round, so changing a game's maths never
changes how an old round verifies.


---

# API reference

Base URL: https://gm.ghostagg.com

## Operator API (you call ghostagg)

Authenticated with `Authorization: Bearer mk_…`.

### GET /operator/games

The catalogue a lobby is built from

Auth: `Authorization: Bearer mk_…`

Response 200: Enabled games with modes, cost multipliers and declared RTP.
- `games` (object[])

Response 401: Missing or unknown API key.

### POST /operator/sessions

Mint a session for a player launch

Auth: `Authorization: Bearer mk_…`

Request body:

- `gameId` (string, required)
- `token` (string, required): The operator's own session token. Kept server-side; wallet calls carry it back — it never appears in a game URL.
- `playerRef` (string, required): Opaque player reference, never an identity.
- `clientSeed` (string): The player's client seed, fixed for the life of the session. Omitted or empty means a weaker proof, and that is the operator’s to explain.
- `device` ("desktop" | "mobile"): Presentation platform selected by the operator for this browser session.
- `currency` (string): The operator ledger rail selected for this launch.
- `maxBet` (string): Maximum total debit in Munja micro-units (1 currency unit = 1,000,000).

Response 200: Munja's sessionID, the epoch commitment, and the launch parameters.
- `sessionID` (string (uuid)): Munja's session token — what the game URL carries.
- `fairness` (object): The epoch commitment and exact maths artefact digests, available before the game loads.
- `fairness.scheme` ("munja-hmac-sha256-v1")
- `fairness.serverSeedHash` (string)
- `fairness.gameVersion` (integer)
- `fairness.setId` (string (uuid))
- `fairness.verifier` ("/verify")
- `fairness.mathVerifier` (string)
- `fairness.mathManifestSha256` (string)
- `fairness.artifactRevision` (string)
- `fairness.modes` (object)
- `launch` (string): Query parameters for the game bundle (ADR-0006 launch shape).

Response 400: Invalid request.
- `error` (string)
- `detail` (string)

Response 401: Missing or unknown API key.

Response 402: The operator's invoice is unpaid seven days past due, or its plan has ended. Only minting is refused: running sessions keep playing.
- `error` (string)
- `detail` (string)

Response 404: Unknown or disabled game.
- `error` (string)
- `detail` (string)

## Wallet API (ghostagg calls you)

Paths are relative to your wallet base URL. Every request is signed: `x-munja-timestamp` and `x-munja-signature` = hex(HMAC-SHA256(secret, `<timestamp>.<raw body>`)).

### POST /authenticate

validate a session, return the player

Signed by munja: `x-munja-timestamp` (ms since epoch) and `x-munja-signature` = hex(HMAC-SHA256(secret, `${timestamp}.${rawBody}`)). Verify over the raw body bytes before parsing; refuse timestamps outside ±5 minutes. Replay inside the window is answered by roundId idempotency.

Request body:

- `token` (string, required): The operator session token from minting.

Response 200: The session is valid.
- `settlementCapabilities` (string[])
- `token` (string)
- `playerRef` (string)
- `gameId` (string)
- `balance` (object)
- `balance.amount` (integer)
- `balance.currency` (string)
- `clientSeed` (string)
- `display` (object): Bet levels for display only — munja never enforces them (ADR-0001).
- `display.betLevels` (number[])
- `display.defaultBetLevel` (number)
- `display.minBet` (number)
- `display.maxBet` (number)
- `display.stepBet` (number)

Response default: Refusal with a wallet StatusCode.
- `code` (string): A wallet StatusCode, e.g. ERR_IPB, ERR_BNF, ERR_GE.
- `message` (string)

### POST /debit

take the stake (idempotent per roundId)

Signed by munja: `x-munja-timestamp` (ms since epoch) and `x-munja-signature` = hex(HMAC-SHA256(secret, `${timestamp}.${rawBody}`)). Verify over the raw body bytes before parsing; refuse timestamps outside ±5 minutes. Replay inside the window is answered by roundId idempotency.

Request body:

- `token` (string, required)
- `roundId` (string, required): Idempotency key for every settlement call of this round.
- `amount` (integer, required): Wire micro-units: 1_000_000 = one unit.
- `currency` (string, required)

Response 200: The balance after the debit (or after the earlier identical one).
- `amount` (integer)
- `currency` (string)

Response default: Refusal — ERR_IPB for insufficient funds.
- `code` (string): A wallet StatusCode, e.g. ERR_IPB, ERR_BNF, ERR_GE.
- `message` (string)

### POST /credit

pay the win (idempotent per roundId)

Signed by munja: `x-munja-timestamp` (ms since epoch) and `x-munja-signature` = hex(HMAC-SHA256(secret, `${timestamp}.${rawBody}`)). Verify over the raw body bytes before parsing; refuse timestamps outside ±5 minutes. Replay inside the window is answered by roundId idempotency. The operator computes payout = debited × payoutMultiplier / costX100, floored. Credit for an unknown roundId is ERR_BNF; credit after rollback is ERR_GE.

Request body:

- `token` (string, required)
- `roundId` (string, required)
- `payoutMultiplier` (string, required): Integer hundredths of the BASE stake, as a decimal string.
- `costX100` (string, required): Mode cost in hundredths. payout = debited × payoutMultiplier / costX100, floored.

Response 200: The payout and the balance after it.
- `payout` (integer)
- `balance` (object)
- `balance.amount` (integer)
- `balance.currency` (string)

Response default: Refusal with a wallet StatusCode.
- `code` (string): A wallet StatusCode, e.g. ERR_IPB, ERR_BNF, ERR_GE.
- `message` (string)

### POST /credit-rational

settle exact rational payout terms

Signed by munja: `x-munja-timestamp` (ms since epoch) and `x-munja-signature` = hex(HMAC-SHA256(secret, `${timestamp}.${rawBody}`)). Verify over the raw body bytes before parsing; refuse timestamps outside ±5 minutes. Replay inside the window is answered by roundId idempotency. Requires rational-payout-v1 capability before debit. Compute floor(debited × payoutMultiplier × 100 / (costX100 × payoutDenominator)) at the existing wire-unit boundary. Persist exact terms with the credit; changed terms are refused even if the rounded payout is equal. Never fall back to /credit.

Request body:

- `token` (string, required)
- `roundId` (string, required)
- `payoutMultiplier` (string, required): Integer hundredths of the BASE stake, as a decimal string.
- `costX100` (string, required): Mode cost in hundredths. payout = debited × payoutMultiplier / costX100, floored.
- `payoutDenominator` (string, required)

Response 200: The original payout and balance.
- `payout` (integer)
- `balance` (object)
- `balance.amount` (integer)
- `balance.currency` (string)

Response default: Refusal with a wallet StatusCode.
- `code` (string): A wallet StatusCode, e.g. ERR_IPB, ERR_BNF, ERR_GE.
- `message` (string)

### POST /rollback

refund a debit that never became a round

Signed by munja: `x-munja-timestamp` (ms since epoch) and `x-munja-signature` = hex(HMAC-SHA256(secret, `${timestamp}.${rawBody}`)). Verify over the raw body bytes before parsing; refuse timestamps outside ±5 minutes. Replay inside the window is answered by roundId idempotency. ERR_BNF when the debit never landed — munja's crash reconciliation depends on exactly that answer. Refused after credit.

Request body:

- `token` (string, required)
- `roundId` (string, required)

Response 200: The balance after the refund (idempotent).
- `amount` (integer)
- `currency` (string)

Response default: Refusal with a wallet StatusCode.
- `code` (string): A wallet StatusCode, e.g. ERR_IPB, ERR_BNF, ERR_GE.
- `message` (string)
