# SoftBet — Customer API contract

This doc is the integration spec for casino operators (= **you**, our
customer) who use SoftBet as their game gateway. We sit between you
and Slotegrator, so the API surface you talk to is **byte-compatible with the
Slotegrator GIS API** — same payloads, same response codes, same HMAC
algorithm. The only differences:

| | Slotegrator direct | SoftBet gateway |
|---|---|---|
| Base URL | `https://api.slotegrator.com` | `https://api.softbet.io` |
| Merchant header | `X-Merchant-Id: <id>` | `X-API-Key: bc_live_…` |
| Shared secret | merchant key (single shared) | `bs_live_…` per key, rotatable |

If you already have a Slotegrator integration, **swap the URL + auth header
+ secret and you're done** — the request/response shapes do not change.

---

## 1. Authentication

Every request to `/api/v1/*` must carry these four headers:

| Header | Description |
|---|---|
| `X-API-Key` | Your full key as shown when you generated it: `bc_live_<8-hex>_<32-base64url>`. Don't truncate to the prefix. |
| `X-Timestamp` | Current unix seconds (integer). We allow ±5 minutes of clock skew. |
| `X-Nonce` | A unique random string per request (8-32 chars). Used once. |
| `X-Sign` | Lowercase hex HMAC-SHA1 of the canonical sorted-query (see below), signed with your `bs_live_…` **secret**. |

### Canonical signing string

1. Collect all request params:
   - GET / DELETE: every key/value in the URL querystring.
   - POST / PUT / PATCH: every key/value in the
     `application/x-www-form-urlencoded` body. (We also accept JSON bodies;
     for signing purposes the keys are the same.)
2. Add the three non-signature auth headers as if they were params:
   - `X-API-Key`
   - `X-Timestamp`
   - `X-Nonce`
3. Sort the merged map by **key, ascending, byte-wise**.
4. Build a querystring: `key1=encodedValue1&key2=encodedValue2&...`
   Values are percent-encoded with `encodeURIComponent` semantics. Keys are
   kept literal (no encoding).
5. HMAC-SHA1 the string with your `bs_live_…` secret. The output is the
   lowercase-hex digest; put it in `X-Sign`.

Example signing input for `GET /api/v1/games?language=en`:

```
X-API-Key=bc_live_a1b2c3d4_AbCdEfGhIjKlMnOpQrStUvWxYz123456&X-Nonce=R5K7QqL2&X-Timestamp=1731600000&language=en
```

### Node.js reference

```js
import crypto from 'node:crypto';

function sign(params, headers, secret) {
  const merged = { ...params, ...headers };
  const canonical = Object.keys(merged).sort()
    .map(k => `${k}=${encodeURIComponent(String(merged[k]))}`)
    .join('&');
  return crypto.createHmac('sha1', secret).update(canonical, 'utf8').digest('hex');
}

const params  = { game_uuid: 'abc-123', player_id: 'p_42', currency: 'EUR' };
const headers = {
  'X-API-Key':   process.env.BC_API_KEY,
  'X-Timestamp': String(Math.floor(Date.now() / 1000)),
  'X-Nonce':     crypto.randomBytes(8).toString('hex'),
};
headers['X-Sign'] = sign(params, headers, process.env.BC_API_SECRET);

await fetch('https://api.softbet.io/api/v1/games/init', {
  method: 'POST',
  headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams(params).toString(),
});
```

### PHP reference

```php
function bcSign(array $params, array $headers, string $secret): string {
    $merged = $params + $headers;
    ksort($merged, SORT_STRING);
    $parts = [];
    foreach ($merged as $k => $v) {
        $parts[] = $k . '=' . rawurlencode((string)$v);
    }
    return hash_hmac('sha1', implode('&', $parts), $secret);
}
```

> **Validation errors you'll see:** `RC_INVALID_SIGN` (signature mismatch),
> `hmac_stale_timestamp` (clock skew > 5 min), `hmac_nonce_replay` (nonce
> reused), `bad_api_key_format`, `api_key_revoked`.

---

## 2. Outbound endpoints (you → us → Slotegrator)

All paths under `/api/v1/`. Bodies for POST are `application/x-www-form-urlencoded`.

