สร้างมาสำหรับนักพัฒนาและ AI assistantเปิดไฟล์ดิบ

ส่งหน้านี้ให้ AI assistant ได้ หรือใช้ไฟล์ข้อความล้วนที่ https://portal-scratcher.onexgame.com/llms.txt

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).

Contents

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.
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).

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.

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:
{ "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.

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:

{
  "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, Maintenance and 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:

[
  {
    "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) 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
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):

{"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:

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

Request headers:

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"}.

// 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):

{ "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:

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

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

settleBets:

{
  "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:

{
  "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:

{
  "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). 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)
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.

// 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:

{ "status": "MAINTENANCE", "message": "The game is under maintenance", "maintenance_message": { "th": "…", "en": "…" } }
{ "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.

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.