跳到正文
KartClub Webhook
V1 Agent Markdown

开发者文档 · 协议版本 V1 · 更新于 2026-09-03

KartClub Webhook

接收完赛成绩、收车提醒与超圈提醒。本文包含接入所需的请求格式、签名规则、字段定义和代码示例。

使用 Codex 或 Claude Code 接入

Agent 版包含完整协议和实现检查项。复制全文后,直接粘贴到 Codex、Claude Code 或其他编码 Agent 中。

打开 Markdown 下载 Markdown

请读取 https://kart.club/docs/webhooks/agent.md,并按文档实现 KartClub Webhook V1 接收端。

快速开始

  1. 向 KartClub 对接人员提供公网可访问的 HTTPS Webhook 地址。
  2. 保存开通时一次性显示的签名密钥。
  3. 在服务端保留原始请求体,校验时间戳和 HMAC-SHA256 签名。
  4. 使用字段 e 作为事件幂等键,同一个 e 只处理一次。
  5. 数据安全落库后返回 HTTP 2xx,推荐 204 No Content
  6. 先发送连通性测试,再发送各订阅事件的模拟数据。
接收地址要求:使用公开 CA 签发证书的公网 HTTPS 地址;不得依赖重定向;建议在 2 秒内返回。KartClub 建立连接最多等待 2 秒,任一次响应读取最多等待 5 秒,整个请求最多等待 8 秒。

事件

中文名称英文事件名t触发条件
连通性测试webhook.testwt管理员手动发送,只验证接收链路
完赛成绩race.result_finalizedrf有效比赛成绩完成结算
收车提醒rolling.return_due_soonrr距购买圈数 1 圈,或距购买时长 60 秒
超圈提醒rolling.purchase_limit_reachedrl达到或超过购买圈数或购买时长

同一比赛成绩可能产生多个事件。使用 e 去重,使用 r 关联同一成绩;不要使用 r 作为事件幂等键。

HTTP 请求

HTTP
POST /kartclub/webhooks HTTP/1.1
Host: hooks.example.com
Content-Type: application/json; charset=utf-8
User-Agent: KartClub-Webhook/1.0
X-KC-Timestamp: 1787196896
X-KC-Signature: v1.ESQi38l2tpSgp6ToapkbI9nEpmioQL8z_aIzMeo-NnY

{"t":"wt","v":1,"e":"abcdefghijklmnop","o":"2026-08-20T12:34:56+08:00","r":"ponmlkjihgfedcba"}
请求头值或说明
Content-Typeapplication/json; charset=utf-8
User-AgentKartClub-Webhook/1.0
X-KC-Timestamp发送时的 Unix 秒时间戳
X-KC-Signaturev1. + 无 padding Base64URL HMAC-SHA256

签名验证

  1. 读取 X-KC-TimestampX-KC-Signature
  2. 拒绝与当前时间相差超过 300 秒的时间戳。
  3. 保留框架解析前的原始 body 字节,不要重新序列化 JSON。
  4. 将签名密钥做 Base64URL 解码。
  5. 计算 HMAC-SHA256(key, timestamp + "." + raw_body)
  6. 将摘要编码为无 padding Base64URL,并加上 v1. 前缀。
  7. 使用恒定时间比较签名;通过后再解析 JSON。
Ruby
timestamp_value = Integer(timestamp, exception: false)
fresh = timestamp_value &&
  (Time.now.to_i - timestamp_value).abs <= 300

valid = false
if fresh
  raw_key = Base64.urlsafe_decode64(signing_secret)
  digest = OpenSSL::HMAC.digest(
    "SHA256", raw_key, "#{timestamp}.#{raw_body}"
  )
  expected = "v1.#{Base64.urlsafe_encode64(digest, padding: false)}"
  received = signature.to_s
  valid = expected.bytesize == received.bytesize &&
    ActiveSupport::SecurityUtils.secure_compare(expected, received)
end

固定测试向量

Text
secret    = MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY
timestamp = 1787196896
body      = {"t":"wt","v":1,"e":"abcdefghijklmnop","o":"2026-08-20T12:34:56+08:00","r":"ponmlkjihgfedcba"}
signature = v1.ESQi38l2tpSgp6ToapkbI9nEpmioQL8z_aIzMeo-NnY

公共字段

Payload 为 UTF-8 紧凑 JSON,字段名使用固定短码。

Key完整含义类型说明
tevent typestringwt / rf / rr / rl
vevent versionintegerV1 固定为 1
eevent IDstring16 字符,事件幂等键
ooccurred atstring当前事件的触发时间;ISO 8601,带时区
rresult IDstring16 字符,成绩关联键
xtest markerinteger可选;1 表示模拟数据

o 具体代表:wt 为管理员发送测试的时间,rf 为成绩结算完成时间,rr/rl 为达到提醒条件的时间。

完赛成绩

race.result_finalized · t = rf

