Slotix
Separate product

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.

Base URL
https://api.slotix.bet/clone
The same base URL is shown, with your credentials, in Dashboard → Clone API.
Getting started

Overview

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.

  1. 1Open Dashboard → Clone API and activate it. You get a merchant_key (starts with CLN_) and an api_secret. The secret is shown in full only once; you can reveal or rotate it later on the same page.
  2. 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 like https://api.slotix.bet/clone/v1/native/games.
  3. 3Set your clone wallet callback URL on the same page. Without it games cannot be launched (CLONE_CASINO_NOT_CONFIGURED).
  4. 4Top up your clone balance in every currency you will use (see Billing).

Differences from the original protocol

TopicClone API
Base URLYour own URL ending in /clone; copy it from Dashboard → Clone API.
Credentialsmerchant_key (CLN_…) + api_secret, from Dashboard → Clone API.
Game uuidPrefix clone-, e.g. clone-vs20olympgate. Launch uses the slug (the uuid without clone-).
RTP and bet limitsPATCH /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.
Launchgame_host is not available: games open on the platform's game host.
Bonus-call listGET /v1/dashboard/casino/bonus-call.
Operator toolsgive-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 callbackExtra action rollback. Wins are never lost: unconfirmed wins are redelivered until you accept them.
IdsFreespin 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 as enabled_currencies or valid_amounts). Validation errors of the request itself (BAD_REQUEST, bad image_style / updated_since, BAD_PRODUCT, BAD_DATE) use the { ok:false, error_code, error_description } form. Read error or error_code.
  • player_id is your player id: 1–64 characters of A-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_key by default (demo launch: 120 per minute per IP). Above it: 429 { success:false, error }. While our service is starting up you may briefly get 503: retry.
Getting started

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.

HeaderValue
X-Merchant-IdYour merchant_key (CLN_…).
X-TimestampUnix time in seconds. Must be within ±60 s of our clock.
X-NonceRandom string, used once. A repeated nonce is refused with NONCE_REUSED.
X-SignLowercase hex HMAC-SHA1 of the hash string, key = your api_secret.

Building the hash string

  1. 1Merge every query parameter, every body field and the three headers X-Merchant-Id, X-Timestamp, X-Nonce into one map.
  2. 2Drop fields whose value is null (they are not signed, but you may still send them, e.g. rtp: null).
  3. 3Sort the keys by byte order (so the X-… keys come before lower-case keys).
  4. 4Join as key=encodeURIComponent(value) with &. Values are signed as the server parses them: the JSON number 150.00 is signed as 150, true as true.
  5. 5X-Sign = lowercase hex of HMAC-SHA1(api_secret, hashString).
Worked example
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&currency=TRY&player_id=u1001

# 4) X-Sign = lowercase hex of HMAC-SHA1(api_secret, hash string)
PHP: 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
// 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_codeMeaning
401 AUTH_REQUIREDOne of the four headers is missing.
401 UNKNOWN_MERCHANTX-Merchant-Id is not a known clone merchant key.
401 BAD_SIGNATUREThe signature does not match. Check sorting, encoding, number formatting and that you use the clone api_secret.
401 TIMESTAMP_EXPIREDX-Timestamp is more than 60 s away from our clock. Sync your server clock (NTP).
401 NONCE_REUSEDThis nonce was already used. Generate a new one for every request.
403 MERCHANT_SUSPENDEDClone API access, or your merchant account, is suspended.
Error body
HTTP/1.1 401 Unauthorized
{
  "ok": false,
  "error_code": "BAD_SIGNATURE",
  "error_description": "Signature does not match"
}
Games

Catalogue

