Webhooks
Webhooks let the platform POST to your server when a round is settled, corrected or voided, when you log a player out or lock them, and when your account state changes, so you do not have to poll constantly. For bets, webhooks only speed things up: they can be late, repeated or out of order, and bet correctness always comes from GET /bets cursor sync. A common pattern is to trigger a bet sync as soon as a round event arrives.
Plans
- Webhooks are a paid-plan feature; the free demo plan does not send round events and the Console does not let it configure webhooks.
- Sandbox tenants also send round events, for integration testing. Set the sandbox webhook URL separately under Console → Go-live & integration → IP allowlist & Webhook → "Sandbox webhook" (same event types as live, a separate signing secret, also available on the free plan).
Setup
In the Console under Go-live & integration → IP allowlist and Webhook:
- Enter your webhook URL (it must be
https://). - Choose the events and enable the webhook. The first time you enable it a webhook secret is created (
whs_followed by 32 letters and digits) for verifying signatures; showing it requires two-factor verification (TOTP). - Click "Send test" and the platform sends a
testevent to your URL.
Events
| Event | When | data |
|---|---|---|
bet.settled | A round is settled and your players had bets in it | The round and the slips (format) |
round.corrected | Settled again after the data source corrected the result | Same; the slips are the new revision with status recalculated |
round.voided | A round is voided and stakes are refunded (for Niu Niu together with the hold) | Same; the slips have status void |
player.kicked | You logged a player out (POST /player/logout) or locked them (POST /player/update with locked) through the API, or kicked them in the Console | {username, reason, at} (format) |
account.grace | The paid plan's prepaid credit ran out and the grace period started | Account state (format) |
account.downgraded | The grace period ended without a top-up and the account was downgraded to the free demo plan | Same |
account.restored | A top-up during grace or after a downgrade restored the paid plan | Same |
account.topup | A payment or top-up was credited | {amountUsd, balanceUsd, ref} |
account.low_balance | The estimated days left dropped below the alert threshold (at most once a day) | {balanceUsd, avgDailyUsd, daysLeft} |
test | You click "Send test" in the Console (no subscription needed) | {"message": "elite webhook test"} |
- Only the events you selected in the Console are sent (except
test). - Each round event is sent once per round and revision, with all of your players' slips of that revision; rounds where none of your players bet are not sent.
player.kickedis not sent when a new launch replaces an older session (SESSION_REPLACED).- The formal definitions (JSON Schema and examples) of every event are under
webhooksin the OpenAPI spec, and listed in the API reference.
Request format
POST /your/webhook/path HTTP/1.1
Content-Type: application/json
User-Agent: elite-webhook/1
X-Elite-Event: bet.settled
X-Elite-Delivery: 8f14e45fceea167a5a36dedd4bea2543
X-Elite-Signature: t=1790218880,v1=5a0f3c…e91b
{"id":"8f14e45fceea167a5a36dedd4bea2543","event":"bet.settled","companyCode":"ACME","createdAt":"2026-09-24T03:01:21.030Z","data":{…}}| Header | Notes |
|---|---|
X-Elite-Event | Event name, same as event in the body |
X-Elite-Delivery | ID of this delivery, same as id in the body; unchanged across automatic retries, so use it to de-duplicate |
X-Elite-Signature | t=<Unix seconds>,v1=<hex(HMAC-SHA256(webhook secret, t + "." + raw body))> |
Body:
| Field | Notes |
|---|---|
id | Delivery ID (same as X-Elite-Delivery) |
event | Event name |
companyCode | Company code of the tenant the event belongs to (…-SBX for the sandbox) |
createdAt | When this delivery was built (updated on every retry) |
data | Event content; depends on the event |
Round events
data for bet.settled, round.corrected and round.voided:
{
"round": {
"roundId": "01K5Y0B8Z6R2M4N7P9Q3S5T8VW",
"tableId": "S01",
"shoe": "260924-03",
"round": 12,
"rev": 1,
"resultCode": "1",
"cardInfo": "122334424000",
"settledAt": "2026-09-24T03:01:20.480Z"
},
"bets": [
{
"recSeq": 1024,
"slipId": "01K5Y0B8Z6R2M4N7P9Q3S5T8VW.7H3KQ2XA",
"rev": 1,
"status": "settled",
"username": "alice",
"currency": "TWD",
"tableId": "S01",
"game": "baccarat",
"variant": "nocomm",
"roundId": "01K5Y0B8Z6R2M4N7P9Q3S5T8VW",
"shoe": "260924-03",
"round": 12,
"bets": [
{ "zone": "B", "amount": "10000", "return": "15000" },
{ "zone": "S6", "amount": "1000", "return": "13000" }
],
"stake": "11000",
"validStake": "6000",
"rolling": "0",
"payout": "28000",
"winLoss": "17000",
"delta": "28000",
"result": { "code": "1", "cardInfo": "122334424000" },
"placedAt": "2026-09-24T03:00:41.120Z",
"settledAt": "2026-09-24T03:01:20.480Z"
}
]
}- Each entry of
data.betshas exactly the same shape as aGET /betsitem, so the same code can store both; de-duplicate on(slipId, rev). The fields are described in Bet sync. data.round.revis the result revision of this event;data.betsonly holds slips of that revision.data.round.shoeis always a string; for a round voided without a result,resultCodeandcardInfoarenull.- Result code and card string formats are in Tables & round results.
- Niu Niu (
game: "niuniu"):data.round.resultCodeis the 5-character result code andcardInfoisfirst card|banker|player 1|player 2|player 3; slips addhold(the total hold), double bets addbets[].holdandbets[].mult,resultaddshandsandwinners, andpayoutincludes the returned hold, so take the win/loss fromwinLoss. See Niu Niu bets.
player.kicked
{ "username": "alice", "reason": "locked", "at": "2026-09-24T04:00:00.050Z" }reason: logged_out (POST /player/logout) or locked (POST /player/update set locked).
Account events
| Event | data |
|---|---|
account.grace, account.downgraded, account.restored | {"from": "PAID", "to": "GRACE", "balanceUsd": "-3.17", "graceUntil": "2026-09-28T00:05:03.300Z", "actor": "system"} |
account.topup | {"amountUsd": "500.00", "balanceUsd": "496.83", "ref": "…"} |
account.low_balance | {"balanceUsd": "61.20", "avgDailyUsd": "14.67", "daysLeft": 4} |
- USD amounts in account events are rounded to cents, always with 2 decimals.
graceUntilis only set when the grace period starts;actorissystem(automatic) or an operator.account.low_balancerequires a billing contact email in the Console; the states are explained in Billing.
Verifying the signature
- Take
tandv1fromX-Elite-Signature. - Compute
hex(HMAC-SHA256(secret, t + "." + body))over the raw body bytes you received (before parsing JSON); the secret includes thewhs_prefix. - Compare with
v1in constant time. - Check that
tis within 300 seconds of your server time and reject older requests to prevent replays.
Example (with the example-only secret whs_0123456789abcdefghijABCDEFGHIJ01):
X-Elite-Signature: t=1790218800,v1=fec6bdb3baaec32d5693cb97a7c7b005a27f37a98aa4d67d09e6bf3808d3f20a
Body: {"id":"8f14e45fceea167a5a36dedd4bea2543","event":"test","companyCode":"ACME","createdAt":"2026-09-24T03:00:00.000Z","data":{"message":"elite webhook test"}}You can check it in the "Webhook signature" tab of the signature debugger.
// elite webhook: signature verification (Node.js 18+, ES module, no dependencies)
import { createHmac, timingSafeEqual } from 'node:crypto';
import { pathToFileURL } from 'node:url';
/**
* X-Elite-Signature: t=<unix seconds>,v1=<hex(HMAC-SHA256(secret, t + "." + rawBody))>
* rawBody must be the exact bytes you received (a Buffer or string), before any JSON parsing.
*/
export function verifyWebhook(secret, signatureHeader, rawBody, { toleranceSeconds = 300, nowSeconds = Date.now() / 1000 } = {}) {
const parts = {};
for (const item of String(signatureHeader).split(',')) {
const i = item.indexOf('=');
if (i > 0) parts[item.slice(0, i).trim()] = item.slice(i + 1).trim();
}
if (!/^\d{1,12}$/.test(parts.t ?? '') || !/^[0-9a-fA-F]{64}$/.test(parts.v1 ?? '')) return false;
if (Math.abs(nowSeconds - Number(parts.t)) > toleranceSeconds) return false; // replay protection
const expected = createHmac('sha256', secret).update(`${parts.t}.`).update(rawBody).digest();
return timingSafeEqual(expected, Buffer.from(parts.v1, 'hex'));
}
// Express example:
// app.post('/elite/webhook', express.raw({ type: 'application/json' }), (req, res) => {
// if (!verifyWebhook(process.env.ELITE_WEBHOOK_SECRET, req.get('X-Elite-Signature') ?? '', req.body)) return res.sendStatus(401);
// const event = JSON.parse(req.body.toString('utf8'));
// // De-duplicate on req.get('X-Elite-Delivery'), queue the work, and answer quickly:
// res.sendStatus(204);
// });
// Worked example from the docs: prints true
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const body =
'{"id":"8f14e45fceea167a5a36dedd4bea2543","event":"test","companyCode":"ACME","createdAt":"2026-09-24T03:00:00.000Z","data":{"message":"elite webhook test"}}';
const header = 't=1790218800,v1=fec6bdb3baaec32d5693cb97a7c7b005a27f37a98aa4d67d09e6bf3808d3f20a';
console.log(verifyWebhook('whs_0123456789abcdefghijABCDEFGHIJ01', header, body, { nowSeconds: 1790218800 }));
}<?php
// elite webhook: signature verification (PHP 7.2+)
/**
* X-Elite-Signature: t=<unix seconds>,v1=<hex(HMAC-SHA256(secret, t + "." + rawBody))>
* $rawBody must be the exact bytes you received: file_get_contents('php://input').
*/
function elite_verify_webhook(string $secret, string $signatureHeader, string $rawBody, int $toleranceSeconds = 300, ?int $now = null): bool
{
$parts = [];
foreach (explode(',', $signatureHeader) as $item) {
$kv = explode('=', trim($item), 2);
if (count($kv) === 2) {
$parts[$kv[0]] = $kv[1];
}
}
if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t']) || strlen($parts['v1']) !== 64) {
return false;
}
if (abs(($now ?? time()) - (int) $parts['t']) > $toleranceSeconds) {
return false; // replay protection
}
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, strtolower($parts['v1']));
}
// Example:
// $raw = file_get_contents('php://input');
// if (!elite_verify_webhook(getenv('ELITE_WEBHOOK_SECRET'), $_SERVER['HTTP_X_ELITE_SIGNATURE'] ?? '', $raw)) {
// http_response_code(401);
// exit;
// }
// $event = json_decode($raw, true);
// // De-duplicate on $_SERVER['HTTP_X_ELITE_DELIVERY'], queue the work, and answer quickly:
// http_response_code(204);
// Worked example from the docs: prints true
if (PHP_SAPI === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
$body = '{"id":"8f14e45fceea167a5a36dedd4bea2543","event":"test","companyCode":"ACME","createdAt":"2026-09-24T03:00:00.000Z","data":{"message":"elite webhook test"}}';
$header = 't=1790218800,v1=fec6bdb3baaec32d5693cb97a7c7b005a27f37a98aa4d67d09e6bf3808d3f20a';
var_export(elite_verify_webhook('whs_0123456789abcdefghijABCDEFGHIJ01', $header, $body, 300, 1790218800));
echo PHP_EOL;
}// elite webhook: signature verification (.NET 6+)
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
// Worked example from the docs: prints True
var body = Encoding.UTF8.GetBytes("{\"id\":\"8f14e45fceea167a5a36dedd4bea2543\",\"event\":\"test\",\"companyCode\":\"ACME\",\"createdAt\":\"2026-09-24T03:00:00.000Z\",\"data\":{\"message\":\"elite webhook test\"}}");
Console.WriteLine(EliteWebhook.Verify("whs_0123456789abcdefghijABCDEFGHIJ01",
"t=1790218800,v1=fec6bdb3baaec32d5693cb97a7c7b005a27f37a98aa4d67d09e6bf3808d3f20a", body, nowSeconds: 1790218800));
public static class EliteWebhook
{
/// X-Elite-Signature: t=<unix seconds>,v1=<hex(HMAC-SHA256(secret, t + "." + rawBody))>
/// rawBody must be the exact bytes you received, before any JSON parsing.
public static bool Verify(string secret, string signatureHeader, byte[] rawBody, int toleranceSeconds = 300, long? nowSeconds = null)
{
string? t = null, v1 = null;
foreach (var item in signatureHeader.Split(','))
{
var kv = item.Trim().Split('=', 2);
if (kv.Length != 2) continue;
if (kv[0] == "t") t = kv[1];
else if (kv[0] == "v1") v1 = kv[1];
}
if (t is null || v1 is null || v1.Length != 64 || !long.TryParse(t, out var ts)) return false;
var now = nowSeconds ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (Math.Abs(now - ts) > toleranceSeconds) return false; // replay protection
var message = Encoding.ASCII.GetBytes(t + ".").Concat(rawBody).ToArray();
var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), message);
byte[] given;
try { given = Convert.FromHexString(v1); } catch (FormatException) { return false; }
return CryptographicOperations.FixedTimeEquals(expected, given);
}
}
// ASP.NET Core example (read the raw body before any model binding):
// app.MapPost("/elite/webhook", async (HttpRequest req) => {
// using var ms = new MemoryStream();
// await req.Body.CopyToAsync(ms);
// if (!EliteWebhook.Verify(secret, req.Headers["X-Elite-Signature"].ToString(), ms.ToArray())) return Results.Unauthorized();
// // De-duplicate on req.Headers["X-Elite-Delivery"], queue the work, and answer quickly.
// return Results.NoContent();
// });// elite webhook: signature verification (Java 11+, no dependencies)
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Locale;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class EliteWebhook {
// X-Elite-Signature: t=<unix seconds>,v1=<hex(HMAC-SHA256(secret, t + "." + rawBody))>
// rawBody must be the exact bytes you received, before any JSON parsing.
public static boolean verify(String secret, String signatureHeader, byte[] rawBody, long nowSeconds, long toleranceSeconds) throws Exception {
String t = null;
String v1 = null;
for (String item : signatureHeader.split(",")) {
String[] kv = item.trim().split("=", 2);
if (kv.length != 2) continue;
if (kv[0].equals("t")) t = kv[1];
else if (kv[0].equals("v1")) v1 = kv[1];
}
if (t == null || v1 == null || !t.matches("\\d{1,12}") || v1.length() != 64) return false;
if (Math.abs(nowSeconds - Long.parseLong(t)) > toleranceSeconds) return false; // replay protection
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((t + ".").getBytes(StandardCharsets.US_ASCII));
byte[] expected = mac.doFinal(rawBody);
return MessageDigest.isEqual(hex(expected).getBytes(StandardCharsets.US_ASCII),
v1.toLowerCase(Locale.ROOT).getBytes(StandardCharsets.US_ASCII));
}
private static String hex(byte[] bytes) {
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(Character.forDigit((b >> 4) & 0xf, 16)).append(Character.forDigit(b & 0xf, 16));
}
return sb.toString();
}
// Worked example from the docs: prints true
public static void main(String[] args) throws Exception {
byte[] body = ("{\"id\":\"8f14e45fceea167a5a36dedd4bea2543\",\"event\":\"test\",\"companyCode\":\"ACME\","
+ "\"createdAt\":\"2026-09-24T03:00:00.000Z\",\"data\":{\"message\":\"elite webhook test\"}}").getBytes(StandardCharsets.UTF_8);
System.out.println(verify("whs_0123456789abcdefghijABCDEFGHIJ01",
"t=1790218800,v1=fec6bdb3baaec32d5693cb97a7c7b005a27f37a98aa4d67d09e6bf3808d3f20a", body, 1790218800L, 300));
}
}"""elite webhook: signature verification (Python 3.8+, standard library only)."""
import hashlib
import hmac
import time
def verify_webhook(secret, signature_header, raw_body, tolerance_seconds=300, now=None):
"""X-Elite-Signature: t=<unix seconds>,v1=<hex(HMAC-SHA256(secret, t + "." + raw_body))>
raw_body must be the exact bytes you received, before any JSON parsing.
"""
parts = {}
for item in signature_header.split(","):
key, sep, value = item.partition("=")
if sep:
parts[key.strip()] = value.strip()
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not (t.isdigit() and len(t) <= 12 and len(v1) == 64):
return False
if abs((time.time() if now is None else now) - int(t)) > tolerance_seconds:
return False # replay protection
expected = hmac.new(secret.encode("utf-8"), t.encode("ascii") + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1.lower())
# Flask example:
# @app.post("/elite/webhook")
# def elite_webhook():
# if not verify_webhook(SECRET, request.headers.get("X-Elite-Signature", ""), request.get_data()):
# abort(401)
# event = request.get_json()
# # De-duplicate on request.headers["X-Elite-Delivery"], queue the work, and answer quickly.
# return "", 204
if __name__ == "__main__":
# Worked example from the docs: prints True
body = (b'{"id":"8f14e45fceea167a5a36dedd4bea2543","event":"test","companyCode":"ACME",'
b'"createdAt":"2026-09-24T03:00:00.000Z","data":{"message":"elite webhook test"}}')
header = "t=1790218800,v1=fec6bdb3baaec32d5693cb97a7c7b005a27f37a98aa4d67d09e6bf3808d3f20a"
print(verify_webhook("whs_0123456789abcdefghijABCDEFGHIJ01", header, body, now=1790218800))// elite webhook: signature verification (Go 1.20+, standard library only)
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
)
// VerifyWebhook checks X-Elite-Signature: t=<unix seconds>,v1=<hex(HMAC-SHA256(secret, t + "." + rawBody))>.
// rawBody must be the exact bytes you received, before any JSON parsing.
func VerifyWebhook(secret, signatureHeader string, rawBody []byte, now time.Time, tolerance time.Duration) bool {
var t, v1 string
for _, item := range strings.Split(signatureHeader, ",") {
kv := strings.SplitN(strings.TrimSpace(item), "=", 2)
if len(kv) != 2 {
continue
}
switch kv[0] {
case "t":
t = kv[1]
case "v1":
v1 = kv[1]
}
}
ts, err := strconv.ParseInt(t, 10, 64)
if err != nil || len(v1) != 64 {
return false
}
if d := now.Sub(time.Unix(ts, 0)); d > tolerance || d < -tolerance {
return false // replay protection
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(t + "."))
mac.Write(rawBody)
given, err := hex.DecodeString(v1)
return err == nil && hmac.Equal(mac.Sum(nil), given)
}
// net/http example:
// http.HandleFunc("/elite/webhook", func(w http.ResponseWriter, r *http.Request) {
// body, _ := io.ReadAll(r.Body)
// if !VerifyWebhook(secret, r.Header.Get("X-Elite-Signature"), body, time.Now(), 5*time.Minute) {
// w.WriteHeader(http.StatusUnauthorized)
// return
// }
// // De-duplicate on r.Header.Get("X-Elite-Delivery"), queue the work, and answer quickly.
// w.WriteHeader(http.StatusNoContent)
// })
// Worked example from the docs: prints true
func main() {
body := []byte(`{"id":"8f14e45fceea167a5a36dedd4bea2543","event":"test","companyCode":"ACME","createdAt":"2026-09-24T03:00:00.000Z","data":{"message":"elite webhook test"}}`)
header := "t=1790218800,v1=fec6bdb3baaec32d5693cb97a7c7b005a27f37a98aa4d67d09e6bf3808d3f20a"
fmt.Println(VerifyWebhook("whs_0123456789abcdefghijABCDEFGHIJ01", header, body, time.Unix(1790218800, 0), 5*time.Minute))
}Responses and retries
- Answer with a
2xx(for example204) within 10 seconds and do slow work in a background job. - Anything else — non-
2xx, a timeout or a connection failure — is a failure, and redirects are not followed (3xxalso fails). - Failures are retried with exponential backoff: the first retry after 30 seconds, then doubling up to 1 hour between attempts, for about 24 hours (at most 31 deliveries). After that the delivery goes to a dead-letter queue, from which the platform can resend it once the problem is fixed; a resend gets a new
X-Elite-Delivery. - Each attempt is built when it is sent, so
createdAt,tand the signature differ between retries. - Events are not guaranteed to arrive in order.
Idempotent handling
- De-duplicate on
X-Elite-Deliveryso you never process the same delivery twice. - For business logic, key on the slip's
(slipId, rev)(or the round's(roundId, rev)), so a platform resend (with a new delivery ID) is still harmless. - The most robust approach: treat a round event as "there is new data", store
data.bets, then confirm with aGET /betscursor sync.