| Method | Path | Purpose |
|---|---|---|
| GET    | `/api/v1/system-categories` | List the 5 system categories (Slots / Table Games / Live Dealers / Bonus Buys / Other) with counts |
| GET    | `/api/v1/providers` | List providers with game counts + latest release |
| GET    | `/api/v1/games/catalog` | **Primary** discovery — paginated, filterable, sorted (reads from our nightly mirror) |
| GET    | `/api/v1/games` | Legacy raw Slotegrator passthrough — kept for backwards compatibility |
| GET    | `/api/v1/game-tags` | Raw Slotegrator tag list |
| POST   | `/api/v1/games/init` | Launch a real-money game session |
| POST   | `/api/v1/games/init-demo` | Launch a demo session |
| GET    | `/api/v1/games/lobby` | Get a lobby URL for a player |
| GET    | `/api/v1/limits` | Player betting limits |
| GET    | `/api/v1/limits/freespin` | Freespin limits |
| GET    | `/api/v1/jackpots` | List active jackpots |
| GET    | `/api/v1/freespins/bets` | Eligible bets for freespins |
| POST   | `/api/v1/freespins/set` | Grant a freespin campaign (persisted locally; see [SLOTS-INTEGRATION-GUIDE.md §7](SLOTS-INTEGRATION-GUIDE.md#7-free-spin-campaigns)) |
| GET    | `/api/v1/freespins/get` | Reconcile SG state + return local campaign records |
| POST   | `/api/v1/freespins/cancel` | Cancel by `campaign_id` or `sg_freespin_id` |
| GET    | `/api/v1/freespins/campaigns` | List your freespin campaigns (paginated; status/player/game filters) |
| POST   | `/api/v1/freevouchers/set` | Grant a free voucher |
| GET    | `/api/v1/freevouchers/get` | Query voucher state |
| POST   | `/api/v1/freevouchers/cancel` | Cancel voucher |
| POST   | `/api/v1/self-validate` | No-op probe (verifies your signing is correct) |

> **New integrators should use `/api/v1/games/catalog`** — it's our enriched
> mirror with materialized `category`, `has_bonus_buy`, and `release_ts`
> columns, paginated, indexed, and never blocked by upstream Slotegrator
> outages. The legacy `/api/v1/games` round-trips to SG on every call.
> See [SLOTS-INTEGRATION-GUIDE.md](SLOTS-INTEGRATION-GUIDE.md) for a full
> walkthrough.

### Catalog browsing endpoints (system-wide)

**`GET /api/v1/system-categories`** — Returns the 5 buckets with game counts:

```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
  }
}
```

**`GET /api/v1/providers`** — Returns providers with counts:

```json
{
  "ok": true,
  "data": {
    "items": [
      { "provider": "KAGaming",     "game_count": 700, "latest_release": "2024-11-02T08:00:00Z", "sample_image": "https://…" },
      { "provider": "PragmaticPlay","game_count": 576, "latest_release": "2025-04-19T13:11:08Z", "sample_image": "https://…" },
      ...
    ],
    "total": 28
  }
}
```

**`GET /api/v1/games/catalog`** — Paginated catalog browse. Query params:

| Param            | Type    | Notes                                                            |
|------------------|---------|------------------------------------------------------------------|
| `category`       | string  | `slots` / `table` / `live` / `other` / `bonus_buys`              |
| `provider`       | string  | Exact provider name                                              |
| `has_bonus_buy`  | bool    | `true` / `false`                                                 |
| `is_mobile`      | bool    | `true` / `false`                                                 |
| `is_freespins`   | bool    | `true` / `false`                                                 |
| `q`              | string  | Substring search on game name (case-insensitive)                 |
| `sort`           | enum    | `newest` / `name` (default) / `provider`                         |
| `page`           | int     | ≥ 1, default 1                                                   |
| `per_page`       | int     | 1..100, default 60                                               |

Response data shape:
```json
{
  "items": [
    {
      "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"
    }
  ],
  "page": 1,
  "per_page": 60,
  "total": 4471
}
```

> **`release_ts` is Slotegrator's last-update timestamp**, not a true
> release date — newer values correlate with newer releases but the
> absolute value isn't a real release date. Use it for sorting, not display.
> The catalog refreshes nightly at 03:00 UTC.

Response shape:
```json
{ "ok": true, "data": { /* SG payload, verbatim */ } }
```
On failure:
```json
{ "ok": false, "error": { "code": "<code>", "message": "<human>", "details": {} } }
```

### Launching a game

`POST /api/v1/games/init` (form-encoded body):

| Field | Required | Notes |
|---|---|---|
| `game_uuid` | yes | The game's Slotegrator UUID (from `/games`) |
| `player_id` | yes | **Your** internal player id (opaque to us) |
| `player_name` | yes | Display name shown in the game UI (1–80 chars). SG-required since 2026-05; we surface a clean 422 if missing. |
| `currency` | yes | Player wallet currency, ISO 4217 (e.g. `EUR`, `USD`) |
| `return_url` | no | Where to send the player when they exit the game |
| `language` | no | 2-letter language code |
| `device` | no | `desktop` or `mobile` |

Successful response data:
```json
{
  "session_id": "9f4b5e6d-...",
  "url":        "https://launch.slotegrator.com/?token=…"
}
```

The **`session_id`** is what we use to route incoming callbacks (bet/win/etc.)
back to your `callback_url`. We store this against your account so you can
view every transaction in the dashboard.

---

## 3. Inbound webhooks (us → your callback_url)

When a player bets, wins, refunds or rolls back inside a launched session,
Slotegrator calls **us**, and we forward to **your** callback URL.

### Configuration

Set your callback URL in the dashboard → Settings → Webhook. It must be:
- HTTPS
- Publicly reachable from our infrastructure
- Idempotent (we will retry on 4xx/5xx)
- Fast: respond within 2 seconds to leave headroom under Slotegrator's 3-second SLA

### Request shape (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 (so you know which environment)
  - `X-Timestamp`, `X-Nonce` — fresh per call
  - `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 | Description |
|---|---|
| `action` | One of `balance`, `bet`, `win`, `refund`, `rollback` |
| `session_id` | Same id you got back from `/games/init` |
| `player_id` | Your internal player id (echoed from the launch params) |
| `currency` | Wallet currency |
| `amount` | Monetary amount (decimal string, in `currency`) — present for bet/win/refund/rollback. **May be `0`** (bonus-buy feature spins, zero wins) — accept it and return `RC_OK`; only reject **negative** amounts |
| `transaction_id` | Slotegrator's unique id for this event (use as your idempotency key) |
| `round_id` | Groups bets+wins for the same spin |
| `parent_transaction_id` | For refund/rollback: the original tx being reversed |
| `gameplay_final` | `"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.

### Response shape (what you POST back to us)

Always HTTP 200 with a JSON body. Use these `status` codes:

#### Success (RC_OK)
```json
{
  "status":         "RC_OK",
  "balance":        "98.50",
  "currency":       "EUR",
  "transaction_id": "your-merchant-tx-id"
}
```
- `balance` is the **player's new balance** in their wallet currency,
  AFTER this event was applied. Decimal string.
- `transaction_id` is your reference for the action (any unique string ≤ 160
  chars). Use the same one if Slotegrator retries the same call.

For `balance` action only, `transaction_id` is optional.

#### Failure (RC_ERROR_*)
```json
{
  "status":            "RC_INSUFFICIENT_FUNDS",
  "error_description": "Player balance is below the requested bet amount."
}
```

Use the appropriate code from the table below. If you return something we
don't recognize, we'll coerce it to `RC_ERROR_UNKNOWN` and Slotegrator will
treat the round as failed — **but we'll surface the problem in your
Transactions panel** with a "validation problems" warning so you can fix it.

### Canonical response codes

| Code | When to use |
|---|---|
| `RC_OK` | Action accepted and applied |
| `RC_ERROR_UNKNOWN` | Generic / catch-all |
| `RC_INVALID_SIGN` | (Reserved — we use this for signature failures) |
| `RC_INVALID_REQUEST` | Malformed request body |
| `RC_INVALID_AMOUNT` | Amount missing, non-numeric, or out of range |
| `RC_INVALID_CURRENCY` | Currency code missing or unsupported by you |
| `RC_CURRENCY_NOT_SUPPORTED` | You don't accept this currency |
| `RC_OPERATION_NOT_ALLOWED` | Action is disabled (e.g. account locked) |
| `RC_SESSION_NOT_FOUND` | session_id unknown / expired |
| `RC_SESSION_EXPIRED` | session_id known but past TTL |
| `RC_PLAYER_NOT_FOUND` | player_id unknown |
| `RC_PLAYER_LOCKED` | Player wallet is frozen |
| `RC_INSUFFICIENT_FUNDS` | Bet exceeds player balance |
| `RC_BET_LIMIT_EXCEEDED` | Bet exceeds your per-player / per-game limit |
| `RC_GAME_NOT_FOUND` | game_uuid not enabled for this player |
| `RC_GAME_DISABLED` | Game globally disabled |
| `RC_TRANSACTION_ALREADY_EXISTS` | Idempotency replay — we will replay the saved response |
| `RC_TRANSACTION_DOES_NOT_EXIST` | Trying to refund/rollback a tx we don't have |
| `RC_PROVIDER_REQUEST_TIMEOUT` | Internal: your callback exceeded our forward timeout |
| `RC_CALLBACK_FAILED` | Internal: we couldn't reach your callback URL |

### Verifying our signature

When we POST to your callback URL, we sign with **your** `bs_live_…` secret.
Verify identically to how we verify your 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()]);
  const canonical = Object.keys(merged).sort()
    .map(k => `${k}=${encodeURIComponent(String(merged[k]))}`)
    .join('&');
  const expected = crypto.createHmac('sha1', secret).update(canonical, 'utf8').digest('hex');
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(presented));
}
```

### Idempotency

We deduplicate by `(transaction_id, action)`. If Slotegrator re-delivers the
same callback (network glitch, retry), we replay our previously stored
response **without** re-forwarding to you. To benefit from this, return
deterministic responses keyed on `transaction_id`.

> **Two rejections that cause infinite retry loops — avoid both:**
> 1. **`amount=0`** bet/win (bonus buy): returning an error stalls the round —
>    the game retries the same spin forever. Always `RC_OK` with the current
>    balance; only negatives are invalid.
> 2. **Already-applied refund/rollback:** return `RC_OK` (or
>    `RC_TRANSACTION_ALREADY_EXISTS`), never a generic/"duplicate" error.
>    Key refund idempotency on the refund's **own** `transaction_id`, not the
>    parent bet's.

---

## 4. Worked example — full bet-and-win round

### Step 1. You launch a game

```http
POST /api/v1/games/init HTTP/1.1
Host: api.softbet.io
Content-Type: application/x-www-form-urlencoded
X-API-Key: bc_live_a1b2c3d4_AbCdEfGhIjKlMnOpQrStUvWxYz123456
X-Timestamp: 1731600000
X-Nonce: R5K7QqL2
X-Sign: c8e9…fed

