# Slots Integration Guide

> Audience: a developer at a new operator-customer who needs to put SoftBet's
> slot catalog onto their casino site. Assumes you have **read nothing** about
> our system before. Companion to [CUSTOMER-API.md](CUSTOMER-API.md), which
> covers authentication, the bet/win callback contract, and error codes; we
> link there rather than duplicate.

---

## 1. What you're building

```
                  ┌────────────────────────────────────────────┐
                  │         your casino (browser)              │
                  │  - homepage, lobby, search, game tiles     │
                  │  - clicks "Play" → opens our launch URL    │
                  └───────────┬──────────────────────────┬─────┘
                              │                          │ iframe
              REST            ▼                          │ to SG
       ┌──────────────────────────────────┐              │
       │      your backend                │              │
       │  - calls api.softbet.io/api/v1/* │──────────┐   │
       │  - receives our webhooks         │          │   │
       │  - debits/credits player wallet  │          │   │
       └───────────┬──────────────────────┘          │   │
                   │ HTTPS + HMAC-SHA1 + JSON         │   │
                   ▼                                  │   │
    ┌──────────────────────────────────┐              │   │
    │   api.softbet.io  (CT130)        │   webhook   │   │
    │  - catalog browse / launch       │ ──────────► │   │
    │  - signs+forwards SG webhooks    │             │   │
    │  - bills GGR fees                │             │   │
    └────────────────────┬─────────────┘             │   │
                         │                            │   │
                         ▼                            │   │
                  ┌─────────────────┐                │   │
                  │  Slotegrator    │  ◄────────────┘   │
                  │  (5000+ games)  │  ◄──────────────  ┘
                  └─────────────────┘
```

**What you do**

1. Browse our catalog (`/api/v1/games/catalog`) and render tiles on your site.
2. When a player clicks a tile, you launch it via `/api/v1/games/init` — we
   return a URL; you load it in an iframe or new tab.
3. The player plays inside Slotegrator's hosted game. SG sends bet/win/refund
   callbacks to us; we sign and forward to your `callback_url`.
4. You debit/credit the player's wallet on each callback and respond `RC_OK`.
5. We compute GGR per round and debit your USD balance with us (default 8%).

You don't need to integrate with Slotegrator at all — that's our job.

---

## 2. Get credentials

In the dashboard:

1. **API Keys** → *Create new key*. Choose a label (e.g. `prod-eu`). Copy
   both the **key** (`bc_live_…`) and **secret** (`bs_live_…`) immediately —
   the secret is shown **once** and we cannot recover it. Lose it and you
   rotate. We store it AES-256-GCM-encrypted server-side.
2. **Settings** → *Webhook URL*. Paste the HTTPS endpoint on your backend
   that will receive bet/win callbacks (e.g. `https://api.your-casino.com/webhooks/softbet`).
   We verify reachability when you save.
3. **Wallet**. Fund your USD balance so GGR fees can be debited — see the
   wallet panel for crypto deposit addresses.

---

## 3. Sign every request

We use the **same HMAC-SHA1 scheme as Slotegrator**, so if you have a
Slotegrator integration already, copy your signing code as-is and swap
`X-Merchant-Id` for `X-API-Key` + the secret.

