Your first sandbox integration
This walkthrough follows a game from your authenticated player session through launch, iframe loading and wallet settlement. Call the HTTP API from your own backend using the language and framework you already use.
1. Confirm the sandbox settings
Complete partner onboarding. You need the sandbox API base URL, operator slug, shared HMAC secret, allowed game origins, enabled game and currency, and your registered public HTTPS callback base URL.
Choose a test player UUID and create a session reference in your backend. Bind it
to that player, game and currency. Read the balance from your wallet as a decimal
string in asset units. The examples below use an illustrative balance of 10 and
stake of 0.01; use the agreed limits and precision for your sandbox currency.
2. Implement your wallet callbacks
Expose these routes beneath your registered callback base URL:
| Route on your backend | Wallet effect | Reference |
|---|---|---|
POST /transactions/bet | Debit an accepted stake once. | Bet request and response |
POST /transactions/win | Credit the gross payout once; a zero payout closes the bet without credit. | Win request and response |
POST /transactions/cancel | Refund the original accepted stake once. | Cancel request and response |
Moonshot calls these routes on your backend during gameplay. They are not endpoints you call on the Moonshot API host.
For each callback, verify the canonical signature against
raw request bytes, validate ownership and the original bet, then atomically store
the operation and wallet change. Use (authenticated operatorSlug, transactionId)
for idempotency. Return a signed JSON response, including for authenticated errors.
The callback flow diagram shows the
transaction boundary and retry handling.
3. Send a signed launch request
Your backend sends POST /launch to the configured Moonshot API base URL. Build
the body from your authenticated session and authoritative wallet balance.
Generate a fresh idempotencyKey, request UUID and timestamp for this launch.
{
"operatorSlug": "your-partner-slug",
"idempotencyKey": "f64382a3-f550-46f0-9a06-14dfbf87c700",
"sessionId": "partner-session-123",
"playerId": "fd63c560-f443-49b5-a721-c40c0e06b7be",
"gameSlug": "crash",
"currency": "SOL",
"balance": "10",
"userAgent": "Mozilla/5.0",
"ip": "203.0.113.10",
"country": "UA",
"language": "en"
}
Send these headers along with that body:
| Header | Value |
|---|---|
content-type | application/json |
x-operator-slug | The same slug as in the body. |
idempotency-key | The launch idempotencyKey. |
x-request-id | Fresh UUID for this HTTP attempt. |
x-request-timestamp | Current canonical UTC timestamp, generated when sending. |
x-signature | HMAC-SHA256 of the canonical request string, using the exact body bytes. |
Use your actual player context in place of the illustrative values. Keep the shared secret on your backend, serialize once, and send the bytes you signed.
Complete server-side example: Node.js 22+
The function below uses Node.js built-ins. Pass your server configuration as
config and build playerContext from the authenticated player/session. It
returns a verified game URL for your own frontend launch handler.
import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
export async function launchGame(config, playerContext) {
const base = new URL(config.apiBaseUrl);
if (
base.protocol !== 'https:' || base.username || base.password || base.search || base.hash ||
!config.secret || !config.operatorSlug || !Array.isArray(config.allowedGameOrigins) ||
config.allowedGameOrigins.length === 0
) {
throw new Error('Configure the HTTPS API base, secret, slug and allowed game origins');
}
const path = '/launch';
const payload = {
operatorSlug: config.operatorSlug,
idempotencyKey: randomUUID(),
sessionId: playerContext.sessionId,
playerId: playerContext.playerId,
gameSlug: playerContext.gameSlug,
currency: playerContext.currency,
balance: playerContext.balance,
userAgent: playerContext.userAgent,
ip: playerContext.ip,
country: playerContext.country,
language: playerContext.language,
};
const rawBody = Buffer.from(JSON.stringify(payload));
const requestId = randomUUID();
const timestamp = new Date().toISOString();
const digest = createHash('sha256').update(rawBody).digest('hex');
const signature = createHmac('sha256', config.secret)
.update(['moonshot-partner-request', timestamp, requestId, 'POST', path, digest].join('\n'))
.digest('hex');
const response = await fetch(`${base.href.replace(/\/$/, '')}${path}`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-operator-slug': config.operatorSlug,
'idempotency-key': payload.idempotencyKey,
'x-request-id': requestId,
'x-request-timestamp': timestamp,
'x-signature': signature,
},
body: rawBody,
redirect: 'error',
signal: AbortSignal.timeout(10_000),
});
const responseBytes = Buffer.from(await response.arrayBuffer());
const responseDigest = createHash('sha256').update(responseBytes).digest('hex');
const expectedSignature = createHmac('sha256', config.secret)
.update(['moonshot-partner-response', requestId, String(response.status), responseDigest].join('\n'))
.digest();
const receivedSignature = response.headers.get('x-response-signature') ?? '';
if (
!response.headers.get('content-type')?.toLowerCase().startsWith('application/json') ||
response.headers.get('x-request-id') !== requestId ||
!/^[0-9a-f]{64}$/.test(receivedSignature) ||
!timingSafeEqual(expectedSignature, Buffer.from(receivedSignature, 'hex'))
) {
throw new Error(`Untrusted launch response (HTTP ${response.status})`);
}
if (!response.ok) {
throw new Error(`Launch rejected (HTTP ${response.status})`);
}
let data;
try {
data = JSON.parse(responseBytes.toString('utf8')).data;
} catch {
throw new Error('Malformed launch response');
}
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (
data?.operatorSlug !== config.operatorSlug ||
data?.idempotencyKey !== payload.idempotencyKey || !uuid.test(data?.operatorId ?? '')
) {
throw new Error('Launch response identity does not match');
}
let gameUrl;
try {
gameUrl = new URL(data.url);
} catch {
throw new Error('Invalid launch URL');
}
if (
gameUrl.protocol !== 'https:' || gameUrl.username || gameUrl.password ||
!config.allowedGameOrigins.includes(gameUrl.origin)
) {
throw new Error('Launch URL origin is not allowed');
}
return data.url;
}
config contains apiBaseUrl, operatorSlug, secret and
allowedGameOrigins (an array of exact HTTPS origins confirmed by Moonshot).
playerContext contains the player/session, game, currency, balance and client
context fields shown in the JSON request above. Load configuration from your
backend's environment or secret manager. Keep launch URLs and raw payloads out of logs.
4. Verify the response and open the iframe
A successful signed HTTP 200 response has this shape:
{
"data": {
"operatorSlug": "your-partner-slug",
"idempotencyKey": "f64382a3-f550-46f0-9a06-14dfbf87c700",
"operatorId": "8e563380-2a27-41b1-8be5-2cdd7d1ec271",
"url": "https://games.example/launch-token"
}
}
Verify the response signature over the
raw response bytes, HTTP status and echoed request UUID. Confirm the echoed
operator slug and launch key, the provider operator UUID and the allowed HTTPS
origin. Return the verified data.url to your authenticated frontend and embed it
exactly as received using the iframe example.
A launch timeout or signed 503 does not establish a successful session. For the
next explicit attempt, create a fresh launch key and partner session. The current
launch API does not guarantee same-key session replay; see the
launch contract.
5. Verify gameplay against your wallet
Open the sandbox game and inspect the signed callbacks that Moonshot sends to your
registered backend. With a starting balance of 10, an accepted stake of 0.01
should leave 9.99. Test the following alternative outcomes on separate bets:
| Outcome after the accepted bet | Callback amount | Final balance |
|---|---|---|
Gross payout of 0.02 | Win "0.02" | 10.01 |
| Zero-payout settlement | Win "0" | 9.99, bet settled |
| Reversal of the original stake | Cancel "0.01" | 10 |
The money flow diagram illustrates these branches. Cancellation is a provider-requested reversal, not an extra credit you send after a completed win. Agree how to exercise cancellation scenarios in sandbox with Moonshot.
For an accepted callback, return a signed HTTP 200 response such as:
{
"data": {
"balance": "9.99",
"operatorTransactionId": "b8a86f01-f5bd-49ba-adfe-2b07b3fce116",
"statusCode": 1000
}
}
For insufficient funds, return a signed business rejection with statusCode: 5100,
unchanged balance and errorMessage. An identical retry must replay the original
result and operator transaction ID; regenerate only the response signature and
request-ID echo for the current HTTP attempt.
6. Complete acceptance
Run the sandbox test matrix against your implementation, including concurrent duplicates, altered signatures, lost responses, late settlements and restart recovery. Use Troubleshooting for failed requests or iframe loading problems.
If promotions are enabled, implement and test the freeplays API separately: capabilities, issuance, state lookup, cancellation and promotion accounting. The regular-wallet walkthrough above does not consume promotion plays.