game_uuid=abc-123&player_id=p_42&currency=EUR
```

Response:
```json
{
  "ok": true,
  "data": {
    "session_id": "sess_9f4b5e6d…",
    "url": "https://launch.slotegrator.com/?token=…"
  }
}
```

### Step 2. Player spins → bet $1

Slotegrator → us → **your** callback URL:

```http
POST /webhooks/softbet HTTP/1.1
Host: your-casino.com
Content-Type: application/x-www-form-urlencoded
X-API-Key: bc_live_a1b2c3d4
X-Timestamp: 1731600100
X-Nonce: m2N9pQ
X-Sign: 73ab…012

action=bet&session_id=sess_9f4b5e6d…&player_id=p_42&currency=EUR&amount=1.00&transaction_id=sg_tx_aaa&round_id=round_xyz
```

You debit p_42's wallet by €1.00 and respond:
```json
{
  "status":         "RC_OK",
  "balance":        "99.00",
  "currency":       "EUR",
  "transaction_id": "your_bet_001"
}
```

We record this transaction in `transactions` (visible in your dashboard).
**No fee** is charged yet — fees accrue on win.

### Step 3. Player wins $1.50

```
action=win&session_id=sess_9f4b5e6d…&player_id=p_42&currency=EUR&amount=1.50&transaction_id=sg_tx_bbb&round_id=round_xyz&parent_transaction_id=sg_tx_aaa
```

You credit p_42 by €1.50 and respond:
```json
{
  "status":         "RC_OK",
  "balance":        "100.50",
  "currency":       "EUR",
  "transaction_id": "your_win_002"
}
```

We compute:
- bet_usd  = €1.00 × 1.07 = **$1.07**
- win_usd  = €1.50 × 1.07 = **$1.605**
- round_ggr_usd = $1.07 − $1.605 = **−$0.535** (player won net)
- Fee charged = **$0.00** (round_ggr ≤ 0)

Both transactions appear under the same `round_id` in your dashboard; the
detail panel shows the full math.

### Step 4. Different round — player loses $5 bet, wins $1

After the round closes:
- bet_usd  = $5.35
- win_usd  = $1.07
- round_ggr_usd = **+$4.28** (operator net win)
- Fee = 8% × $4.28 = **$0.3424** debited from your USD balance with us

A `balance_ledger` entry appears in your wallet view with `reason="fee_ggr"`,
referencing the win transaction.

---

## 5. Where to see this in the dashboard

| Panel | What it shows |
|---|---|
| **Overview** | Balance, active keys, last-24h requests, last-7d GGR |
| **Usage** | Request rate + GGR + fees, bucketed by day (7/30/90 d) |
| **Transactions** | Every bet/win/refund/rollback with: time, action, player, game, amount in player currency, USD equivalent, fee charged, status. Click any row for the raw SG payload + your response + the response we relayed to SG. |
| **Sessions** | Each launched game, with summed bets/wins/fees. Click to drill into all transactions in that session. |
| **Wallet** | USD balance + ledger of every deposit and fee charge |
| **Settings** | Callback URL, notify email, low-balance threshold, default currency |

---

## 6. Going live checklist

- [ ] Create an API key in the dashboard (Keys → Create new key). **Copy the
      full key + secret immediately** — the secret is shown once.
- [ ] Implement signing — verify with `POST /api/v1/self-validate` first.
- [ ] Set your callback URL (Settings → Webhook) and verify it responds to
      a test `balance` action with HTTP 200 + `RC_OK`.
- [ ] Test a real-money game in staging: launch, place a bet, win something,
      check the Transactions panel shows both events with correct amounts.
- [ ] Test a refund / rollback path.
- [ ] Set up a deposit to fund your USD balance with us — see the Wallet
      panel.
- [ ] In production: rotate your key on a regular cadence (recommended:
      quarterly). The old key keeps working for 1 hour after rotation so
      you can roll forward without downtime.

---

## 7. Sportsbook product (Phase 7+ — separate from slots above)

SoftBet also offers a **sportsbook iframe** that customer casinos embed
directly. You don't make outbound API calls for it — players bet inside
our iframe — but your existing `callback_url` is used for wallet
debit/credit, the same way slots work.

### Embedding

In your dashboard's **Sportsbook** tab:
1. Click **Enable sportsbook** (one-click; no negotiation, default 8 % NGR fee + 2.5 % house margin).
2. Copy the HTML snippet from the **Embed** tab and paste it on your casino page.
3. Add the token-minting endpoint to your backend (we provide ready-to-use Node / PHP / Go samples).

The embed snippet looks like:

```html
<div id="softbet-sportsbook" style="width:100%;min-height:780px;"></div>
<script src="https://book.softbet.io/embed.js"
        data-api-key="bc_live_a1b2c3d4_…"
        data-session-endpoint="/your-backend/softbet-session"></script>
