# Scratcher — Merchant Integration Guide

Scratcher is a scratch-ticket game provider on a **seamless wallet**: the
player's money stays in your wallet the whole time. Your server asks us for a
launch link and sends the player to it; while they play, we call your wallet
to read the balance, take the stake when they buy a ticket, and pay the prize
when a ticket they open wins.

This guide is complete on its own: both directions of the integration, every
field, every status, with worked examples. All hosts, keys and secrets here
are **placeholders**; your real values are on the Integration page of the
merchant portal (see [Getting your keys](#getting-your-keys)).

## Contents

- [Quick facts](#quick-facts)
- [Placeholders](#placeholders)
- [How a game session runs](#how-a-game-session-runs)
- [Getting your keys](#getting-your-keys)
- [Your calls to us](#your-calls-to-us)
  - [Authentication](#authentication)
  - [IP whitelist](#ip-whitelist)
  - [Create a session](#create-a-session)
  - [List products](#list-products)
  - [Connection test](#connection-test)
- [Our calls to you (wallet callbacks)](#our-calls-to-you-wallet-callbacks)
  - [Request format and signature](#request-format-and-signature)
  - [Worked signature example](#worked-signature-example)
  - [Verifying a request](#verifying-a-request)
  - [Your answer](#your-answer)
  - [Events](#events)
  - [Example payloads](#example-payloads)
  - [Idempotency and duplicates](#idempotency-and-duplicates)
  - [Timeouts, retries and no answer](#timeouts-retries-and-no-answer)
  - [Reconcile query](#reconcile-query)
  - [Admin cancels](#admin-cancels)
  - [Tickets never opened](#tickets-never-opened)
- [Suspended players](#suspended-players)
- [Maintenance](#maintenance)
- [Currencies](#currencies)
- [Status and error codes](#status-and-error-codes)
- [Errors and tracing](#errors-and-tracing)
- [Integration checklist](#integration-checklist)
- [FAQ](#faq)

## Quick facts

| Item | Value |
| --- | --- |
| Format | JSON, UTF-8. Times in responses are ISO 8601 UTC; times in signatures are Unix seconds |
| Money | JSON number in the player's currency, 2 decimals at most |
| Auth, you to us | `Authorization: Bearer {API_KEY}` (or `X-API-Key: {API_KEY}`) |
| Auth, us to you | `X-Scratcher-Signature`: HMAC-SHA256 of `<timestamp>.<body>` with `{CALLBACK_SECRET}` |
| Your endpoints on our side | `POST /v1/sessions`, `GET /v1/products`, `GET /v1/connection-test` |
| Our events to your callback URL | `connectionTest`, `checkBalance`, `placeBets`, `settleBets`, `cancelBets`, `voidSettled` |
| Reconcile query | `POST` to your callback URL with its last `callback` segment replaced by `query` |
| Your answer to every callback | HTTP `200` + JSON `{ "status": "ok" \| "duplicate" \| "insufficient_balance" \| "user_not_found" \| "user_suspended" \| "invalid" \| "error", ... }` |
| Time limit for your answer | 10 seconds, no redirects followed |
| Launch link | one use, valid 120 seconds |
| Player session | 5 minutes idle by default, 1 hour at most |

## Placeholders

| Placeholder | Meaning | Where to get it |
| --- | --- | --- |
| `{MERCHANT_API_BASE_URL}` | Base of your calls to us, ending in `/api`, e.g. `https://<host>/api`. Endpoint paths below start at `/v1` | Sent with your account |
| `{API_KEY}` | Your API key, starts with `mk_` | Portal, Integration page |
| `{CALLBACK_SECRET}` | Secret we sign callbacks with, starts with `cs_` | Portal, Integration page (shown once when reset) |
| `{CALLBACK_URL}` | Your wallet endpoint that receives every callback | You set it in the portal, Integration page |
| `{GAME_BASE_URL}` | Our game site. You never configure it: every launch answer gives the full `launchUrl` | — |

**Keep these in your configuration, not in your code.** We may move the API
to another address, and keys and secrets can be reset at any time.

## How a game session runs

1. Your server calls `POST {MERCHANT_API_BASE_URL}/v1/sessions` with the player and gets a `launchUrl`.
2. You redirect the player's browser to `launchUrl` (`{GAME_BASE_URL}/loading?code=…`) at once.
3. The game trades the code for a player session and sends you `checkBalance`. You answer with the balance.
4. The player buys a ticket: we send `placeBets`. You take the stake and answer `ok`.
5. The player scratches and opens the ticket. If it wins, we send `settleBets`. You pay the prize and answer `ok`. A losing ticket sends nothing.

```mermaid
sequenceDiagram
    participant M as Merchant server
    participant S as Scratcher API
    participant P as Player browser
    M->>S: POST /v1/sessions (Bearer key)
    S-->>M: launchUrl (120 s, one use)
    M->>P: redirect to launchUrl (/loading?code=…)
    P->>S: open game (code)
    S->>M: checkBalance (signed)
    M-->>S: status ok, balance
    S-->>P: lobby
    P->>S: buy 1 ticket
    S->>M: placeBets (signed)
    M-->>S: status ok, balance
    P->>S: scratch and open
    S->>M: settleBets, only for a winning ticket (signed)
    M-->>S: status ok, balance
```

## Getting your keys

Your **owner** account on the merchant portal has an **Integration** page
(Developer menu, owner only). There you can:

- see your merchant code and the first characters of your API key, and copy the full key;
- reset the API key: the old key stops working at once, so put the new one in your server straight away;
- reset the callback secret: it is shown once, in the reset dialog; from then on every call is signed with the new secret and your server rejects them until it has it;
- set the **Callback URL**: a full `http://` or `https://` URL; use HTTPS in production;
- run **Test connection**, which sends a `connectionTest` event to your callback URL now (see [Connection test](#connection-test)).

Other owner-only pages in the Developer menu: **IP whitelist**, **API log**.
The **Maintenance** page (Settings menu) is also owner only. Our admins can
do the same for you when you ask.

## Your calls to us

All routes are under `{MERCHANT_API_BASE_URL}/v1`. Bodies are JSON. A body
with a field we do not know returns `400`. Error bodies are described in
[Status and error codes](#status-and-error-codes).

### Authentication

Send `Authorization: Bearer {API_KEY}` (`X-API-Key: {API_KEY}` is accepted too).

| Case | Answer |
| --- | --- |
| Key missing | `401` |
| Key wrong | `401` |
| Merchant inactive | `403` |
| IP not whitelisted | `403` `IP_NOT_ALLOWED` |

### IP whitelist

Optional. Your owner can list the server addresses allowed to call this API
on the portal's **IP whitelist** page (Developer menu): single IPv4 / IPv6
addresses or CIDR ranges such as `203.0.113.0/24` or `2001:db8::/32`, up to 50,
each with a note.

- While the list is empty, any caller with your key is accepted.
- Once it has an entry, a call from any other address returns `403`:

```json
{ "status": "IP_NOT_ALLOWED", "message": "Calls from 198.51.100.9 are not allowed for this merchant; ask the merchant owner to add it to the IP whitelist", "ip": "198.51.100.9" }
```

`ip` is the address we saw. The check is the same on every route, and runs
after the key check. It applies only to your calls to us: our wallet calls to
your callback URL and your players' traffic are not filtered. We take the
caller's address from the connection, or from `X-Forwarded-For` set by our own
load balancer; a header you send yourself is not believed. Behind a NAT or a
proxy of yours, whitelist the public address the call leaves from. Refused
calls appear in the portal's API log.

### Create a session

`POST {MERCHANT_API_BASE_URL}/v1/sessions` launches a player.

```http
POST /api/v1/sessions
Authorization: Bearer {API_KEY}
Content-Type: application/json

{
  "player": {
    "id": "0192f3c1-8a7b-4c21-9f3e-5d8e1c2ba91b",
    "currency": "THB",
    "lang": "th",
    "displayName": "•••747"
  },
  "returnUrl": "https://member.example.com/"
}
```

| Field | Rule |
| --- | --- |
| `player.id` | Required. Your id for the player, 1–128 characters; we send it back as `player` in every wallet call. Never reuse one for a different person |
| `player.currency` | Required. ISO 4217, 3 uppercase letters, e.g. `THB`. The player sees lots sold in this currency only |
| `player.lang` | Optional. `th` (default), `en` or a region form like `en-US` |
| `player.displayName` | Optional, up to 64 characters, shown in the game. Send a masked handle, never a phone number |
| `returnUrl` | Optional http(s) URL, up to 2048 characters; the game's **home** button goes there |

Answer `200`:

```json
{
  "launchUrl": "{GAME_BASE_URL}/loading?code=c93ce36d…(64 hex characters)",
  "sessionId": "01a0f0eb-9141-750c-a38b-180c7930a3c8",
  "expiresAt": "2026-09-30T06:08:05.120Z"
}
```

Redirect the player's browser to `launchUrl` at once. The code works **once**,
within **120 seconds**; request a new link for every entry. Opening a used or
expired link shows the player a "link expired" page. We create the player on
their first launch, keyed by your merchant and `player.id`; currency,
language, display name and return URL follow the latest session.

Errors: `400` invalid field (`message` lists which); `401`/`403` for key,
merchant or IP problems; `403` `PLAYER_SUSPENDED`; `503` `MAINTENANCE`;
`503` `MERCHANT_MAINTENANCE`. See [Suspended players](#suspended-players),
[Maintenance](#maintenance) and [Status and error codes](#status-and-error-codes).

**Player session.** Opening the link logs the player in and sends you
`checkBalance`. The session ends after a period without activity (5 minutes by
default; our admins can change it or turn it off), or 1 hour after login at the
latest. The player then sees a message to open the game again from your site.
Suspending a player ends their session at once.

### List products

`GET {MERCHANT_API_BASE_URL}/v1/products`. Optional, for showing our games on
your own lobby. Answer `200`:

```json
[
  {
    "id": "01a0e6a1-9e7b-751d-a2b5-8c7f64fe9f6e",
    "code": "LOTTEST",
    "name": "Lot test",
    "price": 20,
    "currency": "THB",
    "artworkUrl": "{GAME_BASE_URL}/api/lots/01a0e6a1-9e7b-751d-a2b5-8c7f64fe9f6e/artwork",
    "ticketsRemaining": 25,
    "prizeTable": [{ "amount": 100, "quantity": 2, "remaining": 2 }],
    "soldOut": false
  }
]
```

Each item is one lot (a print run of tickets) you have on sale; `code` is the
game code we send as `gameCode`. `artworkUrl` is public. It is the theme's sale
cover, or the ticket background when the theme has none. It is cached, so a new
sale cover can take up to 5 minutes to show.

### Connection test

`GET {MERCHANT_API_BASE_URL}/v1/connection-test`. You are connected when this
returns `200`: it proves your key reaches us and our signed call reaches you.
Before we answer we send you a `connectionTest` event (see [Events](#events))
and wait up to 10 seconds.

| Our answer | Meaning |
| --- | --- |
| `200` `{"status": "OK", "merchant_code", "callback_request_id", "callback_http_status", "tested_at"}` | Both directions work |
| `502` `{"status": "CALLBACK_FAILED", "message", "callback_request_id", "callback_http_status"}` | Your key worked, our call did not; `message` says why (e.g. `ECONNREFUSED`, `HTTP 500`, `Answered invalid: bad signature`, no answer within 10 s) |

Counts as connected: HTTP 2xx from your callback URL with a JSON body (or an
empty body) whose `status` is anything except `invalid` or `error`. So
`user_not_found` is fine. We record the result of every test, from here or
from the portal's **Test connection** button.

## Our calls to you (wallet callbacks)

### Request format and signature

Every call is `POST {CALLBACK_URL}` with `Content-Type: application/json` and:

| Header | Value |
| --- | --- |
| `X-Scratcher-Timestamp` | Unix time in seconds when we sent it |
| `X-Scratcher-Signature` | hex HMAC-SHA256 of `<timestamp>.<body>` with `{CALLBACK_SECRET}` |

The body is a JSON object with these fields:

| Field | Type | Meaning |
| --- | --- | --- |
| `event` | string | `connectionTest`, `checkBalance`, `placeBets`, `settleBets`, `cancelBets` or `voidSettled` |
| `provider` | string | Always `"scratcher"` |
| `player` | string | Your `player.id` from the session |
| `currency` | string | The player's currency |
| `reference` | string | Our id for the operation; see [Events](#events) |
| `txns` | array | The ticket legs (empty for `checkBalance` and `connectionTest`) |
| `timestamp` | number | Unix seconds; the same value as the header |

`<body>` is the exact request body as we sent it (compact JSON, no extra
whitespace). Compute the HMAC over the **raw bytes you received**, not over a
re-serialised copy.

### Worked signature example

All values below are fake. Callback secret:
`cs_EXAMPLE_ONLY_do_not_use_0123456789abcdefghij`. Timestamp: `1790322031`.

Body, exactly as sent (one line):

```text
{"event":"placeBets","provider":"scratcher","player":"0192f3c1-8a7b-4c21-9f3e-5d8e1c2ba91b","currency":"THB","reference":"pur_7bbda828a4194b82b14238060ac08c20","txns":[{"id":"1013ddfe-04a3-4f8c-b229-f2a18ca0eaf1","ticketId":"LOTTEST-HPRGA6PA2G2ZYEFA","betAmount":20,"gameCode":"LOTTEST"}],"timestamp":1790322031}
```

String that is signed: the timestamp, a dot, then the body:

```text
1790322031.{"event":"placeBets","provider":"scratcher",…,"timestamp":1790322031}
```

Request headers:

```http
POST /wallet/callback/shop-1 HTTP/1.1
Content-Type: application/json
X-Scratcher-Timestamp: 1790322031
X-Scratcher-Signature: dab21400860433a19359d039cb786c0b33f4b36420e8611eb0d1104b26e7ec57
```

Check your implementation: with that secret, timestamp and body, your HMAC must
equal `dab21400860433a19359d039cb786c0b33f4b36420e8611eb0d1104b26e7ec57`.

The same secret and timestamp over the reconcile body
`{"providerTxnId":"1013ddfe-04a3-4f8c-b229-f2a18ca0eaf1"}` give
`5df6842c393cbdf650dfe3fd8d4aff10a75f56644ec336fd1f09b2f2960d70d8`.

### Verifying a request

Compute the HMAC over the body you received, compare in constant time, and
reject a timestamp more than 120 seconds away from your clock. The timestamp
is inside the signed string, so a replayed request cannot be made fresh. If the
check fails, answer `{"status": "invalid", "message": "bad signature"}`.

```javascript
// Node.js example
import { createHmac, timingSafeEqual } from 'node:crypto'

function isFromScratcher(rawBody, headers, callbackSecret) {
  const timestamp = headers['x-scratcher-timestamp']
  const signature = headers['x-scratcher-signature'] ?? ''
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 120) return false
  const expected = createHmac('sha256', callbackSecret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex')
  return signature.length === expected.length &&
    timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
}
```

Read the raw body before your framework parses it (for example `express.raw`
or a `verify` hook), because a parsed and re-serialised body may differ.

### Your answer

**Answer every call with HTTP 200** and a JSON body; the result is in
`status` (lowercase, exactly as below):

```json
{ "status": "ok", "balance": 980, "balanceBefore": 1000 }
```

| Field | Meaning |
| --- | --- |
| `status` | Required. One of the values in the table below |
| `balance` | The player's balance after the call, a number (a numeric string is accepted). Required for `checkBalance`; we show it in the game |
| `balanceBefore` | Optional, the balance before the call |
| `message` | Optional text; we keep it and show it to our admins when you refuse |

| `status` | Meaning | What we do |
| --- | --- | --- |
| `ok` | Applied (or nothing to apply) | Continue |
| `duplicate` | You already applied this `event` for this `txns[].id` | Same as `ok` |
| `insufficient_balance` | placeBets: the player cannot pay | Refused, nothing moved. The ticket goes back on sale; the player sees "not enough balance" |
| `user_not_found` | No such player | Refused. placeBets: nothing moved, the ticket goes back on sale. settleBets: see below |
| `invalid` | Bad signature, stale timestamp, a payload you will not act on. `invalid` with a `message` containing "suspended" (any case) is read as `user_suspended` | Refused. placeBets: nothing moved, the ticket goes back on sale. settleBets: see below |
| `user_suspended` | The player is suspended on your side | We suspend them in the game too and their session ends. placeBets: nothing moved, the ticket goes back on sale. settleBets: nothing paid, the ticket goes back to **unopened** and stays the player's; when you launch the player again they are reactivated and can open it, and we send settleBets then |
| `error` | You could not tell whether it applied | Treated like no answer: we ask the query URL |
| anything else, or no `status` | — | Treated like no answer |

A refusal of settleBets does not cancel the prize. It stays "on its way" and
we send it again about every minute until you answer `ok` or `duplicate`. Fix
the cause instead of refusing. The exception is a suspended player: answer
`user_suspended` and the ticket goes back to unopened, with no more calls,
until the player is launched again.

A `checkBalance` answer needs `status: "ok"` and a numeric `balance`; with
anything else we keep the last balance we had.

### Events

| `event` | When | `txns[]` | Money on your side |
| --- | --- | --- | --- |
| `connectionTest` | Test connection | empty; `player` is the nil UUID `00000000-0000-0000-0000-000000000000` and `currency` is `""` | none: verify the signature and answer; any `status` except `invalid` / `error` counts as connected, so `user_not_found` is fine |
| `checkBalance` | Login, and whenever the game refreshes the balance (at most about once per 3 s per open player) | empty | none: answer `balance` |
| `placeBets` | The player buys a ticket | one ticket: `id`, `ticketId`, `betAmount` (its price), `gameCode` | − `betAmount`; refuse with `insufficient_balance` when short |
| `settleBets` | The player opens a winning ticket | one ticket: the **same** `id` as its placeBets, `ticketId`, `payoutAmount` (the prize), `gameCode` | + `payoutAmount` |
| `cancelBets` | An **admin** cancels a ticket that never paid a prize (unopened, or opened with no prize) | one ticket: the **same** `id` as its placeBets, `ticketId`, `betAmount` (its price), `gameCode` | + the stake you took with that placeBets. Refuse with `invalid` (message "no placeBets on record…") when you never applied it |
| `voidSettled` | An **admin** cancels a ticket whose prize was already paid | one ticket: the **same** `id`, `ticketId`, `betAmount` (price), `payoutAmount` (prize), `gameCode` | − (`payoutAmount` − `betAmount`): you take back the net win, so the player ends with the balance they had before buying (a prize below the price gives the difference back). Refuse a negative `betAmount` / `payoutAmount` with `invalid`; answer `insufficient_balance` when the player cannot cover it |

`txns[]` fields:

| Field | Type | Meaning |
| --- | --- | --- |
| `id` | string (UUID) | Our transaction id for the ticket. The same on placeBets, settleBets, cancelBets and voidSettled of one ticket |
| `ticketId` | string | The ticket's serial, e.g. `LOTTEST-HPRGA6PA2G2ZYEFA` |
| `betAmount` | number | The ticket's price |
| `payoutAmount` | number | The prize |
| `gameCode` | string | The lot's code (the `code` from `GET /v1/products`) |

`reference` per event:

| Event | `reference` |
| --- | --- |
| `placeBets` | `pur_…` (one per purchase; shared by all tickets of that purchase) |
| `settleBets` | the ticket serial (same as `ticketId`) |
| `checkBalance` | `bal_…` |
| `connectionTest` | `conn_…` |
| `cancelBets`, `voidSettled` | `cancel_…` |

Rules:

- One ticket per call. A purchase of several tickets (up to 10) is several placeBets with the same `reference` (`pur_…`), sent one after the other. If one is refused, or gets no answer, we stop and put the rest back on sale, so the player may get fewer tickets than asked.
- A losing ticket never reaches you: no settleBets, no money moves. (An admin cancel of a lost, opened ticket still sends `cancelBets`.)

### Example payloads

`checkBalance`:

```json
{
  "event": "checkBalance",
  "provider": "scratcher",
  "player": "0192f3c1-8a7b-4c21-9f3e-5d8e1c2ba91b",
  "currency": "THB",
  "reference": "bal_3f9c1d2e4a5b4c6d8e7f9a0b1c2d3e4f",
  "txns": [],
  "timestamp": 1790322030
}
```

answer: `{ "status": "ok", "balance": 1000 }`

`settleBets`:

```json
{
  "event": "settleBets",
  "provider": "scratcher",
  "player": "0192f3c1-8a7b-4c21-9f3e-5d8e1c2ba91b",
  "currency": "THB",
  "reference": "LOTTEST-HPRGA6PA2G2ZYEFA",
  "txns": [
    {
      "id": "1013ddfe-04a3-4f8c-b229-f2a18ca0eaf1",
      "ticketId": "LOTTEST-HPRGA6PA2G2ZYEFA",
      "payoutAmount": 100,
      "gameCode": "LOTTEST"
    }
  ],
  "timestamp": 1790322095
}
```

answer: `{ "status": "ok", "balance": 1080 }`

`cancelBets`:

```json
{
  "event": "cancelBets",
  "provider": "scratcher",
  "player": "0192f3c1-8a7b-4c21-9f3e-5d8e1c2ba91b",
  "currency": "THB",
  "reference": "cancel_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
  "txns": [
    {
      "id": "1013ddfe-04a3-4f8c-b229-f2a18ca0eaf1",
      "ticketId": "LOTTEST-HPRGA6PA2G2ZYEFA",
      "betAmount": 20,
      "gameCode": "LOTTEST"
    }
  ],
  "timestamp": 1790323000
}
```

`voidSettled` looks the same, with `"event": "voidSettled"` and one more field
in the ticket, `"payoutAmount": 100`.

`connectionTest`:

```json
{
  "event": "connectionTest",
  "provider": "scratcher",
  "player": "00000000-0000-0000-0000-000000000000",
  "currency": "",
  "reference": "conn_6e5d4c3b-2a19-4807-96f5-e4d3c2b1a098",
  "txns": [],
  "timestamp": 1790322000
}
```

answer: `{ "status": "ok" }`

### Idempotency and duplicates

- Dedupe by (`event`, `txns[].id`). We retry with the same `id`; a retry must answer `duplicate` (or `ok`) without moving money again.
- The `id` of one ticket is the same on placeBets, settleBets, cancelBets and voidSettled, so you can match every leg to the original placeBets.
- We never send a second placeBets for a ticket after an unclear answer; we ask the query URL instead (see [Reconcile query](#reconcile-query)). Players cannot double-buy by retrying: the game protects each purchase attempt with its own key.

### Timeouts, retries and no answer

You have **10 seconds**. We do not follow redirects: the callback URL must
answer directly, and a `301`/`302` counts as no answer. No answer, a non-2xx, a
body that is not JSON, or `error` means **we do not know** whether you applied
it.

| Event | When we do not know | What we do |
| --- | --- | --- |
| `placeBets` | no usable answer | The ticket is held as "awaiting payment" and we ask the query URL ([Reconcile query](#reconcile-query)) |
| `settleBets` | no usable answer or a refusal | The prize stays "on its way"; we send settleBets again with the same `id` about every minute until you answer `ok` or `duplicate` |
| `checkBalance` | no usable answer | We keep the last known balance |
| `cancelBets`, `voidSettled` | no usable answer | Nothing changes; the admin is told the merchant did not confirm and tries again with the same `id` |
| `connectionTest` | no usable answer | The test fails and the error is recorded |

### Reconcile query

When a placeBets got no usable answer, our scheduler asks you about that
transaction: the first query comes 60 seconds after the sale, then it repeats
about every 1–2 minutes with no limit until it knows. The query goes to
`POST` your **query URL**: the callback URL with its last `callback` path
segment replaced by `query`. Same headers and signature as a wallet call.

Example: a callback URL `https://api.example.com/wallet/callback/shop-1` means
the query URL `https://api.example.com/wallet/query/shop-1`.

If your callback URL has no `callback` path segment, we have no query URL and
cannot reconcile: tickets with an unclear answer stay "awaiting payment". Keep
a `/callback` segment in your URL and serve `/query` next to it.

```json
// we send
{ "providerTxnId": "1013ddfe-04a3-4f8c-b229-f2a18ca0eaf1" }

// you answer, HTTP 200
{ "status": "ok", "placed": true, "settled": false, "betAmount": 20, "payoutAmount": null }
```

`providerTxnId` is the `txns[].id` of the placeBets. Answer `placed: true`
when you applied that placeBets, `placed: false` when you did not.

| Stuck state | Your answer | We do |
| --- | --- | --- |
| placeBets unanswered | `placed: true` | The ticket is the player's |
| placeBets unanswered | `placed: false` | The ticket goes back on sale |
| settleBets unanswered | — | Not queried. We send settleBets again with the same id until you answer `ok` or `duplicate` (safe, because you dedupe by event + id) |

We read only `status: "ok"` with a boolean `placed`; any other answer counts as
no answer and we ask again. A player who is suspended is skipped until
reactivated. Meanwhile the player sees the ticket as "awaiting payment" or the
prize as "on its way".

### Admin cancels

`cancelBets` and `voidSettled` are sent only when one of our admins cancels a
bought ticket (a reason is required and kept). Never `cancelBets` for a ticket
whose prize was paid: that would refund the stake and leave the prize with the
player, so those get `voidSettled`. Use the legs you stored for that `id` for
the amounts rather than trusting the payload. Both reuse the ticket's placeBets
`id`.

How we read your answer:

| Your answer | Result |
| --- | --- |
| `ok`, `duplicate` | Applied: the ticket is cancelled in the game, the merchant credit is adjusted and `balance` is stored |
| `insufficient_balance` (voidSettled) | Nothing changes; the admin is told the player cannot cover the reversal |
| `invalid`, `user_not_found`, `user_suspended` | Refused, nothing changes; the admin sees your `message` |
| No answer, non-2xx, bad JSON, `error` | Nothing changes; the admin is told the merchant did not confirm and tries again with the **same** `id`, so answer `duplicate` for a leg you already applied |

We do not query these two events; the retry is the repair.

### Tickets never opened

A ticket bought and not opened within **7 days** is opened for the player
automatically; a winning one is paid with settleBets as usual.

## Suspended players

A player can be suspended in two ways, and they differ on how they end.

| Suspended by | How | Ends |
| --- | --- | --- |
| Your wallet | You answer `user_suspended` (or `invalid` with "suspended" in `message`) | The next `POST /v1/sessions` you request for that player reactivates them: we take a new session as your sign that they may play again |
| Your staff on the portal's **Players** page, or our admins | By hand | Stays suspended until reactivated on the Players page. `POST /v1/sessions` returns `403` `{"status": "PLAYER_SUSPENDED"}` |

Suspending a player ends their session at once. While a player is suspended,
we do not run reconcile for them.

## Maintenance

| Kind | Who switches it | Effect |
| --- | --- | --- |
| Whole game | Our admins | `POST /v1/sessions` answers `503` `MAINTENANCE`; players cannot open the game |
| Your own (**Maintenance** page in the portal, owner only) | You, optionally for a time window | `POST /v1/sessions` answers `503` `MERCHANT_MAINTENANCE`; your players already inside get the same answer on every request until it ends |

Bodies:

```json
{ "status": "MAINTENANCE", "message": "The game is under maintenance", "maintenance_message": { "th": "…", "en": "…" } }
```

```json
{ "status": "MERCHANT_MAINTENANCE", "message": "…", "maintenance_message": { "th": "…", "en": "…" }, "starts_at": null, "ends_at": "2026-10-03T03:00:00.000Z" }
```

Show the player `maintenance_message` in their language, or your own text
when it is empty. `starts_at` / `ends_at` are ISO times or `null`; your
maintenance is active from `starts_at` (inclusive) until `ends_at`
(exclusive), and a missing limit is open. No launch link is issued while it is
active. End the maintenance on the portal to let players in again.

`GET /v1/connection-test` and `GET /v1/products` still work during either
kind. **Wallet calls and settlement of tickets keep running meanwhile**, so
keep your wallet endpoint reachable.

## Currencies

`player.currency` is an ISO 4217 code, 3 uppercase letters (`THB`, `USD`, …).
Every wallet call carries the player's currency in `currency`, and every
amount is in that currency. A player sees only lots sold in their currency;
`GET /v1/products` lists the lots you have on sale, each with its `currency`.

## Status and error codes

HTTP errors on your calls to us:

| HTTP | `status` in body | Meaning |
| --- | --- | --- |
| 200 | — | Done |
| 400 | — | A field is invalid or unknown; `message` lists which |
| 401 | — | API key missing or wrong |
| 403 | — | Merchant inactive |
| 403 | `PLAYER_SUSPENDED` | Player suspended by hand (your staff or our admins) |
| 403 | `IP_NOT_ALLOWED` | Your IP whitelist does not list the caller; the body has `ip` |
| 502 | `CALLBACK_FAILED` | Our call to you failed (connection test) |
| 503 | `MAINTENANCE` | The game is under maintenance; `POST /v1/sessions` is refused. `GET /v1/connection-test` and `/v1/products` still work |
| 503 | `MERCHANT_MAINTENANCE` | You switched your own maintenance on (and the window, if any, is open); `POST /v1/sessions` is refused. `GET /v1/connection-test` and `/v1/products` still work |

Errors without a `status` (400, 401, 403 inactive) use the standard shape
`{ "statusCode": 400, "message": "…", "error": "Bad Request" }`; for `400`,
`message` can be an array of strings, one per invalid field.

Wallet `status` values you answer with:

| `status` | Applies to | Meaning |
| --- | --- | --- |
| `ok` | all | Applied, or nothing to apply |
| `duplicate` | placeBets, settleBets, cancelBets, voidSettled | Already applied; counts as `ok` |
| `insufficient_balance` | placeBets, voidSettled | The player cannot cover the amount |
| `user_not_found` | all | No such player |
| `user_suspended` | all | The player is suspended on your side |
| `invalid` | all | Bad signature, stale timestamp, or a payload you will not act on |
| `error` | all | You could not tell whether it applied; we ask the query URL |

## Errors and tracing

Every response to you carries an `X-Request-Id` header, and every wallet call
has a `reference`; quote them when you report a problem. We log every call in
both directions for 3 months. Stored bodies are cut at 16,000 characters and
launch codes are masked.

**In the portal.** The owner has an **API log** page (Developer menu, owner
only): search by `X-Request-Id` or reference, last 3 months. The **Reports**
tabs **Purchases** and **Pending** show purchases and what still waits for your
answer.

## Integration checklist

1. **Get your keys.** Sign in to the merchant portal as owner, open **Integration**, copy the API key and reset the callback secret to see it (shown once). Store `{MERCHANT_API_BASE_URL}`, `{API_KEY}` and `{CALLBACK_SECRET}` in your server configuration.
2. **Set the callback URL.** On the same page, enter your wallet endpoint (a full `http(s)://` URL containing a `/callback` path segment, e.g. `https://api.example.com/wallet/callback/shop-1`) and save.
3. **Implement the callbacks.** One endpoint at the callback URL that handles `connectionTest`, `checkBalance`, `placeBets`, `settleBets`, `cancelBets` and `voidSettled`:
   - verify `X-Scratcher-Signature` over the raw body and the timestamp;
   - always answer HTTP 200 with `status`;
   - dedupe by (`event`, `txns[].id`) and answer `duplicate` on a repeat;
   - answer `insufficient_balance` when a stake cannot be paid;
   - return `balance` on every answer.
4. **Implement the query endpoint.** At the query URL (`/callback` replaced by `/query`), answer `{ "status": "ok", "placed": true | false }` for a `providerTxnId`.
5. **Run the connection test.** Click **Test connection** in the portal, or call `GET {MERCHANT_API_BASE_URL}/v1/connection-test`. Fix the `message` until it answers `200`.
6. **Create a session.** `POST {MERCHANT_API_BASE_URL}/v1/sessions` with a test player; you get a `launchUrl`.
7. **Launch.** Redirect the player's browser to `launchUrl` within 120 seconds. You should receive `checkBalance` at once.
8. **Play a ticket end to end.** Buy one (placeBets), open it (settleBets if it wins), and check balances and the portal's **API log**.
9. **Optional.** Restrict callers with the **IP whitelist**, show our games with `GET /v1/products`, and set a **Maintenance** window before you update your system.

## FAQ

**Which URL do I call?** `{MERCHANT_API_BASE_URL}/v1/...` for your calls to us. You never need the game site's address: `launchUrl` is complete.

**Do I need to call anything when the player buys or wins?** No. We call you. You only create the session; everything after is wallet callbacks.

**What if my wallet is down?** placeBets with no answer is held and queried every 1–2 minutes until you answer, so the player is neither charged twice nor given a free ticket. settleBets repeats about every minute until you answer `ok` or `duplicate`.

**Why does a callback arrive twice?** A retry after a lost answer. It carries the same `txns[].id`; answer `duplicate` and move no money.

**Can I send a non-200 status for a refusal?** No: answer HTTP 200 with a `status` such as `insufficient_balance`. A non-2xx counts as no answer, and we then do not know whether you applied it.

**My signature check fails.** Use the raw request body (not a re-serialised copy), the exact header timestamp, and the current callback secret; reset it on the Integration page if unsure. Compare with the [Worked signature example](#worked-signature-example).

**How long is a launch link valid?** 120 seconds, and once. Request a new one for every entry.

**How do I stop a player from playing?** Suspend them on the portal's **Players** page, or answer `user_suspended` from your wallet. A new session request reactivates a wallet-suspended player only.

**What happens to a ticket nobody opens?** After 7 days it is opened for the player automatically and a prize, if any, is paid with settleBets.

**Why did my call get 403 `IP_NOT_ALLOWED`?** Your IP whitelist has entries and the caller is not on it. Add the public address on the **IP whitelist** page, or empty the list.

**What currencies are supported?** Any ISO 4217 code you send as `player.currency`; the player sees lots sold in that currency.

**Where can I see what was sent?** The portal's **API log** page (owner only) shows every call in both directions for 3 months. Quote the `X-Request-Id` or `reference` to us.
