Webhook
Webhook 让本平台在局结算、修正或作废、玩家被登出或锁定,以及账务状态变化时,主动 POST 通知你的服务器,你不必一直轮询。对注单而言,Webhook 只是加速通知:可能延迟、重送或乱序,注单的正确性一律以 GET /bets 游标同步为准。常见做法是收到局事件后立即触发一次注单同步。
方案
- Webhook 是付费方案的功能;免费展示方案不会送出局事件,Console 也不开放设置。
- 沙箱租户也会送出局事件,方便对接测试。沙箱的 Webhook 网址在 Console「上线与对接 → IP 白名单与 Webhook」的「沙箱 Webhook」另外设置(事件种类同正式,签名密钥与正式分开,免费方案也可使用)。
设置
在 Console 上线与对接 → IP 白名单与 Webhook:
- 填入你的 Webhook 网址(必须是
https://)。 - 勾选要接收的事件并激活。第一次激活时会产生 Webhook 密钥(
whs_加 32 个字母或数字),用来验证签名;显示密钥需要先验证双因子(TOTP)。 - 按「测试送出」,本平台会送一个
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-Signature | t=<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;状态说明见计费说明。
验证签名
- 从
X-Elite-Signature取出t与v1。 - 以收到的原始正文字节(在解析 JSON 之前)计算
hex(HMAC-SHA256(密钥, t + "." + 正文));密钥包含whs_前缀。 - 以固定时间比较与
v1是否相同。 - 检查
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确认。