跳到正文
KartClub Webhook
V1 Agent Markdown

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

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 秒。
每个车场一个地址:每个接收地址对应一把签名密钥。同时服务多家车场时,请为每家车场提供不同的地址(例如用 path 区分),收到请求时按地址即可确定用哪把密钥验签。

事件

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

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

事件不保证按发生顺序到达,例如 rl 等待重试期间,rf 可能先到。o 在重试时保持不变,可用于判断事件实际发生的先后。

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-Timestamp 和 X-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最快有效圈,毫秒

ve.i、rc.i、di 在同一车场的接入下长期不变,修改接收地址或轮换签名密钥都不会改变;ID 按每个车场的接入分别生成,同一车手在不同车场的 di 不同。rr/rl 中的 vi 与 ve.i 是同一个值。

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,只包含公共字段。
  • 业务格式模拟:可选择 rf、rr 或 rl;结构与正式事件一致,并包含 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 是加密的临时下载地址,只允许 GET,从 KartClub 第一次实际投递起最长 1 小时有效;重试不会刷新有效期。响应会阻止其他浏览器来源直接嵌入,需要保留头像时,请在有效期内由服务器下载。

  • 地址的 host 固定为 a.kart.yun,path 是单段 Base64URL token;替换域名、添加 query 或修改 token 都会导致访问失败。
  • 过期地址返回 HTTP 410;无效或被篡改的地址返回 404。

版本兼容

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