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
event | actions | Your wallet does |
|---|---|---|
balance_check | [] | Read the available balance. |
<game>_bet | One bet action | Debit the amount. |
<game>_win | One win action | Credit 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 }]
}| Field | Meaning |
|---|---|
event | The operation, using a case-sensitive game key where applicable. |
customerId | Your clientId from onboarding. |
userId | The player’s ID in your system. |
betId | Groups one game round or Futures position. Absent on balance_check. |
transactionId | Identifies this operation. Present on paid bets, credits and closes. |
roundClosed | Boolean sent on bets, credits and closes, including free-bet placements. true marks a terminal event; false means not final yet. Absent on balance_check. |
currency | The session’s currency, on every request. actions[].amount and balance are in it. |
usdRate | On 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. |
actions | Exactly one matching bet or win action, or an empty array for a read or close. |
sessionToken | Your 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:
| Header | Value |
|---|---|
x-maktub-signature | t=<Unix seconds>,v1=<HMAC-SHA256 hex> |
x-webhook-secret | Your 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:
- Check a unique key
(customerId, userId, transactionId)shared by both endpoints. - If already applied, do not move money. Return 200 with the current balance.
- For a new debit, check funds and subtract atomically. For a new credit, add the full amount.
- 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.
| Situation | HTTP response |
|---|---|
| Operation applied, or already applied | 200 with balance. |
| Missing or invalid credentials | 401, no movement. |
| Insufficient funds | 402, no movement. |
| Invalid payload, account or player | 4xx, no movement. |
| Internal failure preventing confirmation | 5xx; 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.
| Flow | What arrives |
|---|---|
| Instant positive return | A bet, then a credit with roundClosed: true. |
| Instant zero-payout loss | A bet with roundClosed: true; no following credit. |
| Multi-step game | A bet at the start, then a credit or close when the round ends. |
| Blackjack | Additional stakes share a betId but have new transactionIds. Apply each new debit. |
| Plinko / Wire | One 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 / Double | Asynchronous settlement. New rounds can start before an earlier settlement is confirmed. |
| Futures | Multiple 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:
| Field | Meaning |
|---|---|
freeBetId | Always present on the placement: the non-empty ID of the promotional grant used. |
freeBetsRemaining | The 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:
| Header | Value |
|---|---|
x-maktub-retry-attempt | 1 for the first resend, then 2, 3, … |
x-maktub-retry-of | The 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
200accepts, exactly as on the main endpoint:201,202and204do not. - Any answer that is not a
200and not a5xx— a4xx, a3xx, another2xx— 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
transactionIdto 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.