# KartClub Webhook V1 - Agent implementation specification Use this document as the complete implementation specification for a KartClub Webhook receiver. Implement the receiver in the customer's existing stack and follow every MUST requirement below. Do not infer fields that are not documented. Public human-readable documentation: ## 1. Required receiver behavior 1. Expose a publicly reachable HTTPS POST endpoint with a valid public-CA certificate. 2. Read the raw request body bytes before JSON parsing. 3. Validate `X-KC-Timestamp` and `X-KC-Signature` before parsing or storing the payload. 4. Reject timestamps more than 300 seconds before or after the receiver's current time. 5. Use a constant-time comparison for the signature. 6. Parse JSON only after signature validation succeeds. 7. Treat `e` as the event idempotency key and enforce uniqueness in the database. 8. Treat `r` only as the identifier used to correlate events for the same race result. 9. Keep `x = 1` simulated events out of production business tables. 10. Return HTTP 2xx only after the event is durably accepted. `204 No Content` is recommended. 11. Return within 2 seconds where possible. KartClub allows up to 2 seconds to connect, up to 5 seconds for any response read, and up to 8 seconds for the entire request. 12. Ignore unknown additional fields, but reject unsupported values of `v`. ## 2. Endpoint requirements - Scheme: `https://` - Method: `POST` - Certificate: valid chain issued by a public CA - Address: public internet only; no private, loopback, link-local, or reserved destination - Redirects: not followed by KartClub - Recommended response body: empty ## 3. Events | Chinese name | Event name | `t` | Trigger | |---|---|---|---| | 连通性测试 | `webhook.test` | `wt` | Manual connectivity test | | 完赛成绩 | `race.result_finalized` | `rf` | A valid result finishes settlement | | 收车提醒 | `rolling.return_due_soon` | `rr` | One lap or 60 seconds remains | | 超圈提醒 | `rolling.purchase_limit_reached` | `rl` | Purchased lap/time limit is reached or exceeded | The same result may produce `rr`, `rl`, and `rf` events. Those events share `r` but have different `e` values. ## 4. HTTP request ```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"} ``` | Header | Value | |---|---| | `Content-Type` | `application/json; charset=utf-8` | | `User-Agent` | `KartClub-Webhook/1.0` | | `X-KC-Timestamp` | Unix timestamp in seconds | | `X-KC-Signature` | `v1.` plus unpadded Base64URL HMAC-SHA256 digest | ## 5. Signature validation The signing secret is an unpadded Base64URL string. Decode it before using it as the HMAC key. ```text message = ASCII(X-KC-Timestamp) + "." + raw HTTP body bytes digest = HMAC-SHA256(decoded_signing_secret, message) expected = "v1." + Base64URL_without_padding(digest) valid = timestamp_is_integer AND abs(receiver_unix_time - timestamp) <= 300 AND constant_time_equal(expected, X-KC-Signature) ``` MUST NOT parse and reserialize JSON before validation. Whitespace, key order, and escaping are part of the signed raw body. ### Fixed test vector ```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 ``` An automated test MUST verify this exact vector. Add negative tests for a modified body, modified timestamp, modified signature, malformed timestamp, and timestamp outside the 300-second window. ## 6. Common payload fields All payloads are compact UTF-8 JSON. | Key | Meaning | Type | Description | |---|---|---|---| | `t` | event type | string | `wt`, `rf`, `rr`, or `rl` | | `v` | event version | integer | `1` for V1 | | `e` | event ID | string | 16 characters; idempotency key | | `o` | occurred at | string | Time this event was triggered; ISO 8601 with timezone | | `r` | result ID | string | 16 characters; cross-event result correlation | | `x` | test marker | integer | optional; `1` means simulated business data | The exact meaning of `o` depends on `t`: it is the admin test send time for `wt`, result settlement completion time for `rf`, and threshold time for `rr` or `rl`. ## 7. `race.result_finalized` (`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 | Meaning | Type | Description | |---|---|---|---| | `s` | started at | string | ISO 8601 race record start time with timezone; dispatch time in rolling mode | | `f` | finished at | string | ISO 8601 result completion time with timezone | | `ve.i` | venue ID | string | opaque venue ID | | `ve.n` | venue name | string | venue name | | `rc.i` | race class ID | string | opaque class/group ID | | `rc.n` | race class name | string | class/group name | | `di` | driver ID | string/null | `null` for guest/no driver | | `dn` | driver name | string/null | `null` for guest/no driver | | `a` | avatar URL | string/null | opaque server-side download URL, valid for at most one hour | | `b` | best lap time ms | integer/null | fastest valid lap in milliseconds | V1 emits only valid results. DNS, DNF, and DQ results are not emitted. Manual edits after settlement do not emit an update event. When `a` is non-null, the receiver MUST treat it as a short-lived download URL rather than a stable avatar identifier. It is valid for at most one hour from KartClub's first delivery attempt; retries reuse the exact same URL and do not extend expiration. Schedule a server-side `GET` promptly, accept only the `a.kart.yun` HTTPS host, validate the raster image response, and persist the image under the receiver's own URL. The response uses `Cross-Origin-Resource-Policy: same-origin`, so direct embedding from another browser origin is intentionally blocked. Do not use `HEAD`, alter the token, swap the hostname, or expose this temporary URL as a client asset. HTTP `410` means expired and `404` means invalid. HTTP `502` means the source fetch failed; retry it only a limited number of times and abandon a persistently failing image. Historical unsupported avatar sources can produce `a = null`. ## 8. Rolling events (`rr` and `rl`) ### Lap-based `rolling.return_due_soon` (`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 } } ``` ### Time-based `rolling.return_due_soon` (`rr`) ```json { "t": "rr", "v": 1, "e": "V2kLm8QpR5xN7cZa", "o": "2026-08-20T12:30:00+08:00", "r": "nKaLjg6RqgT0bcA0", "vi": "DBLwV6V8oFvA1ADo", "c": "18", "p": { "m": "t", "d": 420, "q": 480, "r": 60 } } ``` ### `rolling.purchase_limit_reached` (`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 | Meaning | Type | Description | |---|---|---|---| | `vi` | venue ID | string | opaque venue ID | | `c` | race car number | string | integer car number converted to string; leading zero is not guaranteed | | `p.m` | mode | string | `l` for laps; `t` for time | | `p.d` | completed | integer | completed laps or elapsed seconds | | `p.q` | purchased quota | integer | purchased laps or seconds | | `p.r` | remaining | integer | remaining laps or seconds; may be zero or negative in `rl` | ## 9. Test events - `webhook.test` / `wt` contains only common fields and validates connectivity, signature handling, and 2xx response handling. - Simulated `rf`, `rr`, and `rl` events have the same schema as production events and include `x = 1`. - IDs in simulated events are random and example business values are not real race data. - `x` is covered by the signature. - Record simulation results for integration diagnostics, but do not insert simulated events into production business tables. ## 10. Idempotency Retries preserve the raw body, `e`, and `r`. Enforce `e` uniqueness atomically. ```sql CREATE TABLE kartclub_webhook_events ( event_id VARCHAR(16) PRIMARY KEY, event_type VARCHAR(2) NOT NULL, result_id VARCHAR(16) NOT NULL, received_at TIMESTAMP NOT NULL, payload JSON NOT NULL ); ``` Use an atomic insert such as `INSERT ... ON CONFLICT DO NOTHING`. Trigger downstream processing only when the first insert succeeds. Do not implement idempotency as a separate read followed by an insert. ## 11. Response and retries | Result | KartClub behavior | |---|---| | HTTP `2xx` | success; no retry | | network error or timeout | retry | | HTTP `408`, `425`, or `429` | retry | | HTTP `5xx` | retry | | any other HTTP `3xx` or `4xx` | final failure; no retry | Maximum attempts: 1. Immediately after the event. 2. No earlier than approximately 2 seconds after the first failure. 3. No earlier than approximately 10 seconds after the second failure. A valid `Retry-After` on HTTP 429 may be honored up to 1 hour. Delivery is best effort. V1 has no historical replay API, and even a short receiver outage may exhaust all three attempts. Repeated final failures can automatically suspend the endpoint. ## 12. Avatar download security The `a` field is a source URL for server-side download. Cache the image in customer-controlled storage; do not make browser or app clients depend on the source URL. The downloader MUST: - accept only public HTTPS URLs; - resolve DNS and reject all private, loopback, link-local, and reserved target addresses; - disable redirects, or repeat DNS/address validation for every redirect hop; - limit response size, Content-Type, total download time, and decoded pixel dimensions; - ignore untrusted file names and extensions from the URL; - avoid logging unnecessary personal data. ## 13. Acceptance checklist - [ ] Public HTTPS endpoint is reachable and does not require redirects. - [ ] Raw body is captured before JSON parsing. - [ ] Timestamp format and 300-second freshness window are validated. - [ ] Signature uses the decoded key and constant-time comparison. - [ ] Fixed signature vector and negative signature cases pass. - [ ] JSON is parsed only after successful validation. - [ ] `e` has a database uniqueness constraint. - [ ] Duplicate deliveries do not repeat business side effects. - [ ] `r` correlates events but is not used for event idempotency. - [ ] `x = 1` events stay outside production business tables. - [ ] Durable acceptance occurs before returning 2xx. - [ ] Avatar downloads implement the required SSRF and resource limits. - [ ] `wt` and every subscribed business event have been tested. - [ ] Unknown additional fields are ignored. - [ ] Unsupported `v` values are rejected and alerted.