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.
- Install Node.js 22.12 or newer on the same computer that runs Stream Pulse.
-
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 --versionAlternatively, 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. - Run
pnpm install --frozen-lockfileonce. The companion loads shared helpers from the@stream-pulse/node-kitworkspace 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
distfolder, 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_PORTand use that port consistently in companion origins and tunnel routing. - Origin rejected: match the actual standalone page’s origin exactly
(
localhostand127.0.0.1differ). 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.sentchannel.followedchannel.subscription.newchannel.subscription.renewalchannel.subscription.giftschannel.reward.redemption.updatedlivestream.status.updatedlivestream.metadata.updatedmoderation.bannedkicks.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.