Otto Gaming Otto Gaming Operator integration
https://ottogaming.io v1.0

Operator integration guide

Integrate Otto crash games

Otto Gaming hosts crash games for operators at ottogaming.io. Your backend asks Otto for a launch URL, your site shows the game in an iframe, and Otto reads and moves the player's money through four signed calls to your wallet. This guide is the complete contract for this deployment.

Base URL
https://ottogaming.io
Operator API
POST /api/platform/games
POST /api/platform/launch
Signing
HMAC-SHA256 over timestamp.body, in both directions
Wallet model
Seamless: Otto calls /balance, /debit, /credit, /rollback on your wallet
Player session
Renewed while the game is open, ends 8 hours after its last renewal; one per player, bound to one game
Game
Garanti Zepline, exclusive to betgaranti

Overview

The integration has two directions. You call Otto to list games and to launch a player into one. Otto calls you whenever money has to move: reading a balance, taking a stake, paying a win and reversing a transaction that did not complete. Every request in either direction is signed with the same shared secret.

Integration flow between the player browser, your platform and Otto 1. Your backend lists games at Otto and gets the game codes. 2. A player opens a game on your site. 3. Your backend asks Otto for a launch URL and gets launch_url for a new player session. 4. Your page embeds launch_url in an iframe. 5. The browser loads the game from ottogaming.io and opens a WebSocket. 6. Otto reads the balance from your wallet. 7. The player bets, cashes out or cancels. 8. Otto debits, credits or rolls back on your wallet. Player browser your site and the game iframe Your platform backend and wallet Otto ottogaming.io POST /api/platform/games 1 game list with codes player opens a game 2 POST /api/platform/launch 3 launch_url and a session <iframe src="launch_url"> 4 GET /play/{code} + WebSocket /app 5 POST /balance 6 bet, cash out or cancel 7 /debit · /credit · /rollback 8
request response wallet call signed by Otto
  1. Otto registers your platform and gives you an API key and an API secret (Onboarding).
  2. Your backend lists the games enabled for you with POST /api/platform/games and stores each game's code (Games list).
  3. When a player opens a game, your backend calls POST /api/platform/launch and receives a launch_url (Launching a game).
  4. Your page loads launch_url in an iframe. From then on the player's browser talks to ottogaming.io directly, over HTTPS and a WebSocket.
  5. Each balance read, bet, cash-out and cancel becomes a signed call from Otto to your wallet (Wallet callbacks).

Garanti Zepline

An airship crash game. The multiplier climbs while the airship flies; players cash out before it crashes.

  • Exclusive to betgaranti
  • Crash
  • RTP 97%
  • Crash points 1.00× to 100.00×
  • Bets 5 to 10,000 TRY
  • Up to 2 bets per round
  • 6 s betting, 3 s between rounds
  • English, Turkish, Spanish, Portuguese

Garanti Zepline is built for betgaranti and opens only to betgaranti and to Otto's own test brand (see Environments). Other operators do not see it in their games list and cannot launch it.

Environments and test brand

Otto runs one environment, production. There is no separate sandbox host. Every host below uses HTTPS.