GET/v1/native/gamesPaged game list
ParameterTypeDescription
page
optional
integerPage number, default 1.
per_page
optional
integerDefault 50, maximum 200.
provider
optional
stringProvider code from /v1/native/providers.
search
optional
stringCase-insensitive search in the game name.
image_style
optional
stringportrait (default) or landscape: decides which picture goes into image.
include_inactive
optional
0 | 11 also returns inactive / removed games (active: false). Default 0.
updated_since
optional
unix secondsOnly games changed at or after this time. Combine with include_inactive=1 for incremental syncs, so you also learn about removed games.
Response
{
  "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 }
}
FieldDescription
uuidGame id with the clone- prefix. Use it in freespins, give-buy and to match wallet callbacks (game_uuid).
slugThe uuid without clone-. Used in the launch path.
providerProvider display name (the filter uses the provider code).
image / image_portrait / image_landscapeimage follows image_style.
type / technologyGame type (e.g. slots, table, crash) and technology.
is_mobile / has_lobby / has_tables0 or 1.
has_freespins1 = the game can be used for freespin campaigns and promo codes.
freespin_valid_until_full_day1 = freespin validity is rounded to the end of the day.
rtp / bet_optionsTheoretical RTP and available bet values (may be empty).
is_native / activeactive: false = no longer playable (only with include_inactive=1).
updated_atUnix 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.

GET/v1/native/providersProviders with active games
Response
{
  "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.

Games

Launch

POST/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.

ParameterTypeDescription
player_id
required
stringYour player id, 1–64 chars A-Z a-z 0-9 _ . : @ -. Created on first launch.
balance
required
numberPlayer's current balance in currency, 0 – 1000000000000. After launch, the balance always comes from your wallet answers.
currency
optional
stringISO-3. Default: your account default currency. Must be enabled for your account.
language
optional
stringGame language, default en (max 8 chars).
return_url
optional
stringWhere the game sends the player on exit / home (max 512 chars).
nickname
optional
stringDisplay name in the game (max 64 chars).
min_bet / max_bet
optional
numberTotal 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.
At launch we apply your RTP for this game (game → provider → merchant-wide rule, or the player’s own RTP) and your bet limits for its provider and currency. Your panel’s Games page can launch any game in demo or with a test player.
Request
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"
}
Response
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 / errorWhen
400 player_id requiredplayer_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_CURRENCYcurrency is not an ISO-3 code.
400 CURRENCY_NOT_ENABLEDCurrency not enabled for your account; the body contains enabled_currencies.
400 BAD_MIN_BET · BAD_MAX_BET · BAD_BET_RANGEmin_bet / max_bet is not a number >= 0, or the max is below the min.
402 CLONE_CREDIT_EXHAUSTEDYour clone balance in this currency is empty. Top it up.
403 CLONE_CASINO_NOT_CONFIGUREDNo clone wallet callback URL set in Dashboard → Clone API.
403 PROVIDER_NOT_ENABLEDThis game's provider is not enabled for your account.
403 PLAYER_BLOCKEDThe player is blocked on our side.
503 RTP_NOT_APPLIEDRTP 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 foundUnknown slug.
502 UPSTREAM_UNAVAILABLE / UPSTREAM_ERRORThe 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 MAINTENANCELaunches are paused for maintenance.
Error examples
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" }
Games

Demo

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

ParameterTypeDescription
balance
optional
numberFun balance, default 5000, max 1000000.
currency
optional
stringISO-3. Default: your account currency when you send your merchant_key (X-Merchant-Id header or merchant_key query parameter, no signature), otherwise TRY.
lang
optional
stringDefault en.
text
# no signature needed - GET or POST
GET https://api.slotix.bet/clone/v1/native/vs20olympgate/launch-demo?balance=5000&currency=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

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-Id is your merchant_key. Verify the signature and accept a clock window of ±300 s.
  • action is bet, win or rollback.
  • Redirects are not followed. Enter the final URL (exact scheme, host and path): a 301/302 answer 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

FieldDescription
actionbet | win | rollback.
transaction_idUnique id of this operation. Your idempotency key. For a rollback it is rb_<bet transaction_id>.
bet_transaction_idRollback only: the transaction_id of the bet to cancel.
player_idYour player id (as you sent it at launch).
session_idGame session id (also used by Replay).
round_idGame round id. A bet and its win share the round.
game_uuidGame uuid, e.g. clone-vs20olympgate.
currencyISO-3, the currency the game was launched in.
amountDecimal string, trailing zeros removed (2.5, 100). For a rollback: the bet amount.
typeOperation type from the game (e.g. bet, win); rollback for a rollback.
finishedtrue / false: the round is over. Always true for a rollback.
transaction_datetimeTime of the operation (ISO-8601).
casino_request_retry_countHow many times we already tried to deliver this transaction (0 = first attempt).
freespin_idFreespin wins: your freespin id.
quantity / freespin_settlement / freespin_total / freespin_partialCampaign 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_idThe 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.