Full reference: [CUSTOMER-API.md §1](CUSTOMER-API.md#1-authentication).

**Recommended first call: `POST /api/v1/self-validate`.** It's a no-op probe
— if your signature is correct we return `{ ok: true, data: {} }`. If it's
wrong we return `RC_INVALID_SIGN` with diagnostic details (canonical string
we computed, etc.). Iterate until you get a 200, then move on.

---

## 4. Discover games

We mirror Slotegrator's full catalog (5,000+ games) into our DB nightly,
enriched with **categories**, a **bonus-buy flag**, and a **release
timestamp** so you can build rich category rows without parsing tag strings
yourself.

### 4.1 List the system categories

```
GET /api/v1/system-categories
```

```json
{
  "ok": true,
  "data": {
    "items": [
      { "slug": "slots",      "label": "Slots",        "game_count": 4471 },
      { "slug": "table",      "label": "Table Games",  "game_count": 237  },
      { "slug": "live",       "label": "Live Dealers", "game_count": 6    },
      { "slug": "bonus_buys", "label": "Bonus Buys",   "game_count": 2, "virtual": true },
      { "slug": "other",      "label": "Other",        "game_count": 286  }
    ],
    "total": 5
  }
}
```

Use this to render your top-nav. `bonus_buys` is a **virtual** category — a
bonus-buy game is still a `slots` row, just additionally tagged. Treat the
five buckets as mutually exclusive in your UI **except** for Bonus Buys
which can overlap with Slots.

### 4.2 List providers

```
GET /api/v1/providers
```

Response: `{ items: [{ provider, game_count, latest_release, sample_image }, ...] }` ordered by `game_count desc`.

Use this to render a provider filter (e.g. "Pragmatic Play (576)",
"NetEnt (101)").

### 4.3 Browse the catalog

```
GET /api/v1/games/catalog
```

| Param            | Type    | Notes                                                            |
|------------------|---------|------------------------------------------------------------------|
| `category`       | string  | `slots` / `table` / `live` / `other` / `bonus_buys`              |
| `provider`       | string  | Exact provider name (case-sensitive; see `/providers`)           |
| `has_bonus_buy`  | bool    | `true` / `false`                                                 |
| `is_mobile`      | bool    | Filter to mobile-supported games                                 |
| `is_freespins`   | bool    | Filter to games that support freespins grants                    |
| `q`              | string  | Free-text substring search on game name                          |
| `sort`           | enum    | `newest` (default `name`) / `name` / `provider`                  |
| `page`           | int     | ≥ 1, default 1                                                   |
| `per_page`       | int     | 1..100, default 60                                               |

Item shape:

```json
{
  "uuid":           "954eb970119c67043bd42b6a8d7c9b6534a352e4",
  "name":           "#buybonus New York",
  "provider":       "Belatra Games",
  "provider_id":    255,
  "category":       "slots",
  "type":           "slots",
  "technology":     "HTML5",
  "image_url":      "https://stage.gis-static.com/games/954eb….png",
  "is_mobile":      true,
  "has_freespins":  true,
  "has_bonus_buy":  true,
  "has_lobby":      false,
  "release_ts":     "2024-08-28T11:11:16+00:00"
}
```

### 4.4 Common patterns

**Homepage hero row — "New Slots"**
```
GET /api/v1/games/catalog?category=slots&sort=newest&per_page=24
```

**"Bonus Buys" row**
```
GET /api/v1/games/catalog?has_bonus_buy=true&sort=newest&per_page=24
```

**Provider page — "Pragmatic Play, A–Z"**
```
GET /api/v1/games/catalog?provider=PragmaticPlay&sort=name&per_page=60&page=1
```

**Mobile-only lobby**
```
GET /api/v1/games/catalog?is_mobile=true&category=slots
```

### 4.5 Caveats

- **`release_ts` is Slotegrator's last-update timestamp**, not the true
  first-release date. Newer values ≈ newer releases, but don't surface the
  absolute value as "Released on 2024-08-28" — call it "Recently updated"
  or just use it for sorting.
- The catalog refreshes nightly at 03:00 UTC. New SG games show up the next
  morning. If you need an out-of-band refresh, ask ops.
- The `live` bucket is small (6 titles at time of writing) because our
  merchant tier on Slotegrator doesn't include the major live-dealer
  studios (Evolution, etc.). If you need that catalog, talk to ops about a
  tier upgrade.
- `category=bonus_buys` is a **virtual filter** — internally it queries
  `has_bonus_buy=true` regardless of which base category the game is in.

### 4.6 What "bonus buy" actually is

"Bonus buy" is a **player-funded in-game mechanic**, not something you or we
grant. In a bonus-buy game the player can pay a multiple of their stake (often
~100×) to jump straight into the game's bonus round instead of waiting to
trigger it naturally. The buy happens **entirely inside the game client** and
settles through the normal `bet`/`win` webhook callbacks — there is **no
separate "bonus buy" API call, and no way to gift one to a player**.

For integrators, bonus buy surfaces only as catalog metadata:

- **`has_bonus_buy: true`** on a game item — the game offers the mechanic.
- The **`bonus_buys` virtual category** (§4.1) and the
  **`?has_bonus_buy=true`** catalog filter (§4.3) — for building a "Bonus Buys"
  shelf and badging tiles.

That's the whole surface. (Contrast with **free spins** §7 and **free
vouchers** §7c, which *are* grantable campaigns with their own endpoints.)
Some jurisdictions restrict bonus-buy mechanics — if you need to hide these
games in a territory, filter on `has_bonus_buy` client-side or ask ops to set a
jurisdiction lock.

---

## 5. Render tiles in 9:16

Slotegrator ships **one 16:9 thumbnail per game**. We don't transform images
server-side. To present portrait 9:16 tiles (the standard for casino lobbies
on mobile), crop in CSS:

```css
.game-tile {
  position: relative;
  aspect-ratio: 9 / 16;
  overflow: hidden;
  border-radius: 12px;
  background: #1a1a1a;       /* shows if the image fails to load */
}

.game-tile img {
  width: 100%;
  height: 100%;
  object-fit: cover;          /* crops, doesn't squash */
  object-position: center;    /* tweak per provider if needed */
}

.game-tile .caption {
  position: absolute; left: 0; right: 0; bottom: 0;
  padding: 8px 10px 10px;
  background: linear-gradient(180deg, rgba(0,0,0,0), rgba(0,0,0,0.85));
  color: white;
}
```

**Caveats**

- Edges of horizontal art will be cropped. For most slot art the action lives
  in the center and this is fine. A small number of provider designs put
  logos on the far left/right and crop poorly — visually inspect a sample
  per provider before going live.
