Sarái — Integration API for operators (1.0.0)

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.

How it works

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
  1. Your server asks to open a game for a player with launch.
  2. The player opens the URL returned by that call in their browser.
  3. On every spin, the game calls Sarái. You take no part in that call.
  4. Sarái calls your wallet to charge the bet and credit the win.

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.

Credentials

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
  • The secret is handed over only once. If it is lost or leaked, ask for it to be renewed: the previous one stops working at that moment.
  • Each token has its own RTP, its assigned games, its bet limits per currency, its wallet mode and its billing.
  • The same player_id under two different tokens is two different players.

Besides the credentials, you need to tell us:

  • The two URLs of your wallet (balance and credit_transfer).
  • The credit_transfer mode: separate or combined.
  • Optionally, the outbound IP addresses of your server, so the integration API is only accepted from them.

Request signing

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.
  • A request with more than 5 minutes of clock skew is rejected: the server clock must be synchronised.

Signing a request

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"

Verifying an incoming request

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.

Common signing mistakes

  • Signing one JSON and sending another, with a different key order or spacing. You must sign exactly the bytes you send.
  • Leaving the query string out of the path of a GET.
  • Verifying the signature over the JSON after decoding and re-encoding it, instead of over the bytes received.
  • An unsynchronised clock.

Amounts and currencies

  • Amounts always travel as decimal text ("2.50"), never as a number, so no precision is lost.
  • Fiat and crypto currencies are supported. Each currency has its own decimals: 2 for EUR, 0 for JPY, 8 for BTC, 18 for ETH. An amount never has more decimals than its currency.
  • There is no conversion: a session plays in a single currency from start to finish, the one you send in launch.
  • A currency can only be used if it is enabled and has bet limits configured for your token.

Errors

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.

Wallet modes

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.

The four wallet rules

These are what prevent double charges and lost money. Your wallet must comply with all of them.

  1. Idempotency. If a 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.
  2. 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.
  3. rollback of something that does not exist. If the round was never applied, respond ok anyway, without changing the balance.
  4. 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.

When your wallet does not respond

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.

Round statuses

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

Before going live

  • The secret is only on the server, never in the browser or the repository.
  • The signature of incoming calls is verified and invalid ones are rejected.
  • A repeated transaction_id returns the same result without moving the balance.
  • A bet with insufficient balance responds rejected, with HTTP 200.
  • A rollback of an applied bet returns the balance.
  • A rollback of an unknown round responds ok.
  • A rollback followed by its bet: the bet is rejected.
  • A repeated win credit is not paid twice.
  • The wallet responds in under 200 ms under normal load.
  • The server clock is synchronised.
  • The outbound IPs have been communicated, if the IP restriction is used.

Operator portal

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.

Launcher

Open a game for a player. Called by your server.

Launch a game

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.

  • The player is created automatically the first time you launch a game for their player_id. There is no need to register them beforehand.
  • The session is valid for that player, that game and that currency, and expires at expires_at. To keep playing after that, launch again.
  • Each launch creates a new session; earlier ones remain valid until they expire.
  • If the player reloads the page, the game shows them their last round.
Authorizations:
(TokenTimestampSignature)
Request Body schema: application/json
required
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 GET /operator/v1/games.

currency
required
string

Code of the session currency, in uppercase.

lang
string <= 16 characters

Game language. es and en are available; any other value shows the game in Spanish.

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.

Responses

Response Schema: application/json
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 url. You only need it if you build the game address yourself.

expires_at
required
string <date-time>

When the session expires (UTC).

Request samples

Content type
application/json
{
  • "player_id": "u-1001",
  • "game": "fruits-classic",
  • "currency": "EUR",
  • "lang": "es",
  • "player_ip": "203.0.113.5"
}

Response samples

Content type
application/json
{}

Queries

Available games and the detail of a round. Called by your server.

Available games

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.

Authorizations:
(TokenTimestampSignature)

Responses

Response Schema: application/json
status
required
string
Value: "ok"
required
Array of objects (Game)

Request samples

games = call("GET", "/operator/v1/games")["games"]

Response samples

Content type
application/json
{
  • "status": "ok",
  • "games": [
    ]
}

Look up a round

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.

Authorizations:
(TokenTimestampSignature)
path Parameters
round_id
required
string
Example: 01M3YFJP89191AAKKN332E3Z1D

Round identifier.

Responses

Response Schema: application/json
status
required
string
Value: "ok"
required
object (Round)

Request samples

round = call("GET", "/operator/v1/rounds/01M3YFJP89191AAKKN332E3Z1D")["round"]

Response samples

Content type
application/json
{
  • "status": "ok",
  • "round": {
    }
}

Your wallet

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.

Balance Webhook

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.

Authorizations:
(TokenTimestampSignature)
Request Body schema: application/json
required
player_id
required
string

The identifier you sent in launch.

currency
required
string

The session currency.

Responses

Response Schema: application/json
status
required
string
Value: "ok"
balance
required
string^\d+(\.\d+)?$

The player's balance after the operation, in the currency of the call.

Request samples

Content type
application/json
{
  • "player_id": "u-1001",
  • "currency": "EUR"
}

Response samples

Content type
application/json
{
  • "status": "ok",
  • "balance": "1000.00"
}

credit_transfer Webhook

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.
  • In a 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.
Authorizations:
(TokenTimestampSignature)
Request Body schema: application/json
required
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 launch.

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 rollback: the original operation of the round. The bet (…-bet) in separate mode, the single call (…-combined) in combined mode.

Responses

Response Schema: application/json
One of
status
required
string
Value: "ok"
balance
required
string^\d+(\.\d+)?$

The player's balance after the operation, in the currency of the call.

Request samples

Content type
application/json
Example
{
  • "transaction_id": "01M3YFJP89191AAKKN332E3Z1D-bet",
  • "round_id": "01M3YFJP89191AAKKN332E3Z1D",
  • "type": "bet",
  • "player_id": "u-1001",
  • "currency": "EUR",
  • "game": "fruits-classic",
  • "bet": "2.50",
  • "win": "0.00"
}

Response samples

Content type
application/json
Example
{
  • "status": "ok",
  • "balance": "997.50"
}