Skip to Content
API ReferenceWallet webhook

Wallet webhook

Expose a main HTTPS endpoint and a separate retry endpoint. Maktub sends JSON POST requests; your backend applies each wallet operation once and returns the balance.

The endpoint must be public, have a valid certificate and answer directly without redirects. These are server-to-server requests; browser CORS is not required. All amounts are in the session’s currency — the currency you sent when creating the session — with up to 10 decimal places.

The four events

eventactionsYour wallet does
balance_check[]Read the available balance.
<game>_betOne bet actionDebit the amount.
<game>_winOne win actionCredit the full amount.
<game>_close[]Record the round ending. Move no money.

Use the prefixes in the game catalog. An action’s amount is already the amount to apply. Do not add the stake, subtract it from a prize, multiply by a batch size or calculate a game outcome yourself.

Request fields

{ "event": "dice_bet", "customerId": "your-client-id", "userId": "test-player", "betId": "6650a3f1e4b0c912d8a74b21", "transactionId": "9f2b1c04-6d51-4a83-b0e2-7c1f8a3d5e60", "roundClosed": false, "currency": "ZAR", "usdRate": 0.0553, "actions": [{ "type": "bet", "amount": 10 }] }
FieldMeaning
eventThe operation, using a case-sensitive game key where applicable.
customerIdYour clientId from onboarding.
userIdThe player’s ID in your system.
betIdGroups one game round or Futures position. Absent on balance_check.
transactionIdIdentifies this operation. Present on paid bets, credits and closes.
roundClosedBoolean sent on bets, credits and closes, including free-bet placements. true marks a terminal event; false means not final yet. Absent on balance_check.
currencyThe session’s currency, on every request. actions[].amount and balance are in it.
usdRateOn bets (including free-bet placements) and credits of a non-USD session: the USD value of one unit of the currency, looked up for that operation. Reporting only; it may differ between the bet and credit or be absent if unavailable. Absent for USD sessions and on balance_check and _close.
actionsExactly one matching bet or win action, or an empty array for a read or close.
sessionTokenYour optional session reference, when supplied.

IDs are opaque strings; do not require UUID format. Amounts are finite JSON numbers, not strings. Bets are non-negative; credits are positive. Free-bet placement is the zero-value exception described below.

Accept additional fields you do not use. Book actions[].amount as it comes, in the session’s currency; usdRate never instructs a conversion. The retry body retains the original operation’s metadata, including its rate when present.

Verify the request

Every configured webhook credential is sent in two headers:

HeaderValue
x-maktub-signaturet=<Unix seconds>,v1=<HMAC-SHA256 hex>
x-webhook-secretYour webhook secret.

Use the signature to authenticate the exact body before accessing the wallet. Capture the raw bytes before your framework parses JSON. Keep the server clock synchronized and compare the signature in constant time.

import { createHmac, timingSafeEqual } from "node:crypto"; function verifyWebhook(rawBody, signature, secret) { if (!Buffer.isBuffer(rawBody) || typeof signature !== "string" || !secret) return false; const match = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(signature); if (!match) return false; const timestamp = Number(match[1]); if (!Number.isSafeInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) return false; const expected = createHmac("sha256", secret) .update(match[1] + ".") .update(rawBody) .digest(); return timingSafeEqual(expected, Buffer.from(match[2], "hex")); }

Existing integrations may instead verify x-webhook-secret with a constant-time comparison. A session reference does not replace webhook authentication.

A request to your retry endpoint carries the same two credential headers, signed with a fresh timestamp, plus x-maktub-retry-attempt and x-maktub-retry-of. Verify it exactly like a first delivery.

After authentication, validate the JSON, check that customerId matches your account, and find the player within it. Reject invalid credentials, identities or payloads with 4xx, without changing the wallet.

Apply each operation once

For a paid bet or credit, use one database transaction:

  1. Check a unique key (customerId, userId, transactionId) shared by both endpoints.
  2. If already applied, do not move money. Return 200 with the current balance.
  3. For a new debit, check funds and subtract atomically. For a new credit, add the full amount.
  4. Save the transaction record and balance change together. Commit before returning 200 with the resulting balance.

The unique key and balance update must remain atomic under simultaneous requests. A separate lookup followed by an unprotected update is not enough. Insufficient funds must leave both the balance and the list of applied movements unchanged.

Use exact decimal storage, such as NUMERIC(30,10), or an integer scaled by 10^10. Never round to cents.

Accept a valid new credit even after logout, without a prior debit notification, or after closing the round in your own records. A payout can be smaller than its stake. Duplicate handling, rather than round state, prevents double payment.

Return the result

For a successful read, debit or credit, return exactly HTTP 200:

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

balance is the available balance in the session’s currency: a finite, non-negative JSON number. Use the committed result for a new movement and the current balance for a replay. Do not return a cached or pre-action balance.

SituationHTTP response
Operation applied, or already applied200 with balance.
Missing or invalid credentials401, no movement.
Insufficient funds402, no movement.
Invalid payload, account or player4xx, no movement.
Internal failure preventing confirmation5xx; record and investigate it. A credit, a close or the debit of an already settled round that gets a 5xx is resent to your retry endpoint, as described below.

Only HTTP 200 accepts a debit, credit or close. 201, 202 and 204 do not. The mutation’s success field does not decide acceptance: even 200 { "success": false } tells Maktub the movement was accepted. To refuse a mutation, return a non-200 status as shown above. This rule applies to the initial request, deferred settlement and retries.