bet callback
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=0

win

Credit amount (it can be 0 when a round is closed without a payout). Freespin wins carry the freespin fields:

freespin win callback
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=0

rollback (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 }.

rollback callback
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=0

Your answer and how we read it

json
// 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 answerWe 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 4xxBet: 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 balanceUnknown: 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_codeUse when
INSUFFICIENT_FUNDSBalance too low.
PLAYER_BLOCKEDThe player may not play.
PLAYER_NOT_FOUNDUnknown player.
CURRENCY_NOT_SUPPORTEDThe player has no wallet in that currency.
INVALID_TOKENYour own session for the player is no longer valid.
BET_LIMIT_EXCEEDEDYour 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 rollback for 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

A win cannot be undone in the game. If your wallet does not confirm a win (timeout, error, even 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.
Some bets are refused before they reach you, e.g. when your clone balance cannot cover the GGR of the bet (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.
Wallet

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.

Tables
-- 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()
);
Node.js (Express)
// 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);
Test it: launch a game, place a bet, then call your handler again with the same body. The second answer must be identical and the balance must not change.
Promotions & tools

Freespin campaigns

POST/v1/dashboard/native-freespinsGive a player free spins
ParameterTypeDescription
player_id
required
stringYour player id (created if new).
game_uuid
required
stringA game with has_freespins: 1 (clone-… or the slug).
quantity
required
integer1 – 10000.
bet_per_spin
optional
integerOnly 1, 2, 4, 10, 20, 40, 100, 200, 400.
valid_until_days
optional
integerDays the spins stay valid, default 7.
currency
optional
stringISO-3, default your account currency.
freespin_id
optional
stringYour 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.
json
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"
  }
}
GET/v1/dashboard/native-freespinsYour campaigns (optional ?status=)
DELETE/v1/dashboard/native-freespins/{freespin_id}Cancel a campaign
text
GET    /v1/dashboard/native-freespins?status=ACTIVE
DELETE /v1/dashboard/native-freespins/WELCOME-50

{ "data": { "freespin_id": "WELCOME-50", "cancelled": true } }
StatusMeaning
ACTIVESpins left to play.
CONSUMEDAll spins played, payout not finished yet.
COMPLETEDPaid 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.

ErrorWhen
400 BAD_QUANTITYquantity not an integer 1–10000.
400 BAD_BETbet_per_spin not in the allowed list.
400 BAD_FREESPIN_IDInvalid characters or too long.
400 BAD_CURRENCY / CURRENCY_NOT_ENABLEDCurrency invalid or not enabled.
400 GAME_NOT_SUPPORTEDThe game has has_freespins: 0.
403 PROVIDER_NOT_ENABLEDProvider not enabled for your account.
404 Game not found / NOT_FOUNDUnknown game / campaign.
409 FREESPIN_ID_EXISTSYou already used this freespin_id.
Promotions & tools

Promo codes

A promo code gives free spins on one game to every player who redeems it.

POST/v1/dashboard/native-promo-codesCreate
ParameterTypeDescription
code
required
stringA-Z 0-9 _ -, upper-cased. Your namespace. Max length is 32 minus your internal prefix (the BAD_CODE message tells you the exact range).
name
optional
stringLabel, max 64 chars.
game_slug
required
stringGame with has_freespins: 1 (slug or clone- uuid).
freespin_count
required
integer≥ 1.
bet_per_spin
optional
integer1, 2, 4, 10, 20, 40, 100, 200, 400.
freebet_expires_days
optional
integerValidity of the spins after redemption.
max_uses
optional
integerTotal redemptions.
max_uses_per_user
optional
integerRedemptions per player.
expires_at
optional
datetimeCode expiry.
GET/v1/dashboard/native-promo-codesList your codes
PATCH/v1/dashboard/native-promo-codes/{code}Activate / deactivate ({ is_active: 0|1 })
DELETE/v1/dashboard/native-promo-codes/{code}Delete
json
POST /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
POST/v1/dashboard/native-promo-codes/redeemRedeem for a player
json
POST /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"
  }
}
ErrorWhen
400 BAD_CODEInvalid characters or length.
400 BAD_COUNTfreespin_count < 1.
400 BAD_BETbet_per_spin not allowed.
400 GAME_NOT_SUPPORTEDThe game has no freespins.
404 Game not found / NOT_FOUNDUnknown game / code.
409 CODE_EXISTSYou already have this code.
409 CODE_IN_USEThe code was already redeemed and cannot be deleted.
404 INVALID_CODERedeem: unknown code.
410 CODE_EXPIREDRedeem: the code has expired.
409 LIMIT_REACHEDRedeem: max_uses reached.
409 ALREADY_REDEEMEDRedeem: this player already used it (max_uses_per_user).
Promotions & tools

