Skip to content

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:

  1. Enter your webhook URL (it must be https://).
  2. 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).
  3. Click "Send test" and the platform sends a test event to your URL.

Events ​

EventWhendata
bet.settledA round is settled and your players had bets in itThe round and the slips (format)
round.correctedSettled again after the data source corrected the resultSame; the slips are the new revision with status recalculated
round.voidedA round is voided and stakes are refunded (for Niu Niu together with the hold)Same; the slips have status void
player.kickedYou 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.graceThe paid plan's prepaid credit ran out and the grace period startedAccount state (format)
account.downgradedThe grace period ended without a top-up and the account was downgraded to the free demo planSame
account.restoredA top-up during grace or after a downgrade restored the paid planSame
account.topupA payment or top-up was credited{amountUsd, balanceUsd, ref}
account.low_balanceThe estimated days left dropped below the alert threshold (at most once a day){balanceUsd, avgDailyUsd, daysLeft}
testYou 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.kicked is not sent when a new launch replaces an older session (SESSION_REPLACED).
  • The formal definitions (JSON Schema and examples) of every event are under webhooks in the OpenAPI spec, and listed in the API reference.

Request format ​

http
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":{…}}
HeaderNotes
X-Elite-EventEvent name, same as event in the body
X-Elite-DeliveryID of this delivery, same as id in the body; unchanged across automatic retries, so use it to de-duplicate
X-Elite-Signaturet=<Unix seconds>,v1=<hex(HMAC-SHA256(webhook secret, t + "." + raw body))>

Body:

FieldNotes
idDelivery ID (same as X-Elite-Delivery)
eventEvent name
companyCodeCompany code of the tenant the event belongs to (…-SBX for the sandbox)
createdAtWhen this delivery was built (updated on every retry)
dataEvent content; depends on the event

Round events ​

data for bet.settled, round.corrected and round.voided:

json
{
  "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.bets has exactly the same shape as a GET /bets item, so the same code can store both; de-duplicate on (slipId, rev). The fields are described in Bet sync.
  • data.round.rev is the result revision of this event; data.bets only holds slips of that revision.
  • data.round.shoe is always a string; for a round voided without a result, resultCode and cardInfo are null.
  • Result code and card string formats are in Tables & round results.
  • Niu Niu (game: "niuniu"): data.round.resultCode is the 5-character result code and cardInfo is first card|banker|player 1|player 2|player 3; slips add hold (the total hold), double bets add bets[].hold and bets[].mult, result adds hands and winners, and payout includes the returned hold, so take the win/loss from winLoss. See Niu Niu bets.

player.kicked ​

json
{ "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 ​

Eventdata
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.
  • graceUntil is only set when the grace period starts; actor is system (automatic) or an operator.
  • account.low_balance requires a billing contact email in the Console; the states are explained in Billing.

Verifying the signature ​

  1. Take t and v1 from X-Elite-Signature.
  2. Compute hex(HMAC-SHA256(secret, t + "." + body)) over the raw body bytes you received (before parsing JSON); the secret includes the whs_ prefix.
  3. Compare with v1 in constant time.
  4. Check that t is within 300 seconds of your server time and reject older requests to prevent replays.

Example (with the example-only secret whs_0123456789abcdefghijABCDEFGHIJ01):

text
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.

js
// 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
<?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;
}
cs
// 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();
//   });
java
// 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));
    }
}
py
"""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))
go
// 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 example 204) 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 (3xx also 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, t and the signature differ between retries.
  • Events are not guaranteed to arrive in order.

Idempotent handling ​

  • De-duplicate on X-Elite-Delivery so 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 a GET /bets cursor sync.

elite Tenant Integration API v1