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 |
| Trovo | POST /v1/webhooks/trovo | HMAC-SHA256 (X-Trovo-Signature) | lo-trovo-api |
| Shopify | POST /v1/webhooks/shopify | HMAC-SHA256 (X-Shopify-Hmac-SHA256), per-shop 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 lives in apps/api/src/routes/webhooks.rs (platforms) and apps/api/src/routes/billing.rs (Stripe).
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 }.
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, stream.online, stream.offline, channel.chat.message, channel.chat.message_delete, channel.chat.notification, 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, channel.subscription.gift and channel.subscription.message are defined as constants but deliberately omitted from ALL: subscription activity is taken from channel.chat.notification instead, which fires in sync with chat and already carries is_prime / sub_tier.
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. When webhooks.kick_public_key (LUMIO__WEBHOOKS__KICK_PUBLIC_KEY, Kick's PEM public key from GET /public/v1/public-key) is set, the handler verifies it via lo_kick_api::webhook::verify_rsa_signature and enforces a ±300s timestamp tolerance (replay protection). If only the legacy webhooks.kick_secret (LUMIO__WEBHOOKS__KICK_SECRET) is set, the older HMAC-SHA256 X-Kick-Signature path is used instead. If neither is configured AND webhooks.require_signatures = false, the endpoint accepts unsigned payloads (local dev only); with the production-default require_signatures = true an unset key causes a 403 rejection.
Events received (from lo_kick_api::types::event_types):
channel.subscription.new→kick:subscribechannel.subscription.gifts→kick:giftchannel.followed→kick:followerlivestream.started→kick:stream_onlinelivestream.ended→kick:stream_offlinechat.message.sent→ routed to the chat pipeline (ChatBuffer+ pubsub), not the events pipeline.moderation.banned→ routed to the moderation-log pipeline with dedup + broadcast.
Account resolution is by payload.channel_id against channel_connections.platform_channel_id where platform = 'kick'. 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, both the chat and event paths log a warn! and drop the payload (still acknowledging with 200) instead of dropping it silently.
Subscription. Connecting a Kick channel 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), registers one subscription per event type against Kick's public API, encrypts and stores any per-subscription secret Kick returns into integration_configs, then exits. The callback URL is {server.public_url}/v1/webhooks/kick.
Registered event types (KICK_WEBHOOK_EVENTS): channel.followed, channel.subscription.new, channel.subscription.gifts, livestream.started, livestream.ended, chat.message.sent, moderation.banned. 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).
Trovo
Endpoint. POST https://api.lumio.vision/v1/webhooks/trovo.
Signature. HMAC-SHA256 of the body, X-Trovo-Signature header, secret in webhooks.trovo_secret (LUMIO__WEBHOOKS__TROVO_SECRET). Implementation is identical in shape to Kick — see crates/lo-trovo-api/src/webhook.rs. With no secret configured, the endpoint rejects with 401 under the default require_signatures = true.
Events received (from lo_trovo_api::types::event_types):
channel.subscribe→trovo:subscribechannel.spell→trovo:spell(Trovo's cheers/tips)
Account resolution is by payload.channel_id against channel_connections.platform_channel_id where platform = 'trovo'. The id and channel name are captured from Trovo's GET /openplatform/channel at connect time (and backfilled at startup for older rows).
Receive-only, like YouTube. Nothing in the codebase registers a Trovo webhook subscription — connecting a Trovo channel starts TrovoWebSocketConfig (apps/api/src/workers/trovo.rs), a chat-WebSocket worker that detects follow/sub/gift/raid activity from the chat stream and feeds the event pipeline directly. The endpoint above will only fire if a subscription is registered out-of-band.
Shopify
Endpoint. POST https://api.lumio.vision/v1/webhooks/shopify.
Headers required:
X-Shopify-Topic— e.g.orders/create,orders/paid,products/create.X-Shopify-Shop-Domain— the*.myshopify.comdomain.X-Shopify-Hmac-SHA256— base64-encoded HMAC-SHA256 of the raw body.
Signature. Per-shop. The secret is stored in integration_configs.config.webhook_secret (not a global env var) and looked up by shop domain via db::connections::find_integration_by_shop_domain. Verified with lo_shopify::webhook::verify_hmac (constant-time); a mismatch returns 401.
Tenant lookup is exact. The integration is resolved by exact normalized-host equality against the stored shop_url (scheme/path stripped, lower-cased) — not a LIKE '%…%' substring. This closes a tenant-confusion hole where an attacker-controlled X-Shopify-Shop-Domain (e.g. a bare myshopify.com or one containing %/_ LIKE wildcards) could steer resolution to an arbitrary account.
Fail-closed on a missing secret. Like the other platform endpoints, Shopify honours webhooks.require_signatures. When the matched integration has no webhook_secret (or an empty one) and the production default require_signatures = true is in effect, the request is rejected with 401; the check is skipped only when require_signatures is explicitly set to false for local development (with a loud warning). Always store the signing secret on the integration.
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. Parsing lives in lo_shopify::webhook::parse_order_webhook and parse_product_webhook.
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, customer.subscription.created, customer.subscription.deleted, invoice.payment_succeeded, invoice.payment_failed, account.updated. Anything else is logged at debug! and acknowledged.
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 three ways:
- Automatic, at connection time — Kick only. When a user connects a Kick channel via
apps/api/src/routes/connections.rs, a short-lived Kick worker (apps/api/src/workers/kick.rs) registers one subscription per event type against Kick's public API using the newly-stored bot OAuth token, stores any returned per-subscription secret, and exits. The callback URL is built fromserver.public_urlas{public_url}/v1/webhooks/kick. - Automatic, at worker startup — Twitch. The Twitch EventSub worker registers all
subscription_types::ALLevery time it (re)connects. This is a WebSocket transport, not an HTTP webhook. - Admin-controlled for Shopify & Stripe. These are configured in the platform's own dashboard (Shopify admin → Notifications → Webhooks, Stripe dashboard → Developers → Webhooks). The callback URL is
https://api.lumio.vision/v1/webhooks/{platform}and the signing secret is copied intointegration_configs.config.webhook_secret(Shopify) or the Lumio TOML config (Stripe).
YouTube and Trovo have no registration path at all. Their receiver endpoints exist and are signature-verified, but nothing in the codebase subscribes them — connecting a YouTube or Trovo channel starts a polling / chat-WebSocket worker instead. A subscription for either 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 |
| Kick webhook RSA public key (preferred) | webhooks.kick_public_key | LUMIO__WEBHOOKS__KICK_PUBLIC_KEY | Kick endpoint |
| Kick webhook secret (legacy HMAC) | webhooks.kick_secret | LUMIO__WEBHOOKS__KICK_SECRET | Kick endpoint |
| Trovo webhook secret | webhooks.trovo_secret | LUMIO__WEBHOOKS__TROVO_SECRET | Trovo endpoint |
| Stripe webhook secret | stripe.webhook_secret | LUMIO__STRIPE__WEBHOOK_SECRET | Stripe endpoint |
| Shopify webhook secret | (per-shop, DB) | integration_configs.config.webhook_secret | Shopify endpoint |
| 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 Trovo endpoints: with it on, an unset secret means every request is rejected with 401. Shopify ignores it (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 / Trovo / Shopify / Stripe — signature mismatch, or (for YouTube / Kick / Trovo) no secret configured whilewebhooks.require_signatures = true. Confirm the secret in config matches the one configured on the platform dashboard and that the reverse-proxy in front of Lumio is not rewriting the raw body (HMAC is computed overreq.bodybytes; any middleware that re-serialises JSON breaks verification). For Kick, also check clock skew: the RSA path rejects aKick-Event-Message-Timestampmore than 300 s from server time.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(Kick / Trovoevent_id, YouTubevideo_id, Shopify order/product 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.