Active features

GET/v1/native/active-featuresFeatures currently running for your players

Lists the features (gifted calls, free spins, bonus buys …) currently active for your players only, with your player_id.

json
GET /v1/native/active-features

{
  "items": [
    { "player_id": "u1001", "...": "feature fields" }
  ],
  "count": 1
}
Promotions & tools

Operator tools

Operator tools must be enabled for your account by the platform; otherwise every call returns 403 OPERATOR_TOOLS_DISABLED. Except POST /v1/native/give-buy, the player must already exist (have launched a game), otherwise 404 NO_SESSION.
EndpointBodyEffect
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.
json
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] }
Option lists
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", ... } ] } }
ErrorWhen
403 OPERATOR_TOOLS_DISABLEDNot enabled for your account.
404 NO_SESSIONUnknown player: launch a game for them first.
400 INVALID_AMOUNTgive-buy: the body includes valid_amounts.
400 NO_BUY_FEATURE / GAME_NOT_SUPPORTEDgive-buy: the game has no bonus buy.
409 ALREADY_PENDINGgive-buy: the player already has a pending one.
404 GAME_NOT_FOUNDoptions: unknown game.
503 CORPUS_UNAVAILABLEoptions: 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.
  • mult is relative to the player's bet (paid win = mult × current bet). A mult that 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 pending until the player opens that game in the same currency before expires_in_hours (default 24, max 168); then a normal bet with operator_buy_id arrives and counts in GGR. If your wallet rejects that bet, the status becomes failed.
  • auto-call: when the player's RTP falls below low, it is raised to boost until it is back above high; cap is 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.

json
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_statusMeaning
1Undergoing
2Stopping
3Finished
ErrorWhen
400 BAD_AMOUNTamount missing or ≤ 0.
402 INSUFFICIENT_PROMO_BALANCEYour clone balance in that currency does not cover the amount at the promo rate.
409 ALREADY_ACTIVEThe player already has a running bonus-call.
404 NO_ACTIVE_CALLCancel: nothing to cancel.

Gifts and bonus-call amounts are promo money and are billed at the promo rate, see Billing.

auto-call
PATCH /v1/native/auto-call
{ "player_id": "u1001", "enabled": true, "low": 60, "high": 120, "boost": 110, "cap": 1000 }
Promotions & tools

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

PATCH/v1/native/rtpMerchant-wide, provider or game RTP
ParameterTypeDescription
all / provider / slug
required
true | stringOne of: all: true, provider (code from /v1/native/providers), slug (or a clone- uuid). Unknown provider or game → 404 NO_GAMES.
rtp
required
number | null10 – 130; null removes the rule. Out of range → 400 BAD_RTP.
PATCH/v1/native/player-rtpPer-player RTP (applied at once)
ParameterTypeDescription
player_id
required
stringExisting player (otherwise 404 NO_SESSION).
rtp
required
number | null10 – 130; null (or leaving it out) removes it, the player then follows your rules again.
json
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 }, ... ] }
player-rtp
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/rtp lists your rules. The panel’s RTP & bet limits page does the same with a form.

Bet limits

