Skip to main content

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

PlatformEndpointVerificationSource crate
YouTubePOST /v1/webhooks/youtube (+ GET for verify)PubSubHubbub challenge echolo-youtube-api
KickPOST /v1/webhooks/kickRSA-SHA256 (Kick-Event-Signature) or legacy HMAC (X-Kick-Signature)lo-kick-api
TrovoPOST /v1/webhooks/trovoHMAC-SHA256 (X-Trovo-Signature)lo-trovo-api
ShopifyPOST /v1/webhooks/shopifyHMAC-SHA256 (X-Shopify-Hmac-SHA256)lo-shopify
StripePOST /v1/webhooks/stripeStripe-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.

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 and include: channel.follow, 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.

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 sends hub.mode, hub.topic, hub.challenge, hub.lease_seconds as query params; Lumio echoes hub.challenge back with a 200 text/plain response.
  • POST https://api.lumio.vision/v1/webhooks/youtube — notifications. Body is Atom XML; the handler extracts <yt:channelId> and <yt:videoId> via lo_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 (see routes/webhooks.rs:124). The raw payload preserves channel_id, video_id, title, published, updated.

Security. No signature is used — PubSubHubbub relies on the hub.secret parameter when subscribing; Lumio currently does not set one. If you need strict verification, add an HMAC check on the X-Hub-Signature header in the handler.

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.newkick:subscribe
  • channel.subscription.giftkick:gift
  • channel.followedkick:follower
  • livestream.startedkick:stream_online
  • livestream.endedkick:stream_offline
  • chat.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. Kick webhook subscriptions are created via the connection flow in apps/api/src/routes/connections.rs using the bot OAuth token against Kick's public API; the callback URL is configured per-environment.

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.

Events received (from lo_trovo_api::types::event_types):

  • channel.subscribetrovo:subscribe
  • channel.spelltrovo: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).

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.com domain.
  • 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.

Events mapped (lo_shopify::types::event_topics → Lumio type):

  • orders/createshopify:order
  • orders/paidshopify:order_paid
  • products/createshopify: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.

Events handled:

  • checkout.session.completed — finalises the account/plan binding (creates an account for new signups or upgrades an existing one via metadata.account_id / metadata.user_id).
  • customer.subscription.updated — updates the plan on an account and refreshes current_period_end.
  • customer.subscription.deleted — downgrades to the free plan.
  • 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):

EndpointVerification
POST /v1/webhooks/stripe-connectStripe-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-payoutsPayPal'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.

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:

  1. Automatic, at connection time. When a user connects a Kick / Trovo / YouTube channel via apps/api/src/routes/connections.rs, the connection worker (or the sync job in workers/) registers the corresponding platform subscription using the newly-stored OAuth token.
  2. Automatic, at worker startup. The Twitch EventSub worker registers all subscription_types::ALL every time it (re)connects.
  3. 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 into integration_configs.config.webhook_secret (Shopify) or the Lumio TOML config (Stripe).

Configuration / env vars

KeyTOML pathEnv varUsed by
Require signatures (gate)webhooks.require_signaturesLUMIO__WEBHOOKS__REQUIRE_SIGNATURESAll three platform endpoints
YouTube hub.secretwebhooks.youtube_secretLUMIO__WEBHOOKS__YOUTUBE_SECRETYouTube endpoint
Kick webhook RSA public key (preferred)webhooks.kick_public_keyLUMIO__WEBHOOKS__KICK_PUBLIC_KEYKick endpoint
Kick webhook secret (legacy HMAC)webhooks.kick_secretLUMIO__WEBHOOKS__KICK_SECRETKick endpoint
Trovo webhook secretwebhooks.trovo_secretLUMIO__WEBHOOKS__TROVO_SECRETTrovo endpoint
Stripe webhook secretstripe.webhook_secretLUMIO__STRIPE__WEBHOOK_SECRETStripe endpoint
Shopify webhook secret(per-shop, DB)integration_configs.config.webhook_secretShopify endpoint
Stripe Connect webhook secretpayments.stripe_connect_webhook_secretLUMIO__PAYMENTS__STRIPE_CONNECT_WEBHOOK_SECRETstripe-connect endpoint
PayPal webhook idpayments.paypal_webhook_idLUMIO__PAYMENTS__PAYPAL_WEBHOOK_IDpaypal-payouts endpoint
Accept unsigned payment webhooks (dev only)payments.allow_unsigned_webhooksLUMIO__PAYMENTS__ALLOW_UNSIGNED_WEBHOOKSboth payment endpoints

YouTube does not need a secret (see caveat above). 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 Unauthorized on Kick / Trovo / Shopify / Stripe — signature mismatch. 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 over req.body bytes; any middleware that re-serialises JSON breaks verification).
  • 400 Bad Request on 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 200 silently when no matching channel_connections row is found (see warn!("No account found for \{platform\} channel") log lines). Verify the channel is connected and that platform_channel_id matches exactly.
  • Duplicate events — the event pipeline deduplicates by external_id (Kick / Trovo event_id, YouTube video_id, Shopify order/product ID). Duplicates are logged at debug! and silently acknowledged with 200.
  • Dead PubSubHubbub subscriptions — YouTube leases expire (typically ≤10 days). If you stop receiving notifications, check the channel-sync worker is alive and has resubscribed; you can force a resubscribe by disconnecting and reconnecting the YouTube channel.
  • Replay attacks — the handlers do not currently pin a max clock skew on the Stripe t= timestamp or reject duplicate event_ids at the HTTP layer. Stripe's own dedup (idempotent event IDs) and the event pipeline's external_id unique index absorb replays in practice, but treat this as defence-in-depth only.