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
- Placeholders
- How a game session runs
- Getting your keys
- Your calls to us
- Our calls to you (wallet callbacks)
- Suspended players
- Maintenance
- Currencies
- Status and error codes
- Errors and tracing
- Integration checklist
- 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
- Your server calls
POST {MERCHANT_API_BASE_URL}/v1/sessionswith the player and gets alaunchUrl. - You redirect the player's browser to
launchUrl({GAME_BASE_URL}/loading?code=…) at once. - The game trades the code for a player session and sends you
checkBalance. You answer with the balance. - The player buys a ticket: we send
placeBets. You take the stake and answerok. - The player scratches and opens the ticket. If it wins, we send
settleBets. You pay the prize and answerok. 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://orhttps://URL; use HTTPS in production; - run Test connection, which sends a
connectionTestevent 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 sameid; a retry must answerduplicate(orok) without moving money again. - The
idof 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
- 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. - Set the callback URL. On the same page, enter your wallet endpoint (a full
http(s)://URL containing a/callbackpath segment, e.g.https://api.example.com/wallet/callback/shop-1) and save. - Implement the callbacks. One endpoint at the callback URL that handles
connectionTest,checkBalance,placeBets,settleBets,cancelBetsandvoidSettled:- verify
X-Scratcher-Signatureover the raw body and the timestamp; - always answer HTTP 200 with
status; - dedupe by (
event,txns[].id) and answerduplicateon a repeat; - answer
insufficient_balancewhen a stake cannot be paid; - return
balanceon every answer.
- verify
- Implement the query endpoint. At the query URL (
/callbackreplaced by/query), answer{ "status": "ok", "placed": true | false }for aproviderTxnId. - Run the connection test. Click Test connection in the portal, or call
GET {MERCHANT_API_BASE_URL}/v1/connection-test. Fix themessageuntil it answers200. - Create a session.
POST {MERCHANT_API_BASE_URL}/v1/sessionswith a test player; you get alaunchUrl. - Launch. Redirect the player's browser to
launchUrlwithin 120 seconds. You should receivecheckBalanceat once. - Play a ticket end to end. Buy one (placeBets), open it (settleBets if it wins), and check balances and the portal's API log.
- 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.
Merchant Portal