Webhooks
用一个端点代替轮询或长连 WebSocket。两种事件类型,都是对当前目录与价格状态的观测。
注册一个 webhook 端点后,只要发生这两件事之一,就会收到一次带签名的 POST请求:一个新匹配的市场对进入目录可见状态,或者某个市场对的跨平台价差穿过了你设定的阈值。这两种事件都只是对当前目录状态和价格状态的观测,都不是交易信号,也都不携带结算结果或任何一方的获胜判定。
Webhook 功能面向 Basic、Premium、Pro 三个付费档位开放,Free 密钥调用会收到403。每个组织最多可以注册 3 个端点,注册第 4 个会收到 409。
注册一个端点
| 字段 | 类型 | 含义 |
|---|---|---|
url | string | 必须是 https://,本地地址和内网地址会被拒绝。 |
events | array | pair.created 与 spread.threshold 之一,或两者都要。 |
min_spread_pts | number | 当 events 包含 spread.threshold 时必填,取值范围 0.5 到 50,否则不要传这个字段。 |
curl -s -X POST "https://api.dino.markets/v1/webhooks" \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "url": "https://yourapp.com/hook", "events": ["pair.created", "spread.threshold"], "min_spread_pts": 5 }'{
"id": "8f3a1c92-47e8-4c3f-b9d2-f1a8e6c4d5f2",
"url": "https://yourapp.com/hook",
"events": ["pair.created", "spread.threshold"],
"min_spread_pts": 5,
"secret": "whsec_9f2c...",
"created_at": "2026-07-31T12:00:00+00:00"
}secret 只在这一次响应中出现,请务必保存,后续每次投递都要用它来签名。GET /v1/webhooks 会列出你的端点,其中用secret_preview(开头几个字符)代替完整密钥,另外还带有 status、failure_count、last_success_at 和 last_failure_at。DELETE /v1/webhooks/{id}用于删除一个端点,操作范围限定在你所属的组织内,传入其他组织的端点 id 会收到404。
事件
pair.created:一个匹配结果第一次进入目录可见状态时触发,每个端点针对同一个市场对只触发一次。
{
"event": "pair.created",
"endpoint_id": "8f3a1c92-47e8-4c3f-b9d2-f1a8e6c4d5f2",
"pair_id": "dino_9b1e2c34-5f6a-4d7e-8b9c-0a1b2c3d4e5f",
"slug": "nyy-vs-bos-2026-08-01",
"title": "Nyy vs Bos",
"sport": "baseball",
"category": "sports",
"status": "open",
"created_at": "2026-07-31T12:00:00+00:00"
}spread.threshold:当某个市场对的跨平台spread_pts 从低于你设定的 min_spread_pts 穿越到大于等于该值时触发。价差跌回阈值以下之后才会重新武装,同一个端点对同一个市场对最多每 30 分钟触发一次,市场对尚未有价格时永远不会触发。
{
"event": "spread.threshold",
"endpoint_id": "8f3a1c92-47e8-4c3f-b9d2-f1a8e6c4d5f2",
"pair_id": "dino_9b1e2c34-5f6a-4d7e-8b9c-0a1b2c3d4e5f",
"slug": "nyy-vs-bos-2026-08-01",
"title": "Nyy vs Bos",
"spread_pts": 6.1,
"threshold_pts": 5,
"observed_at": "2026-07-31T12:00:03+00:00"
}spread_pts 只是 observed_at 这一刻观测到的价差快照。它说明的是那一刻的测量结果,仅此而已。在把任何价差当作可交易之前,请先通过结算数据接口确认结算披露信息。
验证签名
每次投递都是一次 POST 请求,超时时间 5 秒,带有三个请求头。
X-Dino-Event: pair.created
X-Dino-Delivery: 3f1e2a4b-...
X-Dino-Signature: t=1785600000,v1=9c3a7e...v1 的值是用你端点的密钥对 {t}.{raw_body}做 HMAC-SHA256 并转成十六进制。请对原始请求体的字节直接计算同样的哈希,不要用重新序列化过的副本,再和收到的值比较。
Python:
import hashlib
import hmac
import time
def verify_signature(secret: str, raw_body: bytes, header: str, tolerance_seconds: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = parts["t"], parts["v1"]
if abs(time.time() - int(t)) > tolerance_seconds:
return False
signed_payload = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)TypeScript:
import { createHmac, timingSafeEqual } from "node:crypto";
function verifySignature(secret: string, rawBody: string, header: string, toleranceSeconds = 300): boolean {
const parts = Object.fromEntries(header.split(",").map((pair) => pair.split("=") as [string, string]));
const t = Number(parts.t);
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const expectedBuffer = Buffer.from(expected, "utf8");
const receivedBuffer = Buffer.from(parts.v1, "utf8");
return expectedBuffer.length === receivedBuffer.length && timingSafeEqual(expectedBuffer, receivedBuffer);
}重试与自动停用
投递失败会在 1 秒、5 秒、25 秒后各重试一次,最多再重试 3 次。每次最终失败都会让failure_count 加一并更新 last_failure_at。连续失败达到 20 次的端点会被停用(status: "disabled",disabled_reason: "delivery_failures")。一次成功投递会把 failure_count 清零并更新 last_success_at。已停用的端点没有重新启用的接口,需要用 POST /v1/webhooks重新注册一个新的端点。