JSON
{
  "t": "rf",
  "v": 1,
  "e": "FzxY8W2jPa3kLm9Q",
  "o": "2026-08-20T12:34:56+08:00",
  "r": "nKaLjg6RqgT0bcA0",
  "s": "2026-08-20T12:24:20+08:00",
  "f": "2026-08-20T12:34:20+08:00",
  "ve": { "i": "DBLwV6V8oFvA1ADo", "n": "示例车场" },
  "rc": { "i": "f07ELvqKvP4QwFjJ", "n": "成人竞速" },
  "di": "qgP7YieaW9oUW1xG",
  "dn": "车手昵称",
  "a": "https://a.kart.yun/BGqSn5AAAQIDBAUGBxnlf_fDiS0GmcK8Mg9a1duRELjZJfZ-YEgIWE7uIoulSQ",
  "b": 52341
}
Key完整含义类型说明
sstarted atstring比赛记录开始时间(滚动模式为发车时间);ISO 8601,带时区
ffinished atstring完赛时间,ISO 8601,带时区
ve.ivenue IDstring匿名车场 ID
ve.nvenue namestring车场名称
rc.irace class IDstring匿名车型/组别 ID
rc.nrace class namestring车型/组别名称
didriver IDstring/null匿名车手 ID;游客为 null
dndriver namestring/null车手昵称;游客为 null
aavatar URLstring/null最长 1 小时有效的加密头像下载地址
bbest lap time msinteger/null最快有效圈,毫秒

V1 只发送有效成绩;DNS、DNF、DQ 不发送。结算后的人工修改不会再次推送更新事件。

滚动模式提醒

收车提醒

rolling.return_due_soon · t = rr

JSON · 圈数票
{
  "t": "rr",
  "v": 1,
  "e": "V2kLm8QpR5xN7cZa",
  "o": "2026-08-20T12:30:00+08:00",
  "r": "nKaLjg6RqgT0bcA0",
  "vi": "DBLwV6V8oFvA1ADo",
  "c": "18",
  "p": { "m": "l", "d": 9, "q": 10, "r": 1 }
}

时间票使用同一结构:p = { "m": "t", "d": 420, "q": 480, "r": 60 }

超圈提醒

rolling.purchase_limit_reached · t = rl

JSON
{
  "t": "rl",
  "v": 1,
  "e": "K9xQm2ZaP7vN4cLs",
  "o": "2026-08-20T12:31:00+08:00",
  "r": "nKaLjg6RqgT0bcA0",
  "vi": "DBLwV6V8oFvA1ADo",
  "c": "18",
  "p": { "m": "l", "d": 10, "q": 10, "r": 0 }
}
Key完整含义类型说明
vivenue IDstring匿名车场 ID
crace car numberstring整数车号转成字符串,不保证前导零
p.mmodestringl 圈数;t 时间
p.dcompletedinteger已完成圈数或已使用秒数
p.qpurchased quotainteger购买圈数或购买秒数
p.rremaininginteger剩余圈数或秒数,可能为 0 或负数

测试事件

  • 连通性测试:webhook.test / wt,只包含公共字段。
  • 业务格式模拟:可选择 rfrrrl;结构与正式事件一致,并包含 x = 1

x = 1 的数据只用于联调,不应写入正式业务表。

幂等处理

同一事件可能重复送达。请以 e 作为数据库唯一键并原子写入;只有首次写入成功时执行业务操作,重复 e 直接返回 2xx。

返回值与重试

结果KartClub 行为
HTTP 2xx接收成功,不再重试
网络错误或超时自动重试
HTTP 408 / 425 / 429自动重试
HTTP 5xx自动重试
其他 HTTP 3xx / 4xx直接记为失败,不重试

最多尝试 3 次:立即、最快约 2 秒后、最快约 10 秒后。HTTP 429 返回的合法 Retry-After 最长采纳 1 小时。

V1 为 best effort,不提供历史补发接口。接收端短时不可用也可能在三次尝试用尽后漏收事件;连续多次最终失败时,接收地址可能自动暂停。

安全要求

头像下载

字段 a 仅供贵方服务器下载,从 KartClub 第一次实际投递起最长 1 小时有效;重试不会刷新有效期。响应会阻止其他浏览器来源直接嵌入,请收到后尽快由服务器下载并缓存到自己的存储,不要把它当作头像 ID 或让客户端直接引用。

  • 只接受 host 严格等于 a.kart.yun 的 HTTPS 地址,path 必须是单段 Base64URL token;使用 GET 下载,不添加 query、不修改域名或 token。
  • 过期地址返回 HTTP 410;无效或被篡改的地址返回 404
  • 限制下载时间,并校验响应大小、Content-Type 和图片解码后的像素尺寸。
  • 不要在日志中记录签名密钥、完整请求头或不必要的个人信息。

上线检查

  • 使用原始 body 验签,并检查 5 分钟时间窗口。
  • 验签通过后再解析、记录或处理 JSON。
  • e 建立唯一约束。
  • x = 1 的模拟数据不会进入正式业务数据。
  • 数据安全落库后再返回 2xx。
  • 已完成 wt 和全部订阅事件的模拟测试。

版本兼容

  • V1 的 v 固定为 1
  • 接收方应忽略无法识别的额外字段,但不得忽略未知的 v
  • 收到不支持的 v 时,应告警并返回非 2xx。
  • 不兼容变更会提升协议版本并另行通知。