HostWhat runs thereWho uses it
ottogaming.ioOperator API (/api/platform/*), the game client (/play/{code}), game WebSockets (/app), health check (/up)Your backend and your players' browsers
www.ottogaming.ioRedirects to ottogaming.ioNot needed for the integration
docs.ottogaming.ioThis guideYour integration team

Otto's test brand: otto-test

Otto keeps its own brand on production with the platform code otto-test. Its wallet is Otto's reference wallet, a small operator implementation that follows this guide to the letter. Nothing about it is simulated on Otto's side: each bet is a real round, a real ledger entry and a real signed wallet call. Only the money is play money, held in the reference wallet's memory and reset when that wallet restarts.

  • Otto uses otto-test to check releases end to end, and can use it to show you a game before your own integration is ready. otto-test is operated by Otto only; demos on it are run by Otto.
  • Brand-exclusive games, Garanti Zepline included, open to otto-test through an explicit production setting that applies only to platforms registered as Otto test brands. It changes nothing in betgaranti's own registration.
  • There is one live round per game, shared by every brand that carries it. otto-test players therefore fly in the same rounds as betgaranti players and can appear, masked, in the live bets list.

A test registration for your own team

Otto can register a second platform for you, flagged as a test brand and pointed at your staging wallet. The flag is a label only: Otto calls that wallet exactly as it calls a live one, so the same rules apply. Brand-exclusive games stay closed to such a registration unless Otto opens them to it explicitly. Its credentials are delivered privately, like your production ones, and never appear in this guide.

Onboarding

Before the first request, Otto registers your platform. The exchange below is all that is needed.

What Otto gives you

  • Platform URL: https://ottogaming.io, the base of every operator API path.
  • API key: 40 characters. Sent as X-Api-Key on every request, by you and by Otto.
  • API secret: 64 characters. Used only to compute signatures and never sent over the wire. It is shown once, when your platform is registered, and Otto stores it encrypted.
  • Platform code: your brand's identifier at Otto, for example betgaranti.
  • Confirmation of the wallet URL, the IP restriction and the number of games registered for you.

What you give Otto

  • Wallet base URL, over HTTPS. Otto appends /balance, /debit, /credit and /rollback, and all four must answer directly, without a redirect (rules).
  • Egress IP addresses of the servers that will call the operator API, for the allowlist.
  • Currencies you will launch players in, from the supported list.
  • Languages your players use (supported languages).
  • Contacts for incidents and reconciliation.

Wallet base URL

Give one base URL. For the base https://wallet.casino.example/otto, Otto calls https://wallet.casino.example/otto/debit and so on.

  • No placeholder, and no reserved last segment. Operators often send a template such as …/{method}, so Otto removes a trailing / and a last path segment named method, action, endpoint, type, op, command or cmd, in any letter case, with or without {}, <> or a leading :. A real segment with one of these names is removed too: https://api.casino.example/wallet/op is saved as https://api.casino.example/wallet, and Otto would then call /wallet/debit. Choose a base whose last segment is none of these words, and check the URL Otto confirms back to you.
  • No redirects. Point the base at the final address: the right scheme, host and path, with or without a trailing slash as your server expects. Otto follows a redirect, but a 301, 302 or 303 turns its POST into a GET with an empty body, which your wallet cannot verify or process, so every call fails.

IP allowlist

  • Otto compares the source address of each operator API request with your list, as text. Addresses must match exactly: CIDR ranges are not supported, so list every address, IPv4 and IPv6 separately.
  • Write IPv4 addresses in dotted form (203.0.113.10) and IPv6 addresses in their canonical compressed, lowercase form (RFC 5952): 2001:db8::1, not 2001:0DB8:0:0::1 or the expanded form. Otto compares against that canonical form, so any other spelling never matches and every call returns 401.
  • An empty list means no IP restriction. We recommend a list for production.
  • A request from an address that is not on the list gets the same 401 as a bad signature (see below).
  • If your wallet only accepts known sources, ask your Otto contact for the address Otto's wallet calls come from.

You do not need to give Otto your site's domains. The game page and its WebSocket both run on ottogaming.io, inside your iframe.

Looking after the credentials

  • Keep the API secret on your servers, in a secret store. It must never reach a browser, a mobile app, a log or a support ticket.
  • Rotation replaces the API key and the API secret together, and the old pair stops working at once: for your calls to Otto and for the signatures on Otto's calls to your wallet. Agree a switch time with Otto so both sides change in the same minute.
  • Otto rotates by re-issuing your whole registration, so confirm your wallet base URL and your IP allowlist with Otto at the same time, and check one call in each direction straight after the switch.

Authentication and signing

One scheme covers both directions: your requests to Otto and Otto's wallet calls to you. Each request carries three headers.

HeaderValue
X-Api-KeyYour API key. Otto sends your own key on its wallet calls too.
X-TimestampUnix time in whole seconds, digits only, for example 1791374400.
X-SignatureHMAC-SHA256 of the message below, keyed with the API secret, as 64 lowercase hex characters.
text The signature
message   = X-Timestamp + "." + raw request body
signature = lowercase_hex( HMAC-SHA256( key = API secret, data = message ) )

Rules

  • Sign the raw bytes. Serialise your JSON once, sign that exact string and send that same string. Re-encoding the body after signing (key order, spacing, escaping) changes the bytes and breaks the signature.
  • Whole seconds, digits only. Otto reads X-Timestamp as an integer and computes the signature over that integer, not over the header text. A millisecond timestamp always fails, and so does a fractional one such as 1791374400.123 (what Date.now() / 1000 or Python's str(time.time()) produce): it passes the time check, but the signatures never match. Truncate to an integer, then use that same string in the header and in the signed message.
  • ±300 seconds. Otto accepts a timestamp up to five minutes either side of its own clock. Keep your servers on NTP.
  • Empty body. Allowed: the message is then the timestamp followed by a dot, for example 1791374400.. For the games list we recommend sending {}.
  • Content type. Send Content-Type: application/json with every body.
  • Compare in constant time. When you verify Otto's signature, use hash_equals, crypto.timingSafeEqual or your language's equivalent.
  • Replays. The timestamp window is the replay protection on the operator API; there is no nonce. Call Otto over HTTPS only and keep the secret server-side.

Worked example

These values are for checking your implementation. The secret is a dummy and works nowhere.

API secretexample_secret_do_not_use
X-Timestamp1791374400 (2026-10-07 12:00:00 UTC)
Bodythe launch request in the code block below, exactly as written, one line
X-Signaturefc57fb1383621ea05581abf7e3fc56567db64062b3c6412f45690fb1167b9d9e
json Body of the worked example
{"game_code":"48213","player_id":"p-100245","username":"lucky_ace","currency":"TRY","language":"en","device":"MOBILE","return_url":"https://casino.example/lobby"}

More test vectors with the same secret and timestamp:

Raw bodyExpected X-Signature
{}8b618e8b6606ce772c6a09fc0f07876329258894150a2d64e404024797090580
(empty)cbc652a8e7bf40ced24141d7f9174bbee3daa8e5b6bea6905c9448f31ec481e5
the debit example6ae89b0830670ac0d60fd1354543ab83b2c348b188857f75793f5e5364ccf6d5

Pseudo code

pseudo code Calling Otto
body      = json_encode(request)          // serialise once; this exact string is sent
timestamp = to_string(floor(unix_time_seconds()))  // whole seconds, digits only
signature = lowercase_hex(hmac_sha256(key = API_SECRET, data = timestamp + "." + body))

POST https://ottogaming.io/api/platform/launch
  Content-Type: application/json
  Accept:       application/json
  X-Api-Key:    API_KEY
  X-Timestamp:  timestamp
  X-Signature:  signature

  body

curl

bash Signed launch request
# For a quick manual test only: a secret in a shell variable can end up in history.
OTTO_API_KEY='your API key'
OTTO_API_SECRET='your API secret'

BODY='{"game_code":"48213","player_id":"p-100245","username":"lucky_ace","currency":"TRY","language":"en","device":"MOBILE","return_url":"https://casino.example/lobby"}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$OTTO_API_SECRET" | awk '{print $NF}')

curl -sS https://ottogaming.io/api/platform/launch \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json' \
  -H "X-Api-Key: $OTTO_API_KEY" \
  -H "X-Timestamp: $TS" \
  -H "X-Signature: $SIG" \
  --data-raw "$BODY"

Verifying Otto's wallet calls

Read the body as raw bytes before any JSON parsing, check the key, the time window and the signature, then decode.

php Wallet endpoint guard
<?php
$raw       = file_get_contents('php://input');      // raw bytes, before json_decode
$apiKey    = $_SERVER['HTTP_X_API_KEY'] ?? '';
$timestamp = $_SERVER['HTTP_X_TIMESTAMP'] ?? '';
$given     = $_SERVER['HTTP_X_SIGNATURE'] ?? '';

$expected = hash_hmac('sha256', $timestamp . '.' . $raw, OTTO_API_SECRET);

$valid = hash_equals(OTTO_API_KEY, $apiKey)
    && ctype_digit($timestamp)
    && abs(time() - (int) $timestamp) <= 300
    && hash_equals($expected, $given);

if (! $valid) {
    http_response_code(401);
    header('Content-Type: application/json');
    echo json_encode(['code' => 'UNAUTHORIZED', 'message' => 'Invalid signature.']);
    exit;
}

$call = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
node.js Wallet endpoint guard
import crypto from 'node:crypto';

// rawBody: the request body as a Buffer, exactly as received
// (for example from express.raw({ type: 'application/json' })).
export function isSignedByOtto(headers, rawBody) {
  const timestamp = String(headers['x-timestamp'] ?? '');
  const given = Buffer.from(String(headers['x-signature'] ?? ''));

  if (headers['x-api-key'] !== process.env.OTTO_API_KEY) return false;
  if (!/^\d+$/.test(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const expected = Buffer.from(
    crypto
      .createHmac('sha256', process.env.OTTO_API_SECRET)
      .update(timestamp + '.')
      .update(rawBody)
      .digest('hex'),
  );

  // Compare byte lengths, not string lengths: Node decodes header values as
  // latin1, so a forged 64-character header can be longer in bytes, and
  // timingSafeEqual throws on buffers of different lengths.
  return given.length === expected.length
    && crypto.timingSafeEqual(given, expected);
}

Otto escapes slashes and non-ASCII characters

Otto's JSON bodies write / as \/ and non-ASCII characters as \u escapes: ç is sent as ç. Verify against the bytes you received. A body that you decode and encode again will not match the signature.

When authentication fails

ResponseCause
401 {"message":"Authentication headers are missing."}One of the three headers is missing.
401 {"message":"Authentication failed."}Any of: unknown or inactive API key, source IP not on your allowlist, wrong signature, timestamp outside ±300 s. The message is deliberately the same for all of them, so check each one.

Games list

POST/api/platform/gamesyour backend → Otto

Returns the games you may launch. The body can be anything, but it is signed like every other body; send {}.

json Response 200: a flat array, not wrapped in data
[
  {
    "code": "48213",
    "name": "Garanti Zepline",
    "kind": "crash",
    "rtp": "0.9700",
    "version": 1,
    "is_active": true,
    "max_win_multiplier": 5000,
    "images": []
  }
]
FieldTypeMeaning
codestringThe game's code for launch. A five-digit string that Otto assigns once, when the game is first installed. Read it from this list; do not guess it, and keep it as a string. The code 48213 in this guide's examples is illustrative.
namestringDisplay name.
kindstringAlways "crash" today.
rtpstringThe game's return to player as a decimal, "0.9700" = 97%. Crash rounds are shared by every brand, so a crash game has one RTP for all of them.
versionintegerVersion of the game definition.
is_activebooleanAlways true: inactive games are not listed.
max_win_multiplierintegerWin cap as a multiple of the stake; 0 means no cap.
imagesobject or []{"thumbnail", "hero", "banner"} as absolute URLs (hero equals thumbnail; banner is optional), or an empty array when the game has no artwork.
  • A game is listed when it is enabled for your platform, active, available on Otto, and allowed for your brand. Brand-exclusive games appear only to their owner. The list is sorted by name.
  • Garanti Zepline is installed inactive and appears in betgaranti's list once Otto has activated it in production.
  • Garanti Zepline returns "images": [] today, so the API supplies no lobby artwork for it. Ask your Otto contact for the artwork.

Launching a game

POST/api/platform/launchyour backend → Otto

Call this from your backend each time a player opens a game, and hand the returned launch_url to that player's browser.

Request

FieldRulesNotes
game_codestring, required, ≤ 255From the games list. An unknown code returns 422 "Game not found."
player_idstring, required, ≤ 255Your own stable id for the player. Otto keys the player on your platform plus this id, and sends it back on every wallet call.
usernamestring, required, ≤ 255A display name. It appears masked in live bets, so send a nickname, never an e-mail address or a real name.
currencystring, requiredOne of the supported codes, for example "TRY". Any other value returns 422 with the accepted values.
deviceoptional: omit the key, stringDESKTOP, MOBILE or TABLET, in capitals. Default DESKTOP.
languageoptional: omit the key, string, ≤ 5 charactersLower-cased, with _ turned into -. See Languages.
return_urloptional: omit the key, http or https URL, ≤ 512Shown as a “Back to site” link on the game's session error screen. See Exit and lobby.

Optional means “leave the key out”. null and the empty string "" are not accepted for device, language or return_url: either returns 422. If your JSON serialiser writes absent values as null, drop those keys before you sign the body.

json Request body
{
  "game_code": "48213",
  "player_id": "p-100245",
  "username": "lucky_ace",
  "currency": "TRY",
  "language": "en",
  "device": "MOBILE",
  "return_url": "https://casino.example/lobby"
}

Response

json Response 200, not wrapped
{
  "launch_url": "https://ottogaming.io/play/48213?token=<64-character token>",
  "session": {
    "id": 58231,
    "language": "en",
    "expires_at": "2026-10-07T20:00:00+00:00"
  },
  "game": {
    "code": "48213",
    "name": "Garanti Zepline"
  }
}
  • Use launch_url exactly as returned. Otto chooses its host (https://ottogaming.io in this deployment); do not rebuild the URL from parts.
  • session.id is the session_id that Otto's wallet calls carry for this player session.
  • session.language echoes the normalised language you sent.
  • session.expires_at is the first expiry, in UTC. It moves forward while the game is open (Sessions), so do not treat it as the end of the player's session.

What a launch does

  1. Creates the player, or updates it: username and currency are overwritten on every launch.
  2. Checks that the game is open to your brand and active, that it is enabled for your platform, and that it is available on Otto. A refusal returns 403 (Error reference) and leaves nothing behind: everything runs in one database transaction.
  3. Ends the player's other sessions, except a session that still has a bet in play, which lives on until that bet settles.
  4. Opens a new session that expires in 8 hours unless the game renews it, and returns its token, once, inside launch_url.

One currency per player id

Because each launch overwrites the player's currency, launching the same player_id in another currency switches that player over. If a player holds wallets in several currencies, give each currency its own player_id.

Sessions and the token

Lifetime8 hours, sliding. While the game is open it renews its session on its own, without changing the token: when 15 minutes or less remain, and on every balance check (/balance). Each renewal sets the expiry to 8 hours from that moment. A game left open therefore never expires; a session ends 8 hours after its last renewal, in practice about 8 hours after the player closed the game. Once a session has expired the game cannot continue: launch again.
ReuseA launch_url works until its session expires or is replaced. Reloading the game page is fine.
Ending a sessionThere is no operator call that ends a session. To stop a player (logout, self-exclusion, a session or loss limit), reject their /debit calls with a 4xx JSON answer: no new bet can then be placed. Keep accepting /credit and /rollback for bets already in play. A new launch for the same player_id also ends their other sessions, except one that still has a bet in play.
One per playerA new launch ends the player's other sessions, except one that still has a bet in play; it survives until the bet settles.
One game per tokenA token is bound to the game it was launched for. Launch again for another game.
Another deviceIf the session being replaced was active in the last 30 minutes from a different IP address, the new game shows the player a notice that the game was opened elsewhere.

Treat launch_url as a credential

Anyone holding it plays as that player until the session ends. Give it only to that player's browser and keep it out of your logs, analytics and error reports. The game removes the token from the address bar once it has loaded, and Otto's access logs redact it.

Embedding the game

html Iframe
<iframe
  src="LAUNCH_URL"
  title="Garanti Zepline"
  allow="fullscreen"
  style="border:0; width:100%; height:100%"></iframe>
  • Otto sends no X-Frame-Options or frame-ancestors header, so any page may embed /play. If your own site sends a Content Security Policy, allow https://ottogaming.io in frame-src.
  • Inside the iframe, the game opens a WebSocket to wss://ottogaming.io/app/…. That connection belongs to the game page, not to your site.
  • allow="fullscreen" lets the game offer its fullscreen button where the browser supports it. Without it the button is hidden.
  • When the game loads it calls /balance on your wallet to show the player's balance. If that call fails, the game shows the balance as unknown and reads it again a few times in the background.
  • The game page is served with Referrer-Policy: strict-origin.

Exit and lobby

The game has no general exit button. return_url appears only on the session error screen, as a “Back to site” link that opens in the top window (target="_top"), for example after a session has expired or been replaced. Put your own close or back-to-lobby control around the iframe.

Languages

  • The game is translated into English (en), Turkish (tr), Spanish (es) and Portuguese (pt).
  • The region part is ignored: pt-BR and pt_BR both show Portuguese. Any other language shows English.
  • Otto accepts any tag of up to 5 characters and stores it normalised. Longer tags fail with 422: send es, not es-419.
  • Without a language, the game uses the language last used in that browser tab, otherwise English. Send language on every launch.

Currencies and bet limits

Send currency as one of these 17 codes:

TRY USD EUR GBP ARS BOB BRL CLP COP CRC GTQ INR MXN PEN UYU VES ZAR

Garanti Zepline's limits are set in TRY (minimum 5, maximum 10,000) and scaled to other currencies with fixed product parities. The parities are not exchange rates and do not move with the market.

text Scaling
min bet = max(1, floor(5 × parity))
max bet = floor(10000 × parity)
Garanti Zepline bet limits per currency
CurrencyParityMin betMax bet
TRY1510,000
USD, EUR, GBP, BRL, PEN0.111,000
ARS1050100,000
BOB0.525,000
CLP12.562125,000
COP, CRC50250500,000
GTQ1510,000
INR94590,000
MXN0.2512,500
UYU42040,000
VES20100200,000
ZAR1.5715,000
  • Stakes have up to 10 digits and up to 2 decimals. A stake outside the limits is refused with BET_OUT_OF_LIMITS before your wallet is called.
  • A player can hold up to 2 bets per round.
  • Payout = stake × cash-out multiplier, with the multiplier floored to 2 decimals and the result kept to 2 decimals.
  • The win cap is 5,000× the stake, but crash points stop at 100.00×, so the largest possible payout is 100 × the stake: 1,000,000.00 TRY on a 10,000 TRY bet.

Wallet callbacks

Your wallet holds the player's money. Otto reads the balance from it and calls it for every movement: taking a stake, paying a win, reversing a transaction.

Transport

URLYour wallet base URL + /balance, /debit, /credit or /rollback
MethodPOST with a JSON body, for all four
HeadersContent-Type: application/json, Accept: application/json, X-Api-Key (your key), X-Timestamp, X-Signature: the same scheme with your API secret
EncodingSlashes and non-ASCII characters escaped. Verify the raw bytes.
RedirectsNot supported: answer directly. A 301, 302 or 303 turns Otto's POST into a GET with an empty body (details).
During play1 s to connect, 3 s in total, one try per call. This covers /balance, /debit, /credit and a /rollback sent while the player waits. The game client may repeat the player's action, but as a new call (automatic retries).
Queued rollbacks5 s to connect, 15 s in total, 2 tries per attempt on a connection error or a 5xx; up to 10 attempts, with waits between them that add up to about 27 minutes (details)

How Otto reads your response

You answerOtto treats it asWhat follows
2xx with a JSON object, or with an empty bodySuccessReads balance (number or numeric string, normalised to 2 decimals) and the optional reference string. Other fields are ignored. An empty body is read as {}: a success with no balance (see below).
4xx with a JSON object, or with an empty bodyRejectedRecords code (your string, or the HTTP status) and message. The transaction is marked failed and no rollback is sent. An empty 4xx, such as a bare 401, 403, 404 or 405 from a gateway, is a rejection too.
5xx with any body; a 2xx or 4xx whose body is not empty and is not JSON (HTML, plain text, whitespace) or is a bare JSON string, number or null; a timeout or a connection errorUnknownOtto cannot tell whether money moved. The transaction stays pending, a rollback for its transaction_id is queued and the player sees “wallet unavailable” once the game's automatic retries run out.

Four rules that prevent most incidents

  • A 2xx is a success, whatever its JSON says. Otto does not look for an error flag inside a 2xx. Never answer 200 with {"error": …}.
  • Always answer in JSON, errors included. A 4xx with an HTML or text body counts as unknown: Otto rolls back and the player sees “wallet unavailable” instead of a clean rejection.
  • Make sure nothing in front of your wallet answers Otto with an empty 4xx. An authentication layer, a firewall or a wrong path that returns a bare 401, 403, 404 or 405 is read as your wallet rejecting the call. No rollback follows, and on /credit the player loses the win.
  • Answer within 3 seconds, including anything your wallet does internally. Otto does not repeat a call that ran out of time: it rolls it back.
json Success, 200
{
  "balance": "990.00",
  "reference": "op-904512"
}
json Rejection, 400
{
  "code": "INSUFFICIENT_FUNDS",
  "message": "Insufficient funds."
}

Otto keeps your code and message for support. The player sees a translated notice for the outcome, not your message text. Otto reads reference but does not store it: reconcile on transaction_id.

Request fields

The field order in each body is fixed, and the signature covers those exact bytes.

FieldTypeMeaning
player_idstringYour player id, as sent at launch.
game_codestringThe game's code.
session_idintegerOtto's player session. May be null on a queued rollback.
transaction_idintegerOtto's id for this money movement; the only transaction id Otto sends. A rollback carries the id of the transaction it reverses.
round_idintegerThe round. Left out when Otto does not have it; never sent as 0 or a made-up value.
bet_idintegerThe bet, carried by its debit and by its credits. A retried cash-out sends a further credit for the same bet_id, each failed one followed by its rollback (automatic retries), so never deduplicate or reject on it. Left out when Otto does not have it.
amountstringExactly 2 decimals, for example "10.00".
currencystringThe player's currency code, for example "TRY".
typestring"debit", "credit" or "rollback".
reasonstringQueued rollbacks only. Free text for diagnosis, up to 255 characters: not a fixed list of values and not always in English. It can be an internal note such as "Request completed with status 503." or the transport error Otto saw, which may include your wallet URL. Log it; never parse it or branch on it.

/balance

POST{wallet base URL}/balanceOtto → your wallet

Not a once-per-load call. Every session read by the game client asks your wallet for the balance:

  • when the game loads or is reloaded;
  • when the game renews its session (when 15 minutes or less remain);
  • on every balance check: the game tab becomes visible again, the game window gets focus, or a bet card shows “not enough balance” or skips an automatic bet for a low balance. At most one check every 10 seconds per open game;
  • while the balance is unknown (a failed read, or a money answer without balance): up to four more reads, after 3, 10, 30 and 60 seconds.

An active player can therefore cause about 6 calls a minute. Otto never sends more than 30 a minute for one session. Size and rate-limit /balance for that. A rejection or failure only means the game shows the balance as unknown.

json Request
{
  "player_id": "p-100245",
  "game_code": "48213",
  "session_id": 58231
}
json Response 200
{
  "balance": "1000.00"
}

/debit

POST{wallet base URL}/debitOtto → your wallet

Takes the stake when the player places a bet. Otto records the bet first, then calls you.

json Request
{"player_id":"p-100245","game_code":"48213","session_id":58231,"transaction_id":904512,"round_id":77120,"bet_id":310455,"amount":"10.00","currency":"TRY","type":"debit"}
json Response 200
{
  "balance": "990.00",
  "reference": "op-904512"
}
  • Success: the bet is placed.
  • Rejected (for example insufficient funds, a blocked player): the bet is discarded and the player sees a rejection. Do not move any money.
  • Unknown: the bet is discarded, Otto sends a /rollback for this transaction_id straight away, and the game retries the bet as a new debit (automatic retries). The rollback can reach you even if the debit never did, and even before your wallet has finished applying the debit (see rollback).

/credit

POST{wallet base URL}/creditOtto → your wallet

Pays the win when the player cashes out during the flight. Same shape as the debit, with a new transaction_id, the same round_id and bet_id, and amount set to the payout. Example: a 10.00 stake cashed out at 2.35×.

json Request
{"player_id":"p-100245","game_code":"48213","session_id":58231,"transaction_id":904530,"round_id":77120,"bet_id":310455,"amount":"23.50","currency":"TRY","type":"credit"}
json Response 200
{
  "balance": "1013.50",
  "reference": "op-904530"
}

Never reject a credit

If you answer a credit with a 4xx (an empty one included), the bet goes back into play and is lost when the round crashes: the player loses a win they earned. If the credit is unknown (timeout, 5xx, not JSON), the bet also goes back into play, Otto queues a rollback of that credit, and the game retries the cash-out by itself: a new credit with a new transaction_id, the same bet_id and a new, usually higher, amount. If the round crashes first, the bet is lost. Make /credit fast and accept every credit for a bet you debited, including a second or third credit for the same bet_id.

/rollback

POST{wallet base URL}/rollbackOtto → your wallet

Reverses an earlier debit or credit. transaction_id is the id of that original transaction; the body does not say whether it was a debit or a credit, so look up your own entry for that id.

  • Original was a debit: refund the stake.
  • Original was a credit: take the payout back.
  • No entry for that id: answer 2xx as a no-op and keep a tombstone for that transaction_id (see below). This is normal; the original call may never have reached you.
  • Already rolled back: answer 2xx as a no-op. The same rollback can arrive many times: inline during a cancel, then from the queue, where every answer that does not reach Otto as a 2xx is sent again.

A rollback can overtake its debit or credit

Otto queues the rollback of an unknown debit or credit as soon as its own 3-second budget runs out, and a worker sends it within moments. Your wallet may still be processing the original call at that point, so the rollback can arrive before the debit or credit is committed on your side. If you answer it as a no-op and then commit the original, the player is charged for a bet that does not exist, or, for a credit, paid twice, because the game also retries the cash-out.

  • Process a transaction_id and its rollback under the same lock, so one waits for the other.
  • When a rollback finds no entry, record a tombstone for that transaction_id. If a debit or credit with a tombstoned transaction_id arrives or finishes later, do not apply it, or reverse it at once. Otto has already given up on that call and does not read your answer to it.
  • Keep tombstones for at least a day.

Otto sends rollbacks in two ways:

json While the player waits (for example a cancel)
{
  "player_id": "p-100245",
  "game_code": "48213",
  "session_id": 58231,
  "transaction_id": 904512,
  "round_id": 77120,
  "bet_id": 310455,
  "amount": "10.00",
  "currency": "TRY",
  "type": "rollback"
}
json From the rollback queue
{
  "player_id": "p-100245",
  "game_code": "48213",
  "session_id": 58231,
  "transaction_id": 904512,
  "round_id": 77120,
  "bet_id": 310455,
  "amount": "10.00",
  "currency": "TRY",
  "type": "rollback",
  "reason": "Request completed with status 503."
}
  • In both forms amount, round_id and bet_id are the original transaction's; they are left out when Otto cannot read the original. A queued rollback may also carry "session_id": null.
  • Answer 2xx with JSON and the new balance. A rejected rollback during a cancel leaves the bet in play.
  • Never reject a rollback. The queue retries 4xx answers too; it does not give up until its last attempt.

When each call happens

Player actionCallIf you reject (4xx JSON)If the outcome is unknown
Opens or reloads the game, comes back to it, meets a low-balance notice, or keeps it open (details)/balanceBalance shown as unknown; read again in the backgroundBalance shown as unknown; read again in the background
Places a bet (betting phase)/debitBet discarded; player sees a rejectionBet discarded; rollback of this debit queued; the game retries with a new debit, then shows “wallet unavailable” if the retries fail
Cashes out (during the flight)/creditBet back in play and lost at the crashBet back in play; rollback of this credit queued; the game retries with a new credit (new transaction_id, same bet_id)
Cancels a bet (betting phase only)/rollback of the debitBet stays in playBet counts as refunded; rollback queued
Loses (the round crashes)No call. A debit that is followed by no credit and no rollback is a lost bet.

In addition, when a game request fails after a debit or credit, Otto queues a rollback for each debit or credit it could not complete. A cancel is the exception: its /rollback is never compensated. In the rare case that your wallet applied a cancel's rollback and Otto's request then failed, the bet stays in play on Otto's side, and a later /credit can arrive for a bet_id whose debit you already refunded. Accept that credit, and raise the case with Otto for reconciliation.

No settlement call for losses

Otto sends nothing when a round ends. Settle a debit as lost when its round is over and no credit stands for that bet_id (a credit that was rolled back does not count). Keep accepting rollbacks afterwards: a queued rollback for a failed call can arrive more than half an hour later, and there is no fixed upper bound (the rollback queue).

Automatic retries by the game

When a bet or a cash-out fails with an unknown outcome (“wallet unavailable”, a network error, a timeout or a server error) or finds another action still running, the game client repeats the player's action by itself, up to 3 more times, after 250, 750 and 1,500 ms. Otto rolled the failed attempt back, so each repeat reaches your wallet as a new debit or credit with a new transaction_id, and each failed attempt has its own queued rollback. One click can therefore produce up to 4 debits or credits, plus their rollbacks, within a few seconds.

  • A repeated bet carries a new bet_id: the bet is created again.
  • A repeated cash-out carries the same bet_id and a new amount: the multiplier is read again at each attempt, so the amount is usually higher.
  • Never deduplicate, validate or reject on bet_id, or on a rule such as “one credit per bet”. Deduplicate on (transaction_id, type) only. Rejecting a valid repeated credit loses the player's win.

Idempotency and concurrency

  • Deduplicate on (transaction_id, type). A repeat must not move money twice; answer it with the original result. A rollback reuses the original transaction's id with type: "rollback", so it never collides with the debit or credit it reverses.
  • Otto never sends a debit or a credit twice under the same transaction_id. A repeat of the player's action is a new call with a new id (automatic retries), and the failed attempt is covered by its own rollback. Rollbacks are different: the same rollback can arrive many times.
  • Otto runs one bet, cash-out or cancel at a time per player session, so two bets of one session never overlap. Calls for the same player still overlap in two ways: queued rollbacks run on Otto's background workers, outside that order, and typically overlap with the game's automatic retry a quarter of a second later; and an older session whose bet is still in play can overlap with a new session. Make balance changes atomic per player (a row lock or equivalent).

Amounts and precision

  • Every amount is a string with exactly 2 decimals. Use decimal arithmetic, never binary floating point.
  • Return balance on every 2xx, for /balance, /debit, /credit and /rollback alike, as a string with 2 decimals, for example "1013.50". A JSON number is accepted too; Otto normalises it to 2 decimals.
  • Without a numeric balance (missing, null, or formatted like "1,000.00"), the call still succeeds, but the game marks the balance unknown and reads it again through /balance after 3, 10, 30 and 60 seconds: up to four extra signed calls to your wallet, each of which also renews the player's session.
  • currency is the player's currency from the launch.

The rollback queue

  • The first attempt starts at once, usually within a second of the failure. Up to 10 attempts in all; between them Otto waits 10 s, 30 s, 60 s, 120 s, 180 s, then 300 s four times. The waits add up to about 27 minutes.
  • Each attempt allows 15 seconds and tries twice on a connection error or a 5xx. A 4xx is retried on the next attempt as well, and so is a 2xx that did not reach Otto in time.
  • The same rollback can therefore reach you up to about 20 times, besides the inline try during a cancel. With slow answers the last attempt can start more than 30 minutes after the failure, and later still if Otto's queue is running behind: do not close your window at 27 minutes.
  • Any 2xx completes the rollback; JSON is not required here.
  • There is at most one rollback per original transaction_id, however many times it is delivered.
  • If the last attempt fails, Otto stops sending it, marks it failed and reconciles the transaction with you by hand.

Round lifecycle and timing

Each game runs one live round at a time, shared by every brand that carries the game. The engine advances in 250 ms ticks.

Garanti Zepline round timeline A new round opens with 6 seconds of betting. Betting closes and the airship takes off in the same tick. The multiplier grows as 1.06 to the power of the seconds flown until the crash, for example 10.00 times after 39.5 seconds. Three seconds after the crash the next round opens. BETTING RUNNING CRASHED e.g. crash at 10.00× after 39.5 s 6 s flight: ln(crash) ÷ ln(1.06) s 3 s new round takeoff crash next round Not to scale. Betting closes and takeoff happen in the same engine tick.
One Garanti Zepline round: 6 s of betting, a flight whose length depends on the crash point, then 3 s before the next round.
StateGaranti ZeplineWhat players can do
BETTING (1)6 s. The server_seed_hash is published.Place bets and cancel them, until betting ends.
WAITING_START (2)0 s: there is no starting phase. The airship takes off in the tick that closes betting, and the flight is timed from that tick.Nothing.
RUNNING (3)The multiplier grows from 1.00× until the crash point.Cash out.
CRASHED (4)Bets still in play are lost. The seeds are revealed. The next round opens 3 s after the crash.Nothing.
FINISHED (5)The round is closed.Nothing.

The multiplier

text Live multiplier and flight time
multiplier(t) = floor(1.06^t × 100) / 100        t = seconds since takeoff
flight time   = ln(crash point) / ln(1.06)       seconds
Flight time for a given crash point
Crash point1.5×2×3×5×10×20×50×100×
Flight7.0 s11.9 s18.9 s27.6 s39.5 s51.4 s67.1 s79.0 s

Timing rules

  • A bet or a cancel is accepted only while the round is in BETTING and before its betting end time. Otto checks this twice, the second time under a database lock, so a request that arrives at the boundary is either fully in or refused: a bet with BETTING_CLOSED, a cancel with BET_NOT_CANCELLABLE.
  • A cash-out is accepted only while the round is RUNNING and before the crash time. Otherwise it is refused with TOO_LATE or ROUND_NOT_RUNNING.
  • A full cycle takes 6 s of betting, the flight, and 3 s: about 9 s for a crash at 1.00× and about 88 s for 100.00×.

Provably fair

Each crash point is fixed by a secret seed that Otto commits to before any bet is placed, combined with a seed that comes from the bets themselves. After the crash, the secret is revealed and anyone can recompute the result.

  1. Commit. When a round is created, Otto draws server_seed (32 random bytes, hex) and publishes server_seed_hash = sha256(server_seed) as betting starts.
  2. Client seed. When betting closes, the round's client_seed is sha256("round:<round_id>|" + the entries "<bet_id>:<session_id>:<bet client seed>" joined with "|", in bet id order). A player may attach their own client seed of up to 255 characters to a bet; otherwise that part is empty. A round without bets gets a random client seed.
  3. Crash point. Computed from server_seed, client_seed, the round's nonce (its sequence number) and the game's RTP, as below.
  4. Reveal. After the crash, server_seed is revealed. client_seed and nonce are sent in the crash event to every open game.
pseudo code Recomputing a crash point
assert sha256_hex(server_seed) == server_seed_hash   // hash of the 64-character hex text

// The key is server_seed as text (its 64 hex characters), not the decoded bytes.
// Arithmetic below is IEEE 754 double precision, as in JavaScript.
digest = hmac_sha256_hex(key = server_seed, data = client_seed + ":" + nonce)
h      = parse_int(digest[0:13], base 16)          // first 13 hex characters = 52 bits
m      = max(1, rtp × 2^52 / (2^52 − h))            // rtp = 0.97 for Garanti Zepline
m      = min(max(m, 1.00), 100.00)                  // clamp to the game's range
crash  = floor(m × 100) / 100
ValueWhen it becomes visible
server_seed_hashWhen betting starts
crash_point, server_seedOnce the round has crashed
client_seed, nonceOnly in the live crash event; the round history keeps just the round id and its crash point

Live bets and privacy

Garanti Zepline shows players a live list of the current round's bets inside the game.

  • What it shows: the top 20 bets of the current round by stake, plus round totals, refreshed about once a second.
  • Whose bets: bets from every brand in the round. Rounds are shared, so the list covers every brand that carries the game, Otto's test brand included.
  • Privacy: usernames are masked. The list carries no player ids, no currencies and no balances. Stakes are converted to the base currency, TRY, before they are ranked.
  • The masked name is derived from the username you send at launch and shows at most its first two and last two characters, for example lucky_ace as lu***ce. Send a nickname, never an e-mail address or a real name.

Error reference

Operator API

Errors that Otto's application returns on /api/* are JSON with a message. Validation errors (422) also carry an errors object that lists the messages for each failing field.

Not every error comes from the application

The proxies in front of Otto can answer without JSON:

  • 413 with no body for a request body over 1 MB;
  • 503 when Otto sheds load because a request waited more than 10 seconds for a worker;
  • 502, 504 or a Cloudflare 52x HTML page when Otto cannot be reached.

During a deploy, a request can be held for up to 30 seconds until Otto answers again. Give launch calls a timeout of at least 35 seconds, read the body as JSON only when it is JSON, and treat any other error as temporary.

HTTPMessageCause
401Authentication headers are missing.A signing header is missing.
401Authentication failed.Unknown or inactive key, IP not allowed, bad signature or stale timestamp.
403The game is not enabled for this operator.The game is exclusive to another brand, or not enabled for your platform.
403The game is not available.The game is inactive or not available on Otto.
422Game not found.Unknown game_code.
422(validation message)A missing or invalid field: currency (the message lists accepted values), language over 5 characters, device, return_url, an optional field sent as null or "", a field over its length.
429Too Many Attempts.More than 1,200 requests per minute for your platform.

Your wallet's answers

See How Otto reads your response. In short: a 2xx with JSON or an empty body is a success, a 4xx with JSON or an empty body is a rejection, everything else is unknown and leads to a rollback. The code you return is free text for Otto; the reference wallet uses INSUFFICIENT_FUNDS and UNAUTHORIZED.

Game client codes

The game client talks to Otto's player API, not to you. You will meet these codes only in support conversations. Responses use {"data": …, "server_time_ms": …} or {"error": {"code", "message"}, "server_time_ms": …}.

CodeHTTPMeaning
NO_ACTIVE_ROUND409No round is open for the game.
BETTING_CLOSED409The bet arrived after betting closed.
BET_LIMIT_REACHED409The player already has 2 bets in this round.
BET_OUT_OF_LIMITS409The stake is below the minimum or above the maximum for the currency.
BET_NOT_FOUND404No such bet for this session.
BET_NOT_CANCELLABLE409The cancel arrived after betting closed: the bet can no longer be cancelled.
BET_ALREADY_SETTLED409The bet has already been paid or lost.
ROUND_NOT_RUNNING409Cash-out outside the flight.
TOO_LATE409Cash-out reached Otto after the crash.
AMBIGUOUS_BET422The player has two open bets and the cash-out or cancel did not say which one.
WALLET_REJECTED402Your wallet rejected the call (4xx JSON). Carries your message; the player sees a translated notice.
WALLET_UNAVAILABLE503Your wallet timed out, answered 5xx, or answered with a body that is neither empty nor JSON. The game retries the action by itself before it shows this.
OPERATION_IN_FLIGHT429Another wallet action for the same session is still running.

The player API can also answer 401 (session expired or replaced: launch again), 422 (validation) and 429 “Too Many Attempts.”.

Rate limits and health

ScopeLimit
Operator API (games, launch)1,200 requests per minute per platform
Game actions (bet, cash out, cancel)120 per minute per player session
Player sign-in, refresh and switch30 per minute per player session

Over the limit, Otto answers 429 “Too Many Attempts.” with a Retry-After header in seconds; wait that long before you retry. Errors from the proxies in front of Otto are described under Error reference.

Health: GET https://ottogaming.io/up answers 200 while the application is up. It is a liveness check only and does not exercise wallets or games.

Testing checklist

Work through these before asking for go-live. The boxes can be ticked in your browser; they are not sent anywhere.

Operator API

Wallet

Reconciliation

Otto's reference wallet can simulate the failure cases on demand (slow answers, timeouts, server errors, insufficient funds, refused rollbacks, broken signatures). If you want to see the expected behaviour before testing your own wallet, ask your Otto contact for a demonstration on otto-test.

Go-live checklist

Your side

Together with Otto

Support and diagnostics

When you contact Otto about a transaction, include:

  • transaction_id, round_id, bet_id and player_id;
  • the time in UTC, to the second;
  • the HTTP status and body your wallet returned.

For every wallet call, Otto logs the endpoint, the HTTP status, the outcome and the duration. Your response body is kept only when the HTTP status was not 2xx or the call took 1 second or more, up to 5,000 characters. A fast 2xx is logged without its body even when Otto could not read it (for example an HTML page from a firewall), so keep your own log of what your wallet answered. Request bodies and headers are never stored. These logs are kept for 14 days, so report problems within that window.

Changelog

The operator API has no version number in its paths. Changes to this contract are recorded here.

VersionDateChanges
1.07 October 2026First edition for ottogaming.io: operator API, request signing, seamless wallet callbacks, Garanti Zepline, Otto's test brand.