```

Your `/your-backend/softbet-session` calls our API:

```
POST https://api.softbet.io/api/v1/sportsbook/sessions
Headers: X-API-Key + X-Sign + X-Timestamp + X-Nonce  (same HMAC as slots)
Body (form-encoded): player_id, currency, display_name, ip, user_agent

→ { ok: true, data: { session_token, expires_at, embed_url } }
```

You return `{session_token, expires_at}` to `embed.js`, which loads the iframe.

### Wallet integration (extends the existing callback contract)

Same `callback_url`. Same HMAC. Just four new `action` values:

| `action`             | When | Body adds |
|----------------------|------|-----------|
| `sportsbook.balance` | iframe boot, "my bets" balance refresh | (same as `balance`) |
| `sportsbook.debit`   | at bet placement | `bet_id`, `selections` (JSON array), `stake`, `currency` |
| `sportsbook.credit`  | at settlement win or cashout | `bet_id`, `amount`, `currency`, `reason` (`"win"` \| `"cashout"`) |
| `sportsbook.refund`  | at void / rules-cancel | `bet_id`, `amount`, `currency` |

Response shape is identical to the slots actions: `{ status: "RC_OK", balance, currency, transaction_id }` or `{ status: "RC_*", error_description }`.

Your existing callback handler probably needs zero changes beyond an
`action.startsWith("sportsbook.")` branch that mirrors your slots logic.

### Billing

NGR = stakes − payouts, computed per settled bet (or per cashout). On
each settled bet:
- If NGR is positive → `fee = NGR_usd × ngr_fee_pct` debited from your USD balance with us; a `balance_ledger` entry is written with `reason='fee_ngr'`.
- If NGR is zero or negative → no fee for that bet; daily rollup still includes it.

NGR fees show up in your **Wallet → Balance history** ledger and in
**Sportsbook → Overview** under fee buckets.

### What ships in each milestone

The sportsbook is being rolled out in milestones; check the dashboard's Sportsbook tab for what's live in your account today.

- **M1** *(current)*: subscription enable/disable, embed code generator, session minting, default theme.
- **M2**: iframe shell with pre-match singles. First real bet placement.
- **M3**: Parlays, theme editor with live preview, admin override panel.
- **M4**: Live in-play betting, cashout, hero image + title editor, mobile responsive pass.

---

## 8. Support

- Dashboard: `https://softbet.io/dashboard.html`
- Status page: `https://status.softbet.io`
- Email: ops@softbet.io