PATCH/v1/native/bet-limitsMin / max bet per provider and currency
ParameterTypeDescription
provider / all
required
string | trueProvider code, or all: true for every provider.
min, max
required
number | nullTotal bet per spin (slots) or per round (live) in the session currency. Both null remove the limit. Errors BAD_MIN, BAD_MAX, BAD_RANGE.
currency
optional
stringISO-3: a limit for that currency only; omitted = every currency.
json
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_bet sent in a launch override them field by field.
  • Limits restrict the bets the player can choose; they do not change the RTP. Live-casino type limits are not available (400 BAD_TYPE).
  • PATCH /v1/native/bet-scale is managed by the platform: 403 NOT_AVAILABLE.
Reporting & billing

Replay

Replays of your own rounds and sessions. Anything that does not belong to your players returns 404.

EndpointReturns
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.
text
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://..." }
Reporting & billing

Merchant wallet & reconciliation

GET/v1/merchant/walletYour clone balance per currency
json
GET /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.

GET/v1/merchant/reconciliationYour transactions of one UTC day
ParameterTypeDescription
product
optional
stringOnly native (default). Anything else → 400 BAD_PRODUCT.
date
required
YYYY-MM-DDUTC day. Invalid → 400 BAD_DATE.
page
optional
integerDefault 1.
per_page
optional
integerDefault 1000, max 5000.
json
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.

Reporting & billing

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 get 402 CLONE_CREDIT_EXHAUSTED once 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.
Example
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 %)
Keep enough balance in every currency. A refused bet is not an error of your wallet, but your players cannot play until you top up.
Reference

Error codes

Codes our API returns to you

CodeHTTPMeaning
AUTH_REQUIRED401Missing authentication header.
UNKNOWN_MERCHANT401Unknown X-Merchant-Id.
BAD_SIGNATURE401Signature mismatch.
TIMESTAMP_EXPIRED401Clock off by more than 60 s.
NONCE_REUSED401Nonce already used.
MERCHANT_SUSPENDED403Access suspended.
BAD_REQUEST400Invalid body or query.
BAD_CURRENCY / CURRENCY_NOT_ENABLED400Currency invalid / not enabled.
CLONE_CREDIT_EXHAUSTED402Clone balance in that currency is empty.
INSUFFICIENT_PROMO_BALANCE402Bonus-call amount not covered.
CLONE_CASINO_NOT_CONFIGURED403No clone callback URL.
PROVIDER_NOT_ENABLED403Provider not enabled.
PLAYER_BLOCKED403Player blocked.
RTP_NOT_APPLIED503RTP could not be applied; launch not opened (or player-rtp saved but not yet sent). Retry.
OPERATOR_TOOLS_DISABLED403Operator tools not enabled.
NO_SESSION404Unknown player.
NOT_FOUND / Game not found404Unknown resource.
PLAYER_ID_CONFLICT409player_id collides with an existing player; use another id.
(rate limit)429Too many requests; slow down.
UPSTREAM_UNAVAILABLE / UPSTREAM_ERROR502Game server unavailable or failed.
MAINTENANCE503Launches paused.
CLONE_UNAVAILABLE503The clone product is temporarily unavailable.
INTERNAL_ERROR500Unexpected 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

CodeReason
MERCHANT_CREDIT_EXHAUSTEDYour clone balance cannot cover the GGR of this bet.
PLAYER_BLOCKEDYour account or the player is not active.
CURRENCY_NOT_SUPPORTEDCurrency not enabled for your account.
BET_REJECTEDProvider not enabled, or the bet was cancelled.
Reference

Integration checklist

  1. 1Activate the Clone API in Dashboard → Clone API, store merchant_key and api_secret securely (server side only).
  2. 2Copy the base URL; never hard-code the placeholder https://api.slotix.bet/clone.
  3. 3Sign requests exactly as described; test GET /v1/native/providers first. Keep your server clock in sync (±60 s).
  4. 4Sync the catalogue with GET /v1/native/games (per_page=200), then incrementally with updated_since + include_inactive=1.
  5. 5Set your clone wallet callback URL. Verify our signature (±300 s) on every callback.
  6. 6Implement bet, win and rollback, idempotent by transaction_id, answer { ok: true, balance } in under ~2 s.
  7. 7Never reject a win; expect late and repeated wins.
  8. 8Top up your clone balance in each currency you enable.
  9. 9Launch with the slug (uuid without clone-) and the player’s current balance.
  10. 10Reconcile daily with GET /v1/merchant/reconciliation.