- If you need true portrait art for premium placements (e.g. a "Game of the
  Week" hero), contact ops@softbet.io — we can investigate per-game portrait
  overrides on a case-by-case basis.

A live preview of this CSS applied to your real catalog is available in
the dashboard under **Integration → Tile rendering**.

---

## 5b. Embed the game iframe — responsive across desktop + mobile

The launch URL you get from `/games/init` is a Slotegrator-hosted HTML5 game
URL. **The game canvas auto-fits to whatever pixel size the iframe is given**
— there's no Slotegrator-side configuration. Make the iframe wrapper
responsive and the game inside will follow.

```html
<div class="slot-frame">
  <iframe src="<launch_url from /api/v1/games/init>"
          allow="autoplay; fullscreen"
          allowfullscreen></iframe>
</div>

<style>
.slot-frame {
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 9;
  background: #000;
}
.slot-frame iframe {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  border: 0; display: block;
}
/* Mobile: take the full viewport */
@media (max-width: 768px) {
  .slot-frame {
    position: fixed; inset: 0;
    aspect-ratio: auto;
    z-index: 9999;
  }
}
</style>
```

Notes:
- `allowfullscreen` lets the player click the in-game fullscreen button.
- `allow="autoplay; fullscreen"` is required on modern Chrome/Safari for
  the game's intro animation + audio to start without an interaction.
- The launch URL is single-use and short-lived (~5 min). Mint a new one per
  player-game launch; don't cache.

---

## 5c. About RTP / "92% / 94%" badges

**Slotegrator's GIS API does NOT ship per-game RTP.** Not in `/games`
(any `expand`), not in `/game-tags`, no `/games/{uuid}/rtp` endpoint.

To display RTP on tiles, you need an external source:
- Provider-published RTP tables (Pragmatic Play, NetEnt, Play'n GO etc.
  publish to operators on request)
- Third-party DBs (slotcatalog.com, askgamblers JSON dumps)
- Manual CSV maintenance per provider

If you have an RTP source, contact ops@softbet.io — we can add an
`rtp` column to the catalog mirror and surface it on `/api/v1/games/catalog`
with the rest.

What we DO ship from `/api/v1/games/catalog` for tile badges:

| Field | Type | Source |
|---|---|---|
| `is_mobile` | bool | SG flag — Mobile badge |
| `has_freespins` | bool | SG flag — Free Spins badge |
| `has_bonus_buy` | bool | SG `bonus-buy` tag — Bonus Buy badge |
| `tag_codes` | text[] | Full SG tag list per game — `megaways`, `hold-and-win`, `wild-symbol`, themes (`ancient-egypt`, `christmas` etc.) |
| `release_ts` | timestamptz | SG `updated_at` (proxy for release date) — "New" badge |

---

## 6. Launch a game

```
POST /api/v1/games/init
Content-Type: application/x-www-form-urlencoded

game_uuid=<from /games/catalog>&player_id=<your-internal-id>&player_name=<display-name>&currency=EUR
```

Required fields:

- `game_uuid` — from `/games/catalog`.
- `player_id` — **your** opaque internal id for the player. We pass it
  through on every webhook so you know which wallet to debit/credit.
- `player_name` — display name shown inside the game UI (1-80 chars).
  Slotegrator requires this as of 2026-05; omitting it returns
  `422 invalid_input` from us before we even hit SG.
- `currency` — ISO 4217. The player's wallet currency.

Optional:

- `return_url` — where to send the player when they exit the game.
- `language` — 2-letter code (`en`, `es`, …).
- `device` — `desktop` or `mobile`.

Response data: `{ session_id, url }`. Embed `url` in an iframe (or open in
a new tab/window). The `session_id` is how we route bet/win callbacks back
to the right player — store it server-side keyed by `player_id`.

For demo (free play, no GGR fees, no real money), `POST /api/v1/games/init-demo`
with `game_uuid` + `player_name` + optional `currency`/`return_url`/`language`.
No `player_id` needed.

Full Node.js / PHP snippets are in
[CUSTOMER-API.md §4](CUSTOMER-API.md#4-worked-example--full-bet-and-win-round)
and in the dashboard's **Integration → Code samples** tab.

---

## 7. Free-spin campaigns

Grant a player N free spins on a specific game, optionally with an expiry.
The player redeems them by launching the game normally — Slotegrator burns
through the spins server-side, then bet/win callbacks resume as paid play.
Each grant is persisted in our DB so you can list, monitor, and cancel
campaigns without polling Slotegrator.

### 7.1 Grant — `POST /api/v1/freespins/set`

Body (form-encoded):

| Field             | Required | Notes                                                            |
|-------------------|----------|------------------------------------------------------------------|
| `player_id`       | yes      | Your internal player id                                          |
| `player_name`     | yes      | Display name shown in the game; 1-120 chars                      |
| `game_uuid`       | yes      | From `/api/v1/games/catalog`. An unknown uuid returns `422 invalid_game_uuid` |
| `freespins_count` | yes      | 1-10,000                                                         |
| `freespin_bet`    | yes      | Decimal; bet amount per spin in `currency`                       |
| `currency`        | yes      | ISO 4217 (`EUR`, `USD`, …)                                       |
| `valid_until`     | no       | RFC 3339 UTC, **trailing `Z` required** — `YYYY-MM-DDTHH:MM:SSZ` (e.g. `2026-06-30T23:59:00Z`; millis optional). Space-separated, offset-less, date-only or epoch values → `422 Invalid datetime` |
| `name`            | no       | Your label for the campaign (e.g. `weekend-promo-2026`)          |

Response:
```json
{
  "ok": true,
  "data": {
    "campaign": {
      "id": "0e7c…-uuid",
      "name": "weekend-promo-2026",
      "player_id": "p_42",
      "player_name": "Alice",
      "game_uuid": "0b6e2e38…",
      "freespins_count": 25,
      "freespin_bet": 0.20,
      "currency": "EUR",
      "valid_until": "2026-06-30T23:59:00Z",
      "sg_freespin_id": "fs_1729…",
      "status": "active",
      "created_at": "2026-05-20T18:12:00Z"
    }
  }
}
```

If Slotegrator rejects the grant (insufficient game support, bad currency,
etc.), the local row is created with `status='failed'` and `sg_raw.error`
populated; the HTTP response surfaces the SG error code.

### 7.2 List — `GET /api/v1/freespins/campaigns`

Optional query: `status=active|cancelled|consumed|expired|failed`,
`player_id=`, `game_uuid=`, `limit=1..200` (default 50), `before=<ISO>` (for
pagination — use the `created_at` of the last row from the previous page).

Response: `{ ok:true, data:{ items:[ <campaign> ], count } }`.

### 7.3 Cancel — `POST /api/v1/freespins/cancel`

Body: `{ campaign_id: "<our uuid>" }` or `{ sg_freespin_id: "<sg's id>" }`.

We call SG's `/freespins/cancel` and mark the local row `status='cancelled'`.
Cancelling an already-terminal row is a no-op (returns the existing row).

### 7.4 Reconcile / status — `GET /api/v1/freespins/get?player_id=...`

Returns `{ sg_payload, campaigns }`:
- `sg_payload` is SG's `/freespins/get` raw response (live state — remaining
  spin counts per active grant).
- `campaigns` is our list of local rows for the same player, with status
  refreshed inline (active → consumed/expired if SG no longer reports them).

A periodic worker also reconciles active campaigns every 15 min — you don't
strictly need to call `/get` yourself unless you want the freshest state.

### 7.5 Examples

```bash
# Grant 50 spins at €0.40 each on Sweet Bonanza, expires Friday
curl -sS -X POST "https://api.softbet.io/api/v1/freespins/set" \
  -H "X-API-Key: bc_live_…" \
  -H "X-Timestamp: $(date +%s)" \
  -H "X-Nonce: $(openssl rand -hex 8)" \
  -H "X-Sign: <hmac-sha1-hex>" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "player_id=p_42&player_name=Alice&game_uuid=0b6e2e38d76c4a40bf6ab7235f92c5e7&freespins_count=50&freespin_bet=0.40&currency=EUR&valid_until=2026-06-27T23:59:00Z&name=weekend-promo"
```

```javascript
// Node: sign+call helper (HMAC-SHA1 of canonical sorted-query — see §1)
const r = await signedPost('/api/v1/freespins/set', {
  player_id: 'p_42', player_name: 'Alice',
  game_uuid: '0b6e2e38d76c4a40bf6ab7235f92c5e7',
  freespins_count: 50, freespin_bet: 0.40, currency: 'EUR',
  valid_until: '2026-06-27T23:59:00Z', name: 'weekend-promo',
});
console.log('campaign:', r.data.campaign.id, 'sg_id:', r.data.campaign.sg_freespin_id);
```

### 7.6 Dashboard

The customer dashboard has a **Free Spins** tab (sidebar) showing the same
list + a grant form. Useful for ops/support to grant ad-hoc bonuses without
calling the API directly.

---

## 7b. Self-exclusion

Pipe player-initiated self-exclusion requests through this endpoint so that
subsequent `/games/init` calls for that player return
`player_self_excluded` (403) until the exclusion expires.

```bash
# Permanent exclusion
curl -X POST https://api.softbet.io/api/v1/players/PLAYER_42/self-exclude \
  -H "X-API-Key: bc_live_…" \
  -H "X-Timestamp: …" -H "X-Nonce: …" -H "X-Sign: …" \
  -d 'reason=player request'

# 30-day cooling-off
curl -X POST https://api.softbet.io/api/v1/players/PLAYER_42/self-exclude \
  -H "X-API-Key: bc_live_…" \
  ... \
  -d 'days=30&reason=player request'
```

`GET /api/v1/players/:player_id/self-exclude` returns
`{ excluded: true, excluded_until: null | iso }` if the player is locked
out, or `{ excluded: false }` if not. Use it to render appropriate UI
on the merchant side (greyed-out games + a "self-excluded until …" notice).

Exclusions written this way are idempotent — re-submitting the same
`(player_id, days)` within the same row's lifetime returns
`{ deduped: true }` rather than creating a duplicate.

---

## 7c. Free vouchers

Free vouchers work exactly like free spins (§7) but grant a **monetary
voucher** rather than game-specific spins. Every grant is persisted locally in
`freevoucher_campaigns` and mirrored to Slotegrator, so you keep the full
history and can list/cancel/reconcile without polling SG.

> **Merchant-tier gated.** Like free spins, `/freevouchers/set` may return
> `403 freevouchers_disabled` if your Slotegrator merchant tier doesn't have
> vouchers enabled. The full local infrastructure is in place — contact
> ops@softbet.io to request activation.

### 7c.1 Grant — `POST /api/v1/freevouchers/set`

| Field | Required | Notes |
|---|---|---|
| `player_id` | yes | Your internal player id |
| `currency` | yes | Voucher currency |
| `voucher_count` | no | Number of vouchers (≥1) |
| `voucher_amount` | no | Per-voucher amount (decimal) |
| `player_name` | no | Display name in SG |
| `valid_until` | no | ISO 8601 expiry |
| `name` | no | Your own label, e.g. `welcome-voucher-2026` |

```bash
curl -X POST https://api.softbet.io/api/v1/freevouchers/set \
  -H "X-API-Key: bc_live_…" -H "X-Timestamp: …" -H "X-Nonce: …" -H "X-Sign: …" \
  -d 'player_id=p_12345&currency=EUR&voucher_count=1&voucher_amount=5.00&name=welcome'
```
Response: `{ "campaign": { "id": "<uuid>", "status": "active", "sg_voucher_id": "…", … } }`.
On SG rejection the local row is marked `failed` and the upstream error is
surfaced (a "feature not enabled" reply becomes `403 freevouchers_disabled`).

### 7c.2 List — `GET /api/v1/freevouchers/campaigns`

Query params: `status` (active/cancelled/expired/consumed/failed), `player_id`,
`limit` (≤200), `before` (ISO cursor). Returns `{ items, count }`, DB-backed —
no SG round-trip.

### 7c.3 Cancel — `POST /api/v1/freevouchers/cancel`

Body: `{ campaign_id }` (ours) **or** `{ sg_voucher_id }`. Idempotent — a
campaign already in a terminal state is returned unchanged.

### 7c.4 Reconcile / status — `GET /api/v1/freevouchers/get?player_id=…`

Returns `{ sg_payload, campaigns }` — SG's live view plus your local rows, with
any active campaigns reconciled inline (marked `consumed`/`expired` when SG no
longer reports them).

Both ops and you can also grant/cancel vouchers from the **Free Vouchers** tab
in the dashboard (and admins from the admin panel's Free Vouchers tab).

---

## 7d. Limits, jackpots & eligible bets

Thin reads proxied straight to Slotegrator (no local persistence beyond the
`api_requests` audit log). Use them to render game rules and live pools.

| Endpoint | Returns |
|---|---|
| `GET /api/v1/limits` | Per-game / per-currency bet limits (min/max stake). Query: `game_uuid`, `currency`. |
| `GET /api/v1/limits/freespin` | Eligible freespin bet limits for a game. Query: `game_uuid`, `currency`. |
| `GET /api/v1/freespins/bets` | The discrete list of bet-per-spin amounts SG will accept for a freespin grant on a given game. **Call this before `/freespins/set`** and pass one of the returned values as `freespin_bet`. Query: `game_uuid`, `currency`. |
| `GET /api/v1/jackpots` | Live jackpot pool values across providers that expose them. Poll for a ticker; values update in near-real-time on SG's side. |

All four pass your query through to SG verbatim and return SG's JSON. They
share the platform-wide 3 s upstream timeout (`sg_timeout` → 504 on a slow SG).

---

## 7e. Game lobby & curated categories

| Endpoint | Purpose |
|---|---|
| `GET /api/v1/games/lobby` | For providers that expose a multi-game lobby, returns the lobby launch URL. Query mirrors `/games/init` (`game_uuid`, `player_id`, `language`) and is variant-swapped the same way. Use it instead of `/games/init` only for games SG flags as lobby-type. |
| `GET /api/v1/categories` | **Your** curated buckets (created by ops in the admin Game Catalog, distinct from the five system categories in §4.1). Returns `{ items: [{ slug, label, game_count }] }`. |
| `GET /api/v1/categories/:slug/games` | The games inside one curated bucket, same item shape as `/games/catalog`. Use these two to render merchant-specific shelves ("Our top picks") that ops control without a code change. |

---

## 8. Receive webhooks (the wallet callback contract)

When a player bets, wins, refunds or rolls back inside a launched session,
Slotegrator calls **us**; we apply GGR/fee bookkeeping, then forward the event
to **your** `callback_url`. This is the money path — your callback is the
authority on the player's wallet balance.

### 8.1 Configure your callback

Set it in **dashboard → Settings → Webhook**. It must be HTTPS, publicly
reachable from our infra, idempotent, and **respond within 2 seconds** (we sit
inside Slotegrator's 3 s SLA).

### 8.2 What we POST to you

- `Content-Type: application/x-www-form-urlencoded`
- Headers: `X-API-Key` (the prefix of the key tied to this session),
  `X-Timestamp`, `X-Nonce`, `X-Sign` (HMAC-SHA1 of the canonical sorted query
  signed with **your** `bs_live_…` secret — so you can verify it's really us).
- Body fields (verbatim from Slotegrator):

| Field | Present for | Description |
|---|---|---|
| `action` | all | One of `balance`, `bet`, `win`, `refund`, `rollback` |
| `session_id` | all | Same id you got back from `/games/init` |
| `player_id` | all | Your internal player id (echoed from launch params) |
| `currency` | all | Wallet currency |
| `amount` | bet/win/refund/rollback | Decimal string in `currency` |
| `transaction_id` | bet/win/refund/rollback | Slotegrator's unique event id — **your idempotency key** |
| `round_id` | bet/win | Groups bets + wins for the same spin |
| `parent_transaction_id` | refund/rollback | The original tx being reversed |
| `gameplay_final` | bet/win | `"true"`/`"false"` — whether the round is closed |

> Slotegrator may add fields over time. Treat unknown fields as opaque
> passthroughs — never reject a request because of an unexpected field.

### 8.3 What you POST back

Always **HTTP 200** with a JSON body.

**Success:**
```json
{ "status": "RC_OK", "balance": "98.50", "currency": "EUR", "transaction_id": "your-tx-id" }
```
`balance` is the player's **new** balance (decimal string) AFTER the event was
applied. `transaction_id` is your own reference (≤160 chars); reuse the same
value if we re-deliver. For the `balance` action, `transaction_id` is optional.

**Failure:**
```json
{ "status": "RC_INSUFFICIENT_FUNDS", "error_description": "Balance below requested bet." }
```

### 8.4 The five actions

| `action` | Meaning | What you do | What we do on our side |
|---|---|---|---|
| `balance` | Stateless balance probe | Return current balance, no state change | Forward only — no local row written |
| `bet` | Debit a stake | Debit player, return new balance | Persist a `transactions` row (idempotent on `(transaction_id, action)`) |
| `win` | Credit a payout | Credit player, return new balance | Persist tx + compute your **GGR fee** + debit it from your USD balance + roll up `ggr_daily` |
| `refund` | Reverse a prior tx | Re-credit/-debit, return new balance | Persist tx + reverse any fee charged on the parent tx |
| `rollback` | Void a prior tx | Same as refund | Same as refund + mark the parent tx `status='rolled_back'` |

> **`finished` / `gameplay_final`** — a callback may carry `finished: false`,
> meaning the round is still in progress (e.g. a bonus-buy feature mid-play).
> Treat it exactly like any other callback; do not special-case it.

### 8.4a Zero-amount transactions (bonus buy & free features) — **important**

You **will** receive `bet` (and sometimes `win`) callbacks with **`amount: 0`**.
This is normal: in a **bonus buy** the player pays once for the feature, then
each feature spin/choice arrives as a `bet` with `amount = 0` (`finished: false`
until the feature ends). Zero-win spins arrive as `win` with `amount = 0`.

A 0-amount transaction **moves no money**. Your wallet **MUST** accept it and
respond `RC_OK` with the player's **current** balance — never reject it:

```json
{ "status": "RC_OK", "balance": "98.50", "currency": "EUR", "transaction_id": "your-tx-id" }
```

Do **not** return `RC_INVALID_AMOUNT` / a "bad amount" error for `amount = 0`
(only reject **negative** amounts). Rejecting a 0-amount bet stalls the whole
bonus round: the game retries the same spin and the player is stuck in a loop.

> Our gateway now self-heals this case — if your wallet errors on a 0-amount
> bet/win we confirm your balance with a `balance` probe and answer SG `RC_OK`
> so the round still advances — but the contract is that **you accept amount=0
> directly**. Build for it; don't rely on the safety net.

### 8.5 Canonical `RC_*` response codes

`RC_OK`, `RC_ERROR_UNKNOWN`, `RC_INVALID_REQUEST`, `RC_INVALID_AMOUNT`,
`RC_INVALID_CURRENCY`, `RC_CURRENCY_NOT_SUPPORTED`, `RC_OPERATION_NOT_ALLOWED`,
`RC_SESSION_NOT_FOUND`, `RC_SESSION_EXPIRED`, `RC_PLAYER_NOT_FOUND`,
`RC_PLAYER_LOCKED`, `RC_INSUFFICIENT_FUNDS`, `RC_BET_LIMIT_EXCEEDED`,
`RC_GAME_NOT_FOUND`, `RC_GAME_DISABLED`, `RC_TRANSACTION_ALREADY_EXISTS`,
`RC_TRANSACTION_DOES_NOT_EXIST`. If you return something we don't recognize we
coerce it to `RC_ERROR_UNKNOWN` (the round fails) **and** flag a "validation
problem" in your Transactions panel so you can spot the bug.

> To signal an **already-processed** money action, return
> `RC_TRANSACTION_ALREADY_EXISTS` (or `RC_ERROR_DUPLICATE_TRANSACTION`) — see
> §8.7. Do **not** return a generic error for a duplicate; that triggers
> endless retries.

### 8.6 Verifying our signature

We sign with **your** `bs_live_…` secret — verify it the same way you'd verify
your own outbound requests:

```js
import crypto from 'node:crypto';
function verify(params, headers, secret) {
  const presented = headers['x-sign'];
  if (!presented) return false;
  const merged = { ...params };
  ['X-API-Key','X-Timestamp','X-Nonce'].forEach(h => merged[h] = headers[h.toLowerCase()]);
  // form-encoding (RFC 1738): space -> '+', plus encode !*'() ; encode keys AND values
  const enc = v => encodeURIComponent(String(v))
    .replace(/%20/g, '+')
    .replace(/[!*'()]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase());
  const canonical = Object.keys(merged).sort()
    .map(k => `${enc(k)}=${enc(merged[k])}`).join('&');
  const expected = crypto.createHmac('sha1', secret).update(canonical, 'utf8').digest('hex');
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(presented));
}
```

### 8.7 Idempotency

We deduplicate by `(transaction_id, action)`. If Slotegrator re-delivers the
same callback, we replay our stored response **without** re-forwarding to you.
Return deterministic responses keyed on `transaction_id` to stay consistent.

**Refund / rollback must be idempotent.** If you receive a `refund`/`rollback`
you have **already applied**, do not error — respond `RC_OK` with the current
balance (or `RC_TRANSACTION_ALREADY_EXISTS`). Returning a generic failure (or a
non-standard "duplicate"/"conflict" code) makes Slotegrator retry the refund
indefinitely, so the player never settles. Key your refund idempotency on the
refund's own `transaction_id`, **not** on the parent bet's id.

A full bet-and-win worked example lives in
[CUSTOMER-API.md §4](CUSTOMER-API.md).

---

## 9. Going live checklist

- [ ] `POST /api/v1/self-validate` returns 200.
- [ ] You've fetched `/api/v1/system-categories` and `/api/v1/providers`
      to verify your nav populates.
- [ ] You've fetched `/api/v1/games/catalog?category=slots&sort=newest`
      and rendered at least 12 portrait tiles end-to-end.
- [ ] You've launched a **demo** session and the iframe loads.
- [ ] You've launched a **real-money** session and placed a bet — the
      callback hits your webhook with `action=bet`, you debit the wallet,
      and respond `RC_OK`.
- [ ] You've tested a **win** callback and credited the wallet.
- [ ] You've tested a **refund** and a **rollback** (use Slotegrator's
      test-player tools via your ops contact), and a **repeat** of the same
      refund returns `RC_OK` (idempotent — see §8.7).
- [ ] You've tested a **bonus-buy** game: `amount=0` `bet` callbacks return
      `RC_OK`, not an error (see §8.4a).
- [ ] Your USD balance with us is funded — GGR fees deduct from it. See the
      Wallet panel.
- [ ] Your API key is **not** the same key you developed against — rotate
      to a fresh one for production.
- [ ] Your secret is in your vault, not in your repo or CI logs.

---

## 10. Error codes

The bet/win/refund response codes you return are in
[CUSTOMER-API.md §3 — Canonical response codes](CUSTOMER-API.md#canonical-response-codes).

Common errors **we** return on outbound calls:

| Code                 | Status | Meaning                                                          |
|----------------------|--------|------------------------------------------------------------------|
| `missing_api_key`    | 401    | No `X-API-Key` header on the request                             |
| `bad_api_key_format` | 401    | `X-API-Key` doesn't match `bc_live_<8-hex>_<32-base64url>`       |
| `api_key_revoked`    | 401    | Key has been revoked (rotate creates new + revokes old)          |
| `hmac_bad_sign`      | 401    | Computed `X-Sign` doesn't match presented one                    |
| `hmac_stale_timestamp` | 401  | `X-Timestamp` skew > 5 minutes from server clock                 |
| `hmac_nonce_replay`  | 401    | This `(api_key, nonce)` was already used inside the timestamp window |
| `rate_limited`       | 429    | Over your key's RPS — default 100                                |
| `unknown_category`   | 400    | `category=` value not in the allowed list                        |
| `sg_timeout`         | 504    | Slotegrator didn't respond within 3 s on a launch/lobby call     |
| `sg_upstream_error`  | 502    | Slotegrator returned non-2xx; details in `error.details.upstream`|
| `game_blocked_for_merchant`     | 403 | Admin has disabled this `game_uuid` for your account             |
| `provider_blocked_for_merchant` | 403 | Admin has disabled this provider for your account                |
| `bet_above_merchant_cap`        | 422 | Bet exceeds the `max_bet_usd` cap set on your account            |
| `freespins_disabled_for_merchant` | 403 | Free spins are disabled for your account — contact ops          |
| `freespins_disabled` | 403    | Slotegrator merchant tier doesn't have free spins enabled        |
| `freevouchers_disabled` | 403 | Slotegrator merchant tier doesn't have free vouchers enabled     |
| `player_blocked`     | 403    | Player is flagged (banned/suspended) — cross-tenant risk gate    |
| `player_self_excluded` | 403  | Player has an active self-exclusion on your account              |
| `jurisdiction_blocked` | 403  | Game is not available in the player's country                    |

### 10.1 What these mean for your integration

The first 4 fire when ops have configured per-customer slot toggles for your
account (the admin "Slot features" panel). Inspect the admin response from
the dashboard, then either retry against an allowed game/provider, or contact
ops if you believe the configuration is wrong.

`player_blocked`, `player_self_excluded`, `jurisdiction_blocked` are
compliance gates that you SHOULD surface to the player as a friendly message
("This game isn't available in your country") rather than retry. They never
unblock without admin or player action.

`bet_above_merchant_cap` is recoverable by lowering the bet client-side.

---

## 11. Full endpoint reference (slot-related)

| Method | Path                              | Notes                                                       |
|--------|-----------------------------------|-------------------------------------------------------------|
| GET    | `/api/v1/system-categories`       | 5 buckets, fast (DB), public                                |
| GET    | `/api/v1/providers`               | Provider list with counts, fast (DB), public                |
| GET    | `/api/v1/games/catalog`           | **PRIMARY** discovery endpoint — paginated, filterable      |
| GET    | `/api/v1/games`                   | Legacy raw Slotegrator passthrough (kept for compat)        |
| GET    | `/api/v1/game-tags`               | Raw Slotegrator passthrough — tag list                      |
| POST   | `/api/v1/games/init`              | Real-money launch                                           |
| POST   | `/api/v1/games/init-demo`         | Demo launch (no GGR fees)                                   |
| GET    | `/api/v1/games/lobby`             | Game-specific lobby URL                                     |
| GET    | `/api/v1/limits`                  | Player limits                                               |
| GET    | `/api/v1/limits/freespin`         | Freespin limits                                             |
| GET    | `/api/v1/jackpots`                | Live jackpot pools                                          |
| GET    | `/api/v1/freespins/bets`          | Eligible bet amounts for freespins                          |
| POST   | `/api/v1/freespins/set`           | Grant a freespin campaign (§7)                              |
| GET    | `/api/v1/freespins/get`           | Reconcile + cross-reference SG state with local campaigns   |
| POST   | `/api/v1/freespins/cancel`        | Cancel a campaign (`campaign_id` or `sg_freespin_id`)       |
| GET    | `/api/v1/freespins/campaigns`     | **List your campaigns (DB-backed, paginated, filterable)**  |
| POST   | `/api/v1/freevouchers/set`        | Grant a voucher campaign (§7c) — DB-tracked                  |
| GET    | `/api/v1/freevouchers/get`        | Reconcile + cross-reference SG state with local campaigns   |
| POST   | `/api/v1/freevouchers/cancel`     | Cancel a voucher (`campaign_id` or `sg_voucher_id`)         |
| GET    | `/api/v1/freevouchers/campaigns`  | **List your voucher campaigns (DB-backed, paginated)**      |
| POST   | `/api/v1/self-validate`           | Signing sanity probe — call first                           |
| POST   | `/api/v1/players/:player_id/self-exclude` | Pipe a player's self-exclusion request (`{ days?, reason? }`) |
| GET    | `/api/v1/players/:player_id/self-exclude` | Check whether a player is currently self-excluded            |
| GET    | `/api/v1/categories`              | THIS customer's curated buckets (ops-created, not system)   |
| GET    | `/api/v1/categories/:slug/games`  | Games in one curated bucket                                 |

---

## 12. Per-route tracking matrix

What we persist for each route, and how it's keyed. Every authenticated call —
without exception — is written to the `api_requests` audit log keyed by
`user_id` + `api_key_id` (endpoint, status, latency, upstream latency). The
"Also persists" column lists state beyond that audit row.

| Route | Audit (`api_requests`) | Also persists | Keyed by |
|---|---|---|---|
| `GET /api/v1/system-categories` | ✓ | — | user + key |
| `GET /api/v1/providers` | ✓ | — | user + key |
| `GET /api/v1/games/catalog` | ✓ | — | user + key |
| `GET /api/v1/games` | ✓ | — | user + key |
| `GET /api/v1/game-tags` | ✓ | — | user + key |
| `GET /api/v1/categories` | ✓ | — | user + key |
| `GET /api/v1/categories/:slug/games` | ✓ | — | user + key |
| `POST /api/v1/games/init` | ✓ | `game_sessions` (real) + `transactions` (via callbacks) | user + key + session_id |
| `POST /api/v1/games/init-demo` | ✓ | `game_sessions` (demo, no billing) | user + key + session_id |
| `GET /api/v1/games/lobby` | ✓ | — | user + key |
| `GET /api/v1/limits`, `/limits/freespin`, `/jackpots`, `/freespins/bets` | ✓ | — | user + key |
| `POST /api/v1/freespins/set` | ✓ | `freespin_campaigns` | user + key + campaign id |
| `GET /api/v1/freespins/get` | ✓ | `freespin_campaigns` (reconcile) | user + key |
| `POST /api/v1/freespins/cancel` | ✓ | `freespin_campaigns` (status) | user + key |
| `GET /api/v1/freespins/campaigns` | ✓ | — (reads `freespin_campaigns`) | user + key |
| `POST /api/v1/freevouchers/set` | ✓ | `freevoucher_campaigns` | user + key + campaign id |
| `GET /api/v1/freevouchers/get` | ✓ | `freevoucher_campaigns` (reconcile) | user + key |
| `POST /api/v1/freevouchers/cancel` | ✓ | `freevoucher_campaigns` (status) | user + key |
| `GET /api/v1/freevouchers/campaigns` | ✓ | — (reads `freevoucher_campaigns`) | user + key |
| `POST /api/v1/players/:id/self-exclude` | ✓ | `player_exclusions` | user + player id |
| `GET /api/v1/players/:id/self-exclude` | ✓ | — (reads `player_exclusions`) | user + player id |
| `POST /api/v1/self-validate` | ✓ | — | user + key |
| **Inbound** `bet` callback | — | `transactions` | user + `(transaction_id, action)` |
| **Inbound** `win` callback | — | `transactions` + `balance_ledger` + `ggr_daily` | user + `(transaction_id, action)` |
| **Inbound** `refund` / `rollback` callback | — | `transactions` + `balance_ledger` | user + `(transaction_id, action)` |
| **Inbound** `balance` callback | — | — (forwarded only) | session |

Every state-changing **admin** action (grant/cancel/config/flag/etc.) also
writes an `admin_audit` row recording the operator, the target customer, and a
JSON delta. Outbound webhook forwards to your callback are recorded in
`webhook_forwarding_log` (visible in the admin Webhooks tab) — request, response
status, latency, and replay lineage.

---

## Support

- Dashboard: `https://softbet.io/dashboard.html`
- API base:  `https://api.softbet.io`
- Email:     ops@softbet.io
