Download OpenAPI specification:
Seamless integration of Sarái games using your token credentials.
This documentation explains how to integrate Sarái games into an operator's platform using the seamless wallet model: player balances always live with the operator, and Sarái reads and moves them by calling the operator's wallet on every round.
Player ──▶ Operator ──(1) launch──▶ Sarái
│ returns a URL with a session
Player ◀──────(2) opens the game────────┘
Player ──(3) spin──▶ Sarái ──(4) credit_transfer──▶ Operator
Player ◀───result─── Sarái ◀──────new balance────── Operator
launch.The integration therefore has two parts:
| Part | Who calls whom | Section |
|---|---|---|
| Integration API | Your server calls Sarái | Launcher and Queries |
| Wallet | Sarái calls your server | Your wallet |
Both use the same credentials and the same signature.
For each integration (each site or brand) you receive a token, made of two values:
| Value | Use |
|---|---|
| Public key | Identifies the token. Sent in the X-FK-Token header. It is not secret |
| Secret | Signs the requests. It is never sent and must never reach the browser or a repository |
player_id under two different tokens is two different players.Besides the credentials, you need to tell us:
credit_transfer).credit_transfer mode: separate or combined.Every server-to-server request is signed with HMAC-SHA256, in both directions: the ones you send to Sarái and the ones Sarái sends to your wallet. They carry three headers:
| Header | Content |
|---|---|
X-FK-Token |
The token's public key |
X-FK-Timestamp |
Current time in Unix seconds |
X-FK-Signature |
The signature, in lowercase hexadecimal |
The signature is the HMAC-SHA256, keyed with the token's secret, of this string:
timestamp + "." + METHOD + "." + path + "." + body
METHOD is the HTTP method in uppercase: GET or POST.path is the path with its query string, without the domain: /operator/v1/launch.body is the exact body being sent, byte for byte. It is empty for a GET.Python:
import hashlib, hmac, json, time, urllib.request
API = "https://api.sarai-games.com"
TOKEN = "fk_…"
SECRET = b"…"
def call(method, path, payload=None):
body = json.dumps(payload).encode() if payload is not None else b""
timestamp = str(int(time.time()))
message = f"{timestamp}.{method}.{path}.".encode() + body
signature = hmac.new(SECRET, message, hashlib.sha256).hexdigest()
request = urllib.request.Request(API + path, data=body or None, method=method, headers={
"X-FK-Token": TOKEN,
"X-FK-Timestamp": timestamp,
"X-FK-Signature": signature,
"Content-Type": "application/json",
})
with urllib.request.urlopen(request) as response:
return json.load(response)
Node.js:
import { createHmac } from 'node:crypto';
const API = 'https://api.sarai-games.com';
const TOKEN = 'fk_…';
const SECRET = '…';
async function call(method, path, payload) {
const body = payload === undefined ? '' : JSON.stringify(payload);
const timestamp = Math.floor(Date.now() / 1000).toString();
const signature = createHmac('sha256', SECRET).update(`${timestamp}.${method}.${path}.${body}`).digest('hex');
const response = await fetch(API + path, {
method,
headers: { 'X-FK-Token': TOKEN, 'X-FK-Timestamp': timestamp, 'X-FK-Signature': signature, 'Content-Type': 'application/json' },
body: body || undefined,
});
return response.json();
}
PHP:
function call(string $method, string $path, ?array $payload = null): array {
$body = $payload === null ? '' : json_encode($payload);
$timestamp = (string) time();
$signature = hash_hmac('sha256', "$timestamp.$method.$path.$body", SECRET);
$context = stream_context_create(['http' => [
'method' => $method,
'header' => "X-FK-Token: " . TOKEN . "\r\nX-FK-Timestamp: $timestamp\r\nX-FK-Signature: $signature\r\nContent-Type: application/json\r\n",
'content' => $body,
'ignore_errors' => true,
]]);
return json_decode(file_get_contents(API . $path, false, $context), true);
}
Command line:
BODY='{"player_id":"u-1001","game":"fruits-classic","currency":"EUR","lang":"es"}'
TS=$(date +%s)
SIG=$(printf '%s.%s.%s.%s' "$TS" POST /operator/v1/launch "$BODY" \
| openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -X POST "$API/operator/v1/launch" \
-H "X-FK-Token: $TOKEN" -H "X-FK-Timestamp: $TS" -H "X-FK-Signature: $SIG" \
-H 'Content-Type: application/json' -d "$BODY"
Your wallet must verify the signature of every call it receives and reject the ones that fail. This is what prevents a third party from moving your players' balances.
import hashlib, hmac, time
def verify(secret: bytes, headers: dict, method: str, path: str, body: bytes) -> bool:
timestamp = headers.get("X-FK-Timestamp", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
return False
expected = hmac.new(secret, f"{timestamp}.{method}.{path}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, headers.get("X-FK-Signature", ""))
path is the path of your wallet URL, with its query string if it has
one, and body is the raw bytes of the body as received, before decoding
the JSON.
GET."2.50"), never as a number,
so no precision is lost.launch.Errors from the integration API always have this shape, with a stable
code you can rely on in your code. message is an explanation in
English for whoever is debugging; it is not meant to be shown to the player.
{ "status": "error", "code": "game_not_allowed", "message": "the game is not assigned to this token" }
| Code | HTTP | When |
|---|---|---|
unauthorized |
401 | Unknown token, wrong signature, or a request with more than 5 minutes of clock skew |
token_disabled |
403 | The token is disabled |
ip_not_allowed |
403 | The token has a list of allowed addresses and the request comes from another one |
rate_limited |
429 | Too many requests from the token in the last minute |
invalid_request |
400 | The body is not valid JSON, or a field has an unsupported format |
not_found |
404 | The resource does not exist or does not belong to this token |
internal_error |
500 | An error on our side. It can be retried |
Each operation adds its own.
The mode is chosen per token and decides how many calls your wallet receives on each round.
| Mode | Calls per round |
|---|---|
Separate (separate) |
First type: "bet" with the amount bet. Then type: "win" with the win |
Combined (combined) |
A single one, type: "combined", with bet and win. The balance changes by win − bet |
In separate mode, the win call is also sent when the win is zero, so you
can close the round. This can be turned off per token.
These are what prevent double charges and lost money. Your wallet must comply with all of them.
transaction_id you have already processed
arrives, return the same result without moving the balance again. Fruit
King retries with the same transaction_id when it gets no response.rollback. A call with type: "rollback" undoes everything the
round round_id got to apply. Sarái sends it when a round was
voided because your wallet did not respond.rollback of something that does not exist. If the round was never
applied, respond ok anyway, without changing the balance.rollback before the bet. If the rollback arrives first, because
the original bet is still on its way, remember it and reject the bet
when it arrives.Your wallet always responds with HTTP 200 and one of the two documented
bodies (ok or rejected). Anything else —another HTTP status, a body
that is not that JSON, a redirect, or not responding in time— is an
unknown response: Sarái does not know whether you applied the
operation.
| Situation | Outcome |
|---|---|
| Unknown response when charging the bet, or to the single call in combined mode | The round is voided. The player sees an error and no result. Sarái sends a rollback |
| Unknown response when crediting the win (separate mode) | The round is valid and the win belongs to the player. Sarái retries the credit |
rejected to the bet, or to the single call |
The round is not played. The player sees that they have no balance or that the bet was rejected |
Retries of rollbacks and win credits use the same transaction_id, with
increasing waits from 5 seconds to 15 minutes, for about three hours (20
attempts). After that, the call goes to manual review by our team. If you
respond rejected to a rollback or a win credit, it is not retried: it
goes straight to manual review.
The maximum wait for each call is configured per token, up to 30 seconds. A slow wallet makes the game slow: aim to respond in under 200 ms.
| Status | Meaning |
|---|---|
pending |
In progress |
open |
Charged and still being played: the game has several steps and the player has not finished the round |
closed |
Settled |
win_pending |
Valid; the win credit is waiting for your wallet to confirm it |
rejected |
Your wallet rejected the bet; the round was not played |
void |
Voided; it must have no effect on the balance |
transaction_id returns the same result without moving the balance.rejected, with HTTP 200.rollback of an applied bet returns the balance.rollback of an unknown round responds ok.rollback followed by its bet: the bet is rejected.Along with your integration credentials you also receive an account for the read-only operator portal: activity summary, tokens, players, the detail of every round, reports exportable to CSV, and monthly invoices. Each account can turn on two-step verification.
Opens a game session for a player and returns the URL that opens the
game. Redirect the player to that url or open it in an iframe.
player_id. There is no need to register them beforehand.expires_at. To keep playing after that, launch again.launch creates a new session; earlier ones remain valid until
they expire.| player_id required | string [ 1 .. 128 ] characters Your identifier for the player. It is the one your wallet will receive in every call. It must be stable: the same player, always the same value. |
| game required | string Game code, one of those returned by |
| currency required | string Code of the session currency, in uppercase. |
| lang | string <= 16 characters Game language. |
| return_url | string <uri> <= 1024 characters Address on your site the player returns to when leaving the game. |
| player_ip | string Optional. The player's IP address as seen by your server (IPv4 or IPv6). It helps detect a session being used from somewhere else. |
| status required | string Value: "ok" |
| url required | string <uri> The address the player opens the game with. It carries the session; do not store it or reuse it for another player. |
| session_token required | string The session token, the same one that goes in |
| expires_at required | string <date-time> When the session expires (UTC). |
{- "player_id": "u-1001",
- "game": "fruits-classic",
- "currency": "EUR",
- "lang": "es",
- "player_ip": "203.0.113.5"
}{- "status": "ok",
- "session_token": "8nGrP5AEUZki55LxdrPPXt9ZcoNo1xM_wbOnigwQWvw",
- "expires_at": "2026-10-02T18:16:22.795Z"
}The games this token can launch: the published ones assigned to it,
each in its latest version. The code is what you send in launch.
Every game comes with its full catalogue sheet, so you can build your lobby without asking us for material: type and mechanic, texts by language, gallery, RTP and volatility figures, compatibility, compliance data and the bet limits of your token.
The mathematical figures are calculated by the game engine and stored
with each published version; configured_math is the profile your
token plays with.
| status required | string Value: "ok" |
required | Array of objects (Game) |
games = call("GET", "/operator/v1/games")["games"]
{- "status": "ok",
- "games": [
- {
- "code": "fruits-classic",
- "name": "Fruits Classic",
- "version": "4",
- "status": "published",
- "base_game": "fruitking",
- "type": "symbol-bet",
- "subtype": "symbol-draw",
- "mechanic": {
- "round": "individual",
- "result": "rng",
- "steps": "single",
- "charge": "on_bet",
- "payout": "on_resolve"
}, - "needs_realtime": false,
- "studio": "Sarái",
- "release_date": "",
- "tags": [
- "classic",
- "fruits",
- "retro"
], - "texts": {
- "en": {
- "name": "Fruits Classic",
- "short_description": "The classic fruit board: pick your symbols, press play and win up to 100x your bet.",
- "description": "Fruits Classic brings back the fruit machine of the arcades…",
- "rules": "HOW TO PLAY\n\n1. Choose a chip value…"
}, - "es": {
- "name": "Fruits Classic",
- "short_description": "El tablero de frutas de siempre: elige tus símbolos, pulsa jugar y gana hasta 100 veces tu apuesta.",
- "description": "Fruits Classic recupera la máquina de frutas de los salones recreativos…",
- "rules": "CÓMO SE JUEGA\n\n1. Elige el valor de la ficha…"
}
}, - "gallery": {
- "icon": {
- "type": "image/png",
- "width": 512,
- "height": 512
}, - "thumb_square": {
- "type": "image/jpeg",
- "width": 600,
- "height": 600
}, - "thumb_landscape": {
- "type": "image/jpeg",
- "width": 800,
- "height": 450
}, - "screenshot_1": {
- "type": "image/jpeg",
- "width": 1280,
- "height": 720
}
}, - "math": {
- "engine": "fruitking",
- "profiles": [
- {
- "profile": "rtp85",
- "version": "1.0.0",
- "rtp_bps": 8500,
- "hit_frequency_bps": 4548,
- "max_win_multiplier": 100,
- "volatility_min": 1.88,
- "volatility_max": 9.18,
- "volatility": "bet_dependent"
}, - {
- "profile": "rtp90",
- "version": "1.0.0",
- "rtp_bps": 9000,
- "hit_frequency_bps": 4815,
- "max_win_multiplier": 100,
- "volatility_min": 1.92,
- "volatility_max": 9.44,
- "volatility": "bet_dependent"
}, - {
- "profile": "rtp94",
- "version": "1.0.0",
- "rtp_bps": 9400,
- "hit_frequency_bps": 5029,
- "max_win_multiplier": 100,
- "volatility_min": 1.95,
- "volatility_max": 9.65,
- "volatility": "bet_dependent"
}
], - "default_profile": "rtp94",
- "free_profile": "free",
- "free_min_bps": 8000,
- "free_max_bps": 9800,
- "structure": {
- "kind": "symbol_draw",
- "symbols": 8,
- "multipliers": [
- 100,
- 40,
- 30,
- 20,
- 20,
- 15,
- 10,
- 5
]
}, - "features": [
- "multi_bet"
]
}, - "configured_math": {
- "profile": "rtp94",
- "version": "1.0.0",
- "rtp_bps": 9400,
- "hit_frequency_bps": 5029,
- "max_win_multiplier": 100,
- "volatility_min": 1.95,
- "volatility_max": 9.65,
- "volatility": "bet_dependent"
}, - "compat": {
- "devices": [
- "desktop",
- "mobile",
- "tablet"
], - "orientation": "any",
- "languages": [
- "en",
- "es"
], - "currencies": [ ],
- "demo": true
}, - "compliance": {
- "min_age": 18,
- "restricted_countries": [ ],
- "certifications": [ ]
}, - "bet_limits": [
- {
- "currency": "EUR",
- "min_bet": "0.10",
- "max_bet": "100.00",
- "max_total_bet": "500.00"
}
]
}
]
}The status of a round of this token, its bets, its amounts and the calls made to your wallet. Useful for handling player complaints and for reconciliation.
The round_id is the one your wallet receives in every credit_transfer.
A round that was not settled (rejected or void) does not reveal
its result: winning_symbol_id is null and total_won is zero.
| round_id required | string Example: 01M3YFJP89191AAKKN332E3Z1D Round identifier. |
| status required | string Value: "ok" |
required | object (Round) |
round = call("GET", "/operator/v1/rounds/01M3YFJP89191AAKKN332E3Z1D")["round"]
{- "status": "ok",
- "round": {
- "round_id": "01M3YFJP89191AAKKN332E3Z1D",
- "status": "closed",
- "currency": "EUR",
- "bets": {
- "1": "0.50",
- "8": "2.00"
}, - "total_bet": "2.50",
- "total_won": "10.00",
- "base_game": "fruitking",
- "action": "spin",
- "winning_symbol_id": 8,
- "result": {
- "winning_symbol_id": 8
}, - "summary": "Símbolo 8 (x5)",
- "math_profile": "rtp94",
- "math_version": "1.0.0",
- "rtp_bps": 9400,
- "transfer_mode": "separate",
- "created_at": "2026-10-02T14:16:22.795Z",
- "transactions": [
- {
- "transaction_id": "01M3YFJP89191AAKKN332E3Z1D-bet",
- "type": "bet",
- "status": "ok",
- "bet": "2.50",
- "win": "0.00"
}, - {
- "transaction_id": "01M3YFJP89191AAKKN332E3Z1D-win",
- "type": "win",
- "status": "ok",
- "bet": "0.00",
- "win": "10.00"
}
]
}
}The two URLs that you implement and that Sarái calls with
POST, JSON and the token's signature. They are configured on the
token; they can be any https URL on your server.
Read the four wallet rules and what happens when your wallet does not respond first.
Sarái reads the player's balance when the game opens and whenever it needs to refresh it. It must not change anything.
Respond with HTTP 200 and the balance in the requested currency.
| player_id required | string The identifier you sent in |
| currency required | string The session currency. |
| status required | string Value: "ok" |
| balance required | string^\d+(\.\d+)?$ The player's balance after the operation, in the currency of the call. |
{- "player_id": "u-1001",
- "currency": "EUR"
}{- "status": "ok",
- "balance": "1000.00"
}Sarái moves the player's balance: it charges the bet, credits the win or rolls back a round.
type |
What to do with the balance | When it arrives |
|---|---|---|
bet |
Subtract bet. win is zero |
Separate mode, first call |
win |
Add win. bet is zero |
Separate mode, second call |
combined |
Subtract bet and add win |
Combined mode, single call |
rollback |
Undo everything the round round_id got to apply |
Both modes, if the round was voided |
Always respond with HTTP 200: ok with the resulting balance if
you applied the operation, or rejected with a code if you do not
apply it. Reject a bet the player cannot afford with
insufficient_funds.
Identifiers:
transaction_id is unique per call and is the idempotency key:
<round_id>-bet, <round_id>-win, <round_id>-combined or
<round_id>-rollback.round_id groups the calls of the same round.rollback, bet and win are the amounts of the round being
voided, for information only: what you must undo is whatever you got
to apply for that round, which may be nothing.| transaction_id required | string Unique identifier of the call and idempotency key. |
| round_id required | string Round identifier. It groups the round's calls. |
| type required | string Enum: "bet" "win" "combined" "rollback" |
| player_id required | string The identifier you sent in |
| currency required | string |
| game required | string Game code. |
| bet required | string (Amount) ^\d+(\.\d+)?$ Amount as decimal text, with at most the decimals of the currency. |
| win required | string (Amount) ^\d+(\.\d+)?$ Amount as decimal text, with at most the decimals of the currency. |
| reference_transaction_id | string Only in a |
| status required | string Value: "ok" |
| balance required | string^\d+(\.\d+)?$ The player's balance after the operation, in the currency of the call. |
{- "transaction_id": "01M3YFJP89191AAKKN332E3Z1D-bet",
- "round_id": "01M3YFJP89191AAKKN332E3Z1D",
- "type": "bet",
- "player_id": "u-1001",
- "currency": "EUR",
- "game": "fruits-classic",
- "bet": "2.50",
- "win": "0.00"
}{- "status": "ok",
- "balance": "997.50"
}