JavaScript SDK

@xive/stream is the official JavaScript and TypeScript SDK for stream apps. It wraps every endpoint in the API reference in a typed method, and handles EventSub for you: the gateway token and its refresh, de-duplication, and one typed event per subscription type.

On npm as @xive/stream. It is for stream apps (xive_sa_… secrets). Hub bots use xive_as_… secrets and the separate xive.js package.

Install

Node.js 18 or newer. The package ships ESM and CommonJS builds with type declarations, and its only dependency is the centrifuge WebSocket client.

npm install @xive/stream

Create an app and a secret in the developer console, then have a creator add the app to their channel with the scopes you need (consent link).

It also runs in browsers, but your secret belongs on a server. Anyone holding it can act as your app on every channel that added it (Authentication).

Quick start

A bot that answers !hello. It needs chat:read and chat:write.

import { XiveStream } from "@xive/stream";

const xive = new XiveStream({ secret: process.env.XIVE_APP_SECRET });
const events = xive.eventsub.connect();

events.on("channel.chat.message", async (msg) => {
  if (msg.message.text.trim().toLowerCase() === "!hello") {
    await xive.chat.send(msg.broadcaster_user_id, `Hi ${msg.chatter_user_name}!`, {
      replyTo: msg.message_id,
    });
  }
});

events.on("connect", () => console.log("Listening for !hello"));
events.on("error", (err) => console.error(err));

Your bot's own messages are dropped before they reach your listener, so it never answers itself.

Calling the API

Methods are grouped like the API reference. channelId is the creator's profile id: channel_id from channels(), or broadcaster_user_id on an event.

const app = await xive.me();               // { id, client_id, name, icon_url }
const channels = await xive.channels();    // channels that added your app, with scopes

await xive.chat.send(channelId, "Welcome in!");
await xive.moderation.timeout(channelId, userId, { durationSeconds: 300, reason: "Spam links" });
await xive.chat.settings.update(channelId, { slow_mode_seconds: 10 });

const [category] = await xive.categories.search("minecraft", { limit: 1 });
await xive.stream.update(channelId, { title: "Building a castle", categorySlug: category.slug });

const queue = await xive.channelPoints.redemptions(channelId);
await xive.channelPoints.resolve(channelId, queue[0].id, "complete");
GroupMethods
Applicationsme(), channels()
Chatchat.send(channelId, content, {replyTo}), chat.read(channelId)
Moderationmoderation.timeout, ban, unban, deleteMessage, chat.settings.get / update, chat.pin / unpin
VIPsvips.list, add, remove
Chat commandscommands.list, create, update, delete
Streamstream.update, stream.raid
Channel pointschannelPoints.rewards, redemptions, resolve, dropChest
Users & searchusers.get({logins, ids}), streams.live(), categories.search(q)
Channelschannels.goals, channels.replays, channels.schedule, channels.supporterTiers, channels.subscriptions
EventSubeventsub.connect(), eventsub.subscriptions(), eventsub.events()

Responses use the API's own field names. A method returns the object or list the endpoint is about (chat.send returns the message, vips.list the VIPs), and calls that only answer {"success": true} resolve to nothing.

EventSub

xive.eventsub.connect() returns an event emitter straight away and connects in the background. Listen by subscription type; the event's fields are typed from the name.

const events = xive.eventsub.connect();

events.on("channel.follow", (event, meta) => {
  console.log(`${event.user_name} followed (${meta.message_id})`);
});

events.on("channel.channel_points_custom_reward_redemption.add", async (event) => {
  if (event.reward.title === "Hydrate") {
    await xive.chat.send(event.broadcaster_user_id, `${event.user_name} says drink some water!`);
    await xive.channelPoints.resolve(event.broadcaster_user_id, event.id, "complete");
  }
});

// Everything in one place: switch on e.type to narrow e.event.
events.on("notification", (notification, e) => {
  if (e.type === "channel.sparks") console.log(`${e.event.user_name} sent ${e.event.sparks} Sparks`);
});

// A creator removed your app or took away a scope.
events.on("revocation", (r) => console.log("revoked", r.payload.subscription.condition.broadcaster_user_id));

events.on("connect", () => console.log("connected"));
events.on("disconnect", ({ code, reason }) => console.log("disconnected", code, reason));
events.on("error", (err) => console.error(err));

await events.ready; // optional: the first connect, or why it failed
  • The SDK mints the gateway token with POST /api/apps/@me/gateway and mints a fresh one before each expires, which also keeps your app listed for events.
  • Duplicates are dropped on metadata.message_id.
  • Revocations arrive on revocation, never on the typed listeners.
  • After a disconnect, xive.eventsub.events(channelId, { since: cursor }) returns what you missed (catch-up). Pass each item to events.handleMessage(n) so duplicates are dropped, then save page.cursor. Needs version 0.2.0.
  • Your own channel.chat.message events are dropped. Pass connect({ ignoreSelf: false }) to keep them.
  • A listener that throws is reported on error, and the other listeners still run.
  • Node 18 to 21 have no built-in WebSocket: install ws and pass connect({ WebSocket }).
  • events.close() disconnects.

Errors

A refused call rejects with XiveApiError. It has the HTTP status, the API's message, missingScope on a 403 (from missing_scope), retryAfter on a 429, and the whole error body.

import { XiveApiError } from "@xive/stream";

try {
  await xive.chat.send(channelId, "Hello!");
} catch (err) {
  if (!(err instanceof XiveApiError)) throw err;
  if (err.status === 403 && err.missingScope) {
    console.warn(`Ask the creator to grant ${err.missingScope}`);
  } else {
    throw err;
  }
}

Branch on status, not on the message. See Status codes.

Rate limits

On a 429 the SDK waits for Retry-After and tries again, up to 2 times by default, and then throws with retryAfter set. xive.rateLimit holds the Ratelimit-Limit, Ratelimit-Remaining and Ratelimit-Reset headers from the last response that had them.

const xive = new XiveStream({
  secret: process.env.XIVE_APP_SECRET,
  retryOn429: true,       // default
  maxRetries: 2,          // default
  maxRetryWaitMs: 60000,  // default: throw rather than wait longer
});

await xive.channels();
console.log(xive.rateLimit); // { limit, remaining, reset, resetAt } or null

Retries don't replace a send queue: keep chat to 20 messages per 30 seconds per channel. See Rate limits & errors.

README and source

The package README covers every method and option. It will be on npm once the package is published.