Clone API
Native casino games (slots, table, crash) through a protocol that is 1:1 compatible with the original native-casino API. Integrate exactly as that protocol describes, with our base URL and your own credentials.
https://api.slotix.bet/cloneOverview
The Clone API is a separate product from our original API. It gives your players our native casino catalogue (slots, table and crash games). The protocol is deliberately identical to the original native-casino protocol: the same paths, fields, signature and wallet callback. If you already have an integration for that protocol, you only change the base URL and the credentials.
- 1Open Dashboard → Clone API and activate it. You get a
merchant_key(starts withCLN_) and anapi_secret. The secret is shown in full only once; you can reveal or rotate it later on the same page. - 2Copy the base URL from the same page. In this document it is written as
https://api.slotix.bet/clone, so a full URL looks likehttps://api.slotix.bet/clone/v1/native/games. - 3Set your clone wallet callback URL on the same page. Without it games cannot be launched (
CLONE_CASINO_NOT_CONFIGURED). - 4Top up your clone balance in every currency you will use (see Billing).
Differences from the original protocol
| Topic | Clone API |
|---|---|
| Base URL | Your own URL ending in /clone; copy it from Dashboard → Clone API. |
| Credentials | merchant_key (CLN_…) + api_secret, from Dashboard → Clone API. |
| Game uuid | Prefix clone-, e.g. clone-vs20olympgate. Launch uses the slug (the uuid without clone-). |
| RTP and bet limits | PATCH /v1/native/rtp, /bet-limits and /player-rtp work and affect only your own players: they are applied per player when the game is launched (see RTP & bet limits). bet-scale is managed by the platform (403 NOT_AVAILABLE); type-specific live limits are not available. |
| Launch | game_host is not available: games open on the platform's game host. |
| Bonus-call list | GET /v1/dashboard/casino/bonus-call. |
| Operator tools | give-call, give-fs, give-promo (and their /options lists), give-buy, bonus-call and auto-call must be enabled for your account by the platform, otherwise 403 OPERATOR_TOOLS_DISABLED. |
| Wallet callback | Extra action rollback. Wins are never lost: unconfirmed wins are redelivered until you accept them. |
| Ids | Freespin ids and promo codes are your own namespace: another merchant may use the same id. |
| Merchant endpoints | /v1/merchant/wallet shows your clone balances; /v1/merchant/reconciliation returns your transactions as we recorded them. There are no sub-agent endpoints. |
Conventions
- Requests to us: JSON body (
Content-Type: application/json) for POST/PATCH; query string for GET/DELETE. Form-urlencoded bodies are accepted too. - Every endpoint except the demo launch is signed (see Authentication).
- Authentication errors look like
{ ok:false, error_code, error_description }. Endpoint errors look like{ error, message }(some add extra fields such asenabled_currenciesorvalid_amounts). Validation errors of the request itself (BAD_REQUEST, badimage_style/updated_since,BAD_PRODUCT,BAD_DATE) use the{ ok:false, error_code, error_description }form. Readerrororerror_code. player_idis your player id: 1–64 characters ofA-Z a-z 0-9 _ . : @ -. The player is created on our side the first time you launch a game (or create a freespin / promo redemption / give-buy) for it.- Currencies are ISO-3 codes. Nothing is ever converted between currencies. If you do not send
currency, your account default currency is used. - Rate limit: 1200 requests per minute per
merchant_keyby default (demo launch: 120 per minute per IP). Above it:429 { success:false, error }. While our service is starting up you may briefly get503: retry.
Authentication
Every request carries four headers. The signature is HMAC-SHA1 over all request fields plus the three other headers. The same scheme is used in both directions: wallet callbacks we send you are signed with your api_secret and carry your merchant_key in X-Merchant-Id.
| Header | Value |
|---|---|
| X-Merchant-Id | Your merchant_key (CLN_…). |
| X-Timestamp | Unix time in seconds. Must be within ±60 s of our clock. |
| X-Nonce | Random string, used once. A repeated nonce is refused with NONCE_REUSED. |
| X-Sign | Lowercase hex HMAC-SHA1 of the hash string, key = your api_secret. |
Building the hash string
- 1Merge every query parameter, every body field and the three headers
X-Merchant-Id,X-Timestamp,X-Nonceinto one map. - 2Drop fields whose value is
null(they are not signed, but you may still send them, e.g.rtp: null). - 3Sort the keys by byte order (so the
X-…keys come before lower-case keys). - 4Join as
key=encodeURIComponent(value)with&. Values are signed as the server parses them: the JSON number150.00is signed as150,trueastrue. - 5
X-Sign= lowercase hex ofHMAC-SHA1(api_secret, hashString).
POST https://api.slotix.bet/clone/v1/native/vs20olympgate/launch
X-Merchant-Id: CLN_8F2A61C09D3B7E45
X-Timestamp: 1760180000
X-Nonce: 3f9c2b7d6a1e4c58b0d2f7a9e1c3b5d7
X-Sign: <see below>
Content-Type: application/json
{"player_id":"u1001","balance":150.00,"currency":"TRY"}
# 1) merge query + body + the 3 headers, drop null values
# 2) sort keys by byte order (upper-case "X-..." keys come first)
# 3) key=encodeURIComponent(value) joined by "&" (150.00 is signed as 150)
X-Merchant-Id=CLN_8F2A61C09D3B7E45&X-Nonce=3f9c2b7d6a1e4c58b0d2f7a9e1c3b5d7&X-Timestamp=1760180000&balance=150¤cy=TRY&player_id=u1001
# 4) X-Sign = lowercase hex of HMAC-SHA1(api_secret, hash string)http_build_query() and urlencode() encode differently from encodeURIComponent() and break the signature. Use rawurlencode() and then put back ! ' ( ) * with strtr(), as in the example. Send flat fields only (no nested objects or arrays).// Node.js 18+ (global fetch)
const crypto = require('crypto');
const BASE = 'https://api.slotix.bet/clone'; // copy yours from Dashboard -> Clone API
const MERCHANT_KEY = process.env.CLONE_MERCHANT_KEY; // CLN_...
const API_SECRET = process.env.CLONE_API_SECRET;
function hashString(fields) {
return Object.keys(fields)
.filter(k => fields[k] !== null && fields[k] !== undefined) // nulls are not signed
.sort() // byte order
.map(k => k + '=' + encodeURIComponent(String(fields[k])))
.join('&');
}
async function cloneApi(method, path, { query = {}, body = null } = {}) {
const headers = {
'X-Merchant-Id': MERCHANT_KEY,
'X-Timestamp': String(Math.floor(Date.now() / 1000)),
'X-Nonce': crypto.randomBytes(16).toString('hex'), // single use
};
const fields = { ...query, ...(body || {}), ...headers };
headers['X-Sign'] = crypto.createHmac('sha1', API_SECRET).update(hashString(fields)).digest('hex');
const qs = new URLSearchParams(query).toString();
const res = await fetch(BASE + path + (qs ? '?' + qs : ''), {
method,
headers: body ? { ...headers, 'Content-Type': 'application/json' } : headers,
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, data: await res.json() };
}
// examples (CommonJS has no top-level await, so they run inside an async function)
(async () => {
const games = await cloneApi('GET', '/v1/native/games', { query: { page: 1, per_page: 200 } });
const launch = await cloneApi('POST', '/v1/native/vs20olympgate/launch', {
body: { player_id: 'u1001', balance: 150.0, currency: 'TRY', language: 'tr' },
});
console.log(games.status, launch.data);
})();Authentication errors
| HTTP / error_code | Meaning |
|---|---|
| 401 AUTH_REQUIRED | One of the four headers is missing. |
| 401 UNKNOWN_MERCHANT | X-Merchant-Id is not a known clone merchant key. |
| 401 BAD_SIGNATURE | The signature does not match. Check sorting, encoding, number formatting and that you use the clone api_secret. |
| 401 TIMESTAMP_EXPIRED | X-Timestamp is more than 60 s away from our clock. Sync your server clock (NTP). |
| 401 NONCE_REUSED | This nonce was already used. Generate a new one for every request. |
| 403 MERCHANT_SUSPENDED | Clone API access, or your merchant account, is suspended. |
HTTP/1.1 401 Unauthorized
{
"ok": false,
"error_code": "BAD_SIGNATURE",
"error_description": "Signature does not match"
}Catalogue
/v1/native/gamesPaged game list| Parameter | Type | Description |
|---|---|---|
pageoptional | integer | Page number, default 1. |
per_pageoptional | integer | Default 50, maximum 200. |
provideroptional | string | Provider code from /v1/native/providers. |
searchoptional | string | Case-insensitive search in the game name. |
image_styleoptional | string | portrait (default) or landscape: decides which picture goes into image. |
include_inactiveoptional | 0 | 1 | 1 also returns inactive / removed games (active: false). Default 0. |
updated_sinceoptional | unix seconds | Only games changed at or after this time. Combine with include_inactive=1 for incremental syncs, so you also learn about removed games. |
{
"items": [
{
"uuid": "clone-vs20olympgate",
"slug": "vs20olympgate",
"name": "Gates of Olympus",
"provider": "Pragmatic Play",
"image": "https://cdn.example.com/games/vs20olympgate-portrait.webp",
"image_portrait": "https://cdn.example.com/games/vs20olympgate-portrait.webp",
"image_landscape": "https://cdn.example.com/games/vs20olympgate-landscape.webp",
"type": "slots",
"technology": "html5",
"is_mobile": 1,
"has_lobby": 0,
"has_tables": 0,
"has_freespins": 1,
"freespin_valid_until_full_day": 0,
"rtp": 96.5,
"bet_options": [0.2, 0.4, 1, 2, 4, 10, 20, 40, 100],
"is_native": true,
"active": true,
"updated_at": 1760170000
}
],
"_meta": { "totalCount": 1874, "pageCount": 10, "currentPage": 1, "perPage": 200 }
}| Field | Description |
|---|---|
| uuid | Game id with the clone- prefix. Use it in freespins, give-buy and to match wallet callbacks (game_uuid). |
| slug | The uuid without clone-. Used in the launch path. |
| provider | Provider display name (the filter uses the provider code). |
| image / image_portrait / image_landscape | image follows image_style. |
| type / technology | Game type (e.g. slots, table, crash) and technology. |
| is_mobile / has_lobby / has_tables | 0 or 1. |
| has_freespins | 1 = the game can be used for freespin campaigns and promo codes. |
| freespin_valid_until_full_day | 1 = freespin validity is rounded to the end of the day. |
| rtp / bet_options | Theoretical RTP and available bet values (may be empty). |
| is_native / active | active: false = no longer playable (only with include_inactive=1). |
| updated_at | Unix seconds of the last change of this entry. |
Games are ordered by name. If the platform limited your account to certain providers, only their games are listed.
/v1/native/providersProviders with active games{
"items": [
{ "code": "pragmatic", "label": "Pragmatic Play", "games": 512 },
{ "code": "evolution", "label": "Evolution", "games": 230 }
]
}Only providers with at least one active game are listed, ordered by game count.
Launch
/v1/native/{slug}/launchReal-money game URL{slug} is the game uuid without clone- (e.g. clone-vs20olympgate → /v1/native/vs20olympgate/launch). Open the returned url in an iframe or a new window.
| Parameter | Type | Description |
|---|---|---|
player_idrequired | string | Your player id, 1–64 chars A-Z a-z 0-9 _ . : @ -. Created on first launch. |
balancerequired | number | Player's current balance in currency, 0 – 1000000000000. After launch, the balance always comes from your wallet answers. |
currencyoptional | string | ISO-3. Default: your account default currency. Must be enabled for your account. |
languageoptional | string | Game language, default en (max 8 chars). |
return_urloptional | string | Where the game sends the player on exit / home (max 512 chars). |
nicknameoptional | string | Display name in the game (max 64 chars). |
min_bet / max_betoptional | number | Total bet per spin (slots) or per round (live) for this player session, >= 0. Override your stored bet limits field by field and stay in effect until the player’s next launch. Limits work for slots and live casino tables; some suppliers (e.g. TaDa, Flex Games) ignore them. |
POST /v1/native/vs20olympgate/launch
Content-Type: application/json
{
"player_id": "u1001", // your own player id
"balance": 150.00, // player's current balance in that currency
"currency": "TRY",
"language": "tr",
"return_url": "https://your-casino.example/lobby",
"nickname": "lucky_ahmet"
}HTTP/1.1 200 OK
{ "url": "https://play.example.com/game/....." }
// the game is under maintenance: the URL still works, show a notice if you like
{ "url": "https://play.example.com/game/.....", "_error": "GAME_UNDER_MAINTENANCE" }| HTTP / error | When |
|---|---|
| 400 player_id required | player_id missing or invalid (the message explains the format). |
| 400 balance must be a number… | balance missing, not a number or out of range. |
| 400 BAD_CURRENCY | currency is not an ISO-3 code. |
| 400 CURRENCY_NOT_ENABLED | Currency not enabled for your account; the body contains enabled_currencies. |
| 400 BAD_MIN_BET · BAD_MAX_BET · BAD_BET_RANGE | min_bet / max_bet is not a number >= 0, or the max is below the min. |
| 402 CLONE_CREDIT_EXHAUSTED | Your clone balance in this currency is empty. Top it up. |
| 403 CLONE_CASINO_NOT_CONFIGURED | No clone wallet callback URL set in Dashboard → Clone API. |
| 403 PROVIDER_NOT_ENABLED | This game's provider is not enabled for your account. |
| 403 PLAYER_BLOCKED | The player is blocked on our side. |
| 503 RTP_NOT_APPLIED | RTP could not be applied to the player right now (game server did not confirm); the game was not opened so it never runs at a wrong RTP. Retry in a moment. |
| 404 Game not found | Unknown slug. |
| 502 UPSTREAM_UNAVAILABLE / UPSTREAM_ERROR | The game server is temporarily unavailable or failed. Retry later. Other errors of the game server are passed on with its own status and body. |
| 503 MAINTENANCE | Launches are paused for maintenance. |
HTTP/1.1 400 Bad Request
{ "error": "CURRENCY_NOT_ENABLED", "message": "EUR is not enabled on your account", "enabled_currencies": ["TRY", "USD"] }
HTTP/1.1 402 Payment Required
{ "error": "CLONE_CREDIT_EXHAUSTED", "message": "Your clone balance in TRY is empty" }Demo
/v1/native/{slug}/launch-demoNo signatureFun-mode URL (POST works too)Demo launch needs no signature and touches no wallet. Parameters (query string or body):
| Parameter | Type | Description |
|---|---|---|
balanceoptional | number | Fun balance, default 5000, max 1000000. |
currencyoptional | string | ISO-3. Default: your account currency when you send your merchant_key (X-Merchant-Id header or merchant_key query parameter, no signature), otherwise TRY. |
langoptional | string | Default en. |
# no signature needed - GET or POST
GET https://api.slotix.bet/clone/v1/native/vs20olympgate/launch-demo?balance=5000¤cy=TRY&lang=tr
{ "url": "https://play.example.com/demo/....." }Unknown slug → 404 { error: 'Game not found' }. Live casino games and some providers answer 400 DEMO_NOT_SUPPORTED. Limited to 120 requests per minute per IP.
Wallet callback
Money never moves on our side: every stake and every payout is a request to your wallet. We call the single clone callback URL you set in Dashboard → Clone API.
POST,Content-Type: application/x-www-form-urlencoded, every value is a string.- Signed exactly like your requests to us, with your
api_secret;X-Merchant-Idis yourmerchant_key. Verify the signature and accept a clock window of ±300 s. actionisbet,winorrollback.- Redirects are not followed. Enter the final URL (exact scheme, host and path): a
301/302answer counts as unknown, so every bet fails and wins pile up for redelivery. - Everything is idempotent by `transaction_id`: the same transaction can reach you more than once.
Fields we send
| Field | Description |
|---|---|
| action | bet | win | rollback. |
| transaction_id | Unique id of this operation. Your idempotency key. For a rollback it is rb_<bet transaction_id>. |
| bet_transaction_id | Rollback only: the transaction_id of the bet to cancel. |
| player_id | Your player id (as you sent it at launch). |
| session_id | Game session id (also used by Replay). |
| round_id | Game round id. A bet and its win share the round. |
| game_uuid | Game uuid, e.g. clone-vs20olympgate. |
| currency | ISO-3, the currency the game was launched in. |
| amount | Decimal string, trailing zeros removed (2.5, 100). For a rollback: the bet amount. |
| type | Operation type from the game (e.g. bet, win); rollback for a rollback. |
| finished | true / false: the round is over. Always true for a rollback. |
| transaction_datetime | Time of the operation (ISO-8601). |
| casino_request_retry_count | How many times we already tried to deliver this transaction (0 = first attempt). |
| freespin_id | Freespin wins: your freespin id. |
| quantity / freespin_settlement / freespin_total / freespin_partial | Campaign freespin wins: quantity = spins left after this spin, freespin_settlement = per_spin or total_at_end, freespin_total = 1 on a campaign-total message (its amount is the total), freespin_partial = 1 on a partial payout. |
| operator_buy_id | The bet of a give-buy (bonus buy gifted by you). |
bet
Debit amount from the player and answer with the new balance, or reject it. A rejected bet is not played; the stake stays with the player in the game.
POST https://your-casino.example/clone/wallet
Content-Type: application/x-www-form-urlencoded
X-Merchant-Id: CLN_8F2A61C09D3B7E45 <- YOUR merchant_key
X-Timestamp: 1760180000
X-Nonce: 9a1d0c2e7b4f45a8b3e6d1f0c9a8b7e6
X-Sign: 5b2e... <- HMAC-SHA1 with YOUR api_secret
action=bet
transaction_id=tx_7c1f9e20a4
player_id=u1001
session_id=s_51a0e7
round_id=r_88213907
game_uuid=clone-vs20olympgate
currency=TRY
amount=2.5
type=bet
finished=false
transaction_datetime=2026-10-11T09:15:02.000Z
casino_request_retry_count=0win
Credit amount (it can be 0 when a round is closed without a payout). Freespin wins carry the freespin fields:
action=win
transaction_id=tx_7c1f9e31b0
player_id=u1001
session_id=s_51a0e7
round_id=r_88213990
game_uuid=clone-vs20olympgate
currency=TRY
amount=48
type=win
finished=true
transaction_datetime=2026-10-11T09:21:40.000Z
casino_request_retry_count=0
freespin_id=WELCOME-50 <- YOUR freespin_id
quantity=50
freespin_settlement=total_at_end
freespin_total=48
freespin_partial=0rollback (our addition)
We send rollback when a bet could not be confirmed towards the game and we cancel it. If you debited that bet, credit it back. If you never saw it, simply answer ok (and ideally remember it, so that a late copy of that bet is refused). A rollback is idempotent by its own transaction_id (rb_…) and must always be answered with { ok: true, balance }.
action=rollback
transaction_id=rb_tx_7c1f9e20a4 <- unique id of the rollback itself
bet_transaction_id=tx_7c1f9e20a4 <- the bet to cancel
player_id=u1001
session_id=s_51a0e7
round_id=r_88213907
game_uuid=clone-vs20olympgate
currency=TRY
amount=2.5 <- the bet amount
type=rollback
finished=true
transaction_datetime=2026-10-11T09:15:02.000Z
casino_request_retry_count=0Your answer and how we read it
// accepted (HTTP 2xx) - balance is the player's balance AFTER this operation, as a number
{ "ok": true, "balance": 147.5 }
// rejected (only meaningful for a bet)
{ "ok": false, "error_code": "INSUFFICIENT_FUNDS" }| Your answer | We treat it as |
|---|---|
HTTP 2xx + { ok: true, balance: <number> } | Accepted. balance must be a number (or numeric string); it is the balance after the operation. |
{ ok: false, error_code } (any HTTP status below 500) or any 4xx | Bet: rejected, definitively (you did not apply it). Win / rollback: not accepted: we redeliver it until you answer ok: true (see below), so never answer ok:false to those. |
5xx, 3xx redirect, timeout, network error, unparseable body, ok: true without a numeric balance | Unknown: you may or may not have applied it. We ask again with the same transaction_id. |
Bet error codes
These codes are passed to the game as they are. Any other code becomes `BET_REJECTED`.
| error_code | Use when |
|---|---|
| INSUFFICIENT_FUNDS | Balance too low. |
| PLAYER_BLOCKED | The player may not play. |
| PLAYER_NOT_FOUND | Unknown player. |
| CURRENCY_NOT_SUPPORTED | The player has no wallet in that currency. |
| INVALID_TOKEN | Your own session for the player is no longer valid. |
| BET_LIMIT_EXCEEDED | Your own limits (responsible gaming, max bet) block the bet. |
Timing and retries
- Answer within about 2 seconds. The game server gives up waiting at 2.8 s, and we need part of that time ourselves.
- If your bet answer does not arrive in time (or is unknown), the game retries and we call you again with the same `transaction_id` and a higher
casino_request_retry_count. Return the result you already applied; never debit twice. - If the round is finally abandoned (after a few minutes without a definitive answer), we send
rollbackfor that bet. - Wins and rollbacks that you do not confirm are redelivered by us: first after 30 s, then with doubling intervals up to every 30 minutes, until you answer
ok: true.
Never reject a win
ok:false), we still accept it towards the game and keep redelivering it to you until you answer { ok: true, balance }. Wins can therefore arrive late, sometimes minutes after the round, and more than once: credit each transaction_id exactly once.MERCHANT_CREDIT_EXHAUSTED to the game), the player or currency is not allowed, or the provider is not enabled. You receive no callback for those.Reference implementation
A complete wallet callback handler: signature check (±300 s), bet / win / rollback, idempotency by transaction_id, one database transaction per call with the player row locked. Adapt the SQL to your own schema.
-- PostgreSQL. Your players table probably exists already; it only needs a balance per currency.
CREATE TABLE players (
player_id text NOT NULL,
currency char(3) NOT NULL,
balance numeric(20,4) NOT NULL DEFAULT 0,
blocked boolean NOT NULL DEFAULT false,
PRIMARY KEY (player_id, currency)
);
-- one row per transaction_id: this IS your idempotency
CREATE TABLE clone_wallet_tx (
transaction_id text PRIMARY KEY,
action text NOT NULL, -- bet | win | rollback | cancelled
player_id text NOT NULL,
currency char(3) NOT NULL,
amount numeric(20,4) NOT NULL,
ok boolean NOT NULL,
error_code text,
rolled_back boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);// Express + pg. POST https://your-casino.example/clone/wallet
const express = require('express');
const crypto = require('crypto');
const { Pool } = require('pg');
const MERCHANT_KEY = process.env.CLONE_MERCHANT_KEY; // CLN_...
const API_SECRET = process.env.CLONE_API_SECRET;
const db = new Pool();
const app = express();
function hashString(fields) {
return Object.keys(fields)
.filter(k => fields[k] !== null && fields[k] !== undefined)
.sort()
.map(k => k + '=' + encodeURIComponent(String(fields[k])))
.join('&');
}
function safeEqual(a, b) {
const x = Buffer.from(String(a)), y = Buffer.from(String(b));
return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);
}
app.post('/clone/wallet', express.urlencoded({ extended: false }), async (req, res) => {
// 1) authenticate: same scheme as your requests to us, window +-300 s
const h = {
'X-Merchant-Id': req.get('X-Merchant-Id') || '',
'X-Timestamp': req.get('X-Timestamp') || '',
'X-Nonce': req.get('X-Nonce') || '',
};
const sign = String(req.get('X-Sign') || '').toLowerCase();
if (!h['X-Merchant-Id'] || !h['X-Timestamp'] || !h['X-Nonce'] || !sign)
return res.status(401).json({ ok: false, error_code: 'AUTH_REQUIRED' });
if (h['X-Merchant-Id'] !== MERCHANT_KEY)
return res.status(401).json({ ok: false, error_code: 'UNKNOWN_MERCHANT' });
if (Math.abs(Date.now() / 1000 - Number(h['X-Timestamp'])) > 300)
return res.status(401).json({ ok: false, error_code: 'TIMESTAMP_EXPIRED' });
const fields = req.body || {};
const expected = crypto.createHmac('sha1', API_SECRET).update(hashString({ ...fields, ...h })).digest('hex');
if (!safeEqual(expected, sign))
return res.status(401).json({ ok: false, error_code: 'BAD_SIGNATURE' });
if (!['bet', 'win', 'rollback'].includes(fields.action))
return res.status(400).json({ ok: false, error_code: 'UNKNOWN_ACTION' });
// 2) apply - one DB transaction, player row locked
try {
res.json(await apply(fields));
} catch (err) {
console.error('clone wallet error', err);
res.status(500).json({ ok: false, error_code: 'INTERNAL_ERROR' }); // 5xx = "unknown": we will ask again
}
});
async function apply(f) {
const c = await db.connect();
try {
await c.query('BEGIN');
const answer = await decide(c, f);
await c.query('COMMIT');
return answer;
} catch (e) {
await c.query('ROLLBACK').catch(() => {});
throw e;
} finally {
c.release();
}
}
async function decide(c, f) {
// lock the player's balance: serialises every money movement of this player
const p = (await c.query(
'SELECT balance, blocked FROM players WHERE player_id = $1 AND currency = $2 FOR UPDATE',
[f.player_id, f.currency])).rows[0];
if (!p && f.action === 'bet') return { ok: false, error_code: 'PLAYER_NOT_FOUND' };
// never refuse a win or rollback (it would be redelivered forever): open the wallet at 0
if (!p) await c.query('INSERT INTO players (player_id, currency) VALUES ($1, $2) ON CONFLICT DO NOTHING', [f.player_id, f.currency]);
let balance = p ? Number(p.balance) : 0;
// idempotency: the same transaction_id always gets the same outcome
const seen = (await c.query('SELECT ok, error_code FROM clone_wallet_tx WHERE transaction_id = $1', [f.transaction_id])).rows[0];
if (seen) return seen.ok ? { ok: true, balance } : { ok: false, error_code: seen.error_code };
const record = (id, action, amount, ok, code = null) => c.query(
'INSERT INTO clone_wallet_tx (transaction_id, action, player_id, currency, amount, ok, error_code) VALUES ($1,$2,$3,$4,$5,$6,$7)',
[id, action, f.player_id, f.currency, amount, ok, code]);
const move = async (sql, amount) => {
const r = await c.query(sql, [amount, f.player_id, f.currency]);
if (r.rowCount) balance = Number(r.rows[0].balance);
return r.rowCount > 0;
};
if (f.action === 'bet') {
if (p.blocked) {
await record(f.transaction_id, 'bet', f.amount, false, 'PLAYER_BLOCKED');
return { ok: false, error_code: 'PLAYER_BLOCKED' };
}
const debited = await move(
'UPDATE players SET balance = balance - $1::numeric WHERE player_id = $2 AND currency = $3 AND balance >= $1::numeric RETURNING balance',
f.amount);
if (!debited) {
await record(f.transaction_id, 'bet', f.amount, false, 'INSUFFICIENT_FUNDS');
return { ok: false, error_code: 'INSUFFICIENT_FUNDS' };
}
await record(f.transaction_id, 'bet', f.amount, true);
return { ok: true, balance };
}
if (f.action === 'win') {
// never refuse a win: if you answer an error we simply deliver it again later
await move('UPDATE players SET balance = balance + $1::numeric WHERE player_id = $2 AND currency = $3 RETURNING balance', f.amount);
await record(f.transaction_id, 'win', f.amount, true);
return { ok: true, balance };
}
// rollback: cancel bet_transaction_id
const bet = (await c.query(
'SELECT action, ok, amount, rolled_back FROM clone_wallet_tx WHERE transaction_id = $1 FOR UPDATE',
[f.bet_transaction_id])).rows[0];
if (!bet) {
// you never saw that bet: remember it as cancelled so a late copy of it is refused
await record(f.bet_transaction_id, 'cancelled', f.amount, false, 'BET_REJECTED');
} else if (bet.action === 'bet' && bet.ok && !bet.rolled_back) {
// you debited it: give back exactly what you took
await move('UPDATE players SET balance = balance + $1::numeric WHERE player_id = $2 AND currency = $3 RETURNING balance', bet.amount);
await c.query("UPDATE clone_wallet_tx SET rolled_back = true, ok = false, error_code = 'BET_REJECTED' WHERE transaction_id = $1", [f.bet_transaction_id]);
}
await record(f.transaction_id, 'rollback', f.amount, true);
return { ok: true, balance }; // always ok for a rollback
}
app.listen(8080);Freespin campaigns
/v1/dashboard/native-freespinsGive a player free spins| Parameter | Type | Description |
|---|---|---|
player_idrequired | string | Your player id (created if new). |
game_uuidrequired | string | A game with has_freespins: 1 (clone-… or the slug). |
quantityrequired | integer | 1 – 10000. |
bet_per_spinoptional | integer | Only 1, 2, 4, 10, 20, 40, 100, 200, 400. |
valid_until_daysoptional | integer | Days the spins stay valid, default 7. |
currencyoptional | string | ISO-3, default your account currency. |
freespin_idoptional | string | Your own id, A-Z a-z 0-9 _ . : -. Generated if omitted. It is your namespace: another merchant can use the same id. Max length is 64 minus your internal prefix; the BAD_FREESPIN_ID message tells you the exact maximum. |
POST /v1/dashboard/native-freespins
{
"player_id": "u1001",
"game_uuid": "clone-vs20olympgate",
"quantity": 50,
"bet_per_spin": 20,
"valid_until_days": 7,
"currency": "TRY",
"freespin_id": "WELCOME-50"
}
HTTP/1.1 201 Created
{
"data": {
"id": 81234,
"freespin_id": "WELCOME-50",
"player_id": "u1001",
"game_uuid": "clone-vs20olympgate",
"quantity": 50,
"status": "ACTIVE",
"valid_until": "2026-10-18T23:59:59Z"
}
}/v1/dashboard/native-freespinsYour campaigns (optional ?status=)/v1/dashboard/native-freespins/{freespin_id}Cancel a campaignGET /v1/dashboard/native-freespins?status=ACTIVE
DELETE /v1/dashboard/native-freespins/WELCOME-50
{ "data": { "freespin_id": "WELCOME-50", "cancelled": true } }| Status | Meaning |
|---|---|
| ACTIVE | Spins left to play. |
| CONSUMED | All spins played, payout not finished yet. |
| COMPLETED | Paid out. |
Wins arrive as normal win callbacks with freespin_id. With freespin_settlement = per_spin every spin is paid as its own win; with total_at_end you get one win with the campaign total (freespin_total) when the campaign ends. If the player stops playing for 30 minutes, the amount won so far is paid out as a partial win with freespin_partial = 1. Partial payouts are not cumulative: the campaign result is the sum of all wins with freespin_total = 1 for that freespin_id. A retried win can arrive after the campaign has ended — credit it by transaction_id. type = freespin without freespin_id is free spins won inside a normal paid round: credit it like any win.
| Error | When |
|---|---|
| 400 BAD_QUANTITY | quantity not an integer 1–10000. |
| 400 BAD_BET | bet_per_spin not in the allowed list. |
| 400 BAD_FREESPIN_ID | Invalid characters or too long. |
| 400 BAD_CURRENCY / CURRENCY_NOT_ENABLED | Currency invalid or not enabled. |
| 400 GAME_NOT_SUPPORTED | The game has has_freespins: 0. |
| 403 PROVIDER_NOT_ENABLED | Provider not enabled for your account. |
| 404 Game not found / NOT_FOUND | Unknown game / campaign. |
| 409 FREESPIN_ID_EXISTS | You already used this freespin_id. |
Promo codes
A promo code gives free spins on one game to every player who redeems it.
/v1/dashboard/native-promo-codesCreate| Parameter | Type | Description |
|---|---|---|
coderequired | string | A-Z 0-9 _ -, upper-cased. Your namespace. Max length is 32 minus your internal prefix (the BAD_CODE message tells you the exact range). |
nameoptional | string | Label, max 64 chars. |
game_slugrequired | string | Game with has_freespins: 1 (slug or clone- uuid). |
freespin_countrequired | integer | ≥ 1. |
bet_per_spinoptional | integer | 1, 2, 4, 10, 20, 40, 100, 200, 400. |
freebet_expires_daysoptional | integer | Validity of the spins after redemption. |
max_usesoptional | integer | Total redemptions. |
max_uses_per_useroptional | integer | Redemptions per player. |
expires_atoptional | datetime | Code expiry. |
/v1/dashboard/native-promo-codesList your codes/v1/dashboard/native-promo-codes/{code}Activate / deactivate ({ is_active: 0|1 })/v1/dashboard/native-promo-codes/{code}DeletePOST /v1/dashboard/native-promo-codes
{
"code": "AUTUMN50",
"name": "Autumn campaign",
"game_slug": "vs20olympgate",
"freespin_count": 50,
"bet_per_spin": 20,
"freebet_expires_days": 7,
"max_uses": 1000,
"max_uses_per_user": 1,
"expires_at": "2026-11-30T23:59:59Z"
}
GET /v1/dashboard/native-promo-codes
PATCH /v1/dashboard/native-promo-codes/AUTUMN50 { "is_active": 0 }
DELETE /v1/dashboard/native-promo-codes/AUTUMN50/v1/dashboard/native-promo-codes/redeemRedeem for a playerPOST /v1/dashboard/native-promo-codes/redeem
{ "player_id": "u1001", "code": "AUTUMN50", "currency": "TRY" }
HTTP/1.1 201 Created
{
"data": {
"player_id": "u1001",
"game_slug": "vs20olympgate",
"freespin_id": "...",
"freespin_count": 50,
"valid_until": "2026-10-18T23:59:59Z"
}
}| Error | When |
|---|---|
| 400 BAD_CODE | Invalid characters or length. |
| 400 BAD_COUNT | freespin_count < 1. |
| 400 BAD_BET | bet_per_spin not allowed. |
| 400 GAME_NOT_SUPPORTED | The game has no freespins. |
| 404 Game not found / NOT_FOUND | Unknown game / code. |
| 409 CODE_EXISTS | You already have this code. |
| 409 CODE_IN_USE | The code was already redeemed and cannot be deleted. |
| 404 INVALID_CODE | Redeem: unknown code. |
| 410 CODE_EXPIRED | Redeem: the code has expired. |
| 409 LIMIT_REACHED | Redeem: max_uses reached. |
| 409 ALREADY_REDEEMED | Redeem: this player already used it (max_uses_per_user). |
Active features
/v1/native/active-featuresFeatures currently running for your playersLists the features (gifted calls, free spins, bonus buys …) currently active for your players only, with your player_id.
GET /v1/native/active-features
{
"items": [
{ "player_id": "u1001", "...": "feature fields" }
],
"count": 1
}Operator tools
403 OPERATOR_TOOLS_DISABLED. Except POST /v1/native/give-buy, the player must already exist (have launched a game), otherwise 404 NO_SESSION.| Endpoint | Body | Effect |
|---|---|---|
| GET /v1/native/{give-call|give-fs|give-promo}/options | ?game=<slug>&player_id= | The values you can give in that game: real game results (mult, winTotal), free-spin results, wheel prizes or jackpot levels. supported: false = not available in that game. |
| POST /v1/native/give-call | { player_id, mult, winTotal? } | Gives the player a win with the given multiplier on the next spin (send winTotal from the options list when it is above 0). |
| POST /v1/native/give-fs | { player_id, mult? } | Triggers a free-spins feature for the player. |
| POST /v1/native/give-promo | { player_id, prizeId? } or { player_id, jpFamily, jpLevel } | Gives a promo prize or a jackpot level. |
| POST /v1/native/give-buy | { player_id, game, amount, option?, currency?, expires_in_hours? } | Gifts a bonus buy in game (clone- uuid or slug). Its bet carries operator_buy_id. |
| GET /v1/native/give-buy?player_id= | – | The player’s give-buys: pending, done, failed, cancelled, expired. |
| DELETE /v1/native/give-buy/{call_id} | – | Cancel a pending give-buy (yours only). |
| PATCH /v1/native/auto-call | { player_id, enabled, low, high, boost, cap } | Automatic calls for a player; low, high, boost are 20–150. |
POST /v1/native/give-call { "player_id": "u1001", "mult": 50, "winTotal": 100 }
POST /v1/native/give-fs { "player_id": "u1001", "mult": 10 }
POST /v1/native/give-promo { "player_id": "u1001", "prizeId": 3 }
POST /v1/native/give-promo { "player_id": "u1001", "jpFamily": "...", "jpLevel": 2 }
POST /v1/native/give-buy { "player_id": "u1001", "game": "clone-vs20olympgate", "amount": 100,
"option": 1, "currency": "TRY", "expires_in_hours": 24 }
GET /v1/native/give-buy?player_id=u1001
DELETE /v1/native/give-buy/{call_id}
HTTP/1.1 400 Bad Request
{ "error": "INVALID_AMOUNT", "valid_amounts": [20, 40, 100, 200] }GET /v1/native/give-call/options?game=vs20gtsofhades&player_id=u1001
-> { "supported": true, "current_bet": 2, "items": [ { "mult": 0.45, "winTotal": 9 }, ... ] }
GET /v1/native/give-fs/options?game=vs20gtsofhades
-> { "supported": true, "random_allowed": true, "items": [ { "mult": 2.4, "winTotal": 48 }, ... ] }
GET /v1/native/give-promo/options?game=vs20gtsofhades
-> { "promo_type": "wheel_piece", "wheel": { "prizes": [ { "prizeId": 3, "type": "bet_multiplier", ... } ] } }| Error | When |
|---|---|
| 403 OPERATOR_TOOLS_DISABLED | Not enabled for your account. |
| 404 NO_SESSION | Unknown player: launch a game for them first. |
| 400 INVALID_AMOUNT | give-buy: the body includes valid_amounts. |
| 400 NO_BUY_FEATURE / GAME_NOT_SUPPORTED | give-buy: the game has no bonus buy. |
| 409 ALREADY_PENDING | give-buy: the player already has a pending one. |
| 404 GAME_NOT_FOUND | options: unknown game. |
| 503 CORPUS_UNAVAILABLE | options: the result lists are temporarily unavailable; retry. |
- give-call, give-fs and give-promo apply to the player's most recent game session and are paid on the next spin there. If the player has moved to another game, open the target game first, then send the gift.
- A gift never starts inside a feature: during free spins or a bonus it waits for the next normal spin. Gifts sent while another one is waiting are queued (up to 20) and applied in order; reopening the same game moves them to the new session.
multis relative to the player's bet (paid win =mult× current bet). Amultthat is not in the options list is accepted, but the closest real result is used — send list values when the amount must match exactly.supported: false= do not offer that gift for the game.- give-buy: Pragmatic Play games with a bonus buy only. It stays
pendinguntil the player opens that game in the same currency beforeexpires_in_hours(default 24, max 168); then a normal bet withoperator_buy_idarrives and counts in GGR. If your wallet rejects that bet, the status becomesfailed. - auto-call: when the player's RTP falls below
low, it is raised toboostuntil it is back abovehigh;capis the maximum given per day (default 5000).
Bonus-call
Lets the player win approximately amount (target net win, in currency) over their next spins. Works on slot providers with RTP control; live games are excluded. The player keeps playing their own spins; a call waits while the player is offline and finishes when the target is reached, when cancelled, after 6 hours idle or when the promo balance is used up.
POST /v1/native/bonus-call { "player_id": "u1001", "amount": 500, "currency": "TRY" }
POST /v1/native/bonus-call/cancel { "player_id": "u1001" }
# same, dashboard-style paths
POST /v1/dashboard/casino/bonus-call { "player_id": "u1001", "amount": 500, "currency": "TRY" }
POST /v1/dashboard/casino/bonus-call/cancel { "player_id": "u1001" }
GET /v1/dashboard/casino/bonus-call -> { "data": [ { "player_id": "u1001", "call_status": 1, ... } ] }| call_status | Meaning |
|---|---|
| 1 | Undergoing |
| 2 | Stopping |
| 3 | Finished |
| Error | When |
|---|---|
| 400 BAD_AMOUNT | amount missing or ≤ 0. |
| 402 INSUFFICIENT_PROMO_BALANCE | Your clone balance in that currency does not cover the amount at the promo rate. |
| 409 ALREADY_ACTIVE | The player already has a running bonus-call. |
| 404 NO_ACTIVE_CALL | Cancel: nothing to cancel. |
Gifts and bonus-call amounts are promo money and are billed at the promo rate, see Billing.
PATCH /v1/native/auto-call
{ "player_id": "u1001", "enabled": true, "low": 60, "high": 120, "boost": 110, "cap": 1000 }RTP & bet limits
You manage the RTP and the bet limits of your own players. Settings are stored per merchant. A changed RTP rule is sent at once to your players active in the last 24 hours and to everyone else at their next launch; a per-player RTP is sent at once. If an RTP cannot be applied, the launch is refused with 503 RTP_NOT_APPLIED — a game never opens at a wrong RTP. Other merchants are never affected. The game server reads the RTP on every spin, so a change reaches a game that is already open from its next spin. Live casino tables do not use RTP settings (only bet limits apply there).
RTP
/v1/native/rtpMerchant-wide, provider or game RTP| Parameter | Type | Description |
|---|---|---|
all / provider / slugrequired | true | string | One of: all: true, provider (code from /v1/native/providers), slug (or a clone- uuid). Unknown provider or game → 404 NO_GAMES. |
rtprequired | number | null | 10 – 130; null removes the rule. Out of range → 400 BAD_RTP. |
/v1/native/player-rtpPer-player RTP (applied at once)| Parameter | Type | Description |
|---|---|---|
player_idrequired | string | Existing player (otherwise 404 NO_SESSION). |
rtprequired | number | null | 10 – 130; null (or leaving it out) removes it, the player then follows your rules again. |
PATCH /v1/native/rtp { "all": true, "rtp": 96 } # all your games
PATCH /v1/native/rtp { "provider": "Pragmatic", "rtp": 94 } # one provider (code from /providers)
PATCH /v1/native/rtp { "slug": "vs20olympgate", "rtp": 92 } # one game
PATCH /v1/native/rtp { "provider": "Pragmatic", "rtp": null } # remove a rule
GET /v1/native/rtp -> { "items": [ { "scope": "provider", "target": "Pragmatic", "rtp": 94 }, ... ] }PATCH /v1/native/player-rtp
{ "player_id": "u1001", "rtp": 92 }
# remove the override (null is not part of the signature)
{ "player_id": "u1001", "rtp": null }- Precedence: player RTP → game rule → provider rule → merchant-wide rule → platform default.
- A player with two games open at the same time plays both at the RTP of the game launched last.
GET /v1/native/rtplists your rules. The panel’s RTP & bet limits page does the same with a form.
Bet limits
/v1/native/bet-limitsMin / max bet per provider and currency| Parameter | Type | Description |
|---|---|---|
provider / allrequired | string | true | Provider code, or all: true for every provider. |
min, maxrequired | number | null | Total bet per spin (slots) or per round (live) in the session currency. Both null remove the limit. Errors BAD_MIN, BAD_MAX, BAD_RANGE. |
currencyoptional | string | ISO-3: a limit for that currency only; omitted = every currency. |
PATCH /v1/native/bet-limits { "all": true, "min": 1, "max": 500 }
PATCH /v1/native/bet-limits { "provider": "Pragmatic", "min": 0.2, "max": 100, "currency": "TRY" }
PATCH /v1/native/bet-limits { "provider": "Pragmatic", "min": null, "max": null } # remove
GET /v1/native/bet-limits -> { "items": [ { "provider": "Pragmatic", "currency": "TRY", "min": 0.2, "max": 100 }, ... ] }
# per player session, in the launch body (overrides the stored limits field by field)
POST /v1/native/vs20olympgate/launch { "player_id": "u1001", "balance": 150, "currency": "TRY", "min_bet": 0.5, "max_bet": 50 }- Precedence: provider + currency → provider → all + currency → all.
min_bet/max_betsent in a launch override them field by field. - Limits restrict the bets the player can choose; they do not change the RTP. Live-casino
typelimits are not available (400 BAD_TYPE). PATCH /v1/native/bet-scaleis managed by the platform:403 NOT_AVAILABLE.
Replay
Replays of your own rounds and sessions. Anything that does not belong to your players returns 404.
| Endpoint | Returns |
|---|---|
| GET /v1/replay/spin/{round_id} | Details of one round. 404 round_not_found. |
| GET /v1/replay/session/{session_id} | A session and its rounds. 404 session_not_found. |
| GET /v1/replay/launch/{session_id} | A replay video URL. 410 no_replay_video with roundDetailUrl when there is no video. |
| GET /v1/replay/campaign/{freespin_id} | All spins of a freespin campaign. 404 campaign_not_found. |
GET /v1/replay/spin/r_88213907
GET /v1/replay/session/s_51a0e7
GET /v1/replay/launch/s_51a0e7
GET /v1/replay/campaign/WELCOME-50
HTTP/1.1 410 Gone
{ "error": "no_replay_video", "roundDetailUrl": "https://..." }Merchant wallet & reconciliation
/v1/merchant/walletYour clone balance per currencyGET /v1/merchant/wallet
{
"ok": true,
"merchant_id": "CLN_8F2A61C09D3B7E45",
"originals": [],
"clone": [
{ "currency": "TRY", "balance": 12450.75 },
{ "currency": "USD", "balance": 830 }
],
"sportsbook": [],
"totals_by_currency": { "TRY": 12450.75, "USD": 830 },
"generated_at": "2026-10-11T09:30:00.000Z"
}originals and sportsbook are always empty arrays here; the Clone API only has the clone balances.
/v1/merchant/reconciliationYour transactions of one UTC day| Parameter | Type | Description |
|---|---|---|
productoptional | string | Only native (default). Anything else → 400 BAD_PRODUCT. |
daterequired | YYYY-MM-DD | UTC day. Invalid → 400 BAD_DATE. |
pageoptional | integer | Default 1. |
per_pageoptional | integer | Default 1000, max 5000. |
GET /v1/merchant/reconciliation?product=native&date=2026-10-10&page=1&per_page=1000
{
"ok": true,
"product": "native",
"date": "2026-10-10",
"timezone": "UTC",
"summary": [
{ "currency": "TRY", "bets": { "count": 18233, "amount": 92311.5 },
"wins": { "count": 6102, "amount": 88120.25 }, "net": 4191.25 }
],
"transactions": [
{ "transaction_id": "tx_7c1f9e20a4", "action": "bet", "amount": 2.5, "currency": "TRY",
"player_id": "u1001", "round_id": "r_88213907", "game_uuid": "clone-vs20olympgate",
"operator_give": false, "created_at": "2026-10-10T09:15:02.114Z" }
],
"_meta": { "totalCount": 24335, "pageCount": 25, "currentPage": 1, "perPage": 1000 }
}Lists your transactions as we recorded them, completed only (refused or rolled-back bets are not included). A win is listed as soon as the game reports it, even if its delivery to your wallet is still being retried, so a win missing on your side for a few minutes is normal; it arrives with the same transaction_id. operator_give: true marks promo-funded money when the game flags it live; gifts identified later are only billed (see Billing). Compare it daily with your own clone_wallet_tx table; there are no sub-agent endpoints.
Billing
The Clone API is prepaid, per currency. You hold a clone credit balance in each currency you use (see it in Dashboard → Clone API or /v1/merchant/wallet). Currencies are never converted: TRY play is charged from your TRY balance.
- Your GGR rate is applied when you pay. Every payment is loaded as payment ÷ GGR rate of credit: at 10 %, 100 TRY paid gives 1000 TRY credit; at 5 %, 2000. The rate (shown in Dashboard → Clone API) is fixed for that payment.
- Provider pool. For every provider and currency you have a pool. Wins of that provider in that currency refill the pool.
- GGR is deducted 1:1. Each bet first uses the pool. The part of the bet not covered by the pool is deducted from your credit 1:1.
- Refused bets. If your clone balance cannot cover the GGR of a bet, the bet is refused towards the game with
MERCHANT_CREDIT_EXHAUSTED(you receive no callback). New launches in that currency get402 CLONE_CREDIT_EXHAUSTEDonce your balance (plus any credit limit the platform granted you) is used up. - Rejected or rolled-back bets are fully reversed: the pool and the charge are given back.
- Promo money. Operator gifts and bonus-call amounts are a real cost: they cost the promo rate (default 100 %) of their amount, converted into credit at your GGR rate (at 10 %: a 10 TRY gift = 100 credit), and do not refill the pool. A bonus-call is charged on the amount actually given when it ends, not on the requested
amount. - Gift wins are settled the next day. Gifts the game does not flag live are identified in the daily reconciliation (after 04:00 UTC for the previous day): they are then taken out of the pool and charged at the promo rate. Promo charges may take your clone balance below zero.
GGR rate 10 %, currency TRY
payment 100 TRY -> credit loaded 100 / 10 % = 1000 TRY
provider X, pool starts at 0
bet 100 -> pool covers 0, uncovered 100 -> credit -100 pool 0
win 150 -> credit 0 pool 150
bet 100 -> pool covers 100, uncovered 0 -> credit 0 pool 50
bet 80 -> pool covers 50, uncovered 30 -> credit -30 pool 0
------------
credit used: 130 (= 13 TRY of your payment)
credit left: 870
operator gift 10 TRY (promo 100 %) -> credit -100 (10 / 10 %)Error codes
Codes our API returns to you
| Code | HTTP | Meaning |
|---|---|---|
| AUTH_REQUIRED | 401 | Missing authentication header. |
| UNKNOWN_MERCHANT | 401 | Unknown X-Merchant-Id. |
| BAD_SIGNATURE | 401 | Signature mismatch. |
| TIMESTAMP_EXPIRED | 401 | Clock off by more than 60 s. |
| NONCE_REUSED | 401 | Nonce already used. |
| MERCHANT_SUSPENDED | 403 | Access suspended. |
| BAD_REQUEST | 400 | Invalid body or query. |
| BAD_CURRENCY / CURRENCY_NOT_ENABLED | 400 | Currency invalid / not enabled. |
| CLONE_CREDIT_EXHAUSTED | 402 | Clone balance in that currency is empty. |
| INSUFFICIENT_PROMO_BALANCE | 402 | Bonus-call amount not covered. |
| CLONE_CASINO_NOT_CONFIGURED | 403 | No clone callback URL. |
| PROVIDER_NOT_ENABLED | 403 | Provider not enabled. |
| PLAYER_BLOCKED | 403 | Player blocked. |
| RTP_NOT_APPLIED | 503 | RTP could not be applied; launch not opened (or player-rtp saved but not yet sent). Retry. |
| OPERATOR_TOOLS_DISABLED | 403 | Operator tools not enabled. |
| NO_SESSION | 404 | Unknown player. |
| NOT_FOUND / Game not found | 404 | Unknown resource. |
| PLAYER_ID_CONFLICT | 409 | player_id collides with an existing player; use another id. |
| (rate limit) | 429 | Too many requests; slow down. |
| UPSTREAM_UNAVAILABLE / UPSTREAM_ERROR | 502 | Game server unavailable or failed. |
| MAINTENANCE | 503 | Launches paused. |
| CLONE_UNAVAILABLE | 503 | The clone product is temporarily unavailable. |
| INTERNAL_ERROR | 500 | Unexpected error (also a game-server timeout), retry. |
Codes your wallet may return for a bet
INSUFFICIENT_FUNDS, PLAYER_BLOCKED, PLAYER_NOT_FOUND, CURRENCY_NOT_SUPPORTED, INVALID_TOKEN, BET_LIMIT_EXCEEDED are passed to the game; everything else becomes BET_REJECTED.
Codes the game may show for a bet that never reached you
| Code | Reason |
|---|---|
| MERCHANT_CREDIT_EXHAUSTED | Your clone balance cannot cover the GGR of this bet. |
| PLAYER_BLOCKED | Your account or the player is not active. |
| CURRENCY_NOT_SUPPORTED | Currency not enabled for your account. |
| BET_REJECTED | Provider not enabled, or the bet was cancelled. |
Integration checklist
- 1Activate the Clone API in Dashboard → Clone API, store
merchant_keyandapi_secretsecurely (server side only). - 2Copy the base URL; never hard-code the placeholder
https://api.slotix.bet/clone. - 3Sign requests exactly as described; test
GET /v1/native/providersfirst. Keep your server clock in sync (±60 s). - 4Sync the catalogue with
GET /v1/native/games(per_page=200), then incrementally withupdated_since+include_inactive=1. - 5Set your clone wallet callback URL. Verify our signature (±300 s) on every callback.
- 6Implement
bet,winandrollback, idempotent bytransaction_id, answer{ ok: true, balance }in under ~2 s. - 7Never reject a win; expect late and repeated wins.
- 8Top up your clone balance in each currency you enable.
- 9Launch with the slug (uuid without
clone-) and the player’s current balance. - 10Reconcile daily with
GET /v1/merchant/reconciliation.
