A single-document integration reference. Everything you need to go from zero to
live on the BGA platform.

---

## Table of contents

1. [How it works](#1-how-it-works)
2. [Onboarding](#2-onboarding)
3. [Environments & base URLs](#3-environments--base-urls)
4. [Authentication](#4-authentication)
5. [Conventions](#5-conventions)
6. [Game catalogue](#6-game-catalogue)
7. [Launching a game session](#7-launching-a-game-session)
8. [Demo (fun-play) sessions](#8-demo-fun-play-sessions)
9. [Wallet webhooks](#9-wallet-webhooks)
   - [Verifying signatures](#verifying-signatures)
   - [session/verify](#post-sessionverify)
   - [balance](#post-balance)
   - [bet/create](#post-betcreate)
   - [bet/win](#post-betwin)
   - [trx/cancel](#post-trxcancel)
   - [trx/complete](#post-trxcomplete)
   - [Delivery & retry guarantees](#delivery--retry-guarantees)
10. [Free spins (bonus rounds)](#10-free-spins-bonus-rounds)
    - [freerounds/start](#post-freeroundsstart)
    - [freerounds/spin](#post-freeroundsspin)
    - [freerounds/complete](#post-freeroundscomplete)
11. [Querying status](#11-querying-status)
12. [Rate limiting](#12-rate-limiting)
13. [Error reference](#13-error-reference)
14. [Integration checklist](#14-integration-checklist)
15. [Minimal implementation examples](#15-minimal-implementation-examples)

---

## 1. How it works

BGA is a game aggregation platform. One integration gives you access to hundreds
of casino games from dozens of studios. Your players' money always lives in
**your** wallet — BGA never holds funds.

```
   ┌────────────────┐  Platform API (REST)  ┌────────────────────────┐
   │                │ ─────────────────────►│                        │
   │  Your Backend  │                       │   BGA Platform         │
   │                │ ◄─────────────────────│                        │
   └────────────────┘   Wallet Webhooks     └────────────────────────┘
          ▲                                           │
          │  player's browser                         │  serves the game
          └──────────────── launch_url ───────────────┘
```

There are exactly **two sides** to the integration:

| Side | Direction | What it is |
|------|-----------|------------|
| **Platform API** | You → BGA | REST API to browse games and open game sessions. |
| **Wallet Webhooks** | BGA → You | HTTP calls to your backend so you can authorise sessions and move money in real time. Each webhook type has its own route path. |

**The player flow in one paragraph:**
Your backend calls `POST /api/v1/sessions`, receives a `launch_url`, and
redirects the player there. BGA serves the game. As the player plays, BGA calls
your webhook to debit bets and credit wins. You respond with the new balance.
When the player closes the game, they go back to your lobby.

---

## 2. Onboarding

When you sign up, our team provisions an **operator account** and shares:

| Credential | Where you use it |
|------------|-----------------|
| **API key** | `X-API-Key` header on every Platform API call. |
| **Webhook signing secret** | Verify the `X-Webhook-Signature` header on every webhook you receive. |

You provide us with:

| You provide | Purpose |
|-------------|---------|
| **Webhook URL** | Your HTTPS endpoint where BGA delivers wallet events. |
| **Allowed currencies** | So we activate the right game limits. |
| **Default lobby URL** | Where the in-game back button sends the player when `return_url` is omitted. |

> **API keys are shown once.** At creation time the key is displayed in full and
> then stored only as a hash on our side. Copy it immediately and keep it secret.

---

## 3. Authentication

Every Platform API request must carry your API key as a request header:

```
X-API-Key: bga_3f9a8c2b1d4e5f6a7b8c9d0e1f2a3b5c
```

A missing or invalid key returns `401`:

```json
{ "error": { "code": 401, "message": "Invalid or missing API key" } }
```

**Verify you're connected** — make this call right after onboarding:

```bash
curl -s https://<BGA_DOMAIN>/api/v1/games \
  -H "X-API-Key: <YOUR_API_KEY>"
```

A `200` response with a `data` array means authentication is working.

---

## 4. Conventions

### Money

All monetary amounts — balances, bet amounts, payouts — are **integers in the
currency's minor units (cents)**. There are no floats anywhere in the API.

**Rule:** divide the integer value by 100 to get the display amount.

| Integer value | Currency | Display |
|---------------|----------|---------|
| `150000` | USD / EUR | $1500.00 / €1500.00 |
| `1500` | USD / EUR | $15.00 / €15.00 |
| `500` | USD / EUR | $5.00 / €5.00 |
| `50` | USD / EUR | $0.50 / €0.50 |
| `1` | USD / EUR | $0.01 / €0.01 |

This applies to **every** field that carries a monetary value: `balance`,
`amount`, `total_payout`, `spin_payout`, `demo_balance`, `coin_value`.

> **Never** send floats or strings. `150000` is correct; `1500.00` and `"1500"`
> are not.

### Identifiers

BGA-generated identifiers are prefixed strings — always store and compare
them as strings:

| Prefix | Resource |
|--------|----------|
| `gm_…` | Game |
| `gs_…` | Game session |
| `txn_…` | Transaction |
| `fs_…` | Free spins campaign |

Identifiers you generate (and we echo back):

| You generate | Used for |
|--------------|---------|
| `player_id` | Your immutable ID for the player. |
| `freespins_id` | Unique per bonus grant. |

---

## 5. Game catalogue

### List games

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

Returns all games available to your account, paginated.

**Query parameters**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `studio` | string | — | Filter by studio name (e.g. `igrosoft`). |
| `category` | string | — | Filter by category (e.g. `slots`, `live-casino`). |
| `page` | int | `1` | Page number, 1-based. |
| `per_page` | int | `50` | Items per page, max 200. |

**Example**

```bash
curl -s "https://<BGA_DOMAIN>/api/v1/games?category=slots&per_page=2" \
  -H "X-API-Key: <YOUR_API_KEY>"
```

**Response `200`**

```json
{
  "data": [
    {
      "id": "gm_4c27e7a7f3c75086a20e",
      "slug": "crazymonkey",
      "name": "Crazy Monkey",
      "studio": "igrosoft",
      "category": "slots",
      "is_active": true,
      "platforms": {
        "desktop": true,
        "mobile": true
      },
      "thumbnail_url": "https://<BGA_DOMAIN>/images/igrosoft/528-326/crazymonkey"
    }
  ],
  "meta": { "total": 243, "page": 1, "per_page": 2, "total_pages": 122 }
}
```

### Get a single game details

```
GET /api/v1/games/{slug}
```

```bash
curl -s https://<BGA_DOMAIN>/api/v1/games/crazymonkey \
  -H "X-API-Key: <YOUR_API_KEY>"
```

**Response `200`**

```json
{
  "data": {
    "id": "gm_4c27e7a7f3c75086a18e",
    "slug": "crazymonkey",
    "name": "Crazy Monkey",
    "studio": "igrosoft",
    "category": "slots",
    "is_active": true,
    "platforms": {
      "desktop": true,
      "mobile": true
    },
    "thumbnail_url": "https://<BGA_DOMAIN>/images/igrosoft/528-326/crazymonkey"
  }
}
```

Unknown slug → `404`.

### Studio logos

```
GET /api/v1/providers
GET /api/v1/providers/{provider}/logo?size=big&color=white
```

Returns logo URLs for each studio. Use `size=big|small` and `color=white|black|color`
to get the right variant for your UI.

---

## 6. Launching a game session

A **session** represents one player opening one game. Create it server-side, then
redirect the player's browser to the returned `launch_url`.

```
POST /api/v1/sessions
```

**Request body**

| Field | Type | Required | Description                                                                                          |
|-------|------|----------|------------------------------------------------------------------------------------------------------|
| `player_id` | string | ✅ | Your immutable identifier for the player.                                                            |
| `game_slug` | string | ✅ | From the catalogue.                                                                                  |
| `studio` | string | ✅ | From the catalogue.                                                                                  |
| `currency` | string | ✅ | LIST OF SUPPORTED CURRENCIES.                                                                        |
| `balance` | int | ✅ | Player's current balance in minor units (cents). `150000` = $1500.00. Used as the initial display hint; the live balance is always fetched from your wallet webhook during the game.                               |
| `player_group` | string | ❌ | Player segment; defaults to `default`.                                                               |
| `language` | string | ❌ | 2-letter code, defaults to `en`.                                                                     |
| `device` | string | ❌ | `desktop` (default) or `mobile`.                                                                     |
| `return_url` | string | ❌ | URL for the in-game back-to-lobby button. Falls back to your account's default lobby URL if omitted. |
| `freespins_id` | string | ❌ | Attach a free spins campaign to this session (see [§10](#10-free-spins-bonus-rounds)).               |

**LIST OF SUPPORTED CURRENCIES**

```json
[
    ADA, AED, AFN, ALL, AMD, ANG, AOA, ARS, AUD, AWG,
    AZN, BAM, BBD, BC, BDT, BET, BGN, BHD, BIF, BMD,
    BMZ, BNB, BND, BOB, BOV, BRL, BSD, BTC, BTN, BWP,
    BYN, BYR, BZD, CAD, CDF, CHE, CHF, CHW, CLF, CLP,
    CNY, COP, COU, CRC, CUC, CUP, CVE, CZK, DJF, DKK,
    DLS, DOGE, DOP, DZD, EGP, ERN, ETB, ETH, EUR, FJD,
    FKP, FUN, GBP, GC, GEL, GHS, GIP, GMD, GNF, GTQ,
    GYD, HKD, HNL, HRK, HTG, HUF, IDR, ILS, INR, IQD,
    IRR, IRR2, IRT, ISK, JC, JMD, JOD, JPY, KES, KGS,
    KHR, KMF, KPW, KRW, KRW2, KWD, KYD, KZT, LAK, LBP,
    LKR, LRD, LSL, LTC, LYD, MAD, MDL, MGA, MKD, MMK,
    MNT, MOP, MRO, MUR, MVR, MWK, MXN, MXV, MYR, MZN,
    NAD, NGN, NIO, NOK, NPR, NZD, OMR, PAB, PEN, PGK,
    PHP, PKR, PLN, PYG, QAR, RBX, RON, RSD, RUB, RWF,
    SAR, SBD, SBS, SC, SCR, SDG, SEK, SGD, SHP, SLL,
    SOL, SOS, SRD, SSP, STD, SVC, SYP, SZL, THB, TJS,
    TKN, TMT, TND, TOM, TOP, TRX, TRY, TTD, TWD, TZS,
    UAH, UGX, USD, USDC, USDT, USN, UYI, UYU, UZS, VEF,
    VND, VUV, WST, XAF, XCD, XOF, XPF, XRP, YER, ZAR,
    ZMW, ZWL, mBCH, mBTC, mDASH, mETH, mLTC, mXMR, uBTC, uETH,
    uLTC, 
]
```

**Example**

```bash
curl -s -X POST https://<BGA_DOMAIN>/api/v1/sessions \
  -H "X-API-Key: <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{
    "player_id": "user_12345",
    "game_slug": "crazymonkey",
    "studio": "igrosoft",
    "currency": "EUR",
    "balance": 150000,
    "language": "en",
    "device": "desktop",
    "return_url": "https://yourcasino.com/lobby",
  }'
```

**Response `201`**

```json
{
  "session_id": "gs_01j9x7p3session000",
  "launch_url": "https://<BGA_DOMAIN>/play/gs_01j9x7p3session000",
  "expires_at": "2026-06-06T18:22:01Z"
}
```

**What to do with the response:**

1. Save `session_id` — you'll see it in all subsequent webhook events.
2. Redirect the player's browser to `launch_url`. BGA serves the game directly.
3. Do not cache or reuse `launch_url` — it is bound to a single player session.

**Session lifetime:** sessions stay alive while the game is open.

**Errors**

| Status | Reason |
|--------|--------|
| `404` | `game_slug` not found in the catalogue. |
| `410` | Game is disabled — removed from the catalogue or deactivated by the studio. |
| `422` | Game engine rejected the launch (e.g. invalid parameters). See `error.message` for details. |
| `503` | Platform temporarily unavailable — retry with exponential backoff. |

### Complete launch flow

```
Player               Your Backend                    BGA
  │                       │                            │
  │  POST /api/v1/sessions│                            │
  │──────────────────────►│                            │
  │                       │  POST /sessions (create)   │
  │                       │───────────────────────────►│
  │                       │  201 { session_id }        │
  │                       │◄───────────────────────────│
  │  201 { launch_url }   │                            │
  │◄──────────────────────│                            │
  │                       │                            │
  │  GET launch_url (browser redirect)                 │
  │───────────────────────────────────────────────────►│
  │  game HTML / JS / assets                           │
  │◄───────────────────────────────────────────────────│
  │                       │                            │
  │              ─ ─ ─ POST session/verify ─ ─ ─ ─ ─ ─►│
  │                       │◄───────────────────────────│  (BGA → Your Backend)
  │                       │  { player_id, player_group,│
  │                       │    balance }               │
  │                       │───────────────────────────►│
  │  (game loads)         │                            │
  │                       │                            │
  │──── spin (in-browser) ────────────────────────────►│
  │                       │◄─── POST bet/create ───────│  BGA → Your Backend
  │                       │─── 200 { balance } ───────►│
  │                       │◄─── POST bet/win ──────────│  BGA → Your Backend
  │                       │─── 200 { balance } ───────►│
  │◄─── show result ───────────────────────────────────│
```

> All financial operations (`session/verify`, `balance`, `bet/create`, `bet/win`) are
> **BGA → Your Backend** calls. Your backend is the single source of truth for
> player balances.

---

## 7. Demo sessions

Demo sessions use play credits — no real wallet is involved.

```
POST /api/v1/sessions/demo
```

**Request body**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `game_slug` | string | ✅ | From the catalogue. |
| `studio` | string | ✅ | From the catalogue. |
| `currency` | string | ✅ | Currency to display. |
| `language` | string | ❌ | Default `en`. |
| `device` | string | ❌ | `desktop` (default) or `mobile`. |
| `return_url` | string | ❌ | Back-to-lobby URL. |
| `demo_balance` | int | ❌ | Starting demo credits in minor units (cents); default `500000` = $5000.00. |

**Response `201`**

```json
{
  "launch_url": "https://<BGA_DOMAIN>/demo/2f1c9a8b7d6e5f4a",
  "expires_at": "2026-06-06T18:22:01Z"
}
```

Demo sessions use a **sliding 15-minute expiry** — each in-game request resets
the timer. The session expires only after 15 minutes of inactivity. No webhook
events are sent for demo sessions.

---

## 8. Wallet webhooks

While a player is in a game, BGA `POST`s events to your **Webhook URL** so you
can authorise sessions and move money on your players' wallets in real time.

You implement **one HTTPS base URL** (the Webhook URL you registered during
onboarding). BGA `POST`s each webhook type to a dedicated sub-path under that URL:

```
POST {your_webhook_url}/session/verify
POST {your_webhook_url}/balance
POST {your_webhook_url}/bet/create
POST {your_webhook_url}/bet/win
POST {your_webhook_url}/trx/cancel
POST {your_webhook_url}/trx/complete
POST {your_webhook_url}/freerounds/start
POST {your_webhook_url}/freerounds/spin
POST {your_webhook_url}/freerounds/complete
```

Each route must:
- **Verify the signature** on every request (see below).

### Webhook envelope

Every webhook has the same JSON envelope:

```json
{
  "id": "wh_01j9z3a8delivery00",
  "occurred_at": "2026-06-06T14:25:10.412Z",
  "session_id": "gs_01j9x7p3session000",
  "data": { "...": "route-specific fields" }
}
```

| Field | Description |
|-------|-------------|
| `id` | Unique delivery ID. |
| `occurred_at` | When the event happened (UTC). |
| `session_id` | The game session this event belongs to. |
| `data` | Route-specific payload (see each route below). |

The route is conveyed by the HTTP path — your handler already knows which event it received.

**Request headers**

| Header | Description |
|--------|-------------|
| `X-Webhook-Signature` | `sha256=<hex>` — HMAC-SHA256 of the raw body. |
| `Content-Type` | `application/json` |

---

### Verifying signatures

Every webhook request carries an `X-Webhook-Signature` header. Its value is
`sha256=<hex>` — an HMAC-SHA256 of the raw request body.

The signing secret is the **Webhook Secret** issued to you by the BGA manager
when your account is provisioned. Keep it confidential; treat it like a
password.

Compute HMAC-SHA256 over the **raw request body bytes** using that secret, and
compare it to the `X-Webhook-Signature` header in constant time.
**Always reject requests where the signature doesn't match.**

**Python**

```python
import hmac
import hashlib

def verify_webhook(raw_body: bytes, header: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, header)
```

**Node.js**

```js
const crypto = require("crypto");

function verifyWebhook(rawBody, header, secret) {
    const expected = "sha256=" + crypto
        .createHmac("sha256", secret)
        .update(rawBody)
        .digest("hex");
    return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header));
}
```

**PHP**

```php
function verifyWebhook(string $rawBody, string $header, string $secret): bool {
    $expected = "sha256=" . hash_hmac("sha256", $rawBody, $secret);
    return hash_equals($expected, $header);
}
```

> Verify against the **raw bytes** you received before any JSON parsing or
> re-serialisation. Reformatting the body will break the signature check.

---

### `POST session/verify`

Called once when the player opens the game. Confirm the player exists and return
their current balance.

**data**

```json
{
  "player_id": "user_12345",
  "currency": "EUR",
  "game_slug": "crazymonkey"
}
```

**Your response `200`**

```json
{
  "player_id": "user_12345",
  "player_group": "vip",
  "balance": 150000
}
```

`balance: 150000` = $1500.00. Always return the balance in minor units (cents).

If you cannot verify the player, return any non-2xx — the game will not load.

---

### `POST balance`

Called whenever the game needs a fresh balance to display (e.g. after a spin).

**data**

```json
{
  "player_id": "user_12345",
  "currency": "EUR",
  "game_slug": "crazymonkey"
}
```

**Your response `200`**

```json
{
  "balance": 149500,
  "currency": "EUR"
}
```

---

### `POST bet/create`

The player placed a bet. **Debit** the player's wallet and return the new balance.

**data**

```json
{
  "transaction_id": "txn_01j9y2k5bet000000",
  "player_id": "user_12345",
  "amount": 500,
  "currency": "EUR"
}
```

`amount: 500` = €5.00. Debit exactly this integer value from the player's balance.

**Your response `200`** (bet accepted)

```json
{ "balance": 149500 }
```

`balance: 149500` = €1495.00 (balance after the debit).

**Insufficient funds — return HTTP `402`:**

```json
{ "error": "insufficient_funds" }
```

BGA shows the player a "not enough balance" prompt and keeps the session alive.

> **Idempotency:** if you have already processed this `transaction_id`, return
> the current balance and do **not** debit again. Do not return an error.

---

### `POST bet/win`

The round finished with a win. **Credit** the player's wallet.

**data**

```json
{
  "transaction_id": "txn_01j9y9w4payout000",
  "player_id": "user_12345",
  "amount": 1200,
  "currency": "EUR"
}
```

`amount: 1200` = €12.00. Credit exactly this integer value to the player's balance.

**Your response `200`**

```json
{ "balance": 150700 }
```

`balance: 150700` = €1507.00 (balance after the credit).

Rounds that result in zero win send no `bet/win` request.

> **Idempotency:** same rule — if already credited, return current balance.

---

### `POST trx/cancel`

Sent when a `bet/create` could not be confirmed (e.g. your endpoint timed out).
BGA has already cancelled the round. If you did debit the player, **refund** them.

**data**

```json
{
  "transaction_id": "txn_01j9y2k5bet000000",
  "original_type": "bet",
  "amount": 500,
  "currency": "EUR"
}
```

**Your response `200`** — acknowledged.

```json
{}
```

BGA retries this request with exponential backoff until you return 2xx.

---

### `POST trx/complete`

Sent when a `bet/win` could not be confirmed. Ensures the win is credited
exactly once. If you did not yet credit the win, **do so now**.

**data**

```json
{
  "transaction_id": "txn_01j9y9w4payout000",
  "original_type": "payout",
  "amount": 1200,
  "currency": "EUR"
}
```

**Your response `200`** — acknowledged.

```json
{}
```

BGA retries until acknowledged.

### Delivery & retry guarantees

| Route class | Delivery | If you fail / time out |
|-------------|----------|------------------------|
| `session/verify`, `balance`, `bet/create`, `bet/win`, `freerounds/*` | Synchronous — BGA waits for your response | For bets: round is cancelled → `trx/cancel`. For wins: `trx/complete`. |
| `trx/cancel`, `trx/complete` | Asynchronous notifications | Retried: 30 s → 2 min → 10 min → 1 h → 24 h |

This guarantees **no money is ever lost or double-applied** even across network failures.

---

## 10. Free spins (bonus rounds)

Free spins let you grant players a number of bonus rounds that are played without
deducting from their real balance. The total win is credited at the end.

### Attaching a campaign

Pass your `freespins_id` in the session launch request:

```bash
curl -s -X POST https://<BGA_DOMAIN>/api/v1/sessions \
  -H "X-API-Key: <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{
    "player_id": "user_12345",
    "game_slug": "crazymonkey",
    "studio": "Pragmatic Play",
    "currency": "EUR",
    "balance": 150000,
    "freespins_id": "promo_welcome_42"
  }'
```

`freespins_id` is **your** identifier for the grant. Use a unique value per
player per grant — it prevents the same campaign from being activated twice.

### Campaign lifecycle

```
You: POST /api/v1/sessions { freespins_id: "promo_welcome_42" }
             │
             ▼
  ┌── POST freerounds/start ──────► you return { total_spins, bet_level, coin_value }
  │
  ├── POST freerounds/spin ────────► (one per spin; informational; just acknowledge)
  ├── POST freerounds/spin
  │   … (N times)
  │
  └── POST freerounds/complete ────► you credit total_payout, return { balance }
```

### `POST freerounds/start`

The player started the bonus. Return spin configuration.

**data**

```json
{
  "freespins_id": "promo_welcome_42",
  "player_id": "user_12345",
  "game_slug": "crazymonkey",
  "currency": "EUR"
}
```

> No `transaction_id` — none of the freerounds routes carry one. The entire campaign is identified by `freespins_id`; use it for idempotency checks instead. The `session_id` is available in the envelope top level if you need to tie the grant to the session.

**Your response `200`**

```json
{
  "total_spins": 20,
  "bet_level": 1,
  "coin_value": 100
}
```

| Field | Type | Meaning |
|-------|------|---------|
| `total_spins` | int | Number of free spins the engine will execute. |
| `bet_level` | int | An index into the game's internal bet table. Valid values are game-specific — check the game's documentation or use `1` if unsure. |
| `coin_value` | int | Coin denomination in minor units (e.g. `100` = €1.00). |

These three values come from your bonus campaign configuration. Return them as-is — the engine uses them verbatim to configure the spins. **You do not calculate the stake yourself**; the engine resolves `bet_level` against the game's own bet table.

If you cannot activate (e.g. promo already used), return a non-2xx — the bonus
is cancelled.

### `POST freerounds/spin`

Called once per completed spin. **Informational** — do not move money here.

**data**

```json
{
  "freespins_id": "promo_welcome_42",
  "spin_number": 7,
  "spin_payout": 250,
  "total_payout": 1750
}
```

**Your response `200`**

```json
{}
```

These are not retried.

### `POST freerounds/complete`

Campaign finished. **Credit the total win** to the player's wallet.

**data**

```json
{
  "freespins_id": "promo_welcome_42",
  "player_id": "user_12345",
  "total_payout": 4750,
  "currency": "EUR"
}
```

**Your response `200`**

```json
{ "balance": 154750 }
```

> **Idempotency:** if you have already credited this `freespins_id` (BGA's
> `fs_` identifier), return the current balance without crediting again.

---

## 11. Querying status

### Get a transaction

```
GET /api/v1/transactions/{transaction_id}
```

Every wallet movement carries a `transaction_id` in the webhook. Look it up for
reconciliation.

**Response `200`**

```json
{
  "transaction_id": "txn_01j9y2k5bet000000",
  "type": "bet",
  "amount": 500,
  "currency": "EUR",
  "status": "completed",
  "balance_after": 149500,
  "created_at": "2026-06-06T14:25:10Z"
}
```

**Transaction types:** `bet` · `payout` · `cancel` · `finalize`

**Transaction statuses:** `processing` · `completed` · `rejected` · `pending_cancel` · `cancelled` · `pending_finalize` · `finalized` · `stuck`

### Get a free spins campaign

```
GET /api/v1/freespins/{freespins_id}
```

**Response `200`**

```json
{
  "freespins_id": "fs_01j9z8c2promo0000",
  "status": "completed",
  "total_spins": 20,
  "spins_completed": 20,
  "last_spin_payout": 500,
  "total_payout": 4750,
  "currency": "EUR",
  "created_at": "2026-06-06T14:30:00Z",
  "completed_at": "2026-06-06T14:33:12Z"
}
```

**Free spins statuses:** `pending` · `activating` · `active` · `completing` · `completed` · `cancelled`

---

## 12. Rate limiting

Requests to the Platform API are limited per minute per endpoint. The default
limit is configurable per operator — contact us to raise it.

When the limit is exceeded, BGA returns:

```
HTTP/1.1 429 Too Many Requests
Retry-After: 60
```

```json
{ "error": { "code": 429, "message": "Rate limit exceeded" } }
```

**Always honour `Retry-After`.** Implement exponential backoff with jitter for
retrying `429` and `503` responses.

---

## 13. Error reference

### Platform API errors

```json
{ "error": { "code": 404, "message": "Game not found" } }
```

| HTTP status | Meaning | Action |
|-------------|---------|--------|
| `401` | Invalid or missing API key | Check `X-API-Key`. |
| `402` | Insufficient funds | Player balance too low. |
| `404` | Resource not found | Verify `game_slug`, `transaction_id`, or `freespins_id`. |
| `409` | Duplicate transaction / free spins already used | Already processed; idempotency applies. |
| `410` | Game disabled | Game deactivated or removed from the catalogue. |
| `422` | Engine rejected the launch | See `error.message` — invalid parameters or engine-side validation failure. |
| `429` | Rate limit exceeded | Back off; honour `Retry-After`. |
| `503` | Platform temporarily unavailable | Retry with exponential backoff. |
| `500` | Internal error | Retry; contact support if persistent. |

### Webhook response errors

When you return non-2xx on a webhook, the effect depends on the event:

| Your non-2xx on | Effect |
|-----------------|--------|
| `POST session/verify` | Game does not load. |
| `POST bet/create` (non-402) | Round outcome is undefined; BGA cancels and sends `trx/cancel`. |
| `POST bet/create` with `402` | Clean insufficient-funds rejection; session stays alive. |
| `POST bet/win` | Win delivery retried as `trx/complete`. |
| `POST freerounds/start` | Bonus is cancelled. |
| `POST freerounds/complete` | Total payout delivery retried. |

---

## 14. Integration checklist

Use this checklist before going live.

### Platform API

- [ ] Store the API key securely (environment variable or secrets manager).
- [ ] Send `X-API-Key` on every request.
- [ ] Fetch the game catalogue.
- [ ] Redirect the player to `launch_url`.
- [ ] Handle `429` with `Retry-After` backoff.
- [ ] Handle `503` with exponential backoff.

### Wallet webhooks

- [ ] Expose a publicly reachable HTTPS base URL for webhooks.
- [ ] **Verify `X-Webhook-Signature`** on every request — reject mismatches.
- [ ] Implement `POST session/verify` — return `player_id`, `player_group`, `balance`.
- [ ] Implement `POST balance` — return `balance`, `currency`.
- [ ] Implement `POST bet/create` — debit, return new `balance`; return `402` for insufficient funds.
- [ ] Implement `POST bet/win` — credit, return new `balance`.
- [ ] **Idempotency:** process each `transaction_id` at most once.
- [ ] Implement `POST trx/cancel` — refund the bet if it was debited; acknowledge with `{}`.
- [ ] Implement `POST trx/complete` — credit the win if not yet done; acknowledge with `{}`.
- [ ] Implement free spins routes if you offer bonus campaigns.

### Go-live

- [ ] Complete end-to-end testing in **sandbox** (full round cycle: session → bet → payout).
- [ ] Verify idempotency: replay the same `transaction_id` and confirm no double-charge.
- [ ] Verify signature rejection: send a request with an invalid signature and confirm your endpoint rejects it.
- [ ] Confirm your production webhook URL is reachable from the internet.
- [ ] Monitor webhook delivery health for the first 24 h after go-live.

---

## 15. Minimal implementation examples

### Minimal webhook receiver — Python (FastAPI)

```python
import hmac
import hashlib
from fastapi import FastAPI, Request, Response

app = FastAPI()
WEBHOOK_SECRET = "<your_webhook_signing_secret>"


def verify_signature(raw_body: bytes, header: str) -> bool:
    expected = "sha256=" + hmac.new(
        WEBHOOK_SECRET.encode(), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, header or "")


async def verified(request: Request):
    raw = await request.body()
    if not verify_signature(raw, request.headers.get("X-Webhook-Signature", "")):
        raise HTTPException(status_code=401)
    return (await request.json())["data"]


@app.post("/bga/session/verify")
async def session_verify(request: Request):
    data = await verified(request)
    player = db.get_player(data["player_id"])
    if not player:
        return Response(status_code=404)
    return {"player_id": player.id, "player_group": player.group, "balance": player.balance_minor}

@app.post("/bga/balance")
async def balance(request: Request):
    data = await verified(request)
    balance = db.get_balance(data["player_id"], data["currency"])
    return {"balance": balance, "currency": data["currency"]}

@app.post("/bga/bet/create")
async def bet_create(request: Request):
    data = await verified(request)
    txn_id = data["transaction_id"]
    if db.transaction_exists(txn_id):               # idempotency check
        return {"balance": db.get_balance(data["player_id"], data["currency"])}
    if db.get_balance(data["player_id"], data["currency"]) < data["amount"]:
        return Response(
            status_code=402,
            content='{"error":"insufficient_funds"}',
            media_type="application/json",
        )
    # debit must atomically: subtract balance AND record txn_id,
    # so that the idempotency check above returns True on any retry.
    return {"balance": db.debit(data["player_id"], data["amount"], txn_id)}

@app.post("/bga/bet/win")
async def bet_win(request: Request):
    data = await verified(request)
    txn_id = data["transaction_id"]
    if db.transaction_exists(txn_id):               # idempotency check
        return {"balance": db.get_balance(data["player_id"], data["currency"])}
    # credit must atomically: add to balance AND record txn_id.
    return {"balance": db.credit(data["player_id"], data["amount"], txn_id)}

@app.post("/bga/trx/cancel")
async def trx_cancel(request: Request):
    data = await verified(request)
    # BGA could not confirm the original bet/create, so the round was rolled back.
    # Refund the player if you had already debited them — but only if you haven't
    # refunded this transaction_id before (maybe_refund must be idempotent).
    db.maybe_refund(data["transaction_id"], data["amount"])
    return {}

@app.post("/bga/trx/complete")
async def trx_complete(request: Request):
    data = await verified(request)
    # BGA could not confirm the original bet/win delivery, so it is retrying here.
    # Credit the player if you haven't already — ensure_credited must be idempotent
    # (check transaction_id before applying).
    db.ensure_credited(data["transaction_id"], data["amount"])
    return {}

@app.post("/bga/freerounds/start")
async def freerounds_start(request: Request):
    data = await verified(request)
    campaign = db.get_campaign(data["freespins_id"])
    # Reject unknown or already-activated campaigns — each freespins_id is one-use.
    if not campaign or campaign.used:
        return Response(status_code=409)
    # Return the spin config exactly as stored in your campaign record.
    # The engine uses these values verbatim; do not compute them on the fly.
    return {"total_spins": campaign.total_spins, "bet_level": campaign.bet_level, "coin_value": campaign.coin_value}

@app.post("/bga/freerounds/spin")
async def freerounds_spin(request: Request):
    await verified(request)
    # Informational only — one call per completed spin with running payout totals.
    # Do not move money here; the full payout is settled in freerounds/complete.
    return {}

@app.post("/bga/freerounds/complete")
async def freerounds_complete(request: Request):
    data = await verified(request)
    # Idempotency: if the campaign was already credited (e.g. on a retry), return
    # the current balance without crediting again.
    if db.campaign_credited(data["freespins_id"]):
        return {"balance": db.get_balance(data["player_id"], data["currency"])}
    # Credit the total campaign win atomically and mark the campaign as settled.
    return {"balance": db.credit_campaign(data["player_id"], data["total_payout"], data["freespins_id"])}
```

### Minimal session launcher — Python

```python
import httpx
import uuid

BGA_API_KEY = "<your_api_key>"
BGA_BASE_URL = "https://<BGA_DOMAIN>"


def launch_game_session(player_id: str, game_slug: str, studio: str,
                         currency: str, balance_minor: int, language: str = "en",
                         device: str = "desktop", return_url: str | None = None) -> dict:
    """Returns { session_id, launch_url, expires_at }."""
    resp = httpx.post(
        f"{BGA_BASE_URL}/api/v1/sessions",
        headers={"X-API-Key": BGA_API_KEY},
        json={
            "player_id": player_id,
            "game_slug": game_slug,
            "studio": studio,
            "currency": currency,
            "balance": balance_minor,
            "language": language,
            "device": device,
            **({"return_url": return_url} if return_url else {}),
        },
        timeout=10,
    )
    resp.raise_for_status()
    return resp.json()
```

---
