Skip to Content

Webhooks

When gameplay moves money, the server sends a POST request to the webhookUrl configured on your customer account. Bet events debit the operator wallet, win events credit it, and balance_check reads the operator’s authoritative balance.

Your webhook handler is a critical part of the integration. The server enforces game logic, but your handler is responsible for balance integrity — rejecting invalid credits, enforcing idempotency, and handling concurrent requests atomically. See Implementation Requirements for the full list.

Changelog

Additive changes to the webhook contract are listed here. Every entry is optional and backward compatible — an integration that ignores a new field keeps working unchanged. Existing fields and event names are not repurposed.

DateChange
2026-08-29Webhook tester: no more placeholder session tokens. Scenarios that had to be accepted previously carried invented values (position-open, crash-open, session-a) rather than the token you configure, which 401’d any wallet that validates the token against its own sessions — while our own setup instructions said the tester sends the value you set. Fixed: a token you never issued now only ever rides a request whose expected outcome is a rejection your wallet would make on betId anyway. Scenarios proving settlement is not bound to a session now send no sessionToken field instead of a foreign one, which is both the documented contract (the field is optional) and what production emits when the token cannot be rehydrated from the round record. No contract change.
2026-08-27Webhook tester: Cases excluded from the approval set. Cases is no longer a customer-facing game, so it joins Real Estate and Russian Roulette in the tester’s exclusion list — it creates no row and sends no outbound request. The approval set is now 32 games and 64 canonical events across 205 scenarios (181 required, 24 advisories). cases_bet / cases_win remain in the production event catalog above, which stays truthful about service routes.
2026-08-27Webhook tester: optional Session Token field. Production echoes the token you pass to createSession as sessionToken on every webhook for that session — balance_check included — but the tester had no way to supply one, so it could only exercise the token-less shape. Operators whose wallet routes or authorizes on that token could not get past the opening balance read. Set the field to have it ride every tester request; leave it blank and the payloads are unchanged. No contract change: sessionToken remains optional, and betId + transactionId remain the correlation authority.
2026-08-21Return the post-action balance on *_bet and *_win. It is now part of what an integration is expected to send (see Response Format), and the Webhook Tester  checks it. When your response carries it we use it and skip the balance_check that otherwise precedes every debit — one fewer round-trip on every bet, and one fewer on every multi-round cashout. Nothing to enable and nothing to change: a response without a balance simply falls back to the read it already performed, so an integration that omits it behaves and costs exactly as it does today. Acceptance remains exactly HTTP 200.
2026-08-19Unknown events may now be rejected. Handle All Event Types no longer requires accepting events outside the catalog: catalog-validating operators may reject them with a non-200 status and no balance mutation. The tester’s required scenario was updated accordingly (contract.unknown-event-safe replaces contract.unknown-event-valid): it now passes on either policy and gates only wallet safety — a 200 commits exactly, a rejection moves no money. Forward-compatible acceptance remains fully supported. Also documented that balance_check carries sessionToken too (long-standing emission; the field table previously said bet/win only).
2026-08-17Free-bet rounds now honour the one-terminal-event guarantee like paid ones: the placement *_bet carries roundClosed (it previously omitted the field), and a losing multi-round free bet now sends the terminal *_close it was missing. No change for operators who have not enabled round-close events, beyond the new roundClosed field on free-bet placements.
2026-08-06@maktubbet/webhook 0.2.0: handles the terminal *_close via a new onRoundClose callback, and surfaces transactionId, roundClosed, and currency on every callback. Versions ≤ 0.1.5 rejected *_close with 400 Empty actions and dropped those three fields — upgrade before asking us to enable round-close events.
2026-07-30Added optional, opt-in usdRate to *_bet and *_win: the USD value of one unit of the wire currency, frozen at transaction time and identical to the rate our invoicing uses — so operators running a player-currency ledger can reconcile against our USD statements without guessing a rate.
2026-07-29Docs/tester reconciliation: the webhook tester now sends transactionId and roundClosed and exercises *_close; the payload examples show the new fields; and the idempotency guidance no longer claims there is no per-operation identifier — key on transactionId, group by betId.
2026-07-28Added wallet-currency mode (Currency modes): create a session with currency: { code } alone and all wire amounts for that session are in the player’s own currency — no conversion rates needed on either side. Every webhook for a session with currency config now carries an additive currency field naming the wire currency; sessions without currency config are unchanged (field absent = USD).
2026-07-21*_close and the player-currency fields are now opt-in per operator and off by default, so no existing integration receives a new event or field it did not ask for.
2026-07-21Webhook tester: three new advisory scenarios cover *_close, the roundClosed flag, and the player-currency fields, so you can validate the new contract against your own endpoint. Clarified that *_close is exempt from the reject-empty-actions rule.
2026-07-21Added the terminal *_close event for multi-round rounds that end without a credit, completing the one-terminal-event guarantee: every betId now gets exactly one event carrying roundClosed: true. Carries no actions and moves no money.
2026-07-21Added optional playerAmount / playerCurrency / rate to *_bet and *_win, so operators holding balances in the player’s currency can book the exact amount the player saw. Settlement and balance_check remain USD.
2026-07-20Added roundClosed to *_bet and *_win. Deterministic round-closure signal so a round can be settled on the event in hand. true = final on this event; false = not final yet (not a promise that a win follows).
2026-07-17Added transactionId to *_bet and *_win. Unique per money movement, distinct from betId. Key idempotency on it — required to accept blackjack’s multiple debits under one betId. See Blackjack Multi-Debit.
2026-07-09Clarified that debits and credits are never bundled: every round sends separate *_bet and *_win requests sharing a betId, for instant games too. No behavioural change — the docs were corrected to match long-standing emission.

Quick Start with @maktubbet/webhook

The easiest way to implement webhooks is with the helper library:

yarn add @maktubbet/webhook

Express

import express from 'express' import { forExpress, WebhookError } from '@maktubbet/webhook' const app = express() // Capture the raw body so the library can verify the signature. app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf } })) app.post('/webhook', forExpress({ secret: process.env.MAKTUB_WEBHOOK_SECRET, requireSignature: true, // new integrations: verify the HMAC signature expectedCustomerId: process.env.MAKTUB_CUSTOMER_ID, onBalanceCheck: async ({ userId, customerId }) => { const balance = await db.getBalance(userId, customerId) return { balance } }, onBet: async ({ userId, customerId, amount, betId, event }) => { const result = await db.atomicDebit({ userId, customerId, amount, betId, event }) if (!result.ok) throw new WebhookError(result.reason, result.status) return { balance: result.balance } }, onWin: async ({ userId, customerId, amount, betId, event }) => { const result = await db.atomicCredit({ userId, customerId, amount, betId, event }) if (!result.ok) throw new WebhookError(result.reason, result.status) return { balance: result.balance } }, }))

Next.js App Router

// app/api/webhook/route.ts import { forNextjs, WebhookError } from '@maktubbet/webhook' export const { POST } = forNextjs({ secret: process.env.MAKTUB_WEBHOOK_SECRET!, requireSignature: true, // new integrations: verify the HMAC signature expectedCustomerId: process.env.MAKTUB_CUSTOMER_ID!, onBalanceCheck: async ({ userId, customerId }) => { return { balance: await getBalance(userId, customerId) } }, onBet: async ({ userId, customerId, amount, betId, event }) => { const result = await atomicDebit({ userId, customerId, amount, betId, event }) if (!result.ok) throw new WebhookError(result.reason, result.status) return { balance: result.balance } }, onWin: async ({ userId, customerId, amount, betId, event }) => { const result = await atomicCredit({ userId, customerId, amount, betId, event }) if (!result.ok) throw new WebhookError(result.reason, result.status) return { balance: result.balance } }, })

Generic (Web API Request/Response)