Return the post-action balance with each debit and credit. If Maktub cannot read that balance or a subsequent balance query fails, an already confirmed HTTP 200 credit remains accepted; the failure does not request another payment.

A close returns 200 and may omit balance. It always moves nothing, including when repeated. For an unknown game event, either refuse with 4xx or process its actions under these same rules; never acknowledge an unapplied movement.

Balance queries

{ "event": "balance_check", "customerId": "your-client-id", "userId": "test-player", "currency": "ZAR", "actions": [] }

Return HTTP 200 with the JSON boolean success: true and the current balance. Unlike mutations, a balance query requires that body: false, a missing field, strings such as "true", and numbers such as 1 are not valid success values. Zero is a valid balance. An unknown player receives 4xx; a balance query does not create a player.

This query runs during session creation and at other points during play. It need not precede a debit. Your atomic funds check decides whether the wallet can cover the stake.

Rounds and game differences

A round can contain several requests. betId groups them; transactionId identifies each operation. roundClosed describes completion and does not authorize or reverse a payment. If a request omits it, accept the otherwise valid request and record the flag as not provided; absence does not mean false and must not reopen a closed round. When provided, its value is a JSON boolean. Credits and closes are terminal events; never require an open round to accept a valid credit.

FlowWhat arrives
Instant positive returnA bet, then a credit with roundClosed: true.
Instant zero-payout lossA bet with roundClosed: true; no following credit.
Multi-step gameA bet at the start, then a credit or close when the round ends.
BlackjackAdditional stakes share a betId but have new transactionIds. Apply each new debit.
Plinko / WireOne aggregate debit per batch, followed by an aggregate credit if the total return is positive, or a close if it is zero. Apply the received totals once.
Crash / Slide / DoubleAsynchronous settlement. New rounds can start before an earlier settlement is confirmed.
FuturesMultiple positions can be open. A positive remaining value produces a credit; zero return produces no credit or close.

A roundClosed: false bet does not promise a prize or a later notification. Abandoned games, some zero-return cash-outs and failed close delivery can leave no terminal event. Do not refund a stake because a close is missing, or block new rounds while waiting for it. Continue accepting valid later credits.

Free-bet events

A promotional placement is a <game>_bet with amount: 0, freeBet: true and no transactionId. It moves no money. It carries the same currency and non-USD usdRate reporting metadata as a paid bet; a zero amount does not remove the currency or rate. Maktub always sends freeBetId on a promotional placement. It is a non-empty string identifying the consumed grant, not an optional or nullable part of the placement’s identity. The promotional fields are:

FieldMeaning
freeBetIdAlways present on the placement: the non-empty ID of the promotional grant used.
freeBetsRemainingThe grant’s remaining count after this placement.

To record a placement once, deduplicate by (customerId, userId, event, betId, freeBetId). Count the promotion on placement, not again on the prize. Use the freeBetId received; no generated ID, empty string or null fallback is needed in this key.

The placement notification does not gate the round and may arrive late or be missing. Its prize is a normal _win with transactionId: credit the full amount, even without the placement. Free Bets describes how to issue and cancel grants; those API calls are optional.

Retry endpoint

Register a second public HTTPS URL with Maktub as your retry endpoint during onboarding. It follows the rules of the main endpoint (valid certificate, no redirects, server-to-server) and must be a different URL from it.

When a credit, a close, or the debit of a round Maktub had already settled for the player ends in a 5xx, a timeout or a transport failure on your main endpoint, Maktub resends that operation to the retry endpoint instead of the main one. A bet whose failure the player already saw is never resent.

The resend carries the same body, byte for byte, with the same transactionId, signed with a fresh timestamp, plus two headers:

HeaderValue
x-maktub-retry-attempt1 for the first resend, then 2, 3, …
x-maktub-retry-ofThe ISO 8601 instant of the original delivery attempt.

Use the same handler and atomic transaction records on both endpoints. The endpoint URL and retry headers are not part of the idempotency key. Simultaneous copies on either URL must also apply only once. Maktub sends both retry headers on every resend, but they are diagnostic metadata for the operator, not credentials or payment conditions. Authenticate the body using the signature exactly as on the main endpoint. Do not reject an otherwise valid, authenticated operation solely because these retry headers are missing or malformed; treat unusable attempt/date metadata as unknown. Their absence never makes an invalid signature acceptable.

Handle it like a first delivery:

  • Not applied yet (the original never reached your database): apply it and return 200 with the resulting balance.
  • Already applied (you committed but Maktub saw a failure): do not move money. Return 200 with the current balance.
  • Only 200 accepts, exactly as on the main endpoint: 201, 202 and 204 do not.
  • Any answer that is not a 200 and not a 5xx — a 4xx, a 3xx, another 2xx — is your final word: Maktub stops resending that operation.
  • A 5xx, a timeout or a transport failure on the retry endpoint is resent again later, with increasing delays, for up to 24 hours from the original delivery attempt. After that automatic resends stop; use transactionId to reconcile unresolved operations with Maktub.

Use the same duplicate handling on the main and retry endpoints. A repeated transactionId must never move money again.

Delivery and reconciliation

Reply promptly. Balance checks have a deadline of four seconds including the network; background settlement and retries use a longer one. Redirects are not followed.

A timeout can occur after your database committed. Preserve the original transaction identity and recorded outcome for reconciliation with Maktub, and do not apply another movement because its acknowledgement was lost. Background resends arrive at your retry endpoint.

There is no automatic rollback or refund event. Treat 4xx as a final refusal and 5xx or timeouts as failures requiring attention. Keep credentials and session tokens out of logs.

Ready to check your handler? Follow Test before going live.