# Operator API — slots.justsaynot.ru (runtime 4512)

TEST_ONLY platform: own math, non-withdrawable units, no real payments. Server-to-server JSON over HTTPS. Every request carries
`partner` and `api_key` (keep the key on your server); the caller IP must be on the partner allowlist configured in the admin.
Write methods accept an optional `Idempotency-Key` header (1–128 printable characters): a repeat with the same key and body returns
the first answer, the same key with another body answers `IDEMPOTENCY_CONFLICT` (409).
Errors: `{"status":"error","message":"<CODE>"}` (400; 401 `UNAUTHORIZED`; 403 `IP_NOT_ALLOWED`; 404 `GAME_NOT_FOUND`,
`GRANT_NOT_FOUND`, `OUTCOME_NOT_FOUND`, `ROUND_NOT_FOUND`; 409 `IDEMPOTENCY_CONFLICT`, `DUPLICATE_EXTERNAL_ID`, `ROUND_NOT_CANCELLABLE`, `ROUND_BUSY`;
429 `RATE_LIMITED` with `Retry-After`).

## Games and languages

`GET /games/health` lists the published games (`games[].game_id`) with `languages` (standard codes the game's original client
supports), `free_spins` and `target_outcomes` (whether operator free spins / scheduled target outcomes are available for the game).

## Catalog, capabilities and schema (public, no key)

- `GET /games/health` — see above. `GET /capabilities` — the stable casino contract (`status`, `mode`, `currencies`, `games[]` with
  `gameId, name, provider, currencies, languages, defaultCurrency, defaultLanguage, promoSupported, modes`) for every released game;
  `languages` are limited to the casino languages `ru`, `en`, `uk`. `GET /health` — liveness (`{status, mode, backend, client, mathVersion,
  runtime, releaseId}`; 503 `DATABASE_UNAVAILABLE`).
- `GET /docs/openapi.json` — this API as OpenAPI 3.1 (`/playGame.do`, `/gameActions.do`, `/games/health`, `/capabilities`, `/health`, wallet
  callbacks as a webhook). `GET /api` — the partner page. `GET /docs` redirects here.

## Launch — `POST /playGame.do`

`{provider:"own", partner, api_key, player_id, currency, symbol, gametype:"demo", lang?}` → `{status:"ok", link, meta:{session_id}}`.

**Free-play demo** (`gametype:"fun"`): same request and response shape, but the game plays against an isolated demo balance — never your wallet callbacks, nothing in your reports. Optional `demo_balance` (1.00–10,000,000.00, default 100000.00), optional `demo_reset`; `player_id` is optional and only scopes the demo wallet (with it the demo balance persists across launches; without it every launch is a new wallet). Design and evidence: `docs/demo-mode.md`.
`lang` is a standard language code (BCP 47: `en`, `de`, `pt-BR`, `zh-Hans`, `zh-Hant`, `es-419` …; case-insensitive, `_` accepted);
it is mapped to each provider's own client code. Missing `lang` = the project's default language; a language the game does not
support answers `UNSUPPORTED_LANGUAGE`. Relaunching with an unfinished round continues that round in the new language.

## Free spins — `POST /gameActions.do`

| method | fields | answer |
|---|---|---|
| `freespins_create` | `player_id, currency, symbol, count (1–1000), bet, expires_at?, external_id?, meta?` | `{status:"ok", grant}` |
| `freespins_get` | `grant_id` or `external_id` | `{status:"ok", grant}` |
| `freespins_list` | `player_id?, symbol?, currency?, status?` (max 200, newest first) | `{status:"ok", grants:[…]}` |
| `freespins_cancel` | `grant_id` or `external_id` | `{status:"ok", grant}` (idempotent) |

- `bet` = the total bet of one spin as a decimal string; it must be one of the game's own bets in its base mode
  (`INVALID_FREE_SPIN_BET`), within the project's bet limits (`PROJECT_BET_LIMIT`).
- `expires_at` (ISO 8601 with zone): default now + 7 days, minimum now + 1 hour, maximum now + 30 days (`INVALID_EXPIRY`).
- `grant` = `{grant_id, external_id, player_id, currency, symbol, provider, total, remaining, played, bet, total_win, status
  (active|used|expired|cancelled), created_at, expires_at, finished_at}`.
- Several grants per player and game are allowed; the one expiring first is played first. The game client shows the grant natively
  (Pragmatic free rounds, Endorphina Bonus Spins, Nolimit City Free Bets). While a grant is active the player can play only it: paid
  spins, ante bets and feature buys answer `FREE_SPINS_ACTIVE` until the grant is used up, expires or is cancelled.
- A round already started when its grant expires or is cancelled finishes and is credited normally.
- Legacy methods `freespins_set` / `freespins_delete` (and `freespins_get` without an id) answer `METHOD_RETIRED`.

## Target outcomes — `POST /gameActions.do`

