Skip to content

Webhooks

用一个端点代替轮询或长连 WebSocket。两种事件类型,都是对当前目录与价格状态的观测。

注册一个 webhook 端点后,只要发生这两件事之一,就会收到一次带签名的 POST请求:一个新匹配的市场对进入目录可见状态,或者某个市场对的跨平台价差穿过了你设定的阈值。这两种事件都只是对当前目录状态和价格状态的观测,都不是交易信号,也都不携带结算结果或任何一方的获胜判定。

Webhook 功能面向 Basic、Premium、Pro 三个付费档位开放,Free 密钥调用会收到403。每个组织最多可以注册 3 个端点,注册第 4 个会收到 409

注册一个端点

字段类型含义
urlstring必须是 https://,本地地址和内网地址会被拒绝。
eventsarraypair.createdspread.threshold 之一,或两者都要。
min_spread_ptsnumberevents 包含 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(开头几个字符)代替完整密钥,另外还带有 statusfailure_countlast_success_atlast_failure_atDELETE /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重新注册一个新的端点。