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/gamesPOST /api/platform/launch- Signing
- HMAC-SHA256 over
timestamp.body, in both directions - Wallet model
- Seamless: Otto calls
/balance,/debit,/credit,/rollbackon 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.
- Otto registers your platform and gives you an API key and an API secret (Onboarding).
- Your backend lists the games enabled for you with
POST /api/platform/gamesand stores each game'scode(Games list). - When a player opens a game, your backend calls
POST /api/platform/launchand receives alaunch_url(Launching a game). - Your page loads
launch_urlin an iframe. From then on the player's browser talks to ottogaming.io directly, over HTTPS and a WebSocket. - 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.
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.
| Host | What runs there | Who uses it |
|---|---|---|
ottogaming.io | Operator API (/api/platform/*), the game client (/play/{code}), game WebSockets (/app), health check (/up) | Your backend and your players' browsers |
www.ottogaming.io | Redirects to ottogaming.io | Not needed for the integration |
docs.ottogaming.io | This guide | Your 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-testto check releases end to end, and can use it to show you a game before your own integration is ready.otto-testis operated by Otto only; demos on it are run by Otto. - Brand-exclusive games, Garanti Zepline included, open to
otto-testthrough 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-testplayers 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-Keyon 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,/creditand/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 namedmethod,action,endpoint,type,op,commandorcmd, 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/opis saved ashttps://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,302or303turns itsPOSTinto aGETwith 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, not2001:0DB8:0:0::1or the expanded form. Otto compares against that canonical form, so any other spelling never matches and every call returns401. - 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
401as 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.
| Header | Value |
|---|---|
X-Api-Key | Your API key. Otto sends your own key on its wallet calls too. |
X-Timestamp | Unix time in whole seconds, digits only, for example 1791374400. |
X-Signature | HMAC-SHA256 of the message below, keyed with the API secret, as 64 lowercase hex characters. |
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-Timestampas 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 as1791374400.123(whatDate.now() / 1000or Python'sstr(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/jsonwith every body. - Compare in constant time. When you verify Otto's signature, use
hash_equals,crypto.timingSafeEqualor 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 secret | example_secret_do_not_use |
|---|---|
X-Timestamp | 1791374400 (2026-10-07 12:00:00 UTC) |
| Body | the launch request in the code block below, exactly as written, one line |
X-Signature | fc57fb1383621ea05581abf7e3fc56567db64062b3c6412f45690fb1167b9d9e |
{"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 body | Expected X-Signature |
|---|---|
{} | 8b618e8b6606ce772c6a09fc0f07876329258894150a2d64e404024797090580 |
| (empty) | cbc652a8e7bf40ced24141d7f9174bbee3daa8e5b6bea6905c9448f31ec481e5 |
| the debit example | 6ae89b0830670ac0d60fd1354543ab83b2c348b188857f75793f5e5364ccf6d5 |
Pseudo code
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
# 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
$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);
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
| Response | Cause |
|---|---|
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
/api/platform/gamesyour backend → OttoReturns the games you may launch. The body can be anything, but it is signed like every other body; send {}.
data[
{
"code": "48213",
"name": "Garanti Zepline",
"kind": "crash",
"rtp": "0.9700",
"version": 1,
"is_active": true,
"max_win_multiplier": 5000,
"images": []
}
]
| Field | Type | Meaning |
|---|---|---|
code | string | The 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. |
name | string | Display name. |
kind | string | Always "crash" today. |
rtp | string | The 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. |
version | integer | Version of the game definition. |
is_active | boolean | Always true: inactive games are not listed. |
max_win_multiplier | integer | Win cap as a multiple of the stake; 0 means no cap. |
images | object 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
/api/platform/launchyour backend → OttoCall this from your backend each time a player opens a game, and hand the returned launch_url to that player's browser.
Request
| Field | Rules | Notes |
|---|---|---|
game_code | string, required, ≤ 255 | From the games list. An unknown code returns 422 "Game not found." |
player_id | string, required, ≤ 255 | Your own stable id for the player. Otto keys the player on your platform plus this id, and sends it back on every wallet call. |
username | string, required, ≤ 255 | A display name. It appears masked in live bets, so send a nickname, never an e-mail address or a real name. |
currency | string, required | One of the supported codes, for example "TRY". Any other value returns 422 with the accepted values. |
device | optional: omit the key, string | DESKTOP, MOBILE or TABLET, in capitals. Default DESKTOP. |
language | optional: omit the key, string, ≤ 5 characters | Lower-cased, with _ turned into -. See Languages. |
return_url | optional: omit the key, http or https URL, ≤ 512 | Shown 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.
{
"game_code": "48213",
"player_id": "p-100245",
"username": "lucky_ace",
"currency": "TRY",
"language": "en",
"device": "MOBILE",
"return_url": "https://casino.example/lobby"
}
Response
{
"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_urlexactly as returned. Otto chooses its host (https://ottogaming.ioin this deployment); do not rebuild the URL from parts. session.idis thesession_idthat Otto's wallet calls carry for this player session.session.languageechoes the normalised language you sent.session.expires_atis 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
- Creates the player, or updates it:
usernameandcurrencyare overwritten on every launch. - 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. - Ends the player's other sessions, except a session that still has a bet in play, which lives on until that bet settles.
- 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
| Lifetime | 8 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. |
|---|---|
| Reuse | A launch_url works until its session expires or is replaced. Reloading the game page is fine. |
| Ending a session | There 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 player | A 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 token | A token is bound to the game it was launched for. Launch again for another game. |
| Another device | If 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
<iframe
src="LAUNCH_URL"
title="Garanti Zepline"
allow="fullscreen"
style="border:0; width:100%; height:100%"></iframe>
- Otto sends no
X-Frame-Optionsorframe-ancestorsheader, so any page may embed/play. If your own site sends a Content Security Policy, allowhttps://ottogaming.ioinframe-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
/balanceon 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-BRandpt_BRboth 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: sendes, notes-419. - Without a language, the game uses the language last used in that browser tab, otherwise English. Send
languageon 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.
min bet = max(1, floor(5 × parity))
max bet = floor(10000 × parity)
| Currency | Parity | Min bet | Max bet |
|---|---|---|---|
| TRY | 1 | 5 | 10,000 |
| USD, EUR, GBP, BRL, PEN | 0.1 | 1 | 1,000 |
| ARS | 10 | 50 | 100,000 |
| BOB | 0.5 | 2 | 5,000 |
| CLP | 12.5 | 62 | 125,000 |
| COP, CRC | 50 | 250 | 500,000 |
| GTQ | 1 | 5 | 10,000 |
| INR | 9 | 45 | 90,000 |
| MXN | 0.25 | 1 | 2,500 |
| UYU | 4 | 20 | 40,000 |
| VES | 20 | 100 | 200,000 |
| ZAR | 1.5 | 7 | 15,000 |
- Stakes have up to 10 digits and up to 2 decimals. A stake outside the limits is refused with
BET_OUT_OF_LIMITSbefore 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
| URL | Your wallet base URL + /balance, /debit, /credit or /rollback |
|---|---|
| Method | POST with a JSON body, for all four |
| Headers | Content-Type: application/json, Accept: application/json, X-Api-Key (your key), X-Timestamp, X-Signature: the same scheme with your API secret |
| Encoding | Slashes and non-ASCII characters escaped. Verify the raw bytes. |
| Redirects | Not supported: answer directly. A 301, 302 or 303 turns Otto's POST into a GET with an empty body (details). |
| During play | 1 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 rollbacks | 5 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 answer | Otto treats it as | What follows |
|---|---|---|
2xx with a JSON object, or with an empty body | Success | Reads 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 body | Rejected | Records 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 error | Unknown | Otto 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
200with{"error": …}. - Always answer in JSON, errors included. A
4xxwith 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,404or405is read as your wallet rejecting the call. No rollback follows, and on/creditthe 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.
{
"balance": "990.00",
"reference": "op-904512"
}
{
"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.
| Field | Type | Meaning |
|---|---|---|
player_id | string | Your player id, as sent at launch. |
game_code | string | The game's code. |
session_id | integer | Otto's player session. May be null on a queued rollback. |
transaction_id | integer | Otto's id for this money movement; the only transaction id Otto sends. A rollback carries the id of the transaction it reverses. |
round_id | integer | The round. Left out when Otto does not have it; never sent as 0 or a made-up value. |
bet_id | integer | The 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. |
amount | string | Exactly 2 decimals, for example "10.00". |
currency | string | The player's currency code, for example "TRY". |
type | string | "debit", "credit" or "rollback". |
reason | string | Queued 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
{wallet base URL}/balanceOtto → your walletNot 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.
{
"player_id": "p-100245",
"game_code": "48213",
"session_id": 58231
}
{
"balance": "1000.00"
}
/debit
{wallet base URL}/debitOtto → your walletTakes the stake when the player places a bet. Otto records the bet first, then calls you.
{"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"}
{
"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
/rollbackfor thistransaction_idstraight 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
{wallet base URL}/creditOtto → your walletPays 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×.
{"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"}
{
"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
{wallet base URL}/rollbackOtto → your walletReverses 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
2xxas a no-op and keep a tombstone for thattransaction_id(see below). This is normal; the original call may never have reached you. - Already rolled back: answer
2xxas 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 a2xxis 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_idand 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 tombstonedtransaction_idarrives 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:
{
"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"
}
{
"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_idandbet_idare the original transaction's; they are left out when Otto cannot read the original. A queued rollback may also carry"session_id": null. - Answer
2xxwith JSON and the newbalance. A rejected rollback during a cancel leaves the bet in play. - Never reject a rollback. The queue retries
4xxanswers too; it does not give up until its last attempt.
When each call happens
| Player action | Call | If 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) | /balance | Balance shown as unknown; read again in the background | Balance shown as unknown; read again in the background |
| Places a bet (betting phase) | /debit | Bet discarded; player sees a rejection | Bet 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) | /credit | Bet back in play and lost at the crash | Bet 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 debit | Bet stays in play | Bet 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_idand a newamount: 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 withtype: "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
amountis a string with exactly 2 decimals. Use decimal arithmetic, never binary floating point. - Return
balanceon every2xx, for/balance,/debit,/creditand/rollbackalike, 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/balanceafter 3, 10, 30 and 60 seconds: up to four extra signed calls to your wallet, each of which also renews the player's session. currencyis 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. A4xxis retried on the next attempt as well, and so is a2xxthat 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
2xxcompletes 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.
| State | Garanti Zepline | What 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
multiplier(t) = floor(1.06^t × 100) / 100 t = seconds since takeoff
flight time = ln(crash point) / ln(1.06) seconds
| Crash point | 1.5× | 2× | 3× | 5× | 10× | 20× | 50× | 100× |
|---|---|---|---|---|---|---|---|---|
| Flight | 7.0 s | 11.9 s | 18.9 s | 27.6 s | 39.5 s | 51.4 s | 67.1 s | 79.0 s |
Timing rules
- A bet or a cancel is accepted only while the round is in
BETTINGand 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 withBETTING_CLOSED, a cancel withBET_NOT_CANCELLABLE. - A cash-out is accepted only while the round is
RUNNINGand before the crash time. Otherwise it is refused withTOO_LATEorROUND_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.
- Commit. When a round is created, Otto draws
server_seed(32 random bytes, hex) and publishesserver_seed_hash = sha256(server_seed)as betting starts. - Client seed. When betting closes, the round's
client_seedissha256("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. - Crash point. Computed from
server_seed,client_seed, the round'snonce(its sequence number) and the game's RTP, as below. - Reveal. After the crash,
server_seedis revealed.client_seedandnonceare sent in the crash event to every open game.
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
| Value | When it becomes visible |
|---|---|
server_seed_hash | When betting starts |
crash_point, server_seed | Once the round has crashed |
client_seed, nonce | Only 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
usernameyou send at launch and shows at most its first two and last two characters, for examplelucky_aceaslu***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:
413with no body for a request body over 1 MB;503when Otto sheds load because a request waited more than 10 seconds for a worker;502,504or a Cloudflare52xHTML 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.
| HTTP | Message | Cause |
|---|---|---|
401 | Authentication headers are missing. | A signing header is missing. |
401 | Authentication failed. | Unknown or inactive key, IP not allowed, bad signature or stale timestamp. |
403 | The game is not enabled for this operator. | The game is exclusive to another brand, or not enabled for your platform. |
403 | The game is not available. | The game is inactive or not available on Otto. |
422 | Game 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. |
429 | Too 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": …}.
| Code | HTTP | Meaning |
|---|---|---|
NO_ACTIVE_ROUND | 409 | No round is open for the game. |
BETTING_CLOSED | 409 | The bet arrived after betting closed. |
BET_LIMIT_REACHED | 409 | The player already has 2 bets in this round. |
BET_OUT_OF_LIMITS | 409 | The stake is below the minimum or above the maximum for the currency. |
BET_NOT_FOUND | 404 | No such bet for this session. |
BET_NOT_CANCELLABLE | 409 | The cancel arrived after betting closed: the bet can no longer be cancelled. |
BET_ALREADY_SETTLED | 409 | The bet has already been paid or lost. |
ROUND_NOT_RUNNING | 409 | Cash-out outside the flight. |
TOO_LATE | 409 | Cash-out reached Otto after the crash. |
AMBIGUOUS_BET | 422 | The player has two open bets and the cash-out or cancel did not say which one. |
WALLET_REJECTED | 402 | Your wallet rejected the call (4xx JSON). Carries your message; the player sees a translated notice. |
WALLET_UNAVAILABLE | 503 | Your 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_FLIGHT | 429 | Another 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
| Scope | Limit |
|---|---|
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 switch | 30 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_idandplayer_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.
| Version | Date | Changes |
|---|---|---|
| 1.0 | 7 October 2026 | First edition for ottogaming.io: operator API, request signing, seamless wallet callbacks, Garanti Zepline, Otto's test brand. |