Skip to main content

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 backendWallet effectReference
POST /transactions/betDebit an accepted stake once.Bet request and response
POST /transactions/winCredit the gross payout once; a zero payout closes the bet without credit.Win request and response
POST /transactions/cancelRefund 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:

HeaderValue
content-typeapplication/json
x-operator-slugThe same slug as in the body.
idempotency-keyThe launch idempotencyKey.
x-request-idFresh UUID for this HTTP attempt.
x-request-timestampCurrent canonical UTC timestamp, generated when sending.
x-signatureHMAC-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.

Signed launch request
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 betCallback amountFinal balance
Gross payout of 0.02Win "0.02"10.01
Zero-payout settlementWin "0"9.99, bet settled
Reversal of the original stakeCancel "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.