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確認。