| method | fields | answer |
|---|---|---|
| `outcome_schedule` | `player_id, symbol, multiplier, kind? (spin\|bonus), spin_number? (1–100000, default 1), expires_at?, external_id?, comment?` | `{status:"ok", outcome}` |
| `outcome_get` | `outcome_id` or `external_id` | `{status:"ok", outcome}` |
| `outcome_list` | `player_id?, symbol?, status?` (max 200) | `{status:"ok", outcomes:[…]}` |
| `outcome_cancel` | `outcome_id` or `external_id` | `{status:"ok", outcome}` |

- The player's `spin_number`-th paid spin in that game (counted from scheduling; free-spin rounds do not count) wins `multiplier` ×
  the spin's bet (±2 %): `kind:"spin"` without a feature, `kind:"bonus"` with a feature (natural trigger, or a feature buy when the
  player buys). The round is the game's own natural outcome, generated for this entry only.
- A spin that does not fit (another bet mode, the round not found yet) postpones the entry to the next paid spin; entries due on the
  same spin fire one per spin, oldest first. Default expiry 7 days (1 hour – 90 days).
- Codes: `GAME_HAS_NO_BONUS`, `INVALID_MULTIPLIER`, `MULTIPLIER_ABOVE_MAX_WIN`, `INVALID_SPIN_NUMBER`, `INVALID_KIND`,
  `FORCED_OUTCOMES_UNSUPPORTED`.
- `outcome` = `{outcome_id, external_id, player_id, symbol, provider, kind, multiplier, spin_number, paid_spins_since, status
  (pending|ready|unreachable|applied|expired|cancelled), variants, comment, created_at, expires_at, applied_at, round_id}`.
- Forced rounds do not count in the player's adaptive RTP; their round notifications carry `forced: true`.

## Wallet callbacks (operator side)

- Bets of free-spin rounds have amount `0` and `meta:{is_freespins:true, freespins:{grant_id, external_id, nominal_bet, remaining}}`
  (wins carry the same meta) — accept a zero bet when `is_freespins` is true.
- One summary per finished grant, retried until you answer `{"status":"ok"}`:
  `{method:"freespins_finished", grant_id, external_id, player_id, currency, game, status, total, played, bet, total_win, finished_at}`.

- **Signature of every callback** (HMAC-SHA256): headers `X-Notell-Timestamp: <unix seconds>` and `X-Notell-Signature: v1=<hex>`; canonical string
  `<timestamp>\nPOST\n<url path>\n<raw body>`; secret = `HMAC-SHA256(key=api_key, "notell-callback-signing-v1")` as hex, or the partner's explicit
  `callbackSecret` when configured. `api_key` stays in the body. Verification is optional for the operator (reference code: `src/callback-signature.ts`).
  Our outgoing callback IPs: `callbackSourceIps` in `GET /capabilities` (allowlist them).
- **`refund`** (`meta.original_transaction`, `meta.reason`: `wallet_unconfirmed` | `operator_cancel`): give back the debit of the original bet transaction once;
  for a transaction you never received record it as cancelled, change nothing, answer ok (and refuse a later bet with that id). Same transaction id on every
  retry; retried until you answer ok. Design: `docs/refund-rollback-design.md`.

## Catalog fields (public, no key)

`GET /games/health`, `GET /capabilities` (additive) and `GET /games/catalog` carry per game: `image` (absolute URL or null), `category`, `tags`,
`rtp:{default,min:60,max:100,configurable,by_mode,note}` (percent; default = natural RTP, the operator sets 60-100), `volatility` (`low|medium|high|very-high`,
rule: sd of the win multiple of a base spin from the calibration bands < 3.5 / 7 / 14; fallback on the observed max win < 300 / 1500 / 3000),
`volatility_basis`, `hit_frequency`, `max_win` (multiple of the bet when known), `features:{buy_feature,ante_bet,free_spins,target_outcomes}`.
`GET /capabilities` also has `callbackSourceIps` and `rateLimits`.

## Rounds and reports — `POST /gameActions.do`

- `round_cancel {round_id}` — cancels an unfinished round (debit unconfirmed, or confirmed but the result not shown and no win planned) and refunds it.
  Idempotent; `ROUND_NOT_FOUND`, `ROUND_NOT_CANCELLABLE`, `ROUND_BUSY`.
- `report_rounds {player_id?, symbol?, currency?, round_id?, state?, from?, to?, limit?, cursor?}` — round history, newest first, keyset pagination (`next_cursor`).
- `report_ggr {from?, to?, group_by?, symbol?, currency?}` — GGR = bets - refunds - wins of confirmed wallet operations per day / game / currency.
- `report_reconciliation {player_id?, currency?, from?, to?, limit?, cursor?}` — per player and currency: bets, wins, refunds, `net_wallet_change`, pending.

## Rate limits

Per partner and minute (token bucket, runtime config `rateLimits`): `/playGame.do` 1200, `/gameActions.do` 600, `report_*` 120 (own quota). Over the quota: HTTP 429
`{"status":"error","message":"RATE_LIMITED"}` with `Retry-After` (seconds) and `X-RateLimit-Limit`.
