# Integration API v1 > Jumbo · crash game This file is the whole documentation, in Markdown, generated from the same text as the page at https://docs.aircrash.net. The machine-readable contract, with every field and every error code, is at https://docs.aircrash.net/openapi.yaml and has been checked against the running server. Money is an integer, in cents. Business errors arrive as HTTP 200 with `status:"error"`. If you read one section only, read the traps. ## The two directions | Integration | Direction | What it carries | | --- | --- | --- | | Catalogue and launch | operator → provider | which games exist, and opening a game for a player | | Wallet | provider → operator | resolve the session, balance, debit, credit, rollback | ## The eight that break integrations Every one of them contradicts a common convention, which is why they sit together and before the routes: these are the points where implementing *what usually holds* yields a client that compiles, passes the happy-path test, and is wrong in production. | the guess | the contract | | --- | --- | | A refusal comes as 4xx | **Business** errors come as **HTTP 200**, with `status:"error"` and an `error_code`. Branch on `status`, never on the HTTP status. A client testing `response.ok` reads a refusal as success. | | An unknown error code is final | The default is to **retry**, up to 20 times. Only seven are final: `INSUFFICIENT_FUNDS`, `TOKEN_EXPIRED`, `TOKEN_INVALID`, `PLAYER_NOT_FOUND`, `BET_NOT_FOUND`, `CURRENCY_MISMATCH` and `PLAYER_BLOCKED`. Inventing a name to refuse for good produces 20 identical attempts against your wallet. | | Sign the JSON | Sign **the exact bytes of the body**. Deserialising and serialising again before the HMAC changes whitespace and key order, and the signature stops matching identical content. | | `request_id` is the idempotency key | The key is `transaction_id`. The `request_id` names the **attempt** and changes on each one, together with the `timestamp`. Deduplicating by `request_id` deduplicates nothing; comparing the whole body rejects every retry. | | `/balance` always carries `player_id` | The **first** call of each session carries only the `token`: it is the call that discovers the player. Requiring `player_id` there refuses everyone at the door. | | Money is a decimal | It is an **integer, in cents**. `R$ 3.56` is `356`. The game bundle once sent `114.99999999999999` for a R$ 1.15 bet, back when the field was decimal. | | Both directions follow the same convention | They do not. The 200 rule is for the **wallet**; the two **game** routes answer `4xx`, because there is no business decision there, only an invalid request. The two `error_code` sets are disjoint. | | `freebet_id` only shows up on a freebet | The field is **always** there, and is `null` on an ordinary bet. Branch on `freebet_id !== null`, never on the key being present. | > **If you are implementing with AI help** Give the model the [`openapi.yaml`](https://docs.aircrash.net/openapi.yaml) and the [full Markdown text](https://docs.aircrash.net/llms-full.en.txt), not this page: the HTML carries a stylesheet and all three languages at once. Then have it check the client against this table, row by row. ## Authentication: one scheme, both directions ``` X-Signature: hex(HMAC-SHA256(request_body, secret)) Authorization: Bearer ``` Sign **the exact bytes of the body**, and verify over those same bytes. Decoding the JSON and serialising it again before the HMAC changes whitespace and key order, and the signature stops matching identical content. It is the most common mistake, on both sides. The `timestamp` goes **inside the signed body**, in milliseconds, with no header of its own. Outside a **5 minute** window the request is refused, in both directions: that is what stops a captured request from being replayed. Every request carries `operator` and `timestamp`; the ones leaving here also carry `request_id`, `currency` and `game_id`. The `request_id` names the **attempt**, not the movement: two attempts of the same `transaction_id` carry different ids, and that is how you find one of them in your log. The `Bearer` applies only on the direction leaving here. On the two routes we expose there is no key of ours, and the signature authenticates on its own: that is why the game **refuses to start** in operator mode without a secret. With no secret configured, `X-Signature` is not sent and the window does not apply. > **Authentication failures** Unknown operator, disabled operator, missing secret, wrong signature, stale request: all return the same `401 INVALID_SIGNATURE`. Distinguishing them would tell whoever is guessing which guess came closest. ## What we expose (operator → provider) Two routes, on `https://jumbo.aircrash.net`, which is also the base of the launch URLs `/v1/launch` returns. An installation at another address publishes its own. ### POST /v1/games every game enabled for the operator `->` ```json {"operator":"casa-do-norte", "timestamp":1755680000000, "currency":"BRL"} ``` `<-` ```json { "status": "ok", "operator": "casa-do-norte", "currencies": ["BRL", "USD"], "games": [ { "game_id": "jumbo-crash", "name": "Jumbo", "studio": "jumbo", "category": "CRASH", "demo": false, "mobile": true, "desktop": true, "freebet": true, "currency": "BRL", "min_bet": 100, "max_bet": 50000, "max_win": 5000000 } ] } ``` `freebet` says whether the game accepts free rounds granted by the operator. #### The three limits They appear only when you send `currency`, hold for that currency, and are **integer cents**. | Field | Meaning | | --- | --- | | min_bet | smallest accepted stake | | max_bet | largest accepted stake | | max_win | payout ceiling; a bigger prize is paid at the ceiling | > **Missing limits** A missing limit field means the limit is **unknown**, never that there is none. That is why it is omitted rather than sent as zero: a zero would read as a minimum stake of zero, which is exactly what the server refuses to accept. `currencies` is the set of currencies enabled for you. It is where you read which currencies you may grant freebets in: a grant in a currency the table does not accept is a promise that never comes due. ### POST /v1/launch opens a game for one player Returns the URL to load in an iframe or redirect to. `->` ```json { "operator": "casa-do-norte", "timestamp": 1755680000000, "game_id": "jumbo-crash", "player_id": "618004", "token": "e7c1f0a9d3b84e2f95a6", "currency": "BRL", "language": "pt", "demo": false, "mobile": true, "lobby_url": "https://seu-site/lobby", "deposit_url": "https://seu-site/deposito" } ``` | Field | Required | Notes | | --- | --- | --- | | game_id | yes | must be this installation’s game | | token | unless demo | sent on every wallet call | | player_id | unless demo | your player identifier | | currency | recommended | the session currency | | language | no | pt, en or es | | player_name | no | nickname or name; masked before display | | theme | no | the table’s visual theme | | demo, mobile | no | demo needs neither token nor player | | lobby_url, deposit_url | no | stored on the session; the table does not surface them yet | `<-` ```json {"status":"ok", "url":"https://jumbo.aircrash.net/?sid=v3Nq8Lm2&lang=pt&cur=BRL"} ``` > **The token is not in the URL** The URL carries a **ticket** of ours. Your `token` and `player_id` are not in it: we take them server to server and exchange them for that ticket, which is random, **single use** and valid for **15 minutes**. It is the difference between a bearer credential sitting in browser history, in the Referer header and in the access log of every proxy on the way, and none of that. Errors: `MISSING_GAME_ID`, `MISSING_TOKEN`, `MISSING_PLAYER_ID`, `INVALID_TOKEN` (400), `GAME_NOT_FOUND` (404), `DEMO_NOT_AVAILABLE` (400). ## Currencies and languages All money is **integer cents**. `1500` means 15.00 in the session currency. A monetary field in currency units, or in floating point, is invalid. The currencies enabled for an operator come back in `currencies` from `POST /v1/games`. There is no fixed list here on purpose: the list is your installation’s configuration, and publishing it in two places guarantees they diverge one day. ### Languages `pt`, `en` and `es`. Any other value, and an absent field, fall back to the installation’s default language, not to `en`. The divergence from the market reference is deliberate: falling back to a language most of the player base cannot read, because of a forgotten field, is worse than falling back to the house language. ## What the operator exposes (provider → operator) Four routes. ``` POST {wallet_url}/balance token → player, balance, currency POST {wallet_url}/bet debit POST {wallet_url}/win credit POST {wallet_url}/rollback refund ``` ### Response format `<-` ```json {"status":"ok", "balance":98500, "currency":"BRL", "player_id":"618004", "transaction_id":"w-77120"} ``` `<-` ```json {"status":"error", "error_code":"INSUFFICIENT_FUNDS", "error_message":"saldo insuficiente"} ``` `balance` is integer cents, **after** the operation, and is required on success. `currency` is the currency of the account. Send it on every response. `transaction_id` in the response is **your** number for the movement, not ours. It exists for reconciliation: it is what you quote when opening a ticket about one specific movement. ### Fields per route `-> /bet` ```json { "operator": "casa-do-norte", "timestamp": 1755680000000, "request_id": "req-9f1464ae0da0664c", "game_id": "jumbo-crash", "currency": "BRL", "token": "e7c1f0a9d3b84e2f95a6", "player_id": "618004", "transaction_id": "b-4471902", "round_id": "88213", "amount": 1500 } ``` | Route | Fields | | --- | --- | | /balance | player_id, token | | /bet | player_id, token, amount, transaction_id, round_id | | /win | player_id, token, amount, transaction_id, bet_id, round_id, type | | /rollback | player_id, token, amount, transaction_id, bet_id, round_id | Plus `operator`, `timestamp`, `request_id` and `currency` on every one, and `game_id` when the game is known. The freebet cycle adds `freebet_id` to `/bet` and `/win`; the rollback adds `reason`, which is diagnostic and changes nothing on your side. > **The `type` on /win** Names the nature of the credit without making you cross-reference the debit. `win` is a prize on a stake paid with the player’s money, `freebet` is a prize on a round you funded, and `zero_win` is the terminal event of a lost round. Without the label, a zero-amount credit would be indistinguishable from a prize that happened to be zero. > **The `amount` on /rollback** It goes along, and it is **always** the amount of the movement referenced by `bet_id`, never a number chosen on the spot. Check that it matches the debit you hold: a rollback that can hand back more than was taken is an open door, and that check on your side is what closes it. ### Error codes | Code | Meaning | We retry? | | --- | --- | --- | | INSUFFICIENT_FUNDS | not enough balance | no | | TOKEN_EXPIRED | session expired | no | | TOKEN_INVALID | session never existed | no | | PLAYER_NOT_FOUND | unknown player | no | | BET_NOT_FOUND | the referenced bet does not exist | no | | CURRENCY_MISMATCH | wrong currency for this account | no | | PLAYER_BLOCKED | player barred from betting | no | | INTERNAL_ERROR | your side failed | yes |