Skip to content

Webhook ​

Webhook 让本平台在局结算、修正或作废、玩家被登出或锁定,以及账务状态变化时,主动 POST 通知你的服务器,你不必一直轮询。对注单而言,Webhook 只是加速通知:可能延迟、重送或乱序,注单的正确性一律以 GET /bets 游标同步为准。常见做法是收到局事件后立即触发一次注单同步。

方案

  • Webhook 是付费方案的功能;免费展示方案不会送出局事件,Console 也不开放设置。
  • 沙箱租户也会送出局事件,方便对接测试。沙箱的 Webhook 网址在 Console「上线与对接 → IP 白名单与 Webhook」的「沙箱 Webhook」另外设置(事件种类同正式,签名密钥与正式分开,免费方案也可使用)。

设置 ​

在 Console 上线与对接 → IP 白名单与 Webhook:

  1. 填入你的 Webhook 网址(必须是 https://)。
  2. 勾选要接收的事件并激活。第一次激活时会产生 Webhook 密钥(whs_ 加 32 个字母或数字),用来验证签名;显示密钥需要先验证双因子(TOTP)。
  3. 按「测试送出」,本平台会送一个 test 事件到你的网址。

事件 ​

事件何时送出data
bet.settled一局结算完成,而且你的玩家在这局有下注局信息与注单(格式)
round.corrected数据源修正结果后重新结算同上;注单为新版次,status 为 recalculated
round.voided一局作废、本金全额退回(牛牛连同预扣)同上;注单 status 为 void
player.kicked你以 API 登出(POST /player/logout)或锁定(POST /player/update 设 locked)玩家,或在 Console 踢线{username, reason, at}(格式)
account.grace付费方案的预付额度用完,进入宽限期账务状态(格式)
account.downgraded宽限期结束仍未充值,自动降级为免费展示方案同上
account.restored宽限或降级中充值,恢复付费方案同上
account.topup付款或充值已入账{amountUsd, balanceUsd, ref}
account.low_balance预估剩余天数低于提醒门槛(每天最多一次){balanceUsd, avgDailyUsd, daysLeft}
test在 Console 按「测试送出」(不需要订阅){"message": "elite webhook test"}
  • 只会送出你在 Console 勾选的事件(test 除外)。
  • 局事件每一局、每个版次送出一次,包含你所有玩家在该局这个版次的注单;你的玩家没有下注的局不会送出。
  • 新的 launch 取代旧 session(SESSION_REPLACED)时不会送出 player.kicked。
  • 所有事件的正式定义(JSON Schema 与示例)在 OpenAPI 规格的 webhooks,也列在 API 参考。

请求格式 ​

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":{…}}
头部说明
X-Elite-Event事件名称,与正文的 event 相同
X-Elite-Delivery这次投递的 ID,与正文的 id 相同;自动重试时不变,可用来去重
X-Elite-Signaturet=<Unix 秒>,v1=<hex(HMAC-SHA256(Webhook 密钥, t + "." + 原始正文))>

正文:

字段说明
id投递 ID(同 X-Elite-Delivery)
event事件名称
companyCode事件所属租户的公司代码(沙箱为 …-SBX)
createdAt这次投递产生的时间(每次重试都会更新)
data事件内容,格式依事件而定

局事件 ​

bet.settled、round.corrected、round.voided 的 data:

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"
    }
  ]
}
  • data.bets 的每一笔与 GET /bets 的 items 格式完全相同,可以用同一段程序写入你的数据库,并以 (slipId, rev) 去重。字段说明见注单同步。
  • data.round.rev 是这个事件的结果版次;data.bets 只包含这个版次的注单。
  • data.round.shoe 一律是字符串;没有结果就作废的局,resultCode 与 cardInfo 为 null。
  • 结果码与牌面字符串的格式见桌台与局结果。
  • 牛牛(game: "niuniu"):data.round.resultCode 是 5 字符的结果码、cardInfo 是 头牌|庄|闲一|闲二|闲三;注单多 hold(预扣合计),翻倍格多 bets[].hold、bets[].mult,result 多 hands、winners,而且 payout 含退回的预扣,输赢请用 winLoss。见牛牛的注单。

player.kicked ​

json
{ "username": "alice", "reason": "locked", "at": "2026-09-24T04:00:00.050Z" }

reason:logged_out(POST /player/logout)或 locked(POST /player/update 设为 locked)。

账务事件 ​

事件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 金额四舍五入到分,固定 2 位小数。
  • graceUntil 只在进入宽限期时有值;actor 为 system(自动)或操作者。
  • account.low_balance 需要在 Console 设置账单联系 Email;状态说明见计费说明。

验证签名 ​

  1. 从 X-Elite-Signature 取出 t 与 v1。
  2. 以收到的原始正文字节(在解析 JSON 之前)计算 hex(HMAC-SHA256(密钥, t + "." + 正文));密钥包含 whs_ 前缀。
  3. 以固定时间比较与 v1 是否相同。
  4. 检查 t 与你的服务器时间相差不超过 300 秒,拒绝过旧的请求以防重放。

示例(仅供示范的密钥 whs_0123456789abcdefghijABCDEFGHIJ01):

text
X-Elite-Signature: t=1790218800,v1=fec6bdb3baaec32d5693cb97a7c7b005a27f37a98aa4d67d09e6bf3808d3f20a
正文: {"id":"8f14e45fceea167a5a36dedd4bea2543","event":"test","companyCode":"ACME","createdAt":"2026-09-24T03:00:00.000Z","data":{"message":"elite webhook test"}}

可以在签名调试器的「Webhook 签名」页签验算。

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))
}

响应与重试 ​

  • 请在 10 秒内响应 2xx(例如 204),把耗时的处理放到背景工作。
  • 非 2xx、超时或连接失败都视为失败;不会跟随重新导向(3xx 也算失败)。
  • 失败后以指数退避重试:30 秒后第一次重试,之后每次间隔加倍,最长 1 小时,持续约 24 小时(最多 31 次投递)。重试用完后进入死信队列,平台修复问题后可以重送;重送的投递会有新的 X-Elite-Delivery。
  • 每次投递的内容在送出当下重新产生,所以重试时 createdAt、t 与签名都会不同。
  • 事件之间不保证顺序。

幂等处理 ​

  • 以 X-Elite-Delivery 去重,避免重复处理同一次投递。
  • 业务上以注单的 (slipId, rev)(或局的 (roundId, rev))判断是否已处理,这样平台重送(新的投递 ID)也不会重复入账。
  • 最稳健的做法:把局事件当成「有新数据」的提示,写入 data.bets 后再以游标同步 GET /bets 确认。

elite 租户集成 API v1