Webhook gateway

The webhook gateway is the default way to connect a Kick account. It is a small operator-managed Node.js service (apps/gateway) that owns the Kick developer application, completes Kick OAuth, verifies Kick webhook signatures and forwards events to signed-in Stream Pulse devices. Users do not need Node.js, a tunnel or their own Kick application.

The Stream Pulse-operated gateway requires StreamPulse Pro. The app and gateway enforce access for account sign-in, event streaming and actions. Self-hosted companions, gateways and user-provided relays remain free. See the Pro guide for provider setup and build-time managed-origin configuration.

If you prefer to keep Kick tokens on your own PC, switch the account to a self-hosted Kick companion and use your own Cloudflare/ngrok tunnel, raw relay or Smee instead.

How it works

Kick ── signed webhooks ──> gateway /webhooks/kick ── verify ──> per-device queue
                                                                   │
Stream Pulse (standalone or Overwolf) <── NDJSON /v1/events ───────┘
Stream Pulse ── /v1/kick/action (chat, moderation) ──> gateway ──> Kick API
  • Kick tokens stay on the gateway. The app stores only a revocable device session in local storage (stream-pulse.gateway.v1): a short-lived access token and a rotating refresh token. Reusing a refresh token revokes the session. Sessions are never exported with configuration.
  • Sign-in uses a device flow. The app shows a short phrase and opens the gateway approval page. Continue only if the page shows the same phrase; the gateway then sends you to Kick. The app claims the session with a PKCE-style verifier, so a copied approval link cannot be claimed by another device.
  • Events are buffered briefly. Each device has a short replay queue (5 minutes or 1000 events). After a reconnect the app resumes from its last cursor. If events were dropped, the Kick source shows “some events were missed while disconnected”.
  • Actions are idempotent. The app sends an Idempotency-Key with each action, so a retried request does not post a chat message twice.

Use it in Stream Pulse

  1. Connections > Accounts > Add account, choose Kick, and save. New Kick accounts default to Webhook gateway (default). Activate Pro first when using the Stream Pulse managed gateway.
  2. Open Sign-in settings. Enter the Webhook gateway URL if the field is empty. Release builds use the URL bundled at build time, if any.
  3. Select Sign in with Kick, open the approval page, check the phrase and approve on Kick. The account shows Signed in as <name>.
  4. Add a Kick source and choose the account. Webhook delivery is fixed to Webhook gateway (default); no delivery needs to be configured.

Disconnect revokes the device session on the gateway and forgets it locally. Switching an account between gateway and companion signs it out first. Existing accounts created before the gateway keep using the companion.

Run the gateway locally

For development, run the gateway with a built-in mock Kick:

pnpm dev:gateway   # or: task dev:gateway

It listens at http://127.0.0.1:5190, which development builds of the app use as the default gateway URL. The mock Kick approves every sign-in as streamer. Send a signed test chat message to connected devices with:

Invoke-RestMethod -Method Post http://127.0.0.1:5190/dev/emit -Body '{}' -ContentType 'application/json'

The dev gateway uses memory-only storage by default. Restarting it (including watch-mode restarts after code edits) invalidates existing device sessions. If Refresh accounts reports that the gateway session expired or was revoked, sign in with Kick again.

Set KICK_CLIENT_ID and KICK_CLIENT_SECRET to use the real Kick instead. Kick then needs a public HTTPS webhook URL, so a deployed gateway is usually simpler.

Configuration

Variable Purpose
GATEWAY_PUBLIC_URL Required. External https:// base URL. Loopback http:// only with GATEWAY_ALLOW_LOOPBACK_HTTP=1.
KICK_CLIENT_ID Kick application client ID. Without it the gateway starts but rejects sign-ins (/healthz reports kick).
KICK_CLIENT_SECRET Kick application client secret.
YOUTUBE_CLIENT_ID / YOUTUBE_CLIENT_SECRET Operator-owned Google web OAuth credentials. Optional; YouTube sign-in is unavailable without them. Never put the secret in app build variables.
GATEWAY_ENCRYPTION_KEY 32-byte AES-256-GCM key (base64 or 64 hex characters). Required with GATEWAY_DATA_DIR.
GATEWAY_DATA_DIR Directory for the encrypted gateway.json. Omit for memory-only storage; devices then reconnect on restart.
PORT / GATEWAY_PORT Listen port (default 8080).
GATEWAY_HOST Listen address (default 0.0.0.0).
GATEWAY_ALLOWED_ORIGINS Extra browser origins allowed to call the device API, comma separated. Overwolf and loopback are built in.
GATEWAY_TRUST_PROXY 1 to use X-Forwarded-For for rate limits behind a trusted proxy (Azure Container Apps ingress).
GATEWAY_DISABLED 1 is an emergency switch: device and webhook traffic are rejected.
GATEWAY_MANAGED Defaults to true (requires Pro). Set false only on user-operated deployments. Billing variables are listed in the Pro guide.
KICK_OAUTH_URL / KICK_API_URL Override Kick endpoints (testing only).

