Skip to content

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 yet
active — playable, unused or partially used
activated — the player launched the game and received the freebets
canceled — freebets were canceled
expired — 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:

coins = decimalAmount × 10 ^ currencyExponent

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 as UNKNOWN_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:

X-Signature: b70713912f88fca6ed4cea6cdc255aeb61aebd8d84bd1c0854c58be1a30fb2b0

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.

MethodGET

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

Response302 redirect to the game client.

Demo mode

Demo mode requires no token and performs no wallet calls.

https://api.spinach.games/gateway/launch?mode=demo&game=luckygame&partner=34

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
{
    "token": "9f2a1c7e4b3d5a6f8c0e1b2d3a4f5c6e"
}
X-Signature: 5e40312ca4bd0d2bbe22ce294ea56cde7874e51be087a585a95a03b2c62270da

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
}
X-Signature: 95c214b01e92c59c1ff360466b88b504ea9fef08ef732fdcbd29f6f3ae7b8ed7

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
{
    "token": "9f2a1c7e4b3d5a6f8c0e1b2d3a4f5c6e"
}
X-Signature: 5e40312ca4bd0d2bbe22ce294ea56cde7874e51be087a585a95a03b2c62270da

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
{
    "status": true,
    "balance": 500000,
    "currency": "EUR"
}
X-Signature: f7e7767e5b542ab48159710483317709460b96c30ef5ed6cff58a6a334105466

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"
}
X-Signature: b70713912f88fca6ed4cea6cdc255aeb61aebd8d84bd1c0854c58be1a30fb2b0

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
{
    "status": true,
    "balance": 498500,
    "currency": "EUR",
    "operationId": "346"
}
X-Signature: 8d4efa8aa1a5846b534aeefc08e77d6c5edf4628f7e5b8bd49971af94a81d815

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"
}
X-Signature: 8431113c0df0600882a22e01de4df8708a9e1dda0059648507f7ed899a98507a

Response — identical shape to /bet.

{
    "status": true,
    "balance": 503000,
    "currency": "EUR",
    "operationId": "347"
}
X-Signature: 5d5a7fc992a339dd0e99b3587bf0240bf86a8817f2991f8579f4e9a65b830842

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"
}
X-Signature: 738f568774b0deea5b0c252f1cda3aae880c8b1c0faf6c24170fedc7990c6c89

Response — identical shape to /bet.

{
    "status": true,
    "balance": 500000,
    "currency": "EUR",
    "operationId": "348"
}
X-Signature: f0e697497c07cf69a2f76ef53a542bc9745efb6dba6ca52ea4d0e65035b1dca1

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: true with the current balance, not an error.
  • The partner has no record of the bet — return status: true as 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 betwin 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 transactionId and the same roundId.
  • finished: true on /win marks the last transaction of the round. Until then the round is still open and further transactions may arrive on the same roundId.
  • Reporting, reconciliation and responsible-gaming limits should key off roundId, and treat the round as complete only once a finished: true win 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"
}
X-Signature: 09f271721fd8693b230aea43424852ca886ec9cbbd62d3522bfbd01070976623

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 — a DETERMINED failure 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_BALANCE or BET_LIMIT_EXCEED invite another attempt at a different stake, while USER_BLOCKED and LOSS_LIMIT_EXCEED must 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"
}
X-Signature: cc060ddbcf84c6e7352851f6be8805ee2c445b5ba8e10dacff19b512d51758c0

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"
}
X-Signature: d3605e599e47032ce8ab6dcc8bc0b67bd5eb91120eec1894f666bf3082421c2b

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
  • /bet carries amount: 0 and freebetsBetAmount set to the round's bet value.
  • /win carries the real winnings in amount — winnings are credited normally — plus freebetsFinished: true on the spin that consumes the last free spin of the campaign.
  • /cancel carries freebetsBetAmount: 0 when 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"
}
X-Signature: 1f4093e2e742e317aa47a9a02a703ccedde29ba2e4fb4b196cad8a0be4ba2f48

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"
}
X-Signature: 3004fde934d461dde980398ffcc4ecdd06a5b4399c309223b70998392080cbf9

Going live

Checklist for a partner integration:

  1. Receive INTEGRATION_HUB_ID, INTEGRATION_HUB_SECRET from SPINACH GAMES, plus the sandbox base URL.
  2. Implement the five wallet endpoints and publish INTEGRATION_HUB_URL to us.
  3. Verify signature generation against the worked example.
  4. Confirm idempotency on /bet, /win and /cancel.
  5. Pass the testing checklist against the sandbox.
  6. 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