import { createWebhookHandler, WebhookError } from '@maktubbet/webhook' const handler = createWebhookHandler({ secret: process.env.MAKTUB_WEBHOOK_SECRET, requireSignature: true, // new integrations: verify the HMAC signature expectedCustomerId: process.env.MAKTUB_CUSTOMER_ID, onBalanceCheck: async ({ userId, customerId }) => ({ balance: await getBalance(userId, customerId) }), onBet: async ({ userId, customerId, amount, betId, event }) => { const result = await atomicDebit({ userId, customerId, amount, betId, event }) if (!result.ok) throw new WebhookError(result.reason, result.status) return { balance: result.balance } }, onWin: async ({ userId, customerId, amount, betId, event }) => { const result = await atomicCredit({ userId, customerId, amount, betId, event }) if (!result.ok) throw new WebhookError(result.reason, result.status) return { balance: result.balance } }, }) // Works with any framework that supports Web API Request/Response export default handler

The library handles signature verification, payload validation, betId enforcement, response formatting, and proper HTTP status codes automatically. Its mutation responses already carry the post-action balance — returning { balance } from onBet / onWin supplies it directly, and otherwise the library asks your onBalanceCheck — so an integration built on it gets the skipped pre-debit balance_check with no extra work.

It does not make your wallet atomic by itself. Your onBet / onWin implementation still must enforce idempotency, causal ordering, and atomic balance updates in your own database.

Verifying signatures

Every webhook is signed with an HMAC over the raw request body (see Signature verification for the format). The helper library verifies this automatically when it can see the raw bytes. With Express you must capture them — express.json() discards the raw body after parsing, and a re-serialized body will not reproduce the signed bytes:

app.use(express.json({ // Expose the exact bytes the signature was computed over. verify: (req, _res, buf) => { req.rawBody = buf }, }))

Next.js and the generic Web API handler receive the raw Request directly, so no extra step is needed there.

Signature-related options (all optional):

OptionDefaultDescription
requireSignaturefalseReject unsigned (legacy-secret-only) requests. New integrations: set true. Existing integrations leave false until raw-body capture is deployed and signed traffic is confirmed.
signatureToleranceSeconds300Max clock skew tolerated on a signed request. Older signatures are rejected, which bounds replay.
signingSecretsecretOverride the HMAC key if you rotate signing independently of the legacy secret.

Migration: while requireSignature is false, a request that fails signature verification falls back to the legacy x-webhook-secret check — so upgrading the library, or adding rawBody capture, never breaks a live integration. You remain at legacy security until you set requireSignature: true; do that once raw-body capture is deployed and you can see signed requests arriving.


Manual Implementation

If you prefer to implement webhooks manually without the helper library, follow the specification below.

Configuration

The webhook URL is set per customer in the webhookUrl field. It must be a direct HTTPS URL with no embedded credentials and must resolve only to public IP addresses; redirects are rejected, so configure the final endpoint rather than a forwarding URL. DNS resolution and the outbound request share a four-second deadline. All webhook requests are sent as POST with Content-Type: application/json.

Authentication

Every webhook is authenticated with an HMAC signature over the raw request body, sent in the x-maktub-signature header. Your account secret (randomly generated at creation and shared with you separately) is the signing key. Verify the signature on every request — this is the required method for new integrations.

A legacy x-webhook-secret header carrying the raw secret is still sent alongside the signature for backward compatibility with older integrations. It is not the check to build against — see Legacy secret below.

Example Headers

POST /your-webhook-endpoint HTTP/1.1 Content-Type: application/json x-maktub-signature: t=1720000000,v1=3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b x-webhook-secret: whsec_a1b2c3d4e5f6...

Verifying the signature

The signature is HMAC_SHA256(secret, "<t>.<rawBody>"), where:

  • t — the Unix timestamp (in seconds) at which the request was signed (from the header).
  • <rawBody> — the exact request body bytes.
  • secret — your account webhook secret (the signing key).

Recompute the HMAC over the raw body, compare it to v1 in constant time, and reject anything outside the timestamp window:

import crypto from "node:crypto"; function verifyWebhook(rawBody, signatureHeader, secret) { const parts = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("="))); const t = Number(parts.t); // Reject signatures outside a 5-minute window to bound replay. if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false; const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(parts.v1 || ""); return a.length === b.length && crypto.timingSafeEqual(a, b); }

Verify against the raw body, not a re-serialized object — JSON round-trips can reorder keys or change whitespace and break the signature. Capture the raw bytes before JSON parsing (e.g. Express express.json({ verify })).

Important: If your webhook secret is compromised, contact us to regenerate it. The old secret — and any signatures made with it — stops working immediately upon regeneration.

Legacy secret (existing integrations)

New integrations should skip this and verify the signature above.

Older integrations authenticate by comparing the raw x-webhook-secret header to their configured secret. This header is still sent for backward compatibility, but it travels on every request and is being phased out. If you still rely on it, compare in constant time — never !==, which leaks the secret through timing:

import crypto from "node:crypto"; function checkLegacySecret(headerValue, secret) { const a = crypto.createHash("sha256").update(headerValue || "").digest(); const b = crypto.createHash("sha256").update(secret).digest(); return crypto.timingSafeEqual(a, b); }

Balance Check

To read a player’s balance, the server sends a webhook with an empty actions array. Your endpoint must handle this case and respond with the user’s balance.

It is sent when a session starts, whenever the game client asks for a balance, and before a balance-requiring action (e.g. blackjack insurance, double, split). It is also sent before every bet — unless your mutation responses return the post-action balance, which is what removes that per-bet call.

Request

{ "event": "balance_check", "userId": "user-123", "customerId": "acme", "actions": [] }

Expected Response

{ "success": true, "balance": 150.00 }
FieldTypeDescription
successbooleanMust be true for the balance to be read.
balancenumberThe user’s current available balance in USD.

The response must be exactly HTTP 200 with valid JSON, success: true, and a finite numeric balance. A non-200 response, malformed JSON, success: false, or a missing/non-numeric balance rejects the game-side action. If the returned balance is less than the bet amount, the server rejects the debit before sending it.

Important: If your endpoint does not return a valid { success: true, balance: <number> } response (e.g. returns an error or is unreachable), the game action will be rejected. Your endpoint must be available and respond correctly for gameplay to proceed.

Payload Format

Every webhook request has the same top-level structure:

{ "event": "dice_bet", "userId": "user-123", "customerId": "your-client-id", "betId": "6650a3f1e4b0c912d8a74b21", "transactionId": "9f2b1c04-6d51-4a83-b0e2-7c1f8a3d5e60", "roundClosed": false, "sessionToken": "sess-9f3c1a...", "actions": [ { "type": "bet", "amount": 10.00 } ] }

Fields

FieldTypeDescription
eventstringThe event type identifying which game and action triggered the webhook. See Events.
userIdstringThe unique identifier of the user who placed the bet.
customerIdstringYour customer/client identifier. Reject the request if it does not match the customer you issued the webhook to.
betIdstringThe unique identifier of the bet/round associated with this event. Required for every non-balance_check request.
transactionIdstringOptional. Unique identifier for this individual money movement. Distinct from betId, which groups the whole round: a blackjack round legitimately sends several debits under one betId, each with its own transactionId. Key your idempotency on transactionId and group by betId. See Blackjack Multi-Debit.
roundClosedbooleanOptional. Deterministic round-closure signal, so you can settle on the event already in hand instead of waiting to see whether a credit follows. true means the round is final on this event. false means “not final yet” — it is NOT a promise that a win is coming. See Round Closure Signal.
playerAmountnumberOptional, opt-in. Enabled per operator — ask us to turn it on. The actions amount expressed in the player’s display currency, using the rate the SDK applied for this session. Settlement stays USD — this lets you book the exact amount the player saw without re-converting. Present only when the session was launched with a currency.
usdRatenumberOptional, opt-in. USD value of one unit of the wire currency, frozen when this transaction was processed — the same rate our invoicing uses. Present only for non-USD sessions of operators who enabled it, so a player-currency ledger reconciles against our USD statements: usd = amount × usdRate. Reconciliation only; it never converts the amounts we settle. See Opt-in features.
playerCurrencystringOptional. Currency code for playerAmount (e.g. EUR). Travels together with playerAmount and rate.
ratenumberOptional. The USD→player-currency rate used for playerAmount, fixed at session launch. Travels together with playerAmount and playerCurrency.
currencystringOptional. The wire currency of every amount in this request. Present only when the session was created with a currency config: sessions launched in wallet-currency mode (currency: { code } alone) carry that code and amounts are already in the player’s currency; legacy display-rate sessions carry "USD" (the wire stays USD). Absent for sessions with no currency config — treat absence as USD.
sessionTokenstringOptional. The opaque token you supplied to createSession, echoed back so you can map this webhook to a specific game session. Present on *_bet and *_win events, and on balance_check requests that originate from a session (session creation and gameplay balance reads), whenever the session was created with a token.
freeBetbooleanOptional. Present and true only on free-bet rounds. See Free Bets.
freeBetIdstringOptional. The free-bet grant consumed by this round (free-bet rounds only).
freeBetsRemainingnumberOptional. Free bets left in that grant after this round (free-bet rounds only). Decrement your free-bet counter on the *_bet event.
actionsAction[]An array of actions that occurred during this game round. See Actions.

sessionToken lets you disambiguate concurrent sessions for the same userId (e.g. the same player opening two games) without overloading userId. It is only present when you passed a token to createSession; existing integrations are unaffected. The current Crash, Slide, and Double proxy echoes it on ordinary bet and settlement events, but integrations must keep it optional and use betId plus stored debit state as the financial authority.

Free bets: when a round is played with a free bet, the *_bet event carries freeBet: true, freeBetId, and freeBetsRemaining, and its bet action amount is 0 (no balance is debited). This placement event fires on every free bet — win or loss — so it is the event to decrement your free-bet counter on. Treat the matching *_win (which carries the same fields) as payout only. See Free Bets → How free bets are reported.

Example — a bet with the opt-in fields enabled. playerAmount / playerCurrency / rate only appear for operators who asked for them (see Opt-in features); every other field below is part of the base contract:

{ "event": "dice_bet", "userId": "user-123", "customerId": "acme", "betId": "6650a3f1e4b0c912d8a74b21", "transactionId": "9f2b1c04-6d51-4a83-b0e2-7c1f8a3d5e60", "roundClosed": false, "playerAmount": 9.00, "playerCurrency": "EUR", "rate": 0.9, "actions": [ { "type": "bet", "amount": 10.00 } ] }

Actions

Each action in the actions array has the following shape:

{ type: "bet" | "win" amount: number // USD amount (e.g. 10.00) }
Action TypeDescription
betThe wager amount placed by the user. Your handler must debit this amount from the user’s balance.
winThe total payout amount. Your handler must credit this amount to the user’s balance. Only sent when the payout is greater than zero — a losing round sends no win at all. A zero-amount win is a tester advisory, not an emitted event (see rule 12).

All amount values are in the session’s wire currency — USD by default, or the player’s own currency for sessions created in wallet-currency mode (the webhook’s currency field names the unit; absent = USD). Amounts may carry up to 10 decimal places. Do not round the wire amount to cents; use a fixed-precision decimal type or an integer scaled by 10^10 so values such as 0.0000000001 settle exactly.

One action per webhook. The server sends exactly one action per request: a bet action on *_bet events and a win action on *_win events. Debits and credits are never bundled into a single request — a round that debits and credits always produces two webhooks with the same betId. actions is an array for forward compatibility; the Webhook Tester retains bundled payloads as a non-gating advisory.

For every non-balance_check webhook:

  • actions must be a non-empty array
  • every action must have a supported type
  • every bet and win action must include a numeric amount
  • negative amounts must be rejected

Events

The live contract has 34 game keys and 68 canonical events. Every game uses <game>_bet for a debit and <game>_win for a positive payout. Both requests use the same betId; a loss sends only the debit. Instant games normally send the pair back-to-back, while stateful, realtime, and Futures credits can arrive later or in background settlement.

Game keyDebit eventCredit eventLifecycle
coincoin_betcoin_winInstant
dicedice_betdice_winInstant
kenokeno_betkeno_winInstant
limbolimbo_betlimbo_winInstant
rouletteroulette_betroulette_winInstant
wheelwheel_betwheel_winInstant
plinkoplinko_betplinko_winInstant, aggregated batch
baccaratbaccarat_betbaccarat_winInstant
diamondsdiamonds_betdiamonds_winInstant
dropdrop_betdrop_winInstant
voltvolt_betvolt_winInstant
sparkspark_betspark_winInstant
safesafe_betsafe_winInstant
pulsepulse_betpulse_winInstant
launchlaunch_betlaunch_winInstant
defusedefuse_betdefuse_winInstant
wirewire_betwire_winInstant, aggregated batch
hilohilo_bethilo_winStateful
minesmines_betmines_winStateful
towertower_bettower_winStateful
soccersoccer_betsoccer_winStateful
doorsdoors_betdoors_winStateful
chickenchicken_betchicken_winStateful
blackjackblackjack_betblackjack_winStateful, repeated debits allowed
balloonballoon_betballoon_winStateful
casescases_betcases_winStateful
bridgebridge_betbridge_winStateful
stairsstairs_betstairs_winStateful
russianRouletterussianRoulette_betrussianRoulette_winStateful
videopokervideopoker_betvideopoker_winStateful
crashcrash_betcrash_winRealtime/background
slideslide_betslide_winRealtime/background
doubledouble_betdouble_winRealtime/background
futuresfutures_betfutures_winPosition open/close

The Russian Roulette HTTP route is /game/russian-roulette, but its webhook key is camel-case russianRoulette; event matching is case-sensitive. For Plinko and Wire batches, the debit and credit amounts are totals for the batch rather than one event per item.

Blackjack can send several legitimate blackjack_bet requests with one betId for the initial wager, insurance, splits, or doubles, followed by at most one terminal blackjack_win. The current wire format does not include a distinct operation ID for each additional debit, so this endpoint alone cannot prove replay safety for those individual operations.

Free-bet placement uses the same canonical game events. Its *_bet carries a zero-amount bet action plus the free-bet metadata; a positive payout later uses the matching *_win.

Compatibility-only names

Maktub’s current game services do not emit cashout, resolve, buy/sell, or position aliases. The landing tester retains the following payloads as non-gating advisories for operators that intentionally support older integrations:

  • Credit aliases: hilo_cashout, crash_cashout, mines_cashout, slide_resolve, double_resolve, and futures_close_position.
  • Debit alias: futures_open_position.
  • A legacy Video Poker credit-shaped payload can use event videopoker_bet; classify it from its win action, not from the suffix.

Client-facing game aliases do not create additional webhook keys: Coin Flip emits coin_bet / coin_win, and Phoenix uses the Crash wallet contract, crash_bet / crash_win.

realestate_bet and realestate_win exist in dormant service code, but Real Estate is not registered as a live production game. They remain documented for compatibility, but the landing tester intentionally ignores Real Estate entirely: it creates no row and sends no canonical or legacy Real Estate payload.

Russian Roulette and Cases remain part of the truthful production event catalog above, but the landing tester also intentionally excludes them. The customer-facing approval run therefore covers 32 games and 64 canonical events; no excluded game creates a tester row or outbound request.

Selected instant game examples

These games resolve in a single round, but the debit and the credit still arrive as two separate webhook requests sharing the same betId, sent back-to-back while the bet resolves:

  1. <game>_bet — the wager debit. Sent for every bet.
  2. <game>_win — the payout credit. Sent only when the payout is greater than zero, and only after the debit succeeded. On a losing round no second webhook is sent — the debit stands as the final state.
Debit eventCredit event (payout > 0 only)Game
dice_betdice_winDice
coin_betcoin_winCoin Flip
limbo_betlimbo_winLimbo
keno_betkeno_winKeno
plinko_betplinko_winPlinko (batch bets count as one debit/credit pair)
wheel_betwheel_winWheel
roulette_betroulette_winRoulette
diamonds_betdiamonds_winDiamonds
baccarat_betbaccarat_winBaccarat

Example — player wins (two requests, same betId):

Request 1 — debit:

{ "event": "dice_bet", "userId": "user-123", "customerId": "acme", "betId": "6650a3f1e4b0c912d8a74b21", "transactionId": "1a7d3e52-8c04-4f19-9b6a-2d5e7f0c4b83", "roundClosed": false, "actions": [ { "type": "bet", "amount": 10.00 } ] }

Request 2 — credit:

{ "event": "dice_win", "userId": "user-123", "customerId": "acme", "betId": "6650a3f1e4b0c912d8a74b21", "transactionId": "3c5e9a71-2f68-4d0b-8e14-6a9c7b2d5f01", "roundClosed": true, "actions": [ { "type": "win", "amount": 19.60 } ] }

Example — player loses (single request, no dice_win follows):

{ "event": "dice_bet", "userId": "user-123", "customerId": "acme", "betId": "6650a3f1e4b0c912d8a74b21", "transactionId": "5e1b7c93-4a02-4e57-9d36-8f0a2c6b4d19", "roundClosed": true, "actions": [ { "type": "bet", "amount": 10.00 } ] }

Note: For Plinko batch bets, the bet action amount is amount × ballsQty (the total wagered across all balls in the batch).

Selected stateful and delayed-settlement examples

These games span multiple actions before resolving. Depending on the game, you may receive:

  • a debit event when the round starts
  • a credit event when the round concludes with a payout — a round that ends with no payout sends no credit event

Even when a round starts and terminates within a single player action (e.g. a natural blackjack on the initial deal), the debit and credit still arrive as two separate webhook requests — never bundled into one.

Blackjack

Blackjack uses blackjack_bet for debit-side actions and blackjack_win for the terminal credit. If the hand resolves instantly on the initial deal (e.g. a natural blackjack), the two events are simply sent back-to-back within the same game request — still two separate webhooks with the same betId, never a single bundled event.

EventWhen Fired
blackjack_betWhen the game is created and for additional debit-side actions such as insurance, split, or double
blackjack_winWhen the hand ends with a creditable outcome

Game start (bet placed):

{ "event": "blackjack_bet", "userId": "user-123", "customerId": "acme", "betId": "6650a3f1e4b0c912d8a74b21", "transactionId": "7a3f5d18-6b94-4c02-8e71-3d5a9f0c2b46", "roundClosed": false, "actions": [ { "type": "bet", "amount": 10.00 } ] }

Game end (win):

{ "event": "blackjack_win", "userId": "user-123", "customerId": "acme", "betId": "6650a3f1e4b0c912d8a74b21", "transactionId": "9c7e1a35-8d26-4b70-a4f1-2e6b8d0c5a73", "roundClosed": true, "actions": [ { "type": "win", "amount": 25.00 } ] }

Instant result (e.g. natural blackjack) — two requests, back-to-back:

Request 1 — debit:

{ "event": "blackjack_bet", "userId": "user-123", "customerId": "acme", "betId": "6650a3f1e4b0c912d8a74b21", "transactionId": "b5d9f307-2c48-4a16-9e83-5f7a1c0d2b94", "roundClosed": false, "actions": [ { "type": "bet", "amount": 10.00 } ] }

Request 2 — credit:

{ "event": "blackjack_win", "userId": "user-123", "customerId": "acme", "betId": "6650a3f1e4b0c912d8a74b21", "transactionId": "d7f1a529-4e60-4c38-b025-7a9c3f1e5d80", "roundClosed": true, "actions": [ { "type": "win", "amount": 25.00 } ] }

Mines

Mines uses a split debit/credit flow. The mines_bet event is sent when the game starts (debit), and mines_win is sent when the player cashes out or reveals all safe tiles (credit). Both events share the same betId.

EventWhen Fired
mines_betWhen the game is created (bet placed / debit)
mines_winWhen the player cashes out or reveals all safe tiles (credit)

Game start (debit):

{ "event": "mines_bet", "userId": "user-123", "customerId": "acme", "betId": "6650a3f1e4b0c912d8a74b21", "transactionId": "f9a3c74b-6082-4e51-8d37-9b1e5a7c0f42", "roundClosed": false, "actions": [ { "type": "bet", "amount": 5.00 } ] }

Cashout / win (credit):

{ "event": "mines_win", "userId": "user-123", "customerId": "acme", "betId": "6650a3f1e4b0c912d8a74b21", "transactionId": "0b5d7f19-8a24-4c63-9e05-1d3f7a9c5b28", "roundClosed": true, "actions": [ { "type": "win", "amount": 12.50 } ] }

Note: If the player hits a mine (loss), no mines_win event is sent — the debit stands as the final state. Your handler must not credit the user unless a mines_win event with the matching betId is received.

HiLo

HiLo uses hilo_bet for the debit and hilo_win for the credit. hilo_cashout is compatibility-only and is not emitted by the current service.

EventWhen Fired
hilo_betWhen the game is created (bet placed) and when the player guesses wrong (loss)
hilo_winWhen the player cashes out with a win

Game start / Loss:

{ "event": "hilo_bet", "userId": "user-123", "customerId": "acme", "betId": "6650a3f1e4b0c912d8a74b21", "transactionId": "2d7f9b31-0c46-4e85-a127-3f5b9d1e7a04", "roundClosed": false, "actions": [ { "type": "bet", "amount": 10.00 } ] }

Cashout (win):

{ "event": "hilo_win", "userId": "user-123", "customerId": "acme", "betId": "6650a3f1e4b0c912d8a74b21", "transactionId": "4f9b1d53-2e68-4a07-b349-5d7f1b3c9e26", "roundClosed": true, "actions": [ { "type": "win", "amount": 35.00 } ] }

Chicken

Chicken uses a split debit/credit flow, identical to Mines.

EventWhen Fired
chicken_betWhen the game is created (bet placed / debit)
chicken_winWhen the player cashes out (credit)

Tower

Tower uses a split debit/credit flow, identical to Mines.

EventWhen Fired
tower_betWhen the game is created (bet placed / debit)
tower_winWhen the player cashes out (credit)

Crash

Crash uses a split debit/credit flow. The canonical contract is crash_bet for the debit and crash_win for the credit; crash_cashout is compatibility-only.

EventWhen Fired
crash_betWhen the bet is placed (debit)
crash_winWhen the player cashes out (credit)

Slide

Slide uses a split debit/credit flow. The canonical contract is slide_bet for the debit and slide_win for the credit; slide_resolve is compatibility-only.

EventWhen Fired
slide_betWhen the bet is placed (debit)
slide_winWhen the round resolves with a win (credit)

Double

Double uses a split debit/credit flow. The canonical contract is double_bet for the debit and double_win for the credit; double_resolve is compatibility-only.

EventWhen Fired
double_betWhen the bet is placed (debit)
double_winWhen the round resolves with a win (credit)

Video Poker

Video Poker uses a split debit/credit flow: videopoker_bet debits the initial deal, and videopoker_win credits the draw result when the hand ends with a payout.

EventWhen Fired
videopoker_betWhen the game is created (bet placed / debit)
videopoker_winWhen the hand ends with a payout (credit)

Real Estate (dormant)

Real Estate service code uses realestate_bet for purchase debits and realestate_win for sale/cashout credits, but the game is not registered in production. The events remain documented so the dormant implementation is understandable, but the landing webhook tester ignores both the canonical-looking events and the older buy/sell/cashout aliases.

EventWhen Fired
realestate_betWhen a property is purchased (debit)
realestate_winWhen a property is sold or the player cashes out all holdings (credit)

Futures

Futures uses a split debit/credit flow around position open/close. The canonical contract is futures_bet for the debit and futures_win for the credit; the position-named events are compatibility-only.

EventWhen Fired
futures_betWhen a position is opened (debit)
futures_winWhen a position closes with positive remaining equity, including a partial loss; a total loss emits no credit

Response Format

Mutation and balance responses have intentionally different contracts.

For every *_bet and *_win mutation, exactly HTTP 200 means the wallet mutation committed. That rule is the whole of acceptance: the response body never rejects a mutation, and a 200 { "success": false } response does not reject one either — it commits the mutation and breaks wallet integrity. Return a non-200 status whenever a debit, credit, duplicate, malformed request, authentication check, or database transaction is rejected.

Return the resulting balance on every mutation you accept. This is what a new integration is expected to send:

{ "success": true, "balance": 145.00 }

balance is the player’s balance after this mutation was applied — the same number a balance_check would return if it ran immediately afterwards, in the session’s wire currency: the same currency as the actions amounts, so USD unless the session was created with a currency (a wallet-currency session reports it in the player’s currency, with no conversion on either side). It must be a finite, non-negative number.

Sending it is what lets us stop asking. We otherwise send a balance_check purely to learn the balance and pre-check affordability — an entire extra round-trip to your servers on every single bet, and another after every multi-round cashout. Your debit response already answers both questions: you reject an unaffordable stake with a non-200 (rule 6), and you know the resulting balance. Carrying it is all it takes — there is no flag to request. In production this removed roughly 190 ms from every bet.

Older integrations are unaffected: the balance is not a correctness dependency. If a response omits it, we read the balance the way we always did — the same call, at the same cost — so a 200 with { "success": true }, or an empty 200 body, still commits exactly as before.

Only balance_check has a strictly required JSON body: exact HTTP 200 with { "success": true, "balance": <finite USD number> }.

Implementation Requirements

Your webhook handler is the last line of defense for balance integrity. The game server enforces game logic and prevents cheating at the protocol level, but your handler controls the money. A vulnerable webhook can give users infinite balance even though the game server is secure.

Every requirement below maps to a real exploit. The Webhook Tester  validates all of them.

1. Secret Validation

For new integrations, verify x-maktub-signature over the exact raw request bytes before parsing or mutating state. Maktub also sends x-webhook-secret for existing integrations; if you are still migrating on that header, compare it in constant time and reject a missing or incorrect value with a non-200 status.

Without this, anyone who discovers your webhook URL can fabricate credit events and steal funds.

if (!verifyWebhook(req.rawBody, req.headers["x-maktub-signature"], process.env.WEBHOOK_SECRET)) { return res.status(401).json({ error: "Unauthorized" }); }

2. Require betId on Every Non-balance_check Event

Every non-balance_check webhook must include a betId. If betId is missing or empty, reject the request with a non-200 status.

Exploit: An attacker sends a debit without betId, then sends multiple wins with fabricated betIds. Since no debit is recorded under those IDs, a naive handler that doesn’t require betId on debits will accept the orphan credits.

if (!betId) { return res.status(400).json({ error: "betId is required" }); }

Also reject malformed action payloads with a non-200 status:

  • empty actions on money events (*_bet / *_win) — note that balance_check and the terminal *_close legitimately carry an empty actions array and must be accepted
  • unsupported action types
  • missing amount on bet or win
  • non-numeric amount
  • negative amount

3. Idempotency and Replay Handling

Scope every recorded operation by customer, user, betId, and action type, and store its canonical game key. For ordinary games, reject a second debit or credit in that scope, including an attempt to reuse the betId under another game.

Exploits this prevents:

  • Double debit: network retry causes the same bet to be debited twice
  • Double credit: same betId credited multiple times (e.g. 3 concurrent cashout attempts)
  • Full cycle replay: attacker replays an entire debit+credit sequence to double their winnings

Debits and credits arrive as separate requests sharing the same betId, so record them separately. Every money event carries a transactionId that identifies that one movement, so key idempotency on transactionId and group by betId: a repeated transactionId is a replay and must be rejected, while a new one is a distinct movement. This is what makes Blackjack work — it legitimately sends several debits under one betId, each with its own transactionId, even when the amounts are identical. Keep the cumulative debit amount and still accept at most one terminal credit.

-- Transactions table CREATE TABLE transactions ( id SERIAL PRIMARY KEY, customer_id TEXT NOT NULL, game_key TEXT NOT NULL, bet_id TEXT NOT NULL, action_type TEXT NOT NULL, -- 'bet' or 'win' amount NUMERIC(30, 10) NOT NULL, -- USD, no binary floating-point user_id TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), transaction_id TEXT, -- unique per money movement; the idempotency key UNIQUE (customer_id, user_id, transaction_id) ); -- One credit per round, for every game. CREATE UNIQUE INDEX idx_unique_credit ON transactions (customer_id, user_id, bet_id) WHERE action_type = 'win'; -- One debit per round for ordinary games; Blackjack is exempt because a single -- round legitimately debits several times (main bet, insurance, split, double). CREATE UNIQUE INDEX idx_unique_ordinary_debit ON transactions (customer_id, user_id, bet_id) WHERE action_type = 'bet' AND game_key <> 'blackjack';

4. Credits Require a Prior Debit — The Orphan Credit Defense

Before processing any win action, verify that a bet with the same betId was already successfully debited. If no matching debit exists, reject the credit with a non-200 status.

This is the single most critical rule. Without it, an attacker can fabricate win events with random betIds and credit unlimited funds.

Exploits this prevents:

  • Orphan credit: win sent for a betId that was never debited
  • Failed debit + win: debit rejected (insufficient funds), then win sent with the same betId — must not credit
  • 1 bet, 3 wins: single debit, followed by 3 credit attempts with different betIds
  • Cross-game credit swap: debit as mines_bet, credit as dice_win with the same betId
// Before crediting: const debitExists = await db.query( `SELECT 1 FROM transactions WHERE customer_id = $1 AND user_id = $2 AND game_key = $3 AND bet_id = $4 AND action_type = 'bet'`, [customerId, userId, gameKey, betId] ); if (!debitExists.rows.length) { return res.status(400).json({ error: "No matching debit" }); } const alreadyCredited = await db.query( `SELECT 1 FROM transactions WHERE customer_id = $1 AND user_id = $2 AND game_key = $3 AND bet_id = $4 AND action_type = 'win'`, [customerId, userId, gameKey, betId] ); if (alreadyCredited.rows.length) { return res.status(409).json({ error: "Already credited" }); }

5. Atomic Balance Operations

Balance updates must be atomic. If two webhook requests arrive simultaneously, both must be fully applied without one overwriting the other.

Exploit: 5 concurrent $2 loss bets arrive. A read-modify-write handler reads balance $100 five times, subtracts $2, and writes $98 five times. Result: only $2 debited instead of $10. The user effectively got 4 free bets.

Bad (race condition):

const balance = await db.getBalance(userId); // All 5 read $100 await db.setBalance(userId, balance - amount); // All 5 write $98

Good (atomic SQL):

UPDATE wallets SET balance = balance - $1 WHERE user_id = $2 AND balance >= $1;

The AND balance >= $1 clause also handles insufficient balance atomically — if the balance dropped between reads, the update affects 0 rows and you reject the bet.

Good (Prisma with a fixed-precision USD column):

const result = await prisma.$executeRaw` UPDATE wallets SET balance_usd = balance_usd - ${amountUsd} WHERE user_id = ${userId} AND balance_usd >= ${amountUsd} `; if (result === 0) throw new Error("Insufficient balance");

6. Insufficient Balance Rejection

When a bet action would bring the user’s balance below zero, reject with a non-200 HTTP status. Your handler is the authoritative source. The game server may pre-check the balance before sending the webhook, but that check is a courtesy, not a guarantee: it does not run at all when your mutation responses carry the balance, and even when it does run the wallet can move between the check and the debit. Your rejection is the only thing standing between a player and a stake they cannot afford.

Exploit: Under concurrent load, two bets pass the server’s balance check simultaneously (both see $10), but only one should succeed if the user only has $10. Your atomic update (rule 5) handles this naturally.

Do NOT return 200 + { success: false } for rejected bets. Return a non-200 status (e.g. 402, 400, 409). The game server treats any HTTP 200 as success regardless of the success field in the body.

7. Split Debit/Credit Games

Every game sends debit and credit as separate webhook calls with the same betId — instant games back-to-back within a single game request, and multi-round games like Mines, HiLo, Chicken, Tower, Crash, Slide, Double, and Video Poker potentially minutes apart. Your handler must:

  1. Accept the debit first — record it in your transactions table
  2. Only accept the credit if the matching debit exists — see rule 4
  3. Handle missing credits — if the player loses (e.g. hits a mine), no credit event is sent. The debit stands as final. Do not auto-refund.

Exploit — Parallel cashouts: A player with an active Mines game sends 3 concurrent cashout requests. The game server’s atomic findAndDeactivateGame ensures only 1 succeeds at the game level, but your webhook receives 1 debit + potentially multiple credit attempts for the same betId. Only the first credit must be processed (rule 3).

8. Blackjack Multi-Debit

Blackjack is unique: a single round (betId) can have multiple legitimate debits — the initial bet, plus insurance, double down, or split. Each arrives as a separate blackjack_bet event with the same betId but a different amount.

Each of those debits carries its own unique transactionId, even when the betId and the amount are identical (e.g. a split matching the main bet). Key idempotency on transactionId, not on betId + amount — otherwise a genuine additional wager is rejected as a duplicate and the player is never charged for it.

Your handler must:

  • Key idempotency on transactionId (unique per money movement), grouping by betId
  • Accept multiple bet actions for the same betId (cumulative debit)
  • Accept exactly one win action (terminal credit)
  • Track cumulative debit amount per betId if needed for reconciliation
-- Allow multiple debits per betId, but only one credit INSERT INTO transactions (bet_id, action_type, amount, user_id) VALUES ($1, 'bet', $2, $3); -- For credits, use the UNIQUE constraint to prevent duplicates

9. Canonical Events and Compatibility Advisories

The emitted production contract uses the 68 canonical *_bet / *_win events in the event catalog. The landing tester intentionally excludes Real Estate, Russian Roulette, and Cases, so its approval set contains 32 games and 64 canonical events. The remaining older names stay tester advisories rather than production requirements:

  • Credit aliases: hilo_cashout, crash_cashout, mines_cashout, slide_resolve, double_resolve, futures_close_position
  • Debit alias: futures_open_position

If you intentionally support these older names, classify the mutation from actions: credit aliases carry { type: "win", amount: ... } and debit aliases carry { type: "bet", amount: ... }. An advisory alias failure does not fail the current production contract.

10. Return the Post-Action Balance

Answer every mutation you accept with the balance that resulted from it:

{ "success": true, "balance": 145.00 }

Finite, non-negative, in the session’s wire currency (the currency of the actions amounts — USD unless the session was created with a currency), and equal to what a balance_check would report immediately afterwards. We read it to skip the balance_check that otherwise precedes every debit — see Response Format.

It never decides whether the mutation committed: that is the HTTP status alone, so an omitted or malformed balance costs a round-trip, not correctness. Which also means the reverse is not available to you — do not use { "success": false } inside a 200 response to reject an action. Only a non-200 status rejects a mutation.

11. Handle All Event Types

Your handler must accept all game events listed in the Events section. For events not in the catalog, either of two policies is compliant — pick one and apply it consistently:

  • Forward-compatible — accept unknown events (HTTP 200) and process their actions with the same idempotency and balance rules as any known event. New Maktub games then work without a deploy on your side.
  • Catalog-validating — reject events for games you have not configured, with a non-200 status and no balance mutation. This is the right policy for operators who require per-game configuration (reporting, bonus eligibility, session-scoped launches). It means a newly released Maktub game must be configured on your side before you launch it.

Under either policy:

  • do not hardcode business logic solely from the event suffix
  • reject malformed unknown events the same way you reject malformed known events
  • a rejection is always a non-200 status that moves no money — never 200 + success: false
  • if you accept an unknown event, HTTP 200 commits the mutation like any other event: process its valid bet / win actions fully, including the matching credit for a debit you accepted

12. Zero-Amount Win Advisory

The server does not emit a zero-amount win: when a round has no payout, no win webhook is sent and the debit stands. The tester retains amount: 0 as a non-gating forward-compatibility advisory; accepting it is useful but is not part of production approval.

13. High Multiplier Wins

A $1 bet can legitimately win $1,000+ (e.g. mines with 24 mines, limbo with high target). Do not reject credits simply because the win amount is much larger than the bet amount.

14. Decimal Precision

Webhook amounts can have up to 10 decimal places. Use a fixed-precision decimal column/library or integers scaled by 10^10; integer cents lose valid precision and binary floating-point drifts:

10 × $0.001 = $0.010000000000000002 // wrong in binary floating point

Balance comparisons and idempotency records must preserve the exact decimal wire amount.

15. Latency

Low latency matters because balance reads and wallet mutations sit on the gameplay path. The tester reports browser-inclusive timing as an advisory rather than an approval gate, because client and network time are outside the operator endpoint’s processing time. Use your own server telemetry for enforceable latency objectives.


16. Round Closure Signal

Every *_bet and *_win carries an optional boolean roundClosed, so you can close a round on the single event you are already processing — no waiting to see whether a credit follows, and no extra lookup on the hot path.

EventroundClosedMeaning
Instant *_bet, losing roundtrueThe round is final on this event. No *_win will follow.
Instant *_bet, winning roundfalseA *_win with the same betId follows immediately.
Multi-round *_betfalseThe round is open — the outcome does not exist yet.
Any *_wintrueTerminal credit. A round has at most one credit.
*_closetrueOpt-in. Terminal close for a round that ended without a credit (a multi-round loss). Carries no actions — it moves no money.

A free-bet placement *_bet follows the same rule as the paid bet of the same game: terminal on an instant loss, false while a multi-round round is open. Free-bet rounds are not exempt from anything below.

The one-terminal-event guarantee

Every betId receives exactly one terminal event — an event carrying roundClosed: true — after which the round is final. Before it, the round is open and may still accept further debits (as in blackjack). Concretely:

  • instant loss → the *_bet itself is terminal;
  • any win or cashout → the *_win is terminal;
  • multi-round loss → a *_close is terminal.

This holds for free-bet rounds too, which reach it through the same three events — their placement *_bet closes an instant loss, and a losing multi-round free bet is closed by a *_close like any other.

This means a strictly event-driven wallet never has to infer a close, run a timeout sweep, or guess. A round always ends because we told you it ended.

*_close is a new event name, which is why it is opt-in: it is never sent to an operator who has not asked for it, so no policy under Handle All Event Types — forward-compatible or catalog-validating — is affected until you enable it. Treat it as “mark this round final”, never as money movement.

Instant rounds are fully resolved server-side before the debit is sent, which is why the outcome can be published on the *_bet itself. Multi-round games (mines, blackjack, tower, hilo, chicken, crash, slide, double, video poker) cannot: at *_bet time the player has not acted yet.

roundClosed: true is a guarantee that the round is final. roundClosed: false is NOT a promise that a win is coming — on a multi-round game it only means “not final yet”, and that round may still end in a loss with no further event. Treat false as “keep the round open”, never as “expect a credit”.

The field is optional and additive: integrations that ignore it are unaffected, and it is not required by the webhook tester.

*_close carries an empty actions array, and is therefore exempt — alongside balance_check — from the “reject empty actions” rule in Implementation Requirements. It moves no money by design. Accept it and mark the round final; do not reject it as a malformed money event.

With @maktubbet/webhook, handle it with onRoundClose (requires 0.2.0 or later — earlier versions reject *_close with 400 Empty actions):

onRoundClose: async ({ userId, customerId, betId, event }) => { // No money moves — just mark the round final in your ledger. await db.markRoundClosed({ userId, customerId, betId, event }) },

The handler answers 200 to a close whether or not you supply onRoundClose, so upgrading alone stops the rejections — but without the callback the signal is discarded. Close events never reach onEvent, whose contract is action-carrying money movements.

Opt-in features

The behaviours below are disabled by default and enabled per operator. If you never ask for them, your integration keeps receiving exactly the contract it was built against — nothing new appears on the wire.

FeatureWhat it changesWhy it is opt-in
Round-close eventsAdds the terminal *_close on a multi-round lossIt is a new event name. Handlers written against the earlier spec reject the empty actions array it carries, so it is never sent to an operator who has not agreed to it. On @maktubbet/webhook, upgrade to 0.2.0 first.
Player-currency amountsAdds playerAmount / playerCurrency / rate to bet and winOnly useful if you hold balances in the player’s currency, and it costs an extra lookup on the webhook path.
Reconciliation rateAdds usdRate to bet and winOnly useful if you settle in a currency other than USD and need to tie your ledger to our invoicing.

transactionId and roundClosed are not opt-in: they are plain additional fields, and transactionId fixes a real defect for every operator (see Blackjack Multi-Debit).

Reading the balance from your mutation responses is not opt-in either — it is the default for everyone, because it can only ever remove calls we would otherwise make to you, never add any. It does mean your non-200 is the only thing refusing a stake the player cannot afford (rule 6), which the contract already required and the tester already checks. If you need us to keep sending the pre-debit balance_check anyway, ask and we will set balanceInMutationResponse: false on your account.

To enable any of these, contact us and we’ll turn it on for your customer account.

PostgreSQL Storage Pattern

The database transaction is the safety boundary. Use fixed-precision USD columns, scope event identity to the operator and player, and make the credit uniqueness rule a database constraint rather than an application-only check:

-- Schema CREATE TABLE wallets ( customer_id TEXT NOT NULL, user_id TEXT NOT NULL, balance_usd NUMERIC(30, 10) NOT NULL DEFAULT 0, PRIMARY KEY (customer_id, user_id) ); CREATE TABLE transactions ( id SERIAL PRIMARY KEY, customer_id TEXT NOT NULL, user_id TEXT NOT NULL, game_key TEXT NOT NULL, bet_id TEXT NOT NULL, action_type TEXT NOT NULL CHECK (action_type IN ('bet', 'win')), amount_usd NUMERIC(30, 10) NOT NULL CHECK (amount_usd >= 0), event TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE UNIQUE INDEX idx_unique_credit ON transactions (customer_id, user_id, bet_id) WHERE action_type = 'win'; CREATE UNIQUE INDEX idx_unique_ordinary_debit ON transactions (customer_id, user_id, bet_id) WHERE action_type = 'bet' AND game_key <> 'blackjack';

Within one transaction, validate the scoped event identity, insert the idempotency record, and update balance_usd with an atomic insufficient-balance predicate. Commit before returning HTTP 200; roll back and return a non-200 status for every rejected or ambiguous mutation. balance_check remains a separate read that returns the committed balance_usd as a JSON number.

Blackjack needs no operator-specific sequence policy: the transactionId on every event is the per-movement identifier, so a unique constraint on it rejects replays while the partial debit index above still allows Blackjack’s legitimate repeated debits under one round.

Exploit Checklist

#ExploitWhat happens if unprotectedRule
1Orphan credit — win with random betId, no prior debitUser gains money from thin air4
2Double credit — same betId credited twiceUser gets paid twice for one win3
3Failed debit + win — debit rejected (insufficient funds), then win arrivesUser gets credited without paying4
41 bet, 3 wins — one debit, three credit attempts with different betIdsOnly 1 credit should apply; 2 extras are orphan credits2, 4
5Read-modify-write race — 5 concurrent debits, only 1 applied4 bets are free5
6Missing betId on debit — debit without betId, then wins with fabricated IDsDebits untrackable, credits unblockable2
7Cross-game swap — debit as mines_bet, credit as dice_win with same betIdReject a credit whose game scope does not match the recorded debit4
8Parallel cashouts — 3 concurrent mines_win for the same betId2 extra credits3
9Full cycle replay — replay entire debit+credit sequenceUser doubles their winnings3
10Concurrent oversubscription — 10 concurrent $1 bets with balance $5Only 5 should succeed; rest go negative5, 6
11Balance inflation — rapid debits lost to race condition, then sequential wins all applyMore credits than debits5
12HTTP 200 + success: false — debit “rejected” with HTTP 200Game server treats it as success, game proceeds, win gets credited6
13Forged webhook — attacker calls your webhook URL directlyUnlimited credits1

Webhook Tester

Before going to production, run the Webhook Tester  against a disposable integration wallet. A funded wallet runs the complete stateful suite; a wallet at exactly zero runs a limited, non-mutating diagnostic instead.

Your integration will not be approved for production until all required tests pass. Advisories are reported separately, and the browser suite does not independently certify HMAC enforcement.

How to Use

  1. Open the Webhook Tester .
  2. Configure the exact canonical HTTPS webhook URL. It must not redirect. The tester sends requests through Maktub’s private relay, so the operator endpoint does not need browser CORS support.
  3. Fill in your Webhook Secret, User ID, and Customer ID. If your wallet expects a sessionToken on incoming webhooks, also set the optional Session Token — use any value your endpoint accepts for that user. The tester then sends that value — and only that value — exactly as production replays your createSession token. A few scenarios deliberately send no sessionToken at all, because the field is optional and settlement genuinely arrives without it; those must still be accepted. Leave the field blank to exercise a session created without a token throughout.
  4. Choose the appropriate disposable-wallet balance:
    • Exactly $0.00: the tester sends no wallet mutations. It runs 7 required diagnostics and 6 advisories, skips the 192 funded scenarios, and cannot approve the integration.
    • $0.10 through $1.00: the tester runs the complete stateful suite and can produce a full approval result when every required scenario passes.
    • Any other balance: the tester refuses the run rather than manufacturing, draining, or risking an arbitrary balance.
  5. Click Run Tests and wait for final reconciliation. Ordinary probes use $0.001$0.01; bounded high-ratio and recovery actions may be larger, but every action and observed balance deviation is capped at $1.01. The runner must restore the exact opening balance after every money scenario.

The tester keeps the secret and the session token only in page memory and redacts both from logs. It signs the exact raw body with x-maktub-signature, then sends the URL, unchanged body, signature, and compatibility x-webhook-secret through Maktub’s private relay. URL and non-secret identifiers may remain in browser storage for convenience.

Credential rotation: the relay is an explicit trusted boundary and receives the webhook secret. If that boundary or credential may have been exposed, rotate the secret at both ends before reusing it; coordinate the cutover because Maktub’s game server caches webhook configuration for five minutes.

Stopping prevents new browser requests, but a request that already reached the operator may still commit. The funded suite reconciles after cancellation, and a cancelled, ambiguous, unreconciled, or partially skipped funded run never reports full-suite success. The wallet’s transaction history remains populated even when its balance is restored. The zero-balance diagnostic does not create transaction history because it sends only non-mutating balance_check requests.

HMAC limitation: every normal tester request carries a valid x-maktub-signature over the exact raw body, but it also carries the compatibility x-webhook-secret. The suite therefore does not independently prove that your endpoint enforces HMAC rather than accepting only the legacy header. Verify enforcement separately with a raw-body-aware integration test.

The endpoint-only tool also shows four explicit Not certifiable advisories: Blackjack operation-level replay (there is no per-operation action ID), free-bet delivery ordering, realtime restart/disconnect settlement, and ambiguity after an operator has committed a credit but a later transport or balance verification fails. These rows are limitations, not passed security properties.

Test Categories and Result Semantics

The funded catalog contains 205 visible scenarios: 181 required checks and 24 advisories. A 216-row migration manifest preserves every non-Real-Estate legacy assertion through an equivalent test, a stronger replacement, a visible advisory, or a documented retirement. The four historical Real Estate rows, including its old sell/cashout aliases, are absent from the manifest and the runner; Russian Roulette and Cases are also excluded before rows or payloads are generated. Approval requires every required scenario to pass, no required scenario to be skipped or cancelled, and the final balance to match the opening balance exactly. Advisory failures are reported separately.

At exactly $0.00, the tester runs only strict and concurrent balance reads, the 32-game/64-event catalog check, legacy-secret authentication rejection, unknown-identity rejection, balance latency, and the five non-mutating limitation advisories. That is 7 required diagnostics and 6 advisories; the other 192 rows remain visible as skipped because they need an accepted positive debit or a causally valid cleanup reserve. Passing this mode confirms limited endpoint diagnostics only and never produces “All required tests passed.”

Every terminal run generates a sanitized final report with the verdict, target origin, required/advisory counts, wallet reconciliation, certification limits, and all scenario outcomes. The report contains no secret, player/customer identifier, correlation token, request body, or row log, and it remains available for copying after success, failure, refusal, cancellation, or recovery_required.

Balance Check

Sends a balance_check event and validates:

  • HTTP 200 response
  • Response is valid JSON
  • success: true is present
  • balance is a finite number, including under five concurrent reads

Browser-Inclusive Latency (advisory)

Reports balance and mutation round-trip timing without using it as an approval gate, because the measurement includes browser and network latency.

Canonical Game Events

Exercises the 64 canonical events in the tester’s 32-game approval set, with separate production-shaped debit and credit requests. Real Estate, Russian Roulette, and Cases are excluded before rows or payloads are generated. The remaining scenarios cover instant, stateful, Blackjack, realtime/background, Futures, aggregate/batch, loss, delayed win, partial return, push, and high-ratio payout flows. Free-bet placement and payout shapes are exercised by the offline server characterization gate; the live endpoint-only tool leaves their delivery ordering as a visible advisory because synthesizing a credit without a consumed grant would be unsafe.

Lifecycle and Causality

Verifies debit-before-credit ordering, matching game/user/customer/betId scope, rejected-attempt ID poisoning, losses without synthetic win events, delayed settlement, repeated Blackjack debits with one terminal credit, and exact balance reconciliation.

Replay and Orphan Defenses

Exercises sequential and concurrent duplicates, conflicting debit and credit values, full-cycle and old-cycle replay, same-ID and distinct-ID orphan bursts, failed-auth/debit/identity poisoning, metadata variation, parallel terminal storms, cross-game and cross-identity reuse, mixed debit/win races, and replay under exact-capacity oversubscription. Every rejected mutation must use a non-200 response and leave the authoritative balance unchanged.

Compatibility Payloads (advisory)

Retains selected cashout/resolve/position aliases, a legacy Video Poker credit shape, bundled multi-action payloads, zero-amount wins, and mutation response-body diagnostics as visible non-gating checks. Real Estate aliases are excluded with the rest of that game. Current game services do not emit these shapes.

Malformed, null, and top-level-array raw bodies are also visible advisories. They remain non-gating because the browser observes only the endpoint’s final response and cannot prove where upstream parsing rejected the bytes.

Concurrency and Timing

Runs fully simultaneous, 20 ms, 25 ms, and 200 ms schedules; distinct-debit bursts; same-betId bursts; conflicting-value races; parallel wins; exact-capacity oversubscription with duplicate payloads; ten simultaneous debit/win pairs; and interleaved games. Subsequent balance_check calls, rather than mutation response bodies, prove the committed result.

Insufficient Balance

Creates a bounded insufficient-funds condition and requires a non-200 response with no balance change. A 200 response fails even when its JSON says success: false.

Duplicate betId

Sends exact and conflicting replays across instant and stateful flows, then verifies that only the first accepted operation changed the balance.

Authentication and Identity

Validates that your endpoint rejects requests with:

  • Wrong x-webhook-secret header
  • Missing x-webhook-secret header
  • Invalid userId
  • Invalid customerId

These browser tests cover legacy-secret authentication only; HMAC enforcement remains independently unverified.

Malformed Payloads

Validates that your endpoint rejects:

  • Missing required fields (userId, customerId)
  • Invalid action types (not bet or win)
  • Missing or non-numeric amounts on bet / win
  • Negative bet amounts

Cross-Game betId

Sends one betId across different game scopes and requires the conflicting mutation to be rejected without changing balance.

Balance Consistency

Validates that your handler correctly updates balances across various scenarios:

  • Sequential debit/credit pairs
  • Partial returns, pushes, delayed wins, and debit-only losses
  • Two consecutive loss bets
  • Rapid-fire sequential bets (20ms and 200ms apart)
  • Exact ten-decimal results from authoritative balance reads

Atomicity Under Concurrency

Sends concurrent requests to verify your handler uses atomic balance operations:

  • 5 concurrent loss bets — all must be applied
  • 3 concurrent bet+win — no duplicate balances
  • 10 concurrent burst with same betId — only 1 processed

Exploit Simulations

Reproduces real-world attack patterns:

  • Orphan credit: win without prior debit — must be rejected
  • Double credit: same betId credited twice — second must be rejected
  • Failed debit + win: debit rejected (insufficient funds), then win sent — must not credit
  • Debit/credit race: debit and credit fired simultaneously — balance must not exceed fair outcome
  • Cross-game swap: debit as mines_bet, credit as dice_win with the same betId
  • Phantom cashout: cashout for unknown betId — must not credit
  • 1 bet, 3 wins: single debit followed by 3 credits with different betIds
  • Rapid bet→cashout cycles: rapid create→cashout→create→cashout

High Payout Ratio

Uses bounded amounts to verify that a legitimate payout ratio is not rejected merely because the credit is much larger than its matching debit.

Decimal Precision

Validates amounts through ten decimal places with bounded $0.001$0.01 movements and exact opening-balance reconciliation.

Zero Win Amount (advisory)

Sends a bounded debit followed by a zero-amount win as a forward-compatibility diagnostic. Production represents a loss by omitting the win event, so this row is non-gating.

Mutation Response Balance

Required since 2026-08-21: accepts a debit and a credit and checks that each 200 carried a finite, non-negative balance equal to the wallet after that action, on both sides of the round — a pre-action balance, or a correct debit followed by a stale credit balance, fails. This is the Response Format balance the platform reads instead of sending a balance_check before every debit. The success field remains informational, and balance_check responses still independently verify that the money movement itself committed.

Interleaved Games

Sends events for different games interleaved for the same user (mines bet → dice bet+win → mines win) and validates that betId isolation is maintained.

Cashout Event Handling (advisory)

Reports whether compatibility credit aliases are treated like wins without making those never-emitted names an approval requirement.

Edge Cases

Validates that your endpoint rejects:

  • Empty actions array on a bet event (balance_check and *_close are exempt — both legitimately carry no actions)
  • A bounded bet that exceeds the disposable wallet’s available balance
Last updated on