Kick companion

The standalone Kick companion is a small local Node.js service that keeps Kick OAuth credentials off the browser and the Stream Pulse configuration. It binds to 127.0.0.1 and exposes browser /local/* controls, paired /desktop/* controls, and public /callback and /webhook routes. It is not a general-purpose proxy.

The companion supports both the browser simulator and paired Overwolf clients. Its default browser origin is http://127.0.0.1:5173, and requests with a different or missing Origin are rejected. In particular, Overwolf’s file:/null origins are not accepted; do not configure Origin: null or widen the browser origin check. Overwolf instead uses the separate authenticated /desktop/* API after explicit pairing.

Where to get it

The companion is included as source code in the Stream Pulse repository: apps/kick-companion/kick-companion.cjs. It is a separate Node.js process, not a Kick download, a hosted service, or something the delivery dialog starts automatically. The current build does not include a companion installer or executable; users must obtain the source and run it themselves, even when using the Overwolf app.

  1. Install Node.js 22.12 or newer on the same computer that runs Stream Pulse.
  2. Obtain the Stream Pulse repository. With Git installed, open PowerShell in your chosen parent folder:

    git clone https://github.com/camalot/stream-pulse.git
    Set-Location .\stream-pulse
    node --version
    

    Alternatively, use GitHub’s Code > Download ZIP, extract the archive, and open PowerShell in the extracted folder containing package.json. If you already have the source, use that existing folder.

  3. Run pnpm install --frozen-lockfile once. The companion loads shared helpers from the @stream-pulse/node-kit workspace package (packages\node-kit), so do not copy just the one script.

No webpack build is needed; the companion runs directly with Node. See Installation for installing pnpm.

The companion is the self-hosted alternative. Kick accounts use the operator-managed webhook gateway by default, which needs no Node.js, tunnel or Kick application on your PC. Choose Self-hosted Kick companion under the account’s Connection to use this page instead.

Configure and run locally

Register a Kick application at Kick Developer, enable webhooks for the app, and configure its exact redirect URL as https://<your-public-tunnel>/callback. Set the webhook URL in the Kick app to https://<your-public-tunnel>/webhook. The public tunnel must forward these two paths to the local companion; no other public paths are supported. The URLs must use HTTPS.

For illustrated delivery fields, public endpoint services, a path-restricted Cloudflare Tunnel configuration and PowerShell URL examples, see Webhook delivery setup examples. If using a raw-body relay instead, follow its separate webhook and OAuth URL example.

First start: PowerShell

Before starting, obtain your Kick app client ID/secret and a public HTTPS hostname from your tunnel setup. Set the exact webhook and redirect URLs in the Kick developer app. The example below uses a tunnel; replace its hostname and client ID with your real values.

Run these commands from the repository root in the same PowerShell window. The secret prompt avoids putting the secret directly in your command history:

$env:KICK_CLIENT_ID = 'REPLACE_WITH_YOUR_KICK_APP_CLIENT_ID'
$secret = Read-Host 'Kick app client secret' -AsSecureString
$env:KICK_CLIENT_SECRET = [System.Net.NetworkCredential]::new('', $secret).Password
Remove-Variable secret
$env:KICK_PUBLIC_URL = 'https://your-tunnel.example'
$env:KICK_WEBHOOK_URL = 'https://your-tunnel.example/webhook'
$env:STREAM_PULSE_ORIGIN = 'http://127.0.0.1:5173'

node .\apps\kick-companion\kick-companion.cjs

The secret is still present in the running process’s environment: use a trusted computer, do not log it, and never put it in screenshots, exported configuration or source control. These environment settings apply only to this PowerShell session; they are not saved to an .env file or loaded from one automatically. If you open a new terminal later, set them there again before starting.

If pnpm is already available, pnpm kick:companion is an alternative to the last command; it runs the same script. Running the simulator or launching Overwolf alone does not start this companion.

The console should print:

Kick companion listening on 127.0.0.1:5180
Desktop pairing code (expires in 5 minutes): <private single-use code>

Keep that terminal open and the companion running while using Kick accounts, sources or destinations. Run your tunnel in a separate terminal. In the app, use http://127.0.0.1:5180 for Companion origin, then pair Overwolf if needed, sign in under Connections > Accounts, and configure the Kick source or destination. Smee’s generic unverified source does not need this process.

Check that it is listening

In another PowerShell terminal, this browser-style local check should return configured: True:

Invoke-RestMethod -Uri 'http://127.0.0.1:5180/local/status' `
  -Headers @{
    Origin = 'http://127.0.0.1:5173'
    'X-Stream-Pulse' = '1'
  }

Use the exact STREAM_PULSE_ORIGIN value if you changed it. This checks only the local process, not Kick credentials, public tunnel reachability, OAuth or webhook delivery. Opening this URL in a browser address bar is not equivalent: the API requires the origin and custom header.

To stop the companion, press Ctrl+C in its terminal. After it exits, remove the secret from that terminal’s environment if keeping the terminal open:

Remove-Item Env:\KICK_CLIENT_SECRET

If startup fails, check:

  • Cannot find module: open the repository root, not the dist folder, and ensure all three companion scripts are present.
  • Missing/invalid configuration: replace the client ID/secret and HTTPS placeholders; run the environment setup and Node command in the same shell.
  • Address already in use: another process may already be using port 5180. Do not stop unknown processes. Choose an unused KICK_PORT and use that port consistently in companion origins and tunnel routing.
  • Origin rejected: match the actual standalone page’s origin exactly (localhost and 127.0.0.1 differ). Overwolf must pair, not bypass this check.

Environment reference

Configure these environment variables before launching:

Variable Required Default Purpose
KICK_CLIENT_ID Yes — Kick application client ID
KICK_CLIENT_SECRET Yes — Server-side Kick application secret
KICK_PUBLIC_URL Yes — HTTPS base URL for the public tunnel
KICK_PORT No 5180 Local port; the service binds only to 127.0.0.1
STREAM_PULSE_ORIGIN No http://127.0.0.1:5173 Exact browser origin allowed to call /local/*
KICK_CREDENTIALS_FILE No ~/.stream-pulse/kick-credentials.json Local credential and owned-subscription store
KICK_WEBHOOK_URL No KICK_PUBLIC_URL + /webhook Exact public webhook URL used by shared delivery connections

The companion creates the credential directory as needed and requests private file permissions where supported. Only refresh tokens, account metadata, and IDs for subscriptions created by this companion are persisted. Access tokens remain in memory. Refresh-token rotation is persisted; credentials and client secrets are never returned by the local API. On companion restart, saved subscription IDs remain owned but active-source lease counts reset; reconnecting sources acquire fresh leases without leaving old process counts behind.

OAuth and account API

The companion runs the OAuth 2.1 authorization-code flow with PKCE. It uses KICK_CLIENT_SECRET only in server-to-server token and revocation requests. The default requested scopes are user:read, channel:read, channel:write, chat:write, events:subscribe, moderation:ban, and moderation:chat_message:manage.

All /local/* requests require a loopback Host, the exact configured Origin, and X-Stream-Pulse: 1. Forwarded-IP/host/protocol headers are rejected. Browser preflight permits only the configured origin and the companion’s supported methods and headers. JSON errors use { "error": "..." }.

Route Request / response
GET /local/status { "configured": boolean }
GET /local/accounts { "accounts": [{ "id", "login", "userId", "profileImageUrl?" }] }; IDs are the Stream Pulse account IDs supplied at sign-in.
POST /local/login JSON { "accountId" }; returns { "url" } to open in the browser.
GET /local/login?accountId=... { "status": "pending" \| "complete" \| "error", "error"?: string }. Completion tracks the current OAuth attempt, not whether older account metadata exists.
DELETE /local/accounts/:id Revokes the sign-in when possible, removes the local account and its owned subscriptions, and returns { "signedOut": true }.
GET /local/channels?slug=... Uses an application access token and returns { "channelId", "login", "profileImageUrl?" } for a public channel.

The callback must match an unexpired, single-use OAuth state created for that account. A new login attempt resets its polling status. OAuth callback failures are recorded for the corresponding account so the browser can show an error; they do not expose upstream token responses.

Subscriptions and events

POST /local/subscribe accepts { "accountId", "events": ["..."], "broadcasterUserId"?: "..." } and returns { "subscriptionIds": ["..."] }. Event names are allowlisted at version 1:

  • chat.message.sent
  • channel.followed
  • channel.subscription.new
  • channel.subscription.renewal
  • channel.subscription.gifts
  • channel.reward.redemption.updated
  • livestream.status.updated
  • livestream.metadata.updated
  • moderation.banned
  • kicks.gifted

Without broadcasterUserId, subscriptions use the signed-in user token and that account’s channel. With it, the companion uses its app access token to subscribe to the requested broadcaster. The service records each returned subscription ID and its account, event type, and broadcaster. It checks the matching subscriptions before creating; a returned ID that pre-existed outside the companion is borrowed for delivery and is never deleted. If Kick returns an existing companion-owned ID for multiple concurrent sources, the companion leases that ID to each source. Stopping one source releases only its lease; Kick is asked to delete the remote subscription only after the final lease is released. Subscribe and cleanup calls for the same event and broadcaster are serialized so a source restart cannot delete its replacement subscription.

DELETE /local/subscriptions accepts { "accountId", "subscriptionIds": ["..."] }; only IDs owned by that account release a source lease. The remote deletion is sent only when the final active lease is released. Unowned and unknown IDs are ignored.

GET /local/events?accountId=... is an authenticated fetch stream, not a browser EventSource endpoint. It returns newline-delimited JSON containing { "type": "ping" } heartbeats and { "type": "event", "eventType": "...", "body": { ... } }. body is the original parsed Kick webhook JSON payload. Before forwarding, the companion checks the current Kick public key, RSA-SHA256 signature over message-id.timestamp.raw-body, timestamp freshness (five minutes), a bounded replay cache, event type/version, subscription ownership, account, and broadcaster. Keys are fetched dynamically and refreshed on signature failures at a bounded rate.

Shared delivery and desktop access

See Webhook delivery for shared delivery and pairing.

POST /local/deliveries registers an HTTP-tunnel or raw-relay definition: { "id", "name"?, "type", "webhookUrl", "relayUrl"? }. GET /local/deliveries/:id/status returns { "state", "message" }; DELETE /local/deliveries/:id stops the connection. POST /local/subscribe also accepts an optional deliveryId. Its public webhook URL must match KICK_WEBHOOK_URL. Smee definitions are not accepted by the verified provider API.

The equivalent /desktop/* operations require a paired bearer credential, loopback Host/socket, and no forwarded headers. POST /desktop/pair exchanges a locally generated, single-use, five-minute code for { "token", "deviceId" }. DELETE /desktop/pairing/self revokes the calling device. The trusted browser can create codes with POST /local/pairing/code, list devices with GET /local/pairing/devices, and revoke one with DELETE /local/pairing/devices/:id. Desktop codes are rate-limited; token hashes, not plaintext bearer credentials, are persisted server-side.

Only dedicated desktop endpoints allow null/missing origins; they never grant access without a valid token (or a valid single-use code for pairing). Existing /local/* guards and public callback state checks remain unchanged.

Actions

POST /local/action accepts { "accountId", "actionId", "args", "channelId"? }. It implements only these action IDs; it does not accept arbitrary URLs, methods, or API paths:

Action ID Arguments Kick API operation
kick:send-chat-message message, optional replyToMessageId POST /public/v1/chat as a user; message limit is 500 grapheme clusters and 2,048 UTF-8 bytes
kick:delete-chat-message messageId DELETE /public/v1/chat/{message_id}
kick:ban-user userId, optional durationMinutes POST /public/v1/moderation/bans; timeout duration is 1–10,080 minutes, omitted for a permanent ban
kick:unban-user userId DELETE /public/v1/moderation/bans
kick:update-title title PATCH /public/v1/channels for the signed-in account’s channel

The companion validates message IDs and numeric user/channel IDs before making the fixed API calls. Upstream error responses are sanitized before returning them to the browser.

Kick API references