Integration
Wallet callbacks
The platform keeps no balance of its own. For every player action that touches money it calls your wallet endpoint. You implement one HTTPS endpoint that handles several method values.
Transport#
| Property | Value |
|---|---|
| URL | POST <callback URL>/<currency>, e.g. https://casino.example/wallet/EUR. The currency in the path is any of the 157 codes of Currencies. |
| Where to set the URL | Admin panel → Projects → your project → Integration (see Admin panel) |
| Body | JSON object with sorted keys; includes api_key. Amounts are JSON numbers with two decimals (1.50). |
| Headers | Content-Type: application/json, X-Notell-Timestamp, X-Notell-Signature (Callback signature) |
| Authentication | The project api_key in the body: compare it with your stored key. Every request is also signed. |
| Time-out | 2 seconds per attempt; redirects are not followed |
| Success | HTTP 2xx with the JSON answer described in The answer. A non-2xx status counts as a failure even if the body says ok |
| Idempotency | Every bet / win / refund carries a unique meta.transaction; a retry repeats the same id and body |
Connections. If the host of your callback URL is not behind Cloudflare, the platform keeps connections to it alive (idle connections are closed after 60 seconds, and a request that fails on a reused connection is repeated once on a fresh one). Otherwise every request is sent with Connection: close. If your wallet sits behind Cloudflare or hides its origin, the Direct IP setting of the project (admin panel, Integration) pins the connection to a public, non-Cloudflare IP address (the TLS certificate name is still checked).
If a project has no callback URL configured, the platform uses a built-in test wallet (starting balance 10,000.00 per account) so you can try games before your wallet is ready.
Callback signature#
Every callback carries two headers, so you can prove that a request comes from the platform and was not altered (the api_key in the body stays as it was; checking the signature is optional but recommended):
| Header | Value |
|---|---|
X-Notell-Timestamp | Unix time in seconds when this attempt was signed (a retry is signed again) |
X-Notell-Signature | v1= + lowercase hex HMAC-SHA256 |
Canonical string (UTF-8, lines separated by a single \n, no trailing newline):
<X-Notell-Timestamp>
POST
<URL path of the request, e.g. /wallet/EUR>
<raw request body, byte for byte>Secret. By default it is derived from your project api_key: secret = hex(HMAC-SHA256(key = api_key, message = "notell-callback-signing-v1")), so the key itself is never used as an HMAC key and a rotated API key changes the secret. If you prefer an independent secret, ask the account manager to configure an explicit one for your project.
Hash the raw bytes of the body as received. Amounts are written as 1.50; a parser that re-serialises the JSON turns it into 1.5 and the signature no longer matches.
import {createHmac, timingSafeEqual} from 'node:crypto';
const secret = createHmac('sha256', API_KEY).update('notell-callback-signing-v1').digest('hex');
function verify(req, rawBody) { // rawBody: the exact bytes received
const ts = req.headers['x-notell-timestamp'], sig = req.headers['x-notell-signature'] ?? '';
if (!/^\d{9,12}$/.test(ts ?? '') || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // replay window
const mac = createHmac('sha256', secret).update(`${ts}\nPOST\n${new URL(req.url, 'http://x').pathname}\n${rawBody}`).digest('hex');
return sig.length === mac.length + 3 && timingSafeEqual(Buffer.from(sig), Buffer.from('v1=' + mac));
}Test vector. With api_key = YOUR_API_KEY the secret is
43a216923c5d33308ab3944616a873baed88975e9781a84ab18618cb710b0942For timestamp 1800000000, path /wallet/EUR and the raw body (a minimal body; real ones carry more meta fields)
{"amount":1.50,"api_key":"YOUR_API_KEY","meta":{"round_id":"b2a7c1d4-5e60-4f1b-9a3c-7d2e8f901234","transaction":"6f1d0c9e-3b7a-4c52-8e14-a9d05b6c7e21"},"method":"bet","player_id":"player-1024"}the header is
X-Notell-Signature: v1=78d40d425a7ed15ef7a9eaeaedb28baba83babc3d25b2d1c84873f4db608932bOur callback IP addresses#
Callbacks leave the platform from fixed addresses. GET /capabilities lists them in callbackSourceIps; allow exactly those on your firewall. The values per environment are on the Environments page. Tell the account manager before you move your wallet behind a new address range; the callback URL itself is set in the admin panel.
The answer#
Every method is answered with the same JSON object, also when it reports an error:
{"status": "ok", "balance": 998.50, "currency": "EUR"}| Field | Type | Required | Description |
|---|---|---|---|
status | enum | yes | "ok" or "error". |
balance | number | yes | The player's balance after the operation, as a JSON number (not a string): non-negative, at most 2 decimals, up to 13 digits before the point, no exponent (998.5, 1000, 0.50 are valid; 1e3, -1, 988.5000000001 are not). Required for "error" answers too. |
currency | string | yes | Must equal the currency in the request path; otherwise the answer is rejected as invalid. Required for "error" answers too. |
message | string | with status "error" | A code such as INSUFFICIENT_FUNDS. |
An answer that breaks these rules (missing or malformed balance, wrong currency, not JSON) is not read as a refusal but as a broken wallet: INVALID_CALLBACK_RESPONSE. The operation is then treated as unknown (see Retries and failures), and a launch fails.
session_info#
session_infoSent once at game launch (not when an unfinished round is resumed). Nothing is changed; answer with the current balance.
Request#
curl -X POST https://casino.example/wallet/EUR \
-H 'Content-Type: application/json' \
-d '{"api_key":"YOUR_API_KEY","method":"session_info","player_id":"player-1024","session_id":"9d0c3f7e-41b8-4a52-9c0e-6d3f5a7b8c21"}'{
"api_key": "YOUR_API_KEY",
"method": "session_info",
"player_id": "player-1024",
"session_id": "9d0c3f7e-41b8-4a52-9c0e-6d3f5a7b8c21"
}| Field | Type | Required | Description |
|---|---|---|---|
method | string | yes | "session_info". |
player_id | string | yes | Your player id. |
session_id | string | yes | Session created by the launch. |
api_key | string | yes | Your project's API key. |
Response#
{"status": "ok", "balance": 1000.00, "currency": "EUR"}| Field | Type | Description |
|---|---|---|
status, balance, currency | As in The answer. |
Errors#
| Code | HTTP | When |
|---|---|---|
BALANCE_UNAVAILABLE | 400 (returned by the launch) | You answer "status":"error"; the launch fails and no session is created. |
CALLBACK_HTTP_ERROR | 400 (returned by the launch) | You answer a non-2xx HTTP status. |
INVALID_CALLBACK_RESPONSE | 400 (returned by the launch) | The answer is not valid. |
bet#
betSent before a paid round is played. Debit amount and answer.
Request#
curl -X POST https://casino.example/wallet/EUR \
-H 'Content-Type: application/json' \
-d '{"amount":1.50,"api_key":"YOUR_API_KEY","meta":{"round_id":"b2a7c1d4-5e60-4f1b-9a3c-7d2e8f901234","session_id":"9d0c3f7e-41b8-4a52-9c0e-6d3f5a7b8c21","symbol":"gates-of-olympus","time":1800000000,"transaction":"6f1d0c9e-3b7a-4c52-8e14-a9d05b6c7e21"},"method":"bet","player_id":"player-1024"}'{
"amount": 1.50,
"api_key": "YOUR_API_KEY",
"meta": {
"round_id": "b2a7c1d4-5e60-4f1b-9a3c-7d2e8f901234",
"session_id": "9d0c3f7e-41b8-4a52-9c0e-6d3f5a7b8c21",
"symbol": "gates-of-olympus",
"time": 1800000000,
"transaction": "6f1d0c9e-3b7a-4c52-8e14-a9d05b6c7e21"
},
"method": "bet",
"player_id": "player-1024"
}{
"amount": 0.00,
"api_key": "YOUR_API_KEY",
"meta": {
"freespins": {"external_id": "welcome-2027-01", "grant_id": "7b1c4e92-0a3d-4f68-b5c1-2e9d8a6f3b40", "nominal_bet": 1, "remaining": 9},
"is_freespins": true,
"round_id": "d4e8a1b6-93c2-4075-8f1e-5b0a7c2d9e63",
"session_id": "9d0c3f7e-41b8-4a52-9c0e-6d3f5a7b8c21",
"symbol": "gates-of-olympus",
"time": 1800000300,
"transaction": "0b6e5d3a-8c14-4f97-a2d0-1e7c9b4f6a58"
},
"method": "bet",
"player_id": "player-1024"
}| Field | Type | Required | Description |
|---|---|---|---|
method | string | yes | "bet". |
player_id | string | yes | Your player id. |
amount | number | yes | Stake in currency units, positive; 0 for a free-spin round. |
meta.transaction | string | yes | Unique id of this debit; a retry repeats it. |
meta.round_id | string | yes | Round id. |
meta.session_id | string | yes | Session id. |
meta.symbol | string | yes | Game id. |
meta.time | integer | yes | Unix time in seconds. |
meta.is_freespins | boolean | free-spin rounds | true when the round belongs to an operator free-spins grant; accept the zero bet. |
meta.freespins | object | free-spin rounds | grant_id, external_id (or null), nominal_bet (number) and remaining spins. |
api_key | string | yes | Your project's API key. |
Response#
Debit the stake and answer with the new balance:
{"status": "ok", "balance": 998.50, "currency": "EUR"}For a real lack of funds answer HTTP 200 with:
{"status": "error", "message": "INSUFFICIENT_FUNDS", "balance": 0.50, "currency": "EUR"}The round is rejected and nothing is played. Return this code only for a real lack of funds.
Errors#
| Code | HTTP | When |
|---|---|---|
INSUFFICIENT_FUNDS | 200 | Your answer: balance too low. The round is rejected at once; nothing is retried. |
TRANSACTION_CANCELLED | 200 | Your answer: the debit arrives after you recorded a refund for this transaction. |
| any other | 4xx except 408 and 429 | Your HTTP status: a definitive refusal. The round is rejected at once and a refund is queued. |
| any other | 5xx, 408, 429, time-out | The debit is unknown and retried (Retries and failures). |
win#
winSent when a round pays. amount is the total win of the round (positive; zero wins are not sent).
Request#
curl -X POST https://casino.example/wallet/EUR \
-H 'Content-Type: application/json' \
-d '{"amount":12.75,"api_key":"YOUR_API_KEY","meta":{"round_id":"b2a7c1d4-5e60-4f1b-9a3c-7d2e8f901234","session_id":"9d0c3f7e-41b8-4a52-9c0e-6d3f5a7b8c21","symbol":"gates-of-olympus","time":1800000012,"transaction":"0b6e5d3a-8c14-4f97-a2d0-1e7c9b4f6a58"},"method":"win","player_id":"player-1024"}'{
"amount": 12.75,
"api_key": "YOUR_API_KEY",
"meta": {
"round_id": "b2a7c1d4-5e60-4f1b-9a3c-7d2e8f901234",
"session_id": "9d0c3f7e-41b8-4a52-9c0e-6d3f5a7b8c21",
"symbol": "gates-of-olympus",
"time": 1800000012,
"transaction": "0b6e5d3a-8c14-4f97-a2d0-1e7c9b4f6a58"
},
"method": "win",
"player_id": "player-1024"
}{
"amount": 23.50,
"api_key": "YOUR_API_KEY",
"meta": {
"freespins": {"external_id": "welcome-2027-01", "final_credit": true, "grant_id": "7b1c4e92-0a3d-4f68-b5c1-2e9d8a6f3b40", "nominal_bet": 1, "total_win": 23.5},
"is_freespins": true,
"round_id": "e7a02c58-1f94-4b36-9d8a-3c5e6b7f0a12",
"session_id": "9d0c3f7e-41b8-4a52-9c0e-6d3f5a7b8c21",
"symbol": "gates-of-olympus",
"time": 1800000900,
"transaction": "a3f6c1d8-52e7-4b09-8c14-7d2e9a0b5f63"
},
"method": "win",
"player_id": "player-1024"
}| Field | Type | Required | Description |
|---|---|---|---|
method | string | yes | "win". |
player_id | string | yes | Your player id. |
amount | number | yes | Total win of the round, positive. For the free-spins final credit: the sum of all wins of the grant. |
meta.transaction | string | yes | Unique id of this credit; a retry repeats it. |
meta.round_id, meta.session_id, meta.symbol, meta.time | yes | As in bet. For the final credit round_id is the last round of the grant, which may differ from the round of an earlier bet. | |
meta.is_freespins | boolean | free-spins final credit | true. |
meta.freespins | object | free-spins final credit | grant_id, external_id (or null), nominal_bet, total_win (equals amount) and final_credit: true. |
api_key | string | yes | Your project's API key. |
Free-spin wins are paid once. The wins of the rounds of an operator free-spins grant are not sent per spin. After the last spin the platform sends one win for the whole grant, with meta.freespins.final_credit: true, before the freespins_finished summary. The summary's total_win equals that credit. Do not look for a win per winning spin, and do not reconcile such a win with the bet of its round_id.
Response#
{"status": "ok", "balance": 1011.25, "currency": "EUR"}Errors#
| Code | HTTP | When |
|---|---|---|
| any | any | A credit is never cancelled: if you do not answer ok it is retried until confirmed. |
refund#
refundSent when a round is cancelled after its bet may have been debited: the bet stayed unconfirmed past the cancel policy (10 minutes or 600 attempts, whichever comes first), the wallet definitively refused it with a 4xx status, or you cancelled the round with round_cancel.
Request#
curl -X POST https://casino.example/wallet/EUR \
-H 'Content-Type: application/json' \
-d '{"amount":1.50,"api_key":"YOUR_API_KEY","meta":{"original_transaction":"6f1d0c9e-3b7a-4c52-8e14-a9d05b6c7e21","reason":"wallet_unconfirmed","round_id":"b2a7c1d4-5e60-4f1b-9a3c-7d2e8f901234","session_id":"9d0c3f7e-41b8-4a52-9c0e-6d3f5a7b8c21","symbol":"gates-of-olympus","time":1800000600,"transaction":"c41e8a07-6d2b-4f35-9e1c-0a8b7d3f5c92"},"method":"refund","player_id":"player-1024"}'{
"amount": 1.50,
"api_key": "YOUR_API_KEY",
"meta": {
"original_transaction": "6f1d0c9e-3b7a-4c52-8e14-a9d05b6c7e21",
"reason": "wallet_unconfirmed",
"round_id": "b2a7c1d4-5e60-4f1b-9a3c-7d2e8f901234",
"session_id": "9d0c3f7e-41b8-4a52-9c0e-6d3f5a7b8c21",
"symbol": "gates-of-olympus",
"time": 1800000600,
"transaction": "c41e8a07-6d2b-4f35-9e1c-0a8b7d3f5c92"
},
"method": "refund",
"player_id": "player-1024"
}| Field | Type | Required | Description |
|---|---|---|---|
method | string | yes | "refund". |
player_id | string | yes | Your player id. |
amount | number | yes | Equals the amount of the bet. |
meta.transaction | string | yes | Id of the refund itself. |
meta.original_transaction | string | yes | The meta.transaction of the bet being refunded. |
meta.reason | enum | yes | wallet_unconfirmed or operator_cancel. |
meta.round_id, meta.session_id, meta.symbol, meta.time | yes | As in bet. | |
api_key | string | yes | Your project's API key. |
Rules for your wallet:
- The original bet was debited and not yet refunded: credit
amountback, answerokwith the new balance. - The original bet was never received (or you rejected it): remember that this transaction id is cancelled, change nothing and answer
ok. If that bet arrives later, refuse it with{"status":"error","message":"TRANSACTION_CANCELLED","balance":1000.00,"currency":"EUR"}. - A repeat of the same refund (same
meta.transaction) returns the first answer. One bet is refunded at most once. - An error answer is retried until you answer
ok; the platform never sends the bet again after it created the refund.
Response#
{"status": "ok", "balance": 1000.00, "currency": "EUR"}Errors#
| Code | HTTP | When |
|---|---|---|
| any | any | If you do not answer ok the refund is retried (2 s, doubling, at most 5 minutes) until confirmed. |
round_info#
round_infoInformational round notification: record it, change nothing, answer ok with the current balance. Two are sent per round, in order: start (after the debit is confirmed) and finish (after the credit is confirmed, or after the round ended without a win). finish is delivered only after start was acknowledged.
Request#
curl -X POST https://casino.example/wallet/EUR \
-H 'Content-Type: application/json' \
-d '{"api_key":"YOUR_API_KEY","bet_amount":1.50,"bet_type":"spin","bonus_buy":false,"bonus_trigger":true,"currency":"EUR","game_symbol":"gates-of-olympus","has_win":true,"method":"round_info","multiplier":8.5,"player_id":"player-1024","round_id":"b2a7c1d4-5e60-4f1b-9a3c-7d2e8f901234","session_id":"9d0c3f7e-41b8-4a52-9c0e-6d3f5a7b8c21","status":"finish","timestamp":1800000015,"win_amount":12.75}'{
"api_key": "YOUR_API_KEY",
"bet_amount": 1.50,
"bet_type": "spin",
"bonus_buy": false,
"bonus_trigger": true,
"currency": "EUR",
"game_symbol": "gates-of-olympus",
"has_win": true,
"method": "round_info",
"multiplier": 8.5,
"player_id": "player-1024",
"round_id": "b2a7c1d4-5e60-4f1b-9a3c-7d2e8f901234",
"session_id": "9d0c3f7e-41b8-4a52-9c0e-6d3f5a7b8c21",
"status": "finish",
"timestamp": 1800000015,
"win_amount": 12.75
}| Field | Type | Required | Description |
|---|---|---|---|
method | string | yes | "round_info". |
status | enum | yes | start or finish. |
player_id, round_id, session_id, game_symbol, currency | string | yes | Identifiers of the round. |
timestamp | integer | yes | Unix time in seconds. |
bet_amount | number | yes | Stake of the round (nominal bet for free-spin rounds). |
bet_type | string | yes | Type of bet of the round, for example spin or purchase. |
bonus_buy | boolean | yes | The round was a bonus buy. |
bonus_trigger | boolean | yes | The round triggered a bonus feature. |
win_amount | number | on finish | Total win of the round. |
multiplier | number | on finish | Win divided by the bet. |
has_win | boolean | on finish | Whether the win is positive. |
forced | boolean | no | true when the round was produced for an operator target outcome. |
api_key | string | yes | Your project's API key. |
The finish notification is the round summary: use it for reporting; money has already moved through bet and win.
Response#
{"status": "ok", "balance": 1011.25, "currency": "EUR"}Errors#
| Code | HTTP | When |
|---|---|---|
| any | any | If you do not answer ok it is retried later; a project that does not respond is skipped for a while so that it never blocks others. |
freespins_finished#
freespins_finishedOne per finished free-spins grant, sent after the final-credit win. Answer in the usual format; it is repeated until you do.
Request#
curl -X POST https://casino.example/wallet/EUR \
-H 'Content-Type: application/json' \
-d '{"api_key":"YOUR_API_KEY","bet":"1.00","currency":"EUR","external_id":"welcome-2027-01","finished_at":"2027-01-15T12:30:00.000Z","game":"gates-of-olympus","grant_id":"7b1c4e92-0a3d-4f68-b5c1-2e9d8a6f3b40","method":"freespins_finished","played":10,"player_id":"player-1024","status":"used","total":10,"total_win":"23.50"}'{
"api_key": "YOUR_API_KEY",
"bet": "1.00",
"currency": "EUR",
"external_id": "welcome-2027-01",
"finished_at": "2027-01-15T12:30:00.000Z",
"game": "gates-of-olympus",
"grant_id": "7b1c4e92-0a3d-4f68-b5c1-2e9d8a6f3b40",
"method": "freespins_finished",
"played": 10,
"player_id": "player-1024",
"status": "used",
"total": 10,
"total_win": "23.50"
}| Field | Type | Required | Description |
|---|---|---|---|
method | string | yes | "freespins_finished". |
grant_id | string | yes | Id of the grant. |
external_id | string | no | Your id of the grant, if you gave one. |
player_id, currency, game | string | yes | Player, currency and game of the grant. |
status | enum | yes | Final status of the grant, for example used; values: Free spins. |
total | integer | yes | Spins granted. |
played | integer | yes | Spins played. |
bet | money | yes | Bet of each spin. |
total_win | money | yes | Total win of the grant; equals the amount of the final-credit win. |
finished_at | string | yes | ISO 8601 time. |
api_key | string | yes | Your project's API key. |
Response#
{"status": "ok", "balance": 1011.25, "currency": "EUR"}Errors#
| Code | HTTP | When |
|---|---|---|
| any | any | If you do not answer ok it is retried: 10 s × 2<sup>attempts</sup>, at most 1 hour. |
Order of calls in a round#
bet -> (debit confirmed) -> round_info start -> [play] -> win (if > 0) -> (credit confirmed) -> round_info finishFree-spin grants: bet (amount 0) per spin, no win per spin, then one win with meta.freespins.final_credit: true, then freespins_finished.
Retries and failures#
| Situation | What the platform does |
|---|---|
bet answered INSUFFICIENT_FUNDS | The round is rejected; nothing is retried |
bet answered with HTTP 4xx except 408 and 429 | Definitive refusal: the round is rejected at once and a refund is queued |
bet with HTTP 5xx, 408, 429, a time-out, a network error or an invalid answer | The debit is unknown and retried with the same transaction id and body: the first 4 attempts immediately, then after 1 s, doubling up to 60 s. If it is still unconfirmed after 10 minutes or 600 attempts (whichever comes first) the round is cancelled and a refund is sent. The round stays pending meanwhile |
The same on win | Retried until confirmed (a win is never cancelled) |
refund not answered ok | Retried with growing delays (2 s doubling, at most 5 minutes) until confirmed |
round_info not answered ok | Retried later; a project that does not respond is skipped for a while so that it never blocks others |
freespins_finished not answered ok | Retried with growing delays: 10 s × 2<sup>attempts</sup>, at most 1 hour |
Because retries are expected, your wallet must be idempotent: remember the answer you gave for each meta.transaction and return it again for a repeat of the same request. A repeat must never debit or credit twice.
Checklist for your wallet#
- [ ] Verify
api_key; serve HTTPS only; answer within 2 seconds. - [ ] Parse
amountas a decimal; returnbalanceas a JSON number (non-negative, at most 2 decimals) in every answer, errors included. - [ ] Echo the currency from the URL path in
currency. - [ ] Idempotency by
meta.transaction(same id, same body → same answer). - [ ]
INSUFFICIENT_FUNDSas HTTP 200; no HTTP 4xx for temporary problems. - [ ] Accept
amount: 0forbetwhenmeta.is_freespinsistrue; expect one final-creditwinper free-spins grant. - [ ] Answer
round_infoandfreespins_finishedwithok(status,balance,currency). - [ ] Implement
refund(idempotent, tombstone for unknown transactions) and, if you can, verifyX-Notell-Signatureover the raw body. - [ ] Allow the callback source IPs from
/capabilities. - [ ] Use Test connection in the admin panel and watch API Logs while testing.