Webhooks
Lumio exposes a small set of inbound webhook endpoints under /v1/webhooks/* that receive platform event notifications (chat, subscriptions, follows, orders, payments) and fan them out into the same lo-events / lo-chat pipelines used by the rest of the stack. This page is the public contract for those endpoints.
Overview
| Platform | Endpoint | Verification | Source crate |
|---|---|---|---|
| YouTube | POST /v1/webhooks/youtube (+ GET for the challenge echo) | HMAC-SHA256 (X-Hub-Signature-256) or HMAC-SHA1 (X-Hub-Signature) | lo-youtube-api |
| Kick | POST /v1/webhooks/kick | RSA-SHA256 (Kick-Event-Signature) or legacy HMAC (X-Kick-Signature) | lo-kick-api |
| Shopify | POST /v1/webhooks/shopify | HMAC-SHA256 (X-Shopify-Hmac-SHA256), app client secret | lo-shopify |
| Stripe | POST /v1/webhooks/stripe | Stripe-Signature (t=…, v1=…) | stripe-rust |
All endpoints are unauthenticated - they are protected by per-platform signature verification, not JWT or API key auth. Callers are expected to be the platform itself, not Lumio users.
The GET /v1/webhooks/youtube challenge echo is the only endpoint with no signature step: it exists so the PubSubHubbub hub can confirm a subscription, and it simply reflects hub.challenge back as 200 text/plain.
Route wiring: all seven receivers are mounted together in a dedicated web::scope("/v1/webhooks") in apps/api/src/main.rs, registered before the main /v1 scope. The handlers themselves live in apps/api/src/routes/webhooks.rs (YouTube, Kick, Shopify), apps/api/src/routes/webhooks_payments.rs (Stripe Connect, PayPal Payouts) and apps/api/src/routes/billing.rs (Stripe billing).
Rate limiting — webhook receivers are exempt
The /v1/webhooks/* scope sits outside the generic per-request rate limiter (RateLimitMiddlewareFactory) that wraps the authenticated /v1 scope. This is deliberate: webhook deliveries arrive unauthenticated from a small, shared pool of the platform's own egress IPs, so under the limiter they all share one anon:{ip} bucket at the anonymous tier of 120 req/min — a single global cap across every tenant. An active Kick chat (chat.message.sent) exceeds 120/min instantly, and because Kick auto-unsubscribes an app whose webhook fails continuously for ~a day, the cap would not merely drop events — it would tear down the subscription, which a worker then re-creates, looping (ZAF-1519).
The abuse barrier that replaces the limiter is the mandatory per-platform signature check (webhooks.require_signatures, on by default): a forged or unsigned request is rejected with 401 before any database work, so the endpoint is signature-gated, not an open firehose. Delivery load is absorbed by the async pipeline (Redis chat buffer + external_id event dedup), never by rejecting deliveries. Any finite per-IP ceiling would only reproduce the auto-unsubscribe failure at a higher threshold, so no numeric limit is applied.
Twitch is not in this list. Twitch events do not arrive via HTTP webhook in Lumio - see Twitch (EventSub, WebSocket) below.
Twitch (EventSub, WebSocket, not webhook)
Twitch events do not use a webhook in Lumio. Instead, the API server runs a background worker (apps/api/src/workers/twitch_eventsub.rs) that opens a persistent EventSub WebSocket connection to wss://eventsub.wss.twitch.tv/ws per account, then calls the Helix POST /eventsub/subscriptions endpoint to register subscriptions with transport: { method: "websocket", session_id }.
Session lifecycle:
- New session. Before it opens the WebSocket, the worker checks the channel token and deletes the account's old subscriptions. It then registers all types as soon as the
session_welcomearrives, because Twitch closes a session that has no subscription after 10 seconds. - Twitch
session_reconnect. The EventSub client (crates/lo-twitch-api/src/eventsub.rs) opens thereconnect_urland keeps reading the old connection. It closes the old connection only after the new connection sends itssession_welcome. Twitch moves the subscriptions to the new connection, so the worker does not delete or recreate them, and no event is lost. - Failed handover or lost session. If the handover fails (for example close code
4007), the connection drops, or the keepalive times out, the worker opens a fresh session on the default endpoint and subscribes again. A Helix400saying the WebSocket transport session no longer exists stops the subscription run and also triggers a fresh session, so that no event type stays without a subscription.
Subscribed types are defined in subscription_types::ALL in crates/lo-twitch-api/src/types.rs (each paired with its EventSub version) and include: channel.follow, channel.update, channel.cheer, channel.raid, channel.channel_points_custom_reward_redemption.add and .update (twitch:reward, twitch:reward_status), channel.channel_points_custom_reward.add / .update / .remove (twitch:reward_add / _update / _remove, broadcast only), stream.online, stream.offline, channel.chat.message, channel.chat.message_delete, channel.chat.notification, channel.subscription.gift, channel.ban, channel.unban, channel.moderate, hype-train / poll / prediction / goal / ad-break events, and suspicious-user signals. channel.chat.message_delete (single-message moderator deletes) uses the same broadcaster_user_id + user_id condition as channel.chat.message, so it needs no additional scope beyond the chat-read grant already required for chat ingest.
channel.subscribe and channel.subscription.message are defined as constants but deliberately omitted from ALL: subscribe / resub activity is taken from channel.chat.notification instead, which fires in sync with chat and already carries is_prime / sub_tier.
channel.subscription.gift is in ALL, but only as a straggler safeguard. The canonical gift line still comes from channel.chat.notification (richer — sub_tier, community_gift_id, per-recipient rows). channel.subscription.gift is a single self-contained record (gifter + total + is_anonymous) that the worker holds for a short window keyed on (gifter, total): if a canonical chat header for the same gift arrives in that window it wins and the safeguard is dropped, otherwise the gift line is generated from it. This keeps a gift visible even when the chat-notification path fails (missing / out-of-order per-recipient messages, reconnect, or tracker age-out) without ever doubling the line.
channel.chat.notification notice types that become events: sub (twitch:subscribe), resub (twitch:resub), sub_gift and community_sub_gift (twitch:gift), prime_paid_upgrade (twitch:prime_upgrade), gift_paid_upgrade (twitch:gift_upgrade), pay_it_forward (twitch:pay_it_forward) and bits_badge_tier (twitch:bits_badge). The other documented types (raid, unraid, announcement, charity_donation, plus their shared_chat_* variants) are dropped without logging; raids come from channel.raid. Any notice type Twitch has not documented is dropped too. Its first occurrence per API process is logged at info with the raw event (Unrecognized channel.chat.notification notice_type dropped), and later ones at debug. This is how payloads such as the Gift Match types (gift_sub_base_match, gift_sub_bonus_match_summary, gift_sub_bonus_match) are captured before they get a mapping. To capture from a channel the account does not own, use eventsub-debug --watch-channel (Platform Stream Debuggers).
There is no HTTP endpoint to expose. Lifecycle is fully managed by the worker - subscriptions are recreated on every reconnect and cleaned up when the channel connection is removed.
YouTube (PubSubHubbub)
YouTube uses Google's PubSubHubbub (WebSub) hub at https://pubsubhubbub.appspot.com to push Atom XML feed updates.
Endpoints
GET https://api.lumio.vision/v1/webhooks/youtube- subscription verification. The hub sendshub.mode,hub.topic,hub.challenge,hub.lease_secondsas query params; Lumio echoeshub.challengeback with a200 text/plainresponse.POST https://api.lumio.vision/v1/webhooks/youtube- notifications. Body is Atom XML; the handler extracts<yt:channelId>and<yt:videoId>vialo_youtube_api::webhook::parse_atom_notification.
Lifecycle - receive-only. Only the receiving half of PubSubHubbub exists today. The two endpoints above answer the hub's verification challenge and accept notifications, but Lumio never subscribes itself: the hub URL (YOUTUBE_PUBSUBHUBBUB_HUB in crates/lo-youtube-api/src/types.rs) has no caller in the codebase, so nothing ever POSTs a hub.callback / hub.topic pair. There is consequently no lease to renew and no automatic re-subscription - a subscription has to be registered out-of-band before these endpoints ever fire.
Nothing else depends on this path. Live-broadcast discovery and stream statistics run on InnerTube polling plus the public channel Atom feed - see Channel Status.
Events emitted. Every notification becomes a single youtube:subscribe event. The raw payload preserves channel_id, video_id, title, published, updated. The account is resolved from <yt:channelId> against channel_connections.platform_channel_id where platform = 'youtube'; an unmatched channel logs a warn! and is acknowledged with 200.
Security. The POST handler verifies the PubSubHubbub signature header against webhooks.youtube_secret (LUMIO__WEBHOOKS__YOUTUBE_SECRET), which must equal the hub.secret used at subscribe time. It accepts X-Hub-Signature-256 (HMAC-SHA256) and falls back to X-Hub-Signature (HMAC-SHA1); a missing or mismatched header returns 401. When the secret is unset, the request is rejected with 401 under the production default webhooks.require_signatures = true, and only accepted (with a loud warning) when require_signatures is explicitly set to false for local development.
Kick
Endpoint. POST https://api.lumio.vision/v1/webhooks/kick, Content-Type: application/json.
Signature. Kick's official API signs webhooks asymmetrically: an RSA-SHA256 signature over "{message_id}.{timestamp}.{body}" in the Kick-Event-Signature header. The handler verifies it with a ±300s timestamp tolerance against Kick's public key. Kick publishes that key without authentication at https://api.kick.com/public/v1/public-key. It is the same for every Kick app, is not a secret, and is not shown in kick.com/settings/developer.
No key setup is needed. The API fetches the key itself, caches it in process and in Redis (24 h TTL), and refreshes it every 6 h through a leader-elected worker ([webhooks.kick_public_key_observer]). Details: Kick Webhook Public Key.
- Fail-closed. There is no built-in fallback key. If no key can be resolved (the Kick fetch failed and Redis is empty), every delivery returns
401under the production defaultwebhooks.require_signatures = true, and the refresher logs atERRORand raises a cold-boot alert. A signature that fails against a resolved key is always401, whateverrequire_signaturessays. - Rotation. When a signature fails against the fetched key, the receiver re-fetches once and re-verifies once. The reload is single-flight and runs at most once per
refetch_cooldown_secs(at least 60 s), so forged requests cannot trigger a storm of fetches against Kick. - Optional pin.
webhooks.kick_public_key(LUMIO__WEBHOOKS__KICK_PUBLIC_KEY) overrides the fetched key. Leave it empty. When it is set, it always wins and there is no automatic refresh or rotation refetch. An unparseable pin rejects every delivery. If you pin, preserve the real PEM newlines, because literal\ncharacters break PEM parsing.
Kick's official webhook API does not issue an HMAC shared secret. Leave webhooks.kick_secret empty. It is a legacy X-Kick-Signature compatibility path, not the Kick OAuth Client Secret, and it is consulted only when no RSA key resolves at all. If no key resolves and webhooks.require_signatures = false, the endpoint accepts unsigned payloads for local development only.
Payload shape. Kick delivers the bare event payload as the request body; the event type, version and message id travel in the Kick-Event-Type, Kick-Event-Version and Kick-Event-Message-Id headers (there is no JSON envelope). Dispatch is driven by Kick-Event-Type.
Response codes. A correctly signed delivery is never answered 4xx. Kick unsubscribes an app whose webhook keeps failing for about a day, and a retry never fixes a bad body, so a 4xx would only put the subscription at risk.
| Case | Answer | Log | outcome |
|---|---|---|---|
Signature invalid, or no key while require_signatures = true | 401 | warn! | (not counted) |
Signed, but the body is not JSON or Kick-Event-Type is missing | 200 | error! | parse_failed |
| Signed JSON that does not match the documented shape of its type | 200, payload stored raw without canonical fields (a moderation.banned payload is dropped) | warn! with the event type; for chat.message.sent and moderation.banned also the JSON path of the first mismatch (e.g. banned_user.user_id), with string values redacted | parse_failed |
| Event type Lumio does not handle | 200 | warn! | unmapped |
No connected account for broadcaster.user_id | 200 | warn! | unrouted |
| Already stored under the same dedup key | 200 | debug! | duplicate |
livestream.metadata.updated with nothing changed against the stored baseline (first observation or redelivery) — baseline recorded, no event | 200 | debug! | unchanged |
Stored (or, for moderation.banned, logged) | 200 | debug! | stored |
| Database error | 500 (Kick retries) | error! | error |
Every case except the signature check is counted in platform_webhook_deliveries_total{platform="kick", outcome} on the internal /metrics (see Metrics). The Kick event type is only in the log, never a label. Chat deliveries that decode have their own counter in the chat pipeline.
Events received (lo_kick_api::types::event_types, all version 1), 10 of 10 Kick events:
| Kick event | Lumio event type |
|---|---|
channel.followed | kick:follower |
channel.subscription.new | kick:subscribe |
channel.subscription.renewal | kick:resub |
channel.subscription.gifts | kick:gift |
kicks.gifted | kick:kicks |
channel.reward.redemption.updated | kick:reward for the first delivery of a redemption id, kick:reward_status for every later one |
livestream.status.updated | kick:stream_online when is_live is true, otherwise kick:stream_offline (one event, two outcomes; there is no livestream.started/livestream.ended) |
livestream.metadata.updated | kick:stream_update |
chat.message.sent | chat pipeline (ChatBuffer + pubsub), not the events pipeline |
moderation.banned | moderation-log pipeline, with dedup and broadcast |
The user objects in chat.message.sent and moderation.banned (sender, broadcaster, moderator, banned_user) need user_id and username. Their is_anonymous and is_verified flags read null or a missing value as false, because Kick sends null for them in some moderation.banned deliveries.
Kick sends channel.reward.redemption.updated on every status change (pending, accepted, rejected), so one redemption can arrive two or three times. The receiver looks the redemption id up in platform_event_logs for the account: the first delivery is the redemption line (kick:reward), each later one is a status line (kick:reward_status). The rule holds whether or not Kick delivers pending.
Canonical fields. lo_kick_api::normalize_event (crates/lo-kick-api/src/normalize.rs) keeps the original payload complete in raw and adds Lumio's canonical fields at the top level, because Kick nests the acting user one level down (follower, subscriber, gifter, sender, redeemer). Twitch field names win; Kick-only fields get a kick_ prefix.
| Lumio type | Added top-level fields |
|---|---|
| every event with an actor | user_id (string of actor.user_id), user_login (actor.channel_slug), user_name (actor.username), avatarUrl (actor.profile_picture, omitted when empty so the GraphQL avatar back-fill runs), is_anonymous |
kick:subscribe, kick:resub | cumulative_months (from duration), expires_at |
kick:gift | giftCount and total (number of giftees), is_gift, isGift, giftRecipients (giftee usernames); with exactly one giftee also recipient_user_id, recipient_user_login, recipient_user_name. An anonymous gifter gets gifter_is_anonymous: true, no user_id/avatarUrl, and the Twitch placeholder names AnAnonymousGifter / ananonymousgifter |
kick:kicks | kicks (from gift.amount), message, kick_gift_name, kick_gift_type, kick_gift_tier, kick_pinned_time_seconds; when gift.name matches the static gift table lo_kick_api::kicks_gifts (case-insensitive) also kick_gift_id, kick_gift_image{static, animated} (animated is null for BASIC gifts) and kick_gift_color. An unknown gift adds none of the three. See Events → KICKs gift graphics |
kick:reward, kick:reward_status | the Twitch reward shape: id, status, user_input, redeemed_at, reward{id, title, cost, prompt} (prompt copied from Kick's description) |
kick:stream_update | title, category_id (string), category_name, language, has_mature_content, plus title_changed / category_changed / language_changed (see Live status below) and, like Twitch, old_title / old_category_name / old_language for a field that changed |
kick:stream_online | none (the payload's title, started_at, ended_at are already top level) |
kick:stream_offline | duration_seconds = Kick's ended_at − started_at (whole seconds; time of receipt when ended_at is missing). Kick's own started_at is kept; when it is missing or Kick's zero timestamp, the start captured in channel_status is added as started_at instead. Unknown start or a negative duration adds neither field. Added at ingest, before the event is stored and before the status below clears the start. See Events → Stream offline payloads |
Live status. The two livestream events also drive Kick's live status, through the same channel_status service as Twitch (apps/api/src/services/kick_live_status.rs), after the event is stored:
| Stored event | Effect |
|---|---|
kick:stream_online | set_online(…, "kick") with broadcast_id = started_at normalised to UTC seconds (default when missing) and the webhook title. A redelivery for the same stream is a no-op; a different stream first closes the old one. Opens the stream-history session, publishes the status that the relay turns into the WebSocket channel:online, and starts the Spotify worker. |
kick:stream_offline | set_offline(…, "kick"): ends the Kick stream row, WebSocket channel:offline. |
kick:stream_update | While Kick is online: update_stream_data with the new title/category (WebSocket status update, live card). While offline it changes nothing but the stored event. title_changed / category_changed / language_changed are computed at ingest against the stored metadata baseline (platform_channel_metadata), because Kick sends the new state without a diff — online and offline. The first observation (no baseline) records it and stores no event; an update where nothing changed is not stored either (outcome unchanged). The baseline advances only once the event is stored, so a failed insert (500, Kick retries) still reports the change on the retry. See Events → Stream update payloads. |
A failure here is logged and never changes the delivery answer: the status is corrected by two API-side checks (apps/api/src/workers/kick_live_status.rs), both on GET /public/v1/channels?broadcaster_user_id=… with the connection's token (stream.is_live, stream.viewer_count, stream.start_time, channel-level stream_title and category; the stream key in that response is never read):
- Start reconcile — whenever the Kick worker starts (API start, channel connect): sets online when Kick says live, clears a stale online status when it does not.
- 60-s poll while live — one call per live Kick channel per minute, none for offline channels: viewer count, title and category via
update_stream_data. Two consecutive "not live" answers set the channel offline; a single one changes nothing. A failed call (429, 5xx, token) skips the tick.
Neither check writes a feed event — only the webhook stores kick:stream_online / kick:stream_offline. See Channel Status.
Deduplication. external_id is the Kick-Event-Message-Id header. A delivery without that header is keyed by sha256(event_type + body) (lowercase hex), so a byte-identical redelivery is still dropped as a duplicate.
Account resolution is by the payload's broadcaster.user_id against channel_connections.platform_channel_id where platform = 'kick' (there is no envelope channel_id). That id is captured from Kick's GET /public/v1/channels at connect time (and backfilled at startup for older rows), so it is never left NULL. When no connection matches, the chat, moderation and event paths all log a warn! and drop the payload (still acknowledging with 200) instead of dropping it silently.
Subscription. Connecting a Kick channel (and every API start) spawns run_kick_webhook_worker (apps/api/src/workers/kick.rs), which pulls a fresh access token for that channel_connections row via get_fresh_connection_token (never refreshing inline), reads the account's existing subscriptions (GET /public/v1/events/subscriptions), subscribes to any missing event in one call (POST /public/v1/events/subscriptions with body {events:[{name,version:1}], method:"webhook"} — all events in a single request; broadcaster_user_id is inferred from the user token and omitted), inspects each per-event data[].error (a 200 does not mean every event subscribed), deletes any subscription for an event Lumio no longer wants, then exits. Reading before writing makes re-runs a no-op, so it never creates duplicate subscriptions. Kick returns no per-subscription secret — deliveries are verified with the configured RSA public key.
Kick has no API to set where deliveries are POSTed. The webhook URL and the "Enable Webhooks" toggle are configured by hand at kick.com/settings/developer → Edit app → "Enable Webhooks" + "Webhook URL". The worker logs the value to configure ({server.public_url}/v1/webhooks/kick) once at start, but an operator must paste that exact URL into the Kick app or no delivery ever arrives. Because the URL is per-app, one Kick app serves one environment.
Registered event types (KICK_WEBHOOK_EVENTS), all 10 Kick events: chat.message.sent, channel.followed, channel.subscription.new, channel.subscription.renewal, channel.subscription.gifts, kicks.gifted, channel.reward.redemption.updated, livestream.status.updated, livestream.metadata.updated, moderation.banned. A unit test checks that every name in VALID_EVENT_NAMES is subscribed and that every one except chat and moderation maps to a stored Lumio type. The gift subscription is registered — and mapped by map_event_type — under the single canonical name channel.subscription.gifts (the plural form Kick actually delivers). Every registered name is validated against lo_kick_api::types::VALID_EVENT_NAMES by a unit test, so a non-existent name (like the old livestream.started/livestream.ended) cannot silently ship again.
Shopify
Endpoint. POST https://api.lumio.vision/v1/webhooks/shopify.
Headers required:
X-Shopify-Topic- e.g.orders/create,orders/paid,products/create,app/uninstalled, or a compliance topic.X-Shopify-Shop-Domain- the*.myshopify.comdomain.X-Shopify-Hmac-SHA256- base64-encoded HMAC-SHA256 of the raw body.X-Shopify-Webhook-Id- the delivery id (the same on every redelivery); used as the idempotency key of theapp/uninstalledtombstone and of a compliance request.
Signature. One secret for every shop: Lumio is one public Shopify app, and Shopify signs each delivery with the app's client secret ([shopify] client_secret, LUMIO__SHOPIFY__CLIENT_SECRET). services::integration_secrets::verify_shopify_webhook checks it with lo_shopify::webhook::verify_hmac_any (constant-time) before the shop is resolved to a tenant; a mismatch returns 401. During a client-secret rotation Shopify keeps signing with the old secret, so the check also accepts [shopify] client_secret_previous (LUMIO__SHOPIFY__CLIENT_SECRET_PREVIOUS) while it is set; shopify_client_secret_matches_total{verifier="webhook", secret} counts which one matched. See Shopify → Client-secret rotation.
Fail-closed on a missing secret. Like the other platform endpoints, Shopify honours webhooks.require_signatures. With no client_secret configured and the production default require_signatures = true, every delivery is rejected with 401; the check is skipped only when require_signatures is explicitly false for local development (with a loud warning).
Tenant lookup is exact. After the signature check, the header must be a plain {store}.myshopify.com host (400 otherwise) and resolves through the indexed integration_configs.shop_host column by exact equality (db::shopify::find_by_shop_host). Each shop belongs to at most one account (partial unique index, see Shopify). Order and product deliveries need an enabled integration; a shop with no enabled integration answers 200 and is dropped.
app/uninstalled. Shopify has already revoked the token. The handler deletes the shop's row (whether or not it is enabled) and writes the app/uninstalled tombstone into shopify_compliance_requests in one transaction, then emits account:integration_disconnected with no actor (reason: app_uninstalled). A redelivery finds nothing and writes nothing.
Events mapped (lo_shopify::types::event_topics → Lumio type):
orders/create→shopify:orderorders/paid→shopify:order_paidproducts/create→shopify:product
Any other topic returns 200 and is dropped silently. The event external_id is namespaced per topic - order:{id}, order_paid:{id}, product:{id} - because the event dedup key has no type: with the bare order id, orders/paid was always dropped as a duplicate of orders/create. A redelivery keeps its id, so dedup still holds per topic. Parsing lives in lo_shopify::webhook::parse_order_webhook and parse_product_webhook.
What an order event stores. orders/create and orders/paid store and broadcast order_number, name (e.g. #1001) and financial_status, plus the order details the store's settings switch on at the time the order arrives (see Shopify → Order details): with show_items (default on) line_items (title, quantity), with show_price (default off) total_price and currency and, when items are shown, each item's price. A switched-off detail is absent, not null, so it never reaches a WebSocket client, a widget, an automation ({{event.raw.total_price}} exists only while show_price is on) or the events API. The customer appears only as customer_display: the reduced label in the form chosen in the integration's customer_display setting (Alex, A. or Alex M., or a sanitized typed name under custom_name), only with the buyer's consent record on the order, and the key is absent with the default none, without consent, or when the order has no customer first name (see Integrations → Shopify customer label). The customer's full name, email, phone, addresses, customer ID, order notes, note_attributes and browser IP are never stored, broadcast or logged; lo_shopify::Order does not even parse them (of note_attributes it keeps only __lumio_name_consent and __lumio_stream_name, as input to the label). A payload that fails to parse is logged with its error category and position only, never with a value from the payload.
Unlinked stores. The webhook subscriptions are app-level, so every store that has the app installed sends its orders, linked or not. The signature is checked first; a store without a linked account is answered 200, and nothing is stored or logged about the delivery.
Shopify compliance topics
Endpoint. POST https://api.lumio.vision/v1/webhooks/shopify/compliance, configured on the app as the compliance_topics URI. A compliance topic that reaches /v1/webhooks/shopify is queued the same way.
Topics. customers/data_request, customers/redact, shop/redact. Any other topic on the compliance endpoint is 400.
Receipt. Same headers and the same app-secret signature check as above, before anything else: an invalid or missing hmac is 401, as Shopify requires. Then the payload's order ids are read (orders_to_redact / orders_requested; the customer object is skipped by the parser and never stored), the store resolves to its account by shop_host whether or not the integration is enabled, and one row goes into shopify_compliance_requests (ON CONFLICT (webhook_id) DO NOTHING). The answer is 200 (also for a redelivery and for an unknown store), 400 for a body of the wrong shape, 500 when the queue cannot be written (Shopify redelivers). The work runs later in the compliance worker; see Shopify → Compliance webhooks.
Stripe
Endpoint. POST https://api.lumio.vision/v1/webhooks/stripe (wired in routes/billing.rs, not routes/webhooks.rs).
Signature. Stripe-Signature header (format t=<timestamp>,v1=<hex>), verified against config.stripe.webhook_secret (LUMIO__STRIPE__WEBHOOK_SECRET) by the in-file verify_stripe_signature helper. Verification fails closed on an empty secret, enforces a ±300s timestamp tolerance (replay protection), and compares the signature in constant time via the MAC's own verifier - matching the Stripe-Connect twin in routes/webhooks_payments.rs. A stale, forged, or unsigned request returns 401.
Events handled:
checkout.session.completed- finalises the account/plan binding (creates an account for new signups or upgrades an existing one viametadata.account_id/metadata.user_id).customer.subscription.updated- updates the plan on an account and refreshescurrent_period_end.customer.subscription.deleted- downgrades to thefreeplan.invoice.payment_failed- logged for now.
All other Stripe event types are acknowledged with 200 and ignored. Feature-flag caches are invalidated on every plan change.
Stripe Connect & PayPal Payouts (extension marketplace)
Two additional money-moving webhooks power the extension marketplace and developer payouts (wired in routes/webhooks_payments.rs):
| Endpoint | Verification |
|---|---|
POST /v1/webhooks/stripe-connect | Stripe-Signature HMAC-SHA256 over the raw body, verified against payments.stripe_connect_webhook_secret. Enforces a ±300s timestamp tolerance (replay protection) and a constant-time signature comparison. |
POST /v1/webhooks/paypal-payouts | PayPal's asymmetric scheme, verified server-side by forwarding the PAYPAL-TRANSMISSION-* / PAYPAL-CERT-URL / PAYPAL-AUTH-ALGO headers and the raw event to PayPal's /v1/notifications/verify-webhook-signature API. Requires payments.paypal_webhook_id; the cert_url host must be paypal.com or a subdomain. |
Neither endpoint carries a utoipa::path annotation, so they do not appear in apps/api/openapi.json.
Stripe Connect events handled: checkout.session.completed (extension purchase), charge.refunded, transfer.paid (legacy: Stripe no longer sends it; the payout cycle marks a Stripe payout completed when the transfer is created), customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.payment_succeeded, invoice.payment_failed, account.updated. Anything else is logged at debug! and acknowledged.
checkout.session.completed records one extension_purchases row per Checkout Session: the session id (cs_…) is the idempotency key, so a redelivered event neither inserts a second row nor credits the developer twice, also after a refund. stripe_payment_id holds the PaymentIntent for one-time purchases and is null for subscription checkouts, which have none. A Checkout Session stays open for up to 24 hours; if the extension or its version is deleted or unpublished in that window, the captured payment is still recorded, with extension_id and/or version_id set to null for what no longer exists. Nothing is installed. The same holds when the account can no longer install the extension (for example, a private access grant was revoked after checkout).
Automatic refund. Both cases are refunded in full in the same webhook pass, after the purchase and the withdrawal-waiver proof are stored:
- One-time purchase (
mode=payment):POST /v1/refundson the session's PaymentIntent,reason=requested_by_customer,metadata[purchase_id]andmetadata[cause](detachedfor a deleted or unpublished extension or version,not_installablefor lost access). - Subscription (
mode=subscription): the subscription is cancelled at once, without proration. The PaymentIntent of its first invoice is then written onto the purchase row, and that PaymentIntent is refunded with the same fields.
Every refund call carries Idempotency-Key: refund:<checkout session id>, so a redelivered event cannot refund twice. A purchase that is no longer completed is skipped. The webhook does not change the ledger itself. The refund triggers charge.refunded, and that handler marks the purchase refunded and debits the developer's balance, also when the extension no longer exists (extension_id is null). A successful refund is recorded as the extension:purchase_auto_refunded audit event. If Stripe refuses the refund or cannot be reached, the API logs an error and still answers 200, because the payment is already recorded and a 500 would make Stripe redeliver the event for days. The purchase then stays completed. A payment that could not be installed because of a temporary error is not refunded automatically. Both kinds stay on the admin list payment without delivery until someone refunds or installs them.
Delivery record. Every purchase records whether it was delivered: delivered_at is set when the extension is installed for the buying account, at checkout or by any later install. A purchase that was not delivered keeps delivered_at empty and records why in undelivered_reason (detached, not_installable, install_failed, or refund_failed once an automatic refund failed). Later uninstalls, deleting the extension or deleting the account do not change a delivered purchase.
Buyer notice. Stripe sends its own refund receipt only when e-mail receipts are enabled in the Stripe Dashboard, so once Stripe has accepted an automatic refund, Lumio mails the purchasing user (metadata.user_id of the session) in German or English, by the user's resolved e-mail language. The mail names the extension (extensions.name; for a deleted extension, the description of the session's Stripe line item), says that it was no longer available when the checkout completed and that the full amount was refunded, shows the amount and the Stripe refund id, and says that the credit appears within 5–10 business days depending on the bank. The mail is sent in the background: the refund never waits for or depends on it, and a failed send is only logged. A session without a buyer, or a buyer without an e-mail address, gets no mail. When Stripe replays an earlier refund response for a redelivered event (Idempotent-Replayed: true), no second mail is sent.
charge.refunded matches the purchase by its PaymentIntent, marks it refunded and debits the developer's balance. A refund issued in the Stripe Dashboard takes the same path. A PaymentIntent that paid no purchase is matched to a renewal invoice (by the PaymentIntent stored on the invoice, otherwise by asking Stripe which invoice it paid). The invoice is marked refunded, and the developer that invoice credited is debited. Refunding one renewal does not end access: access follows the subscription.
Extension subscriptions. The Checkout Session of a subscription records the sub_… id on the purchase and puts extension_id and account_id into the subscription's metadata, which Stripe copies onto every invoice.
invoice.payment_succeededwithbilling_reason = subscription_createis invoice #1. The checkout already credited it, so it writes no invoice row and credits nothing. It only stores its PaymentIntent on the purchase, so a refund of invoice #1 from the Stripe Dashboard finds the purchase; if the PaymentIntent cannot be resolved, the API answers500and Stripe redelivers. When this event arrives beforecheckout.session.completed, the API answers500and Stripe redelivers it.- Every later paid invoice is recorded once in
extension_subscription_invoices(keyed on thein_…id, so a redelivered event credits nothing). The developer is credited at their current revenue split, the same way as at checkout: a changed split applies from the next renewal on. Bothrevenue_balanceandtotal_earnedrise. A purchase whose extension or developer no longer exists records the invoice and credits nobody. A database error answers500, so Stripe redelivers the event. customer.subscription.created/.updatedstore Stripe's subscriptionstatus(active,past_due,unpaid, …), the cancel flag and the period end on the purchase. Stripe does not deliver events in order, so an event older than the last one applied changes nothing.invoice.payment_failedis only logged. Thecustomer.subscription.updated(past_due) that Stripe sends with it carries the state. Access continues while Stripe retries the payment.customer.subscription.deletedends the subscription (subscription_ended_at, statuscanceled). Access ends with it.- Both shapes of the Stripe API are read: the subscription of an invoice sits at
parent.subscription_details.subscriptionsince2025-03-31.basiland atsubscriptionbefore.
Renewal invoices are booking records with the same 8-year retention as purchases. They are deleted first, and a purchase is kept while it still has invoices. Their revenue moves into the per-extension rollup, so developer totals do not shrink.
Payouts of a deleted developer. A payout row outlives the developer profile (developer_id becomes null, see data design). A profile cannot be deleted while one of its payouts is pending or processing, so the webhooks normally find an attached row. They look payouts up by transfer or batch id, not by developer, so a detached row is still marked completed. When a PayPal item fails for a detached row, nobody can be credited: the row is marked failed, the money stays with Lumio, and the API logs a warning with the payout id.
PayPal Payouts events handled: PAYMENT.PAYOUTS-ITEM.SUCCEEDED, PAYMENT.PAYOUTS-ITEM.FAILED, PAYMENT.PAYOUTS-ITEM.UNCLAIMED, PAYMENT.PAYOUTSBATCH.SUCCESS. Anything else is logged at debug! and acknowledged.
Fail-closed by default. If the corresponding secret / webhook id is not configured, both endpoints reject every request. The only way to accept unsigned payloads is the explicit dev-only escape hatch payments.allow_unsigned_webhooks = true - never enable it in production. A missing secret in production therefore causes rejection, not silent acceptance.
Registering subscriptions
There is no public "register a webhook" Lumio endpoint. Subscriptions are created in one of four ways:
- Automatic, at connection time and every API start - Kick only. When a user connects a Kick channel via
apps/api/src/routes/connections.rs(and for every active connection at API start), a short-lived Kick worker (apps/api/src/workers/kick.rs) reconciles the account's event subscriptions against Kick's Events API (GET/POST/DELETE /public/v1/events/subscriptions) using a fresh channel token, then exits. Kick returns no per-subscription secret. The delivery callback URL is not an API field — it must be set by hand in the Kick developer dashboard (see the Kick section above); the worker only logs the value ({public_url}/v1/webhooks/kick) to configure. - Automatic, at worker startup - Twitch. The Twitch EventSub worker registers all
subscription_types::ALLon every new session. A Twitchsession_reconnecthands the subscriptions over and needs no new registration. This is a WebSocket transport, not an HTTP webhook. - App configuration - Shopify. The subscriptions (
orders/create,orders/paid,products/create,app/uninstalledat{server.public_url}/v1/webhooks/shopify, plus the compliance topics) are app-level, declared inapps/shopify/shopify.app.<env>.tomland deployed with the app. Every store that installs the app sends them; a store that is not linked to a Lumio account is dropped on arrival (200, nothing stored), also after a disconnect in Lumio. - Admin-controlled for Stripe. Configured in the Stripe dashboard (Developers → Webhooks). The callback URL is
https://api.lumio.vision/v1/webhooks/stripeand the signing secret lives in the Lumio TOML config.
YouTube has no registration path at all. Its receiver endpoint exists and is signature-verified, but nothing in the codebase subscribes it - connecting a YouTube channel starts a polling worker instead. A subscription has to be registered out-of-band.
Configuration / env vars
| Key | TOML path | Env var | Used by |
|---|---|---|---|
| Require signatures (gate) | webhooks.require_signatures | LUMIO__WEBHOOKS__REQUIRE_SIGNATURES | All three platform endpoints |
YouTube hub.secret | webhooks.youtube_secret | LUMIO__WEBHOOKS__YOUTUBE_SECRET | YouTube endpoint |
| Optional pin for Kick's public RSA PEM key (empty by default: the key is fetched automatically) | webhooks.kick_public_key | LUMIO__WEBHOOKS__KICK_PUBLIC_KEY | Kick endpoint |
| Kick key refresher (interval, cooldown, URL, cold-boot alert) | webhooks.kick_public_key_observer.* | LUMIO__WEBHOOKS__KICK_PUBLIC_KEY_OBSERVER__* | Kick endpoint. See Kick Webhook Public Key |
| Legacy HMAC compatibility secret (Kick does not issue one; leave empty; not the OAuth Client Secret) | webhooks.kick_secret | LUMIO__WEBHOOKS__KICK_SECRET | Used only when no Kick public key resolves |
| Stripe webhook secret | stripe.webhook_secret | LUMIO__STRIPE__WEBHOOK_SECRET | Stripe endpoint |
| Shopify app client secret | shopify.client_secret | LUMIO__SHOPIFY__CLIENT_SECRET | Shopify webhooks, ID tokens and token exchange |
| Stripe Connect webhook secret | payments.stripe_connect_webhook_secret | LUMIO__PAYMENTS__STRIPE_CONNECT_WEBHOOK_SECRET | stripe-connect endpoint |
| PayPal webhook id | payments.paypal_webhook_id | LUMIO__PAYMENTS__PAYPAL_WEBHOOK_ID | paypal-payouts endpoint |
| Accept unsigned payment webhooks (dev only) | payments.allow_unsigned_webhooks | LUMIO__PAYMENTS__ALLOW_UNSIGNED_WEBHOOKS | both payment endpoints |
webhooks.require_signatures defaults to true and gates the YouTube, Kick and Shopify endpoints: with it on, an unset YouTube secret, a Kick public key that cannot be resolved (no pin, fetch failed, Redis empty), or an unset Shopify app client secret means requests are rejected with 401 (see the Shopify section). Twitch uses the Helix OAuth tokens from channel_connections + app_credentials; no webhook secret is involved.
Permissions
Webhook handlers bypass Lumio's JWT/API-key auth and therefore bypass RBAC - they are trust-boundary protected by signature verification only. Once a payload is verified, the handler resolves the target account_id from the signed payload (channel_id, shop_domain, or Stripe customer/metadata) and writes events under that account. There is no webhooks:* permission.
Troubleshooting
401 Unauthorizedon YouTube / Kick / Shopify / Stripe - signature mismatch, or (for YouTube / Kick / Shopify) no verification value configured whilewebhooks.require_signatures = true. Shopify uses the app client secret, shared by all stores, for its HMAC; check that the configured secret matches the Shopify app. For Kick, fetch the current public key fromhttps://api.kick.com/public/v1/public-key, preserve PEM newlines, and check clock skew: the RSA path rejects aKick-Event-Message-Timestampmore than 300 s from server time. For HMAC-based providers, confirm the configured secret and ensure the reverse proxy does not rewrite the raw body.400 Bad Requeston YouTube - Atom XML failed to parse or did not contain<yt:channelId>. Check that the topic URL you used when subscribing matches the channel you expect notifications for.- Events not arriving in the dashboard - the endpoint may be returning
200silently when no matchingchannel_connectionsrow is found (seewarn!("No account found for \{platform\} channel")log lines). Verify the channel is connected and thatplatform_channel_idmatches exactly. - Duplicate events - the event pipeline deduplicates by
external_id(KickKick-Event-Message-Id, orsha256(event_type + body)when that header is missing; YouTubevideo_id; Shopifyorder:{id},order_paid:{id}orproduct:{id}). Duplicates are logged atdebug!and silently acknowledged with200. - Dead PubSubHubbub subscriptions - YouTube leases expire (typically ≤10 days) and Lumio has no resubscription path, so a lapsed lease stays lapsed until someone re-registers it out-of-band. Reconnecting the YouTube channel does not resubscribe. Live-broadcast discovery and stream statistics do not depend on this - they run on InnerTube polling plus the public channel Atom feed.
- Replay attacks - the handlers do not currently pin a max clock skew on the Stripe
t=timestamp or reject duplicateevent_ids at the HTTP layer. Stripe's own dedup (idempotent event IDs) and the event pipeline'sexternal_idunique index absorb replays in practice, but treat this as defence-in-depth only.
Related
- Three-protocol architecture: API Reference → REST, GraphQL, WebSocket
- Events feature - how inbound events are normalised and broadcast once a webhook has been processed.
- Token Refresh - how the OAuth tokens that back webhook subscriptions are kept fresh.