Webhooks
One signed POST per battle, when it ends. This is the authority on the result, not the WebSocket feed, which is a live view a viewer may have missed.
The request
POST /your/endpoint HTTP/1.1
Content-Type: application/json
X-Slotbattle-Sign: 9f2c4a...
{
"battle_id": "btl_CIkE8lctOrFDe1hJlG0T",
"tenant_id": "acme",
"status": "completed",
"seats": [
{
"user_id": "usr_s0",
"seat_ref": "usr_s0",
"is_bot": false,
"ok": true,
"outcome": 3450.0,
"multiplier": 138.0,
"currency": "BRL"
},
{
"user_id": "usr_s1",
"seat_ref": "usr_s1",
"is_bot": true,
"ok": true,
"outcome": 210.5,
"multiplier": 8.42,
"currency": "BRL"
}
]
}
| Field | Notes |
|---|---|
status | completed, failed or cancelled |
reason | Present on failed and cancelled. Why it ended that way |
seats | Absent on cancelled, because nothing was recorded |
user_id / seat_ref | The seat reference, usr_s<index>. Both carry the same value |
ok | Whether this seat succeeded. A completed battle can contain a failed seat |
multiplier | The seat's raw multiplier |
status: "completed" does not mean every seat succeededIt means the battle finished. Check ok per seat. A seat whose recording failed comes back
with ok: false inside an otherwise completed battle, and settling it as a win is a
real-money bug.
Verify the signature
X-Slotbattle-Sign is the hex-encoded HMAC-SHA256 of the raw request body, keyed with your
webhook secret.
Verify over the bytes you received, before parsing. Re-serialising the JSON and hashing that will not match, because key order and number formatting change.
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(rawBody, header, secret) {
const expected = createHmac('sha256', secret).update(rawBody).digest('hex')
const a = Buffer.from(expected, 'utf8')
const b = Buffer.from(header ?? '', 'utf8')
return a.length === b.length && timingSafeEqual(a, b)
}
Use a constant-time comparison. === on a signature leaks it a byte at a time.
$expected = hash_hmac('sha256', $rawBody, $secret);
if (! hash_equals($expected, $request->header('X-Slotbattle-Sign', ''))) {
abort(401);
}
Reject anything that fails. An unsigned or wrongly signed call is not a SlotBattle call.
Delivery is durable and idempotent
Deliveries are keyed by battle_id : status : version, so a duplicate terminal event never
produces a second POST.
- 3 attempts, then the delivery is persisted as failed.
- A reconciler retries failed deliveries with a 30-second backoff.
- Delivery is per tenant: a casino with no configured endpoint is skipped, never defaulted to the platform's webhook.
Your endpoint still needs to be idempotent. Network timeouts mean you may receive a delivery
you already processed, since the guarantee is at-least-once rather than exactly-once. Key your
own processing on battle_id + status.
Respond fast
Answer 2xx as soon as you have durably accepted the payload, and do settlement
asynchronously.
A slow endpoint burns the attempt budget and pushes the delivery into the reconciler, which delays the result for everyone in that battle. Write it to a queue and return.
Any non-2xx is a failure and will be retried.
Configuring the endpoint
The webhook is per casino, in the webhook settings category: in the console,
Settings → Webhook. Set the URL and the secret together.
Setting the URL but leaving the secret blank is refused at the write. Half configured, it would send your results signed with the platform's secret.
The secret also never comes back. Reading the settings answers whether it is set, not what it is, and submitting the form without it leaves the stored one alone, so pressing Save cannot silently clear it.
Testing without a public endpoint
The endpoint must be reachable from the instance. During development, a tunnel such as
ngrok or cloudflared pointed at your local server is the usual approach.
The tutorial builds a receiver that verifies signatures, and shows how to exercise it.
Why the socket is not a substitute
The WebSocket is best-effort and has no replay. A viewer, or your own listener, can miss
battle.completed by being disconnected for two seconds, and nothing will redeliver it. The
webhook retries, is idempotent, and is signed.
Use the socket to keep the UI live, and the webhook to move money.
Next
- Events: the difference between the three consumers
- WebSocket: the live half
- Settings in the console