Kick Webhook Public Key
Kick signs every webhook delivery with one RSA key. It publishes that key without authentication at https://api.kick.com/public/v1/public-key, in the shape {"data":{"public_key":"<SPKI PEM>"},"message":"OK"}. The API fetches, caches and refreshes this key itself. Operators do not set it by hand.
The refresher has the same shape as the InnerTube credential refresher, with one deliberate difference: it fails closed.
Resolution order
webhooks.kick_public_key (optional pin) -> in-process key -> Redis -> live fetch from Kick
| Stage | Source | Notes |
|---|---|---|
| 1 | webhooks.kick_public_key | Optional operator pin. When set, the pin always wins, and the refresher and the rotation refetch are both off. If the pin cannot be parsed, every delivery is rejected. The fetched key is never used silently in place of a broken pin. |
| 2 | In-process key | This is the parsed key the receiver verifies against. An ordinary delivery never parses PEM and never reads Redis. |
| 3 | Redis lumio:kick:webhook_public_key | Holds {pem, fetched_at} with a 24 h TTL. The leader writes it and every replica reads it. |
| 4 | GET /public/v1/public-key | Returns the live key from Kick. A successful fetch is written to Redis and installed in the process. |
There is no compiled-in fallback key. A baked-in key could not be revoked without a deploy if Kick ever rotated its key because of a compromise. When nothing resolves, the receiver answers 401 while webhooks.require_signatures = true (the default). The legacy webhooks.kick_secret HMAC path is consulted only in this no-key case.
Refresher worker
apps/api/src/workers/kick_public_key.rs runs one cycle at startup and then one cycle every refresh_interval_secs:
- Leader (Redis
SET NXonlumio:kick:public_key_refresh:leader, TTL about 90 % of the interval, left to expire): fetches the key from Kick, writes Redis, and installs the key in its own process. - Followers: pull the Redis value into their own process.
- Failed fetch: the key already held stays in use, because it is most likely still valid.
- Cold boot: if a leader cycle ends with no usable key, the leader logs at
ERRORand fires one Discord alert per incident. The alert is edge-triggered and latched in Redis underlumio:kick:public_key_cold_boot_alert, using the same state machine as the InnerTube alert (ZAF-206).
On-demand reload in the receiver
POST /v1/webhooks/kick reloads the key itself in exactly two cases:
- Cold process. No key is held yet, for example when a delivery arrives before the refresher's first cycle. The receiver reads Redis, then fetches from Kick.
- Rotation. A signature fails against a fetched key (never against a pin). The receiver reloads once, checking Redis first because another replica may already hold the rotated key and then Kick. If the new key differs, it re-verifies once. It never loops.
Both reloads are single-flight (a Tokio mutex) and are skipped when the previous on-demand attempt was less than refetch_cooldown_secs ago, or when the failing key was itself fetched that recently. The cooldown is floored at 60 s. A flood of forged deliveries therefore costs Kick at most one request per cooldown window, and a bad signature against a fresh key is plain 401 with no refetch.
Configuration
[webhooks.kick_public_key_observer] in apps/api/config/default.toml:
| Key | ENV | Default | Meaning |
|---|---|---|---|
refresh_interval_secs | LUMIO__WEBHOOKS__KICK_PUBLIC_KEY_OBSERVER__REFRESH_INTERVAL_SECS | 21600 | Scheduled refresh interval (6 h). Shorter than the 24 h Redis TTL. |
refetch_cooldown_secs | LUMIO__WEBHOOKS__KICK_PUBLIC_KEY_OBSERVER__REFETCH_COOLDOWN_SECS | 60 | Minimum gap between on-demand reloads. Values below 60 are raised to 60. |
public_key_url | LUMIO__WEBHOOKS__KICK_PUBLIC_KEY_OBSERVER__PUBLIC_KEY_URL | https://api.kick.com/public/v1/public-key | Fetch endpoint. Override it only for tests or an egress proxy. |
cold_boot_alert_webhook_url | LUMIO__WEBHOOKS__KICK_PUBLIC_KEY_OBSERVER__COLD_BOOT_ALERT_WEBHOOK_URL | empty | Discord webhook for the no-key alert. Empty disables the alert, but the ERROR log still fires. |
cold_boot_alert_after_failures | LUMIO__WEBHOOKS__KICK_PUBLIC_KEY_OBSERVER__COLD_BOOT_ALERT_AFTER_FAILURES | 1 | Number of consecutive no-key cycles before the alert fires. 0 counts as 1. |
Metrics
| Metric | Type | Labels | Meaning |
|---|---|---|---|
kick_webhook_public_key_refresh_total | counter | trigger (scheduled/on_demand), outcome (changed/unchanged/fetch_failed) | Fetches against Kick |
kick_webhook_public_key_age_seconds | gauge | - | Seconds since the key in use was fetched. 0 means pinned, -1 means there is no key and deliveries are rejected. The value is computed at scrape time. |
A value above 24 h means refreshes are failing. A value of -1 means Kick events are being dropped.
Audit
This worker emits no audit event. Refreshing the key is internal bookkeeping with no actor and no tenant effect (see Audit Events, "do not emit").
Code
| File | Role |
|---|---|
apps/api/src/services/kick_public_key.rs | Resolution order, in-process key, single-flight reload, age collector |
apps/api/src/workers/kick_public_key.rs | Scheduled refresher, leader lock, cold-boot alert |
crates/lo-kick-api/src/webhook.rs | fetch_public_key, parse_public_key, verify_rsa_signature_with_key |
apps/api/src/routes/webhooks.rs | POST /v1/webhooks/kick receiver |