Integration API¶
Version¶
1.0.0 — see the changelog.
Overview¶
This document describes the integration between SPINACH GAMES and a partner platform (casino platform or aggregator).
The integration has two directions:
| Direction | Implemented by | Purpose |
|---|---|---|
| Game launch & promo | SPINACH GAMES | The partner calls us to launch a game client and to manage freebets |
| Wallet endpoints | The partner | We call the partner to authorize a player and to move money |
To go live a partner must implement the five wallet endpoints:
/auth, /balance, /bet, /win and /cancel.
Constants¶
| Value | Description |
|---|---|
| SPINACH_URL | SPINACH GAMES API base URL. Production: https://api.spinach.games/gateway |
| INTEGRATION_HUB | The integration counterparty — casino platform or aggregator |
| INTEGRATION_HUB_URL | Partner base URL that serves the wallet endpoints |
| INTEGRATION_HUB_ID | The partner's unique identifier, issued by SPINACH GAMES |
| INTEGRATION_HUB_SECRET | The partner's secret key, issued by SPINACH GAMES. Used as the HMAC key. Unique per partner — never share it and never transmit it |
| LAUNCH_TOKEN | Token generated by the partner for a game launch, identifying the player for the life of the session. Not required when mode=demo |
| GAME_ID | Game identifier, e.g. luckygame |
| PROVIDER_FREEBET_ID | The freebet identifier assigned by SPINACH GAMES |
| FREEBET_STATUS | created — freebets exist but cannot be played yetactive — playable, unused or partially usedactivated — the player launched the game and received the freebetscanceled — freebets were canceledexpired — the activation window closed |
Conventions¶
Data format¶
All request and response bodies are JSON, Content-Type: application/json.
All field names are camelCase.
Transport¶
| Method | POST over HTTPS |
| Request timeout | 10 seconds |
| Expected status | 200 for every outcome — success and error |
A business rejection is an HTTP 200 carrying status: false and a code. Declining a
bet is a request your wallet received and answered — a successful exchange with a
negative answer.
The two layers carry different questions:
| Answers | |
|---|---|
| Status line | Did this request reach your wallet and get a considered answer? |
| Body | What was the answer? |
Do not signal errors with an HTTP status code
Any non-200 response is treated as UNKNOWN_ERROR, which is
UNDETERMINED, so we assume the request may have taken effect and
send a /cancel. The body of a non-200 response is never read — a code you put
there is discarded.
So returning 402 for insufficient funds, instead of 200 with
INSUFFICIENT_BALANCE, produces a rollback for a bet you deliberately refused and
never took. The same applies to a 500 from a crashed handler, or to anything your
load balancer returns on your behalf.
Monetary amounts¶
Every monetary value in this API — amount, balance, betValue, winAmount — is an
integer in coins. Coins are the currency's minor units:
currencyExponent is the number of decimal places the currency supports. For EUR
(exponent 2), 15.00 EUR is 1500 coins. For BTC (exponent 8), 0.00000001 BTC
is 1 coin.
Currency exponent¶
The exponent is a fixed property of the currency — neither side chooses it. For fiat it is the ISO 4217 minor unit; for crypto it is the token's standard precision. Both sides derive it from the currency code, so we do not publish a table.
The cases worth knowing, because the common assumption of 2 is wrong for all but the first row:
| Currency | Exponent | 1 unit in coins |
|---|---|---|
EUR, USD, GBP |
2 | 100 |
JPY, KRW |
0 | 1 |
KWD, BHD, JOD, TND |
3 | 1000 |
BTC |
8 | 100000000 |
ETH |
18 | 1000000000000000000 |
currencyExponent is therefore optional on /bet, /win and /cancel, and is
omitted for the currencies above. When it is absent, the currency's standard exponent
applies.
The partner does not send currencyExponent back to us. Balances the partner
returns in /auth, /balance, /bet, /win and /cancel responses must already be
expressed in coins at the exponent that matches the currency. /balance responses may
include it optionally, as a consistency check.
Timestamps¶
ISO 8601, UTC, e.g. 2026-09-01T08:29:06.778Z.
Security¶
Every request and every response is authenticated with an HMAC-SHA256 signature
carried in the X-Signature header.
Algorithm¶
| Algorithm | HMAC-SHA256 |
| Key | INTEGRATION_HUB_SECRET, as UTF-8 bytes |
| Message | the raw body, exactly as transmitted |
| Output | lowercase hexadecimal, in the X-Signature header |
You sign the bytes you send, and you verify the bytes you received:
- Requests we send you carry
X-Signature. Verify it before acting on the body. - Responses you return must carry
X-Signature, computed the same way over your response body. An unsigned or mis-signed response is treated asUNKNOWN_ERROR.
Verify the raw bytes, not a re-serialized object
Compute the HMAC over the body as received, before parsing.
Capture the raw body first:
| Node / Express | express.raw({ type: 'application/json' }) |
| PHP | file_get_contents('php://input') |
| Java / Spring | byte[] or String @RequestBody |
| Python / Flask | request.get_data() |
| .NET | await new StreamReader(Request.Body).ReadToEndAsync() |
Worked example¶
A /bet request. The body as transmitted, on one line:
{"amount":1500,"currency":"EUR","currencyExponent":2,"gameId":"luckygame","roundId":"756","token":"9f2a1c7e4b3d5a6f8c0e1b2d3a4f5c6e","transactionId":"756-bet-1"}
With INTEGRATION_HUB_SECRET = a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6:
Every example in this document is signed with that secret, so each one can be verified against the reference implementation below.
Formatting is part of the signature
The example above is signed as a single line with no spaces. Reformat it — add indentation, reorder the keys — and the signature changes, because the bytes changed. This is expected: the signature covers the exact payload, which is what makes it unambiguous.
Reference implementation¶
const crypto = require('crypto')
// Signing an outgoing body.
function sign(rawBody, secret) {
return crypto.createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex')
}
// Verifying an incoming body, against the bytes as received.
function verify(rawBody, receivedSignature, secret) {
const received = Buffer.from(String(receivedSignature ?? ''), 'utf8')
const expected = Buffer.from(sign(rawBody, secret), 'utf8')
return received.length === expected.length && crypto.timingSafeEqual(received, expected)
}
Compare signatures in constant time, as above — never with ===.
Transport is HTTPS in all environments, and the secret itself is never transmitted.
Launching a game¶
The partner redirects the player's browser or iframe to this endpoint. We validate the
launch token via /auth and redirect to the game client.
Method — GET
URL — <SPINACH_URL>/launch
Query parameters
| Parameter | Mandatory | Type | Description |
|---|---|---|---|
mode |
true | string | real or demo |
game |
true | string | GAME_ID |
partner |
true | string | INTEGRATION_HUB_ID |
token |
true* | string | LAUNCH_TOKEN. *Not required when mode=demo |
lang |
false | string | Game interface language, ISO 639-1. Defaults to en |
currency |
false | string | Player currency, ISO 4217 or crypto ticker |
returnTo |
false | string | URL the game's home button returns the player to |
Example
https://api.spinach.games/gateway/launch
?mode=real
&game=luckygame
&partner=34
&token=9f2a1c7e4b3d5a6f8c0e1b2d3a4f5c6e
&lang=en
&returnTo=https://casino.example.com
Response — 302 redirect to the game client.
Demo mode¶
Demo mode requires no token and performs no wallet calls.
Wallet endpoints¶
These five endpoints are implemented by the partner, served under INTEGRATION_HUB_URL.
All are POST with a JSON body. All requests and responses are signed.
auth¶
Validates a LAUNCH_TOKEN and returns the player it belongs to. Called once per game launch.
URL — <INTEGRATION_HUB_URL>/auth
Request
| Parameter | Mandatory | Type | Description |
|---|---|---|---|
token |
true | string | LAUNCH_TOKEN |
Response
| Parameter | Mandatory | Type | Description |
|---|---|---|---|
status |
true | boolean | true on success |
playerId |
true | string | Player identifier, unique and stable within the partner platform |
balance |
true | integer | Player balance in coins |
currency |
true | string | Player currency |
lang |
false | string | Player language, ISO 639-1. Omit to fall back to the browser locale |
userName |
false | string | Display name |
testUser |
false | boolean | true marks the player as a test account, excluded from reporting |
{
"status": true,
"playerId": "77777",
"userName": "Christopher",
"balance": 500000,
"currency": "EUR",
"lang": "en",
"testUser": false
}
The same token may be authorized more than once
A player reloading the game re-runs /auth with the token they launched with.
Repeat calls for a still-valid token must succeed. Reject with
INVALID_TOKEN only once the token is genuinely expired or revoked.
balance¶
Returns the player's current balance. Called when the game client needs to refresh the displayed balance, and during reconciliation.
URL — <INTEGRATION_HUB_URL>/balance
Request
| Parameter | Mandatory | Type | Description |
|---|---|---|---|
token |
true | string | LAUNCH_TOKEN |
Response
| Parameter | Mandatory | Type | Description |
|---|---|---|---|
status |
true | boolean | true on success |
balance |
true | integer | Player balance in coins |
currency |
true | string | Player currency |
currencyExponent |
false | integer | Decimal places for currency, as a consistency check |
bet¶
Debits the player's balance for a bet.
URL — <INTEGRATION_HUB_URL>/bet
Request
| Parameter | Mandatory | Type | Description |
|---|---|---|---|
amount |
true | integer | Bet amount in coins |
currency |
true | string | Player currency |
currencyExponent |
false | integer | Decimal places for currency. Omit for standard currencies — see currency exponent |
gameId |
true | string | GAME_ID |
roundId |
true | string | Game round identifier. Shared by every transaction of the same round. Opaque — do not parse |
token |
true | string | LAUNCH_TOKEN |
transactionId |
true | string | Unique identifier of this operation. See idempotency |
freebetsId |
false | integer | PROVIDER_FREEBET_ID, present when the spin is played from a freebet |
freebetsBetAmount |
false | integer | The round's bet value in coins. Present with freebetsId — see freebet spins |
{
"amount": 1500,
"currency": "EUR",
"currencyExponent": 2,
"gameId": "luckygame",
"roundId": "756",
"token": "9f2a1c7e4b3d5a6f8c0e1b2d3a4f5c6e",
"transactionId": "756-bet-1"
}
Response
| Parameter | Mandatory | Type | Description |
|---|---|---|---|
status |
true | boolean | true on success |
balance |
true | integer | Balance after the operation, in coins |
currency |
true | string | Player currency |
operationId |
false | string | The partner's own transaction identifier |
win¶
Credits the player's balance with the round's winnings. A /win is sent for every
bet, including a zero-amount win for a losing round, so that the round can be closed.
URL — <INTEGRATION_HUB_URL>/win
Request
| Parameter | Mandatory | Type | Description |
|---|---|---|---|
amount |
true | integer | Win amount in coins. 0 for a losing round |
currency |
true | string | Player currency |
currencyExponent |
false | integer | Decimal places for currency. Omit for standard currencies |
finished |
true | boolean | true when this closes the game round. See round lifecycle |
gameId |
true | string | GAME_ID |
roundId |
true | string | Game round identifier |
token |
true | string | LAUNCH_TOKEN |
transactionId |
true | string | Unique identifier of this operation |
freebetsId |
false | integer | PROVIDER_FREEBET_ID |
freebetsFinished |
false | boolean | true when this win consumes the last spin of the freebet campaign |
{
"amount": 4500,
"currency": "EUR",
"currencyExponent": 2,
"finished": true,
"gameId": "luckygame",
"roundId": "756",
"token": "9f2a1c7e4b3d5a6f8c0e1b2d3a4f5c6e",
"transactionId": "756-win-1"
}
Response — identical shape to /bet.
cancel¶
Rolls back a bet and returns the staked amount to the player. Wins are never canceled.
URL — <INTEGRATION_HUB_URL>/cancel
Request
| Parameter | Mandatory | Type | Description |
|---|---|---|---|
amount |
true | integer | Amount to return, in coins. Equals the original bet amount |
currency |
true | string | Player currency |
currencyExponent |
false | integer | Decimal places for currency. Omit for standard currencies |
gameId |
true | string | GAME_ID |
referenceTransactionId |
true | string | transactionId of the bet being rolled back |
roundId |
true | string | Game round identifier |
token |
true | string | LAUNCH_TOKEN |
transactionId |
true | string | Unique identifier of this cancel operation — distinct from the bet's |
freebetsId |
false | integer | PROVIDER_FREEBET_ID |
freebetsBetAmount |
false | integer | The round's bet value being rolled back. 0 if the freebet was canceled |
{
"amount": 1500,
"currency": "EUR",
"currencyExponent": 2,
"gameId": "luckygame",
"referenceTransactionId": "756-bet-1",
"roundId": "756",
"token": "9f2a1c7e4b3d5a6f8c0e1b2d3a4f5c6e",
"transactionId": "756-cancel-1"
}
Response — identical shape to /bet.
Cancel must be idempotent and must converge on success
We retry /cancel on the retry schedule until it returns
status: true, so it has to converge on success in all three cases:
- The bet was applied — roll it back, return the new balance.
- The bet was already rolled back — return
status: truewith the current balance, not an error. - The partner has no record of the bet — return
status: trueas well.
The third case arises when a /bet never arrived, or arrived but its response was
lost on the way back. From our side those look identical, so we always send a
cancel to be sure no stake is left stranded.
Read cancel as "ensure this bet is not applied" rather than "undo this specific record". If there is nothing to undo, the requested state already holds and the answer is success. Returning an error instead leaves us retrying indefinitely, and in blocking mode the player cannot start a new round until it clears.
Round lifecycle¶
A game round is a bet → win pair sharing one roundId.
- A round may contain several bet/win pairs — for example a bonus game or a
retriggered feature. Each carries its own
transactionIdand the sameroundId. finished: trueon/winmarks the last transaction of the round. Until then the round is still open and further transactions may arrive on the sameroundId.- Reporting, reconciliation and responsible-gaming limits should key off
roundId, and treat the round as complete only once afinished: truewin is received.
Idempotency¶
transactionId is the idempotency key. It is globally unique — across every
partner, player, game, round and endpoint — and never reused. Do not parse it or infer
structure from it; treat it as an opaque string of up to 128 characters.
If the partner receives a transactionId it has already processed, it must not
apply the money movement a second time. It must return status: true with the balance
as of the original operation.
This matters because we retry: a response lost in transit will cause the identical
request — same transactionId — to arrive again. Treating that repeat as a new
transaction double-charges the player.
Retain processed transactionIds for at least 30 days. The retention window must
exceed our retry horizon, or a late retry will be treated as a new transaction and
applied twice.
Late bets¶
A /cancel can arrive before the /bet it refers to. We send a cancel when a bet
times out, and a bet that was merely delayed — not lost — can still land afterwards.
So a /cancel for a referenceTransactionId you have no record of must not simply be
forgotten. Record it, and if a /bet later arrives carrying that transactionId,
answer status: true without moving any money. The round it belonged to has already
been abandoned on our side and no /win will ever follow it.
Applying such a bet debits the player for a round that cannot pay out.
Orphan wins¶
The mirror case: a /win arrives for a round you have no bet recorded against, because
the /bet was lost. Answer BET_NOT_FOUND.
That is a DETERMINED answer — you know the win was not credited, and
retrying the same /win would return BET_NOT_FOUND forever. So we do not retry it.
Instead we replay the round: we re-send the /bet for that round, and on success
follow it with the /win. The player is charged the stake and paid the winnings
exactly once, and both carry their original transactionIds — so if the bet had in
fact been applied and only its response was lost, idempotency makes the
replay a no-op rather than a double charge.
Error handling¶
Error response¶
When an operation cannot be completed the partner returns status: false with a code.
An error response is signed like any other body.
| Parameter | Mandatory | Type | Description |
|---|---|---|---|
status |
true | boolean | false |
code |
true | string | One of the codes below |
message |
false | string | Human-readable detail, for logs |
{
"status": false,
"code": "INSUFFICIENT_BALANCE",
"message": "Insufficient funds to place the bet"
}
Error codes¶
Any of the five wallet endpoints may answer with an error, and the code list below is shared by all of them. What we do with an error depends on which endpoint returned it.
On /auth and /balance nothing changes state, so every error has the same effect:
the operation fails. An error on /auth refuses the launch; an error on /balance
fails the balance refresh. The code only determines what the player is shown.
On /bet, /win and /cancel money is at stake, so the code carries a second
meaning — it tells us whether you know the request took effect. That is what decides
whether we follow up with a /cancel, and it is the one thing worth getting right.
DETERMINED — you know the request did not take effect. There is nothing to roll
back, so no /cancel is sent and we do not retry.
| Code | Player sees | Meaning |
|---|---|---|
INSUFFICIENT_BALANCE |
Specific | Not enough funds to cover the bet |
INVALID_TOKEN |
Specific | Token unknown, expired or revoked |
BET_LIMIT_EXCEED |
Specific | Bet exceeds the player's maximum bet limit |
LOSS_LIMIT_EXCEED |
Specific | Player reached a configured loss limit |
USER_BLOCKED |
Specific | Player is blocked, or self-excluded |
USER_NOT_FOUND |
Generic | Player does not exist or is not permitted to play |
CURRENCY_MISMATCH |
Generic | Transaction currency does not match the player's wallet |
INVALID_SIGN |
Generic | Signature validation failed — rejected before processing |
BET_NOT_FOUND |
Generic | A /win arrived with no bet recorded for that round — see orphan wins |
UNDETERMINED — you cannot tell whether the request took effect. It may or may not
have, so we send a /cancel for a failed bet, and retry failed wins and cancels.
| Code | Player sees | Meaning |
|---|---|---|
INTERNAL_ERROR |
Generic | Partner-side internal error |
UNKNOWN_ERROR |
Generic | Anything else |
Any unrecognised code, and any connection timeout, is treated as UNKNOWN_ERROR —
the safe default, since an unparsed answer is an unknown outcome.
A duplicate transaction is not an error
Do not return an error code for a transactionId that was already processed.
Return status: true and the balance — see idempotency. Reporting
a duplicate as a failure while we are retrying causes the player to be debited and
shown an error for the same spin.
Transaction failure handling¶
| Failure | What we do |
|---|---|
/bet fails with an UNDETERMINED code, or times out |
We issue a /cancel for that bet, and retry it on the schedule below |
/bet fails with a DETERMINED code |
Nothing to roll back — no /cancel is sent |
/win fails with BET_NOT_FOUND |
We replay the round — re-send the /bet, then the /win |
/win fails for any other reason |
We retry /win. The bet is not rolled back — the round already happened |
/cancel fails |
We retry it |
Retry schedule¶
A failed /win or /cancel is persisted and retried with a widening backoff. Retries
survive our service restarts — a queued transaction is not lost by a deploy.
| Attempts | Spacing |
|---|---|
| 2 | 5 seconds |
| 10 | 1 minute |
| 6 | 10 minutes |
| 24 | 1 hour |
Retries are not unlimited. After 42 attempts spanning roughly 25 hours the transaction is marked failed and left for manual reconciliation. That window is the budget you have to become reachable and idempotent again after an outage; a wallet still failing after 25 hours has moved beyond what the protocol can settle on its own.
Player-facing behaviour¶
A failed transaction leaves the player's balance temporarily uncertain: we know the round happened, but not whether the partner's wallet reflects it yet. The two modes below differ in who absorbs that uncertainty — the player, or the operator.
Continue — the player carries on playing while the failed transactions are retried in the background.
- The player sees an error for the affected round and can immediately start a new one.
- Their displayed balance may briefly disagree with the partner's wallet until the retries settle.
- Best for entertainment-led operators who prize session continuity, and where the wallet reconciles reliably within seconds.
Block — the game session is blocked until the outstanding transactions clear.
- The player sees an error and cannot start a new round. On relaunch they are still blocked until every pending transaction for that session has been processed.
- The displayed balance is never allowed to drift from the partner's wallet.
- Best for regulated markets, high-stakes games, and operators whose reconciliation or responsible-gaming controls cannot tolerate a divergent balance, even briefly.
Two rules hold in both modes:
- Only
UNDETERMINED-class failures block a session. Blocking here means blocked on unprocessed transactions — aDETERMINEDfailure leaves nothing outstanding, so there is nothing to wait for. - That is not a statement that the player may keep gambling. Whether they can
depends on the code, and enforcement stays with you:
INSUFFICIENT_BALANCEorBET_LIMIT_EXCEEDinvite another attempt at a different stake, whileUSER_BLOCKEDandLOSS_LIMIT_EXCEEDmust keep being rejected for as long as the condition holds. We surface the error and do not lock the session; your wallet remains the authority on whether a bet is permitted. - A round that is not yet finished always blocks, even in Continue mode. "Not finished" means a feature is still in progress — freespins, a bonus game, a respin — not merely that a bet has been placed. An ordinary spin is finished the moment it settles, so Continue applies to it normally. A partially played feature cannot be resumed against an uncertain balance.
A cancel you cannot reverse must still be recorded
Answering status: true is only correct when the referenced bet was never
applied. If it was applied, you must return the stake — answering success without
reversing leaves the player permanently debited for a round that was abandoned, and
the two ledgers diverge silently with nothing to detect it.
If your wallet genuinely cannot reverse a transaction, it must still record the
cancel and refuse to apply the referenced transactionId should the /bet arrive
afterwards. See late bets.
Freebets¶
Freebets (free spins) are created and managed by the partner through the SPINACH GAMES API. These endpoints are implemented by SPINACH GAMES and served under SPINACH_URL.
All are POST, JSON, and signed exactly like the wallet endpoints — using
the same INTEGRATION_HUB_SECRET.
Create freebets¶
URL — <SPINACH_URL>/freebets/create
Request
| Parameter | Mandatory | Type | Description |
|---|---|---|---|
operatorKey |
true | string | INTEGRATION_HUB_ID |
operatorFreebetId |
true | string | The partner's own identifier for this freebet campaign. Used for all later lookups |
opPlayerId |
true | string | Player identifier, as returned by /auth |
betValue |
true | integer | Value of a single free spin, in coins |
currency |
true | string | Freebet currency |
freeBets |
true | integer | Number of free spins granted. Minimum 1 |
game |
true | string | GAME_ID |
endDate |
false | string | Expiry timestamp. Must be in the future. Defaults to the campaign default |
{
"operatorKey": "34",
"operatorFreebetId": "46891",
"opPlayerId": "77777",
"betValue": 1500,
"currency": "EUR",
"freeBets": 10,
"game": "luckygame",
"endDate": "2026-09-01T08:29:06.778Z"
}
Response
| Parameter | Mandatory | Type | Description |
|---|---|---|---|
operatorFreebetId |
true | string | The partner's identifier, echoed back |
providerFreebetId |
true | integer | PROVIDER_FREEBET_ID |
opPlayerId |
true | string | Player identifier |
currency |
true | string | Freebet currency |
betValue |
true | integer | Value of a single free spin, in coins |
freeBets |
true | integer | Number of free spins granted |
totalCount |
true | integer | Total spins including bonus spins won from the freebets |
rest |
true | integer | Free spins remaining |
winAmount |
true | integer | Total won from the campaign so far, in coins |
createDate |
true | string | Creation timestamp |
endDate |
true | string | Expiry timestamp |
status |
true | string | FREEBET_STATUS |
game |
true | string | GAME_ID |
{
"operatorFreebetId": "46891",
"providerFreebetId": 156,
"opPlayerId": "77777",
"currency": "EUR",
"betValue": 1500,
"freeBets": 10,
"totalCount": 10,
"rest": 10,
"winAmount": 0,
"createDate": "2026-08-25T08:29:06.778Z",
"endDate": "2026-09-01T08:29:06.778Z",
"status": "created",
"game": "luckygame"
}
Cancel freebets¶
Cancels a campaign. Spins already played are unaffected.
URL — <SPINACH_URL>/freebets/cancel
Request
| Parameter | Mandatory | Type | Description |
|---|---|---|---|
operatorKey |
true | string | INTEGRATION_HUB_ID |
operatorFreebetId |
true | string | The partner's freebet identifier |
| Response — the freebet object, as in create, with | |||
status: "canceled". |
Get freebets¶
Returns a single campaign by the partner's identifier.
URL — <SPINACH_URL>/freebets
Request
| Parameter | Mandatory | Type | Description |
|---|---|---|---|
operatorKey |
true | string | INTEGRATION_HUB_ID |
operatorFreebetId |
true | string | The partner's freebet identifier |
| Response — the freebet object, as in create. |
Get freebets by player¶
Returns every campaign belonging to one player.
URL — <SPINACH_URL>/freebets/get-by-player
Request
| Parameter | Mandatory | Type | Description |
|---|---|---|---|
operatorKey |
true | string | INTEGRATION_HUB_ID |
opPlayerId |
true | string | Player identifier |
| Response — a JSON array of freebet objects, as in create. |
How a freebet spin is settled¶
A spin played from a freebet follows the normal bet / win flow, with
freebetsId set to the PROVIDER_FREEBET_ID.
The distinction between the two amount fields matters:
| Field | Meaning |
|---|---|
amount |
What actually moves in the player's wallet. 0 on a freebet bet — the freebet covers the stake, so nothing is debited |
freebetsBetAmount |
The game round's bet value. What the round was played at, for the partner's bonus accounting and reporting |
/betcarriesamount: 0andfreebetsBetAmountset to the round's bet value./wincarries the real winnings inamount— winnings are credited normally — plusfreebetsFinished: trueon the spin that consumes the last free spin of the campaign./cancelcarriesfreebetsBetAmount: 0when the freebet campaign itself was canceled.
Example — a freebet spin bet, played at 15.00 EUR with nothing debited:
{
"amount": 0,
"currency": "EUR",
"currencyExponent": 2,
"freebetsBetAmount": 1500,
"freebetsId": 156,
"gameId": "luckygame",
"roundId": "802",
"token": "9f2a1c7e4b3d5a6f8c0e1b2d3a4f5c6e",
"transactionId": "802-bet-1"
}
And the win that follows, crediting 45.00 EUR with free spins still remaining:
{
"amount": 4500,
"currency": "EUR",
"currencyExponent": 2,
"finished": true,
"freebetsFinished": false,
"freebetsId": 156,
"gameId": "luckygame",
"roundId": "802",
"token": "9f2a1c7e4b3d5a6f8c0e1b2d3a4f5c6e",
"transactionId": "802-win-1"
}
Going live¶
Checklist for a partner integration:
- Receive INTEGRATION_HUB_ID, INTEGRATION_HUB_SECRET from SPINACH GAMES, plus the sandbox base URL.
- Implement the five wallet endpoints and publish INTEGRATION_HUB_URL to us.
- Verify signature generation against the worked example.
- Confirm idempotency on
/bet,/winand/cancel. - Pass the testing checklist against the sandbox.
- Agree the player-facing failure mode and go live.
Testing checklist¶
Every scenario below must be demonstrated against the sandbox before go-live. They are ordered so that each builds on the last.
The failure cases are the point. The ones that cost money are the lost cancel, the replayed transaction and the orphan win, and none of them will occur during ordinary testing unless they are provoked deliberately.
Signature
| # | Scenario | Expected |
|---|---|---|
| 1 | Send a request with a valid signature | Accepted |
| 2 | Alter one byte of a request body after signing | Rejected with INVALID_SIGN |
| 3 | Every response you return carries X-Signature |
We accept it; an unsigned or mis-signed response is treated as UNKNOWN_ERROR |
Authorisation
| # | Scenario | Expected |
|---|---|---|
| 4 | /auth with a valid token |
Player, balance, currency returned |
| 5 | /auth twice with the same still-valid token |
Both succeed — tokens are not single-use |
| 6 | /auth with an expired or unknown token |
INVALID_TOKEN |
| 7 | /balance returns the same balance /auth did |
Equal, in coins |
Bet and win
| # | Scenario | Expected |
|---|---|---|
| 8 | Ordinary bet | Debited exactly once; returned balance matches your ledger |
| 9 | Win for that round | Credited exactly once |
| 10 | Losing round — /win with amount: 0 |
Accepted, round closed, balance unchanged |
| 11 | Bet exceeding the player's balance | INSUFFICIENT_BALANCE, no debit |
| 12 | Resend an already-processed /bet with the same transactionId |
status: true, no second debit |
| 13 | Resend an already-processed /win with the same transactionId |
status: true, no second credit |
| 14 | /win for a round with no bet recorded |
BET_NOT_FOUND — and no credit. See orphan wins |
Cancel
| # | Scenario | Expected |
|---|---|---|
| 15 | Cancel a bet that was applied | Stake returned exactly once |
| 16 | Repeat that same cancel | status: true, no second refund |
| 17 | Cancel a referenceTransactionId you have no record of |
status: true — see cancel |
| 18 | After 17, send the /bet it referenced |
status: true with no debit — see late bets |
Freebets
| # | Scenario | Expected |
|---|---|---|
| 19 | Freebet bet: amount: 0 with freebetsBetAmount set |
No debit. freebetsBetAmount is reporting only and must never move money |
| 20 | Win on a freebet spin | Winnings credited normally |
Amounts
| # | Scenario | Expected |
|---|---|---|
| 21 | A round in a currency whose exponent is not 2 | Amounts scaled correctly — see currency exponent |