KartClub Webhook
接收完赛成绩、收车提醒与超圈提醒。本文包含接入所需的请求格式、签名规则、字段定义和代码示例。
使用 Codex 或 Claude Code 接入
Agent 版包含完整协议和实现检查项。复制全文后,直接粘贴到 Codex、Claude Code 或其他编码 Agent 中。
请读取 https://kart.club/docs/webhooks/agent.md,并按文档实现 KartClub Webhook V1 接收端。
快速开始
- 向 KartClub 对接人员提供公网可访问的 HTTPS Webhook 地址。
- 保存开通时一次性显示的签名密钥。
- 在服务端保留原始请求体,校验时间戳和 HMAC-SHA256 签名。
- 使用字段
e作为事件幂等键,同一个e只处理一次。 - 数据安全落库后返回 HTTP 2xx,推荐
204 No Content。 - 先发送连通性测试,再发送各订阅事件的模拟数据。
事件
| 中文名称 | 英文事件名 | t | 触发条件 |
|---|---|---|---|
| 连通性测试 | webhook.test | wt | 管理员手动发送,只验证接收链路 |
| 完赛成绩 | race.result_finalized | rf | 有效比赛成绩完成结算 |
| 收车提醒 | rolling.return_due_soon | rr | 距购买圈数 1 圈,或距购买时长 60 秒 |
| 超圈提醒 | rolling.purchase_limit_reached | rl | 达到或超过购买圈数或购买时长 |
同一比赛成绩可能产生多个事件。使用 e 去重,使用 r 关联同一成绩;不要使用 r 作为事件幂等键。
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-Type | application/json; charset=utf-8 |
User-Agent | KartClub-Webhook/1.0 |
X-KC-Timestamp | 发送时的 Unix 秒时间戳 |
X-KC-Signature | v1. + 无 padding Base64URL HMAC-SHA256 |
签名验证
- 读取
X-KC-Timestamp和X-KC-Signature。 - 拒绝与当前时间相差超过 300 秒的时间戳。
- 保留框架解析前的原始 body 字节,不要重新序列化 JSON。
- 将签名密钥做 Base64URL 解码。
- 计算
HMAC-SHA256(key, timestamp + "." + raw_body)。 - 将摘要编码为无 padding Base64URL,并加上
v1.前缀。 - 使用恒定时间比较签名;通过后再解析 JSON。
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)
endconst key = Buffer.from(signingSecret, "base64url")
const digest = crypto.createHmac("sha256", key)
.update(Buffer.from(`${timestamp}.`, "utf8"))
.update(rawBody)
.digest("base64url")
const expected = Buffer.from(`v1.${digest}`, "utf8")
const received = Buffer.from(signature || "", "utf8")
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300
const valid = fresh && expected.length === received.length &&
crypto.timingSafeEqual(expected, received)valid = False
if isinstance(timestamp, str) and timestamp.isascii() and timestamp.isdigit():
fresh = abs(int(time.time()) - int(timestamp)) <= 300
if fresh:
padding = "=" * (-len(signing_secret) % 4)
key = base64.urlsafe_b64decode(signing_secret + padding)
message = timestamp.encode() + b"." + raw_body
digest = hmac.new(key, message, hashlib.sha256).digest()
encoded = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
expected = f"v1.{encoded}"
received = (signature or "").encode("utf-8", errors="replace")
valid = hmac.compare_digest(expected.encode("ascii"), received)固定测试向量
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 | 完整含义 | 类型 | 说明 |
|---|---|---|---|
t | event type | string | wt / rf / rr / rl |
v | event version | integer | V1 固定为 1 |
e | event ID | string | 16 字符,事件幂等键 |
o | occurred at | string | 当前事件的触发时间;ISO 8601,带时区 |
r | result ID | string | 16 字符,成绩关联键 |
x | test marker | integer | 可选;1 表示模拟数据 |
o 具体代表:wt 为管理员发送测试的时间,rf 为成绩结算完成时间,rr/rl 为达到提醒条件的时间。
完赛成绩
race.result_finalized · t = rf
{
"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 | 完整含义 | 类型 | 说明 |
|---|---|---|---|
s | started at | string | 比赛记录开始时间(滚动模式为发车时间);ISO 8601,带时区 |
f | finished at | string | 完赛时间,ISO 8601,带时区 |
ve.i | venue ID | string | 匿名车场 ID |
ve.n | venue name | string | 车场名称 |
rc.i | race class ID | string | 匿名车型/组别 ID |
rc.n | race class name | string | 车型/组别名称 |
di | driver ID | string/null | 匿名车手 ID;游客为 null |
dn | driver name | string/null | 车手昵称;游客为 null |
a | avatar URL | string/null | 最长 1 小时有效的加密头像下载地址 |
b | best lap time ms | integer/null | 最快有效圈,毫秒 |
V1 只发送有效成绩;DNS、DNF、DQ 不发送。结算后的人工修改不会再次推送更新事件。
滚动模式提醒
收车提醒
rolling.return_due_soon · t = rr
{
"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
{
"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 | 完整含义 | 类型 | 说明 |
|---|---|---|---|
vi | venue ID | string | 匿名车场 ID |
c | race car number | string | 整数车号转成字符串,不保证前导零 |
p.m | mode | string | l 圈数;t 时间 |
p.d | completed | integer | 已完成圈数或已使用秒数 |
p.q | purchased quota | integer | 购买圈数或购买秒数 |
p.r | remaining | integer | 剩余圈数或秒数,可能为 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 仅供贵方服务器下载,从 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。 - 不兼容变更会提升协议版本并另行通知。