Generate an encryption key with openssl rand -base64 32 and store it as a secret. Losing or changing it makes the stored data unreadable, so all users must sign in again.

In the Kick developer settings, set the redirect URL to <public URL>/oauth/kick/callback and the webhook URL to <public URL>/webhooks/kick.

To bundle a default gateway URL into the app, set STREAM_PULSE_GATEWAY_URL when building (for example STREAM_PULSE_GATEWAY_URL=https://gateway.example.com pnpm build). Without it, release builds leave the field empty and users enter the URL.

YouTube operator setup

YouTube uses Google’s web server authorization-code flow with state and PKCE, not the TV/device flow.

  1. Enable YouTube Data API v3 in the operator’s Google Cloud project.
  2. Configure OAuth consent, privacy policy and authorized domains. While in testing, add authorized test users; production requires the applicable Google verification and quota review before public launch.
  3. Create a Web application OAuth client and register the exact redirect <gateway public URL>/oauth/youtube/callback. For loopback development, register http://127.0.0.1:5190/oauth/youtube/callback.
  4. Set YOUTUBE_CLIENT_ID and YOUTUBE_CLIENT_SECRET only in the gateway’s environment/secret manager. Configure encrypted persistent storage and the managed app build origin as above. The existing infrastructure template does not provision Google credentials; supply these secrets separately.

Sign-ins request youtube.readonly and, with Allow actions, youtube. Google tokens remain on the gateway. Device APIs expose only the supported YouTube operations, never an arbitrary URL proxy. YouTube requires verified Pro even on a gateway with GATEWAY_MANAGED=false; only the explicit local development bypass temporarily skips licensing. All customers share this Google project’s quota; plan capacity before selling access.

Build and deploy

The gateway compiles to a single file with no runtime dependencies:

pnpm build:gateway    # dist/gateway/server.cjs
pnpm start:gateway    # needs GATEWAY_PUBLIC_URL and Kick settings

Container image

task gateway:docker IMAGE=stream-pulse-gateway:local
# or: docker build -f apps/gateway/Dockerfile -t stream-pulse-gateway:local .

Build from the repository root. The image runs as a non-root user on port 8080, has a /healthz health check and uses /data for optional persistence. The Gateway image GitHub workflow builds it for pull requests and publishes ghcr.io/<owner>/stream-pulse-gateway (latest and the commit SHA) from main.

Published images carry supply-chain metadata, produced by the shared .github/workflows/container-image.yml workflow:

  • BuildKit SLSA provenance (mode=max) and an SBOM attached to the image index.

Inspect the image’s BuildKit provenance before you deploy it:

docker buildx imagetools inspect ghcr.io/<owner>/stream-pulse-gateway:latest --format '{{ json .Provenance }}'

Azure Container Apps

apps/gateway/infra/main.bicep deploys a Consumption Container Apps environment and one gateway replica, with optional Azure Files persistence:

az group create --name stream-pulse-gw --location eastus
$env:KICK_CLIENT_ID = '<client id>'
$env:KICK_CLIENT_SECRET = '<client secret>'
$env:GATEWAY_ENCRYPTION_KEY = '<openssl rand -base64 32>'
task gateway:deploy RESOURCE_GROUP=stream-pulse-gw IMAGE=ghcr.io/<owner>/stream-pulse-gateway:latest

The deployment outputs publicUrl, kickRedirectUri and kickWebhookUrl; copy the last two into the Kick developer settings. Optional parameters: publicUrl (custom domain), allowedOrigins, enablePersistence (default true), disabled and registry credentials for private images. See main.parameters.json for an example.

The gateway keeps state in one process, so the template pins it to exactly one replica. An always-on replica at 0.25 vCPU and 0.5 GiB usually fits in the monthly Azure Container Apps free grant, but this is not guaranteed. Azure Files persistence adds a small storage cost. Set enablePersistence=false to avoid it; devices then sign in again after each restart. Check current Azure pricing before deploying.

Device API

Connect and claim routes are public (rate limited); the other /v1 routes require the device bearer token.

Route Purpose
POST /v1/connect/kick Start sign-in; returns the approval URL, phrase and poll info
POST /v1/connect/<attempt>/claim Claim the session with the verifier (202 while pending)
POST /v1/session/refresh Rotate the access and refresh tokens
GET /v1/accounts The connected Kick identity
GET /v1/kick/channels Look up channels by slug
POST /v1/kick/subscribe Subscribe this device to Kick events
DELETE /v1/kick/subscriptions Release this device’s subscriptions
GET /v1/events?after=<cursor> NDJSON stream of ping, gap and event lines
POST /v1/kick/action Run a Kick action (optional Idempotency-Key)
DELETE /v1/devices/self Revoke this device (the last device also releases Kick)
DELETE /v1/integrations/self Disconnect the Kick account from every device
GET /healthz Liveness and whether Kick is configured
/connect/<attempt>, /oauth/kick/callback Browser approval page and Kick OAuth callback
POST /webhooks/kick Kick webhook ingress (signature verified)