Version 2.0 · For operator casinos integrating Fox Reel Studio games.
Questions at any point: [email protected].
Fox Reel Studio provides provably-fair casino games. You, the operator, keep the player relationship and the money. Two integration modes are supported; your account is configured for exactly one:
| Mode | Where player money lives | What you implement |
|---|---|---|
| Seamless wallet (recommended) | Your wallet. We call your callbacks on every bet and win. | 4 HTTPS endpoints (Section 6) |
| Wallet transfer | Our wallet during play. You move funds in and out. | Nothing. You call our API (Section 7) |
Your team also gets the operator portal (Section 10): reports, transactions, rounds, game and RTP settings within your contract, API credentials, wallet callbacks, IP allow-list, webhooks and team access.
These docs are served from the API host so your team can read them without repository access:
https://api.foxreelstudio.com/docs/docs/integration · Games: /docs/games · OpenAPI: /docs/openapi.yamlGET /api/games (Section 5a)GET /api/v1/games (Section 5a)| Environment | API base URL | Operator portal |
|---|---|---|
| Staging | https://api-staging.foxreelstudio.com |
https://portal-staging.foxreelstudio.com |
| Production | https://api.foxreelstudio.com |
https://portal.foxreelstudio.com |
Staging and production are separate deployments with separate data and separate portal accounts. Everything you configure (credentials, IPs, callback URLs, webhooks) belongs to the deployment you configured it on: a staging key does not exist in production, and vice versa. API requests and payloads carry no environment field. You complete certification on staging (Section 9) before production access is set up.
You give us:
IP_NOT_WHITELISTED.We give you:
api_key and hmac_secret for each deployment, or you create them yourself
in the portal. The secret is shown once. Store it in a secrets manager
immediately.Every request to /api/v1/* carries three headers:
X-Api-Key: ck_<32 hex chars>
X-Timestamp: <unix seconds>
X-Signature: <hex HMAC>
The signature is:
X-Signature = hex( HMAC-SHA256( hmac_secret, X-Timestamp + "." + raw_request_body ) )
Rules:
GET requests the body is empty, so you sign X-Timestamp + ".".100 = 1.00.hmac_secret = "mysecret"
X-Timestamp = "1700000000"
body = {"a":1}
string to sign = 1700000000.{"a":1}
X-Signature = 239d0cbc5357b9e36b7b991b5f55e1bcf02cf51acd4e2d1918d3194d24d4c4c9
Check that your implementation reproduces that exact signature before going further:
printf '1700000000.{"a":1}' | openssl dgst -sha256 -hmac "mysecret"
We sign our seamless wallet callbacks to you the same way, with the same
secret. While you have more than one active credential (during a key
rotation), callbacks are signed with the oldest one until you revoke it.
Verify X-Signature on every callback and reject mismatches with
HTTP 401. Webhooks use a separate per-endpoint secret (Section 11).
Your backend requests a launch URL, then opens it for the player in an iframe or as a redirect:
POST /api/v1/launch
{
"player_id": "your-player-id", // your stable ID for the player, never their PII
"game_code": "test-game",
"currency": "USD", // optional, defaults to your account currency
"lang": "en", // optional
"brand_code": "lucky-red" // optional, see Section 7d
}
200 → { "status": "OK", "url": "https://api.foxreelstudio.com/launch/<one-time-token>" }
The URL is single-session and expires in 15 minutes. Launching a game that is
disabled for your account returns 403 GAME_DISABLED. A player's currency is
fixed by their first launch: launching the same player_id with a different
currency returns 422 CURRENCY_MISMATCH (use a separate player_id per
currency).
Two views of the same catalog:
Public catalog, no auth. Safe to call from a browser (CORS *, cached
for 60 seconds).
GET /api/games every game Fox Reel Studio has live
GET /api/games?operator=<code> only games enabled for operator <code>
Use the operator form for a public lobby page: games you have disabled are
left out. An unknown or suspended operator code returns an empty list, never
the full catalog.
Your lobby, signed. Call from your backend with the usual signing headers. Returns only the games enabled for you, with your bet limits and the RTP you run. Not cached.
GET /api/v1/games
200 → {
"status": "OK",
"provider": "Fox Reel Studio",
"count": 19,
"games": [
{
"code": "dice",
"title": "Dice",
"description": "Roll over or under your target …",
"category": "instant",
"thumbnail": "https://lockeandcrow-games.s3.eu-west-1.amazonaws.com/thumbnails/dice.jpg",
"rtp_default": 99.0,
"rtp": 97.0,
"min_bet": 10,
"max_bet": 1000000,
"max_win": 100000000,
"features": ["provably-fair", "auto-bet", "free-spins"],
"launch_code": "dice"
}
]
}
Pass launch_code as game_code when launching. rtp, min_bet, max_bet
and max_win appear only in the signed view. A game we disable globally
leaves both views within a minute and stops accepting bets in sessions that
are already open.
Configure a base URL (for example https://casino.example.com/provider-wallet)
in the portal or with us. We POST JSON to base_url + path. Default paths are
/balance, /debit, /credit and /rollback; each can be changed. All four
endpoints must respond within 5 seconds with HTTP 200 and a JSON body:
{ "status": "<STATUS>", "balance": <int minor units>, "currency": "USD" }
status values we understand: OK, INSUFFICIENT_FUNDS, DUPLICATE,
PLAYER_BLOCKED, ERROR.
POST {base}/balanceRequest: { "player_id": "...", "currency": "USD" }. Return the current balance.
POST {base}/debit (bet){ "player_id": "...", "currency": "USD", "amount": 100,
"transaction_uuid": "bet-…", "game_code": "…", "round_id": "…" }
amount and return the new balance.transaction_uuid. If you did, do not debit again.
Return DUPLICATE with the balance as it was after the original processing.INSUFFICIENT_FUNDS with HTTP 200, not an
HTTP error.rollback for this transaction_uuid arrived before the debit
(network reordering), reject the late debit with DUPLICATE.POST {base}/credit (win)Same schema as debit. Increase the balance. Same idempotency rule. Credits can arrive late: we retry failed credits with exponential backoff for up to 30 attempts. Always accept them for known players.
POST {base}/rollback{ "player_id": "...", "currency": "USD", "related_uuid": "bet-…" }
Undo the debit identified by related_uuid:
OK.related_uuid so a late debit returns
DUPLICATE, and still return OK.DUPLICATE.| Event | Our behaviour |
|---|---|
| Debit returns non-200 or times out | Round is aborted. We send rollback and retry it with exponential backoff until you acknowledge. |
Debit returns INSUFFICIENT_FUNDS |
Bet rejected, player informed. No rollback. |
| Credit fails for any reason | We retry with exponential backoff (up to about 72 hours). Credits are never dropped. |
These rules apply to wallet callback URLs and webhook URLs alike:
http:// or https:// URL. Production requires
https://. Staging accepts http:// for testing.169.254.169.254), carrier-grade NAT
(100.64.0.0/10), multicast or unspecified addresses. The check runs on the
resolved IP at connect time, so a hostname that resolves to an internal
address fails too. Use a public hostname with a valid TLS certificate.Use Test callback in the portal (Integration page) to check connectivity before you go live.
You move funds into a play wallet we hold; bets and wins settle internally.
POST /api/v1/wallet/deposit { "player_id","amount","transaction_uuid","currency" }
POST /api/v1/wallet/withdraw { "player_id","amount","transaction_uuid","currency" }
POST /api/v1/wallet/balance { "player_id" }
Responses: { "status": "OK|DUPLICATE|INSUFFICIENT_FUNDS|ERROR", "balance": <int>, "currency": "..." }.
transaction_uuid must be unique per operation. Replays return DUPLICATE
with the current balance and are safe. On a seamless account these endpoints
return 409 WRONG_MODE.
BET_LIMIT. Launching a disabled game returns 403
GAME_DISABLED.SHA-256(server_seed), can set their own client seed, and every bet uses a
nonce. roll = first 4 bytes of HMAC-SHA256(server_seed, client_seed + ":" + nonce) mod 10000 / 100.
After a seed rotation the server seed is revealed, and the public verifier
at /fair (no auth) recomputes any outcome. You can link players there.Grant promotional spins, funded by you, at a fixed bet size. Idempotent on
award_ref:
POST /api/v1/freespins/award
{ "player_id": "...", "game_code": "coin-flip", "award_ref": "promo-123",
"spins": 10, "bet_amount": 100, "expires_at": "2026-08-01T00:00:00Z" }
200/201 → { "status": "OK" | "DUPLICATE", "award_id": 1 }
While an award is active, the player's plays in that game use spins first. There is no wallet debit; wins are credited normally through your wallet.
Ask us to enable the bonus free spins module on your account, then pass
"mode": "bonus" with the extra fields below. Instead of paying each win
straight to your wallet, winnings collect in a bonus pot on our side. The
player must wager it in cash before it is released, in one credit call at the
end.
POST /api/v1/freespins/award
{ "player_id": "...", "game_code": "rocket", "award_ref": "promo-124",
"spins": 10, "bet_amount": 10, "expires_at": "2026-09-21T00:00:00Z",
"mode": "bonus",
"max_win": 10000, // pot cap (minor units); wins above it are discarded
"wager_multiplier": 1, // pot × this must be wagered in cash; 0 = no wagering
"wager_max_bet": 10 } // max real-money bet while wagering (larger bets → BET_LIMIT)
403 → { "status": "FEATURE_DISABLED" } module not enabled for your account
Lifecycle, as the player sees it in every game:
bet_amount. Wins collect
in the pot, capped at max_win. Nothing is debited or credited.pot × wager_multiplier in real-money bets on any Fox Reel Studio game. While
wagering, any bet above wager_max_bet is rejected with BET_LIMIT; bets
at or under it are debited from your wallet as usual and count in full. A
pot of 0 ends the award with nothing to wager.credit callback for the pot (round id bonus-<award_id>).Awards expire at expires_at at any stage and the pot is forfeited. Players
can discard an award. You can cancel one at any non-terminal stage
(idempotent):
POST /api/v1/freespins/cancel
{ "award_ref": "promo-124" }
200 → { "status": "OK", "award_status": "cancelled" } 404 → NOT_FOUND
POST /api/v1/player/limits
{ "player_id": "...", "daily_loss_limit": 50000, "session_minutes": 60 }
daily_loss_limit is in minor units, net of wins, and resets at UTC midnight.
Bets that would exceed it are rejected in-game with LIMIT_REACHED.
session_minutes caps game session length (default 4 hours). Send null to
clear either limit.
If you run several casino brands, register their codes with us and pass
brand_code in the launch call. Reporting can then be split per brand.
Unknown codes are rejected with UNKNOWN_BRAND.
API requests are limited per API key (default 20 req/s, burst 40). Excess
requests receive HTTP 429 RATE_LIMITED. Back off and retry.
| HTTP | body.status | Meaning |
|---|---|---|
| 401 | MISSING_AUTH_HEADERS / INVALID_API_KEY / INVALID_SIGNATURE / TIMESTAMP_OUT_OF_RANGE / INVALID_TIMESTAMP |
Fix auth |
| 403 | IP_NOT_WHITELISTED |
Source IP not on your allow-list |
| 403 | OPERATOR_SUSPENDED |
Contact us |
| 403 | GAME_DISABLED |
Game is off for your account |
| 422 | CURRENCY_MISMATCH |
player_id already exists with another currency |
| 409 | WRONG_MODE |
Endpoint not available in your integration mode |
| 400 | BAD_REQUEST / BAD_BODY |
Malformed body |
| 429 | RATE_LIMITED |
Slow down |
On staging, using the test game (game_code: "test-game"):
transaction_uuid) returns DUPLICATE and does not double-applyINSUFFICIENT_FUNDSDUPLICATEGET /api/v1/games returns your lobby with your limitsWhen every box passes, we set up production: your production portal account, then you add production credentials, IPs and callback URLs (https only) and we schedule go-live.
The portal is where your team runs the integration day to day. Staging and production each have their own portal and their own accounts; each portal manages only its own deployment:
https://portal-staging.foxreelstudio.comhttps://portal.foxreelstudio.comSign-in is email and password, with TOTP two-factor authentication (any authenticator app). We can require 2FA for your whole team; when we do, a member without it can only reach the 2FA setup page until they enrol. Accounts lock for a short period after repeated failed logins. Sessions can be reviewed and revoked from your profile. Forgotten passwords are reset by email.
| Area | What you can do |
|---|---|
| Overview and reports | GGR, turnover and per-game performance by day, CSV export |
| Transactions | Every bet, win and rollback, with filters and CSV export |
| Rounds | Round history with the full provably-fair detail, for disputes |
| Games | Enable or disable each game, set min and max bet |
| RTP | Set each game's RTP within the range in your contract |
| API credentials | Create and revoke API keys and HMAC secrets |
| Integration | Wallet callback URL and paths, callback connectivity test, IP allow-list |
| Webhooks | Up to 10 endpoints, event selection, secret rotation, test delivery, delivery log, redelivery |
| Team | Invite teammates, set roles or custom permissions, resend invites, reset a member's 2FA, disable access |
| Audit log | Who changed what, when, and from which IP |
Things that stay with Fox Reel Studio: your contract terms (RTP range, max win, integration mode, currency), suspending an account, enabling optional modules such as bonus free spins, and adding new games to the catalog.
Everyone on the team can read the integration setup: API keys (not secrets), callbacks, IP allow-list, webhooks and the game list. Everything else is gated by permission:
| Permission | Allows |
|---|---|
reports:read |
GGR, turnover and per-game performance, CSV export |
transactions:read |
Every bet, win and rollback, CSV export |
rounds:read |
Round history and provably-fair detail for disputes |
games:write |
Enable or disable games and set bet limits |
rtp:write |
Change each game's RTP within the contracted range |
credentials:write |
Create and revoke API keys and HMAC secrets |
integration:write |
Wallet callback URLs and the IP allow-list |
webhooks:write |
Create endpoints, rotate secrets, redeliver events |
team:write |
Invite teammates and change their access |
audit:read |
Who changed what, when, from where |
Roles are presets of those permissions:
| Role | reports | transactions | rounds | games | rtp | credentials | integration | webhooks | team | audit |
|---|---|---|---|---|---|---|---|---|---|---|
| owner | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes |
| admin | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes |
| finance | yes | yes | yes | yes | ||||||
| developer | yes | yes | yes | yes | yes | yes | ||||
| viewer | yes | yes | yes | |||||||
| custom | any combination you choose |
Invites are valid for 7 days and can be resent.
Webhooks tell your systems about events as they happen, so you do not have to
poll. Create endpoints in the portal under Webhooks (needs
webhooks:write). Each endpoint has its own
signing secret (shown once, starts with whsec_), and subscribes to a list of
events or to * for all of them.
| Event | When | data |
|---|---|---|
transaction.confirmed |
A bet, win or rollback is settled | Transaction fields (below) |
transaction.failed |
A wallet operation failed | Transaction fields |
transaction.rolled_back |
A transaction was rolled back | Transaction fields |
game.settings_updated |
A game's settings changed for your account (portal or Fox Reel Studio) | game_id, game_code, enabled, min_bet, max_bet, max_win, config (includes rtp_bp when set) |
credential.created |
An API credential was created | id, api_key |
credential.revoked |
An API credential was revoked | id, api_key |
freespins.awarded |
Free spins were awarded | Reserved; not emitted yet |
webhook.test |
You pressed Send test in the portal | message |
Transaction fields: transaction_uuid, related_uuid, kind (bet, win,
rollback, deposit or withdraw), amount
(minor units), currency, player_id (your ID), game_code, round_id,
created_at.
We send POST with a JSON body:
POST https://casino.example.com/hooks/foxreel
Content-Type: application/json
User-Agent: FoxReel-Webhooks/1
X-LC-Event: transaction.confirmed
X-LC-Delivery: 81723
X-LC-Timestamp: 1760000000
X-LC-Signature: 5f2c…
{
"event": "transaction.confirmed",
"created_at": "2026-10-13T09:21:07.114Z",
"data": {
"transaction_uuid": "win-…",
"related_uuid": null,
"kind": "win",
"amount": 250,
"currency": "USD",
"player_id": "p-1001",
"game_code": "dice",
"round_id": 4411,
"created_at": "2026-10-13T09:21:07.090Z"
}
}
Respond with any 2xx within 10 seconds. Do the work asynchronously if it takes longer.
X-LC-Signature = hex( HMAC-SHA256( webhook_secret, X-LC-Timestamp + "." + raw_body ) )
The same scheme as API signing, with the endpoint's own whsec_… secret.
Always:
Node.js (Express):
const crypto = require('crypto');
const express = require('express');
const app = express();
const SECRET = process.env.LC_WEBHOOK_SECRET; // whsec_…
app.post('/hooks/foxreel', express.raw({ type: 'application/json' }), (req, res) => {
const ts = req.get('X-LC-Timestamp') || '';
const sig = req.get('X-LC-Signature') || '';
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(401);
const expected = crypto.createHmac('sha256', SECRET)
.update(ts + '.').update(req.body) // req.body is a Buffer of the raw bytes
.digest('hex');
const a = Buffer.from(sig, 'hex'), b = Buffer.from(expected, 'hex');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);
const deliveryId = req.get('X-LC-Delivery');
// if (alreadyProcessed(deliveryId)) return res.sendStatus(200);
const event = JSON.parse(req.body);
// queue event for processing, record deliveryId
res.sendStatus(200);
});
Python (Flask):
import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["LC_WEBHOOK_SECRET"].encode() # whsec_…
@app.post("/hooks/foxreel")
def foxreel_hook():
ts = request.headers.get("X-LC-Timestamp", "")
sig = request.headers.get("X-LC-Signature", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
abort(401)
raw = request.get_data() # raw bytes, before JSON parsing
expected = hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
abort(401)
delivery_id = request.headers["X-LC-Delivery"]
# if already_processed(delivery_id): return "", 200
event = request.get_json()
# queue event for processing, record delivery_id
return "", 200
Deliveries can arrive more than once (for example after a timeout on a request
you actually processed, or a manual redelivery) and are not guaranteed to
arrive in order. X-LC-Delivery is the same on every attempt of a delivery,
so store it and ignore repeats. For transaction events, transaction_uuid is
also a safe business key. Use created_at to order events, not arrival time.
The URL rules in Section 6.6 apply: production endpoints must be https://
on a public address.
Rotate secret in the portal issues a new secret immediately and shows it once. Deliveries after that are signed with the new secret, so update your receiver straight away. Retries of older deliveries are also signed with the new secret at send time.