Skip to content
twitch.apiv2.0

Events

Managing subscriptions

Wait for subscriptions, stop them, list what your application has on Twitch's side, create and delete by hand, and read statuses and cost.

The listeners create and delete subscriptions for you as handlers come and go. This page covers what happens underneath, and the calls of twitch.eventsub that let you inspect and clean up the subscriptions Twitch holds for your application.

import { Twitch } from "@ix-xs/twitch.api";
const twitch = new Twitch({ clientId, clientSecret });
const hooks = twitch.eventsub.webhook({ callback, secret });
const handle = hooks.on("stream.online", { broadcaster_user_id: "141981764" }, (event) => {
console.log(`${event.broadcaster_user_name} is live`);
});
await handle.ready; // Twitch accepted the subscription
console.log(handle.type, handle.version, handle.condition);
await handle.stop(); // remove the handler, and the subscription with it

handle.ready is a promise that resolves once the subscription is in place: when Twitch has answered the request on a WebSocket or a conduit, when the verification challenge has arrived on a webhook. Awaiting it is optional.

It rejects with a TwitchEventSubError when Twitch refuses the subscription. The message reads Could not subscribe to <type> (version <version>): <reason>, and cause holds the original error, so you can tell a missing scope from another failure:

import { TwitchApiError, TwitchEventSubError } from "@ix-xs/twitch.api";
const handle = events.on("channel.follow", { broadcaster_user_id: channelId, moderator_user_id: botId }, () => {});
try {
await handle.ready;
} catch (error) {
if (error instanceof TwitchEventSubError && error.cause instanceof TwitchApiError && error.cause.status === 403) {
console.error(`Not authorized for ${handle.type}: ask for the missing scope`);
} else {
throw error;
}
}

A refused subscription is removed from the listener. Two other cases are worth knowing:

  • If you stop the handle or close the listener before Twitch confirmed, ready rejects with The subscription to <type> was removed before Twitch confirmed it.
  • While a WebSocket is disconnected, ready stays pending until the connection is back. On a conduit it also waits for the shard to be assigned, and rejects if that assignment fails for good.

Handlers that share a subscription share the same ready promise.

handle.stop() removes that one handler. When other handlers are still attached to the same subscription, nothing else happens. When it was the last one, the listener forgets the subscription and deletes it on Twitch’s side:

TransportWhat the last stop() does
WebSocketDeletes the subscription with the user token, when the session is connected. Closes the connection if this was its last subscription. While disconnected it sends nothing: Twitch has already disabled the subscription.
WebhookDeletes the subscription with the app token. With subscribe: false it sends nothing.
ConduitDeletes the subscription with the app token, connected or not.

The promise returned by stop() resolves when the request is done. A 404 from Twitch, meaning the subscription was already gone, is ignored. Any other failure rejects it.

Calling stop() while the subscription is still being created is safe: the listener deletes it as soon as Twitch returns it.

A webhook or conduit subscription that already existed when the process started has no known ID until it delivers its first event. Until then stop() removes the handler without deleting anything. Delete such a subscription by ID, as described below.

twitch.eventsub.subscriptions() returns a paginator over the subscriptions Twitch holds, oldest first.

for await (const subscription of twitch.eventsub.subscriptions({ type: "stream.online" })) {
console.log(subscription.id, subscription.status, subscription.transport.method, subscription.condition);
}
const failed = await twitch.eventsub.subscriptions({ status: "notification_failures_exceeded" }).all();
FilterKeeps
statusSubscriptions in this state, for example "enabled".
typeSubscriptions of this type.
user_idSubscriptions whose condition mentions this user.
subscription_idThe subscription with this ID.
conduit_idSubscriptions delivered through this conduit.

Use at most one filter per call. The endpoint has no page size parameter: Twitch decides how many items a page holds.

Which subscriptions you see depends on the token. With the app token you get the webhook and conduit subscriptions of your application. To see the WebSocket subscriptions created with a user’s token, make the call as that user:

const ofBot = await twitch.eventsub.subscriptions({}, { as: botId }).all();
for (const subscription of ofBot) {
console.log(subscription.type, subscription.status, subscription.transport.session_id);
}

A client that has a client secret uses the app token for this call unless you pass { as }, even when a user is registered.

The body of each page carries three totals next to the items. They are on page.raw:

const page = await twitch.eventsub.subscriptions();
const { total, total_cost, max_total_cost } = page.raw;
console.log(`${total} subscriptions, cost ${total_cost} of ${max_total_cost}`);
FieldMeaning
totalNumber of subscriptions you have created.
total_costSum of the costs of your subscriptions.
max_total_costHighest total cost you are allowed to reach.

Each subscription has its own cost, which is what it adds to total_cost. The rules that decide the cost of a subscription are Twitch’s, and they are explained in Subscription limits in the Twitch documentation.

twitch.eventsub.subscribe() creates one subscription and resolves with it. You need it for subscriptions a listener does not create: those of a webhook listener built with subscribe: false, or those of an endpoint the library does not serve.

const created = await twitch.eventsub.subscribe({
type: "stream.online",
condition: { broadcaster_user_id: "141981764" },
transport: { method: "webhook", callback: "https://example.com/twitch/eventsub", secret },
});
console.log(created.id, created.status, created.cost);

The type and the condition are typed like in on(), and the version is filled in from the name. The token follows the transport: a user token for { method: "websocket", session_id }, the app token for webhook and { method: "conduit", conduit_id }.

A webhook subscription starts as webhook_callback_verification_pending and becomes enabled once the callback has answered the challenge. Unlike a listener, subscribe() throws when the subscription already exists: catch the TwitchApiError whose status is 409 if that is fine for you. The other errors are described in Errors.

A listener only runs handlers for the subscriptions it knows. If the events of a subscription created by hand arrive at a listener, register a handler with the same type and condition there.

twitch.eventsub.unsubscribe(id) deletes a subscription by ID.

await twitch.eventsub.unsubscribe(subscription.id);
// A WebSocket subscription is deleted as the user who created it.
await twitch.eventsub.unsubscribe(subscription.id, { as: botId });

A listener is not told when you delete one of its subscriptions this way: its handlers stay registered and no longer receive anything. Inside a running program, prefer handle.stop().

Delete webhook subscriptions that no longer work

Section titled “Delete webhook subscriptions that no longer work”

Webhook subscriptions pile up when a callback URL changes or a server goes away. This script removes every webhook subscription that is neither enabled nor waiting for its challenge.

scripts/clean-subscriptions.ts
import { Twitch } from "@ix-xs/twitch.api";
const twitch = new Twitch({
clientId: process.env.TWITCH_CLIENT_ID!,
clientSecret: process.env.TWITCH_CLIENT_SECRET!,
});
// Read the whole list first, so that deleting does not shift the pages.
const subscriptions = await twitch.eventsub.subscriptions().all();
const dead = subscriptions.filter(
(subscription) =>
subscription.transport.method === "webhook" &&
subscription.status !== "enabled" &&
subscription.status !== "webhook_callback_verification_pending",
);
for (const subscription of dead) {
await twitch.eventsub.unsubscribe(subscription.id);
console.log(`Deleted ${subscription.type} (${subscription.status}) of ${subscription.transport.callback}`);
}
console.log(`${dead.length} of ${subscriptions.length} subscriptions deleted`);

To drop the subscriptions of an old tunnel or an old domain instead, filter on subscription.transport.callback.

status is typed as EventSubSubscriptionStatus. Only enabled subscriptions deliver events.

StatusMeaning
enabledThe subscription delivers events.
webhook_callback_verification_pendingTwitch is waiting for the callback to answer the challenge.
webhook_callback_verification_failedThe callback did not answer the challenge correctly.
notification_failures_exceededToo many deliveries to the callback failed.
authorization_revokedThe user withdrew the authorization the subscription relied on.
moderator_removedThe moderator named in the condition is no longer a moderator of the channel.
user_removedA user named in the condition no longer exists.
chat_user_bannedThe user named in the condition was banned from the broadcaster’s chat.
version_removedThe type or this version of it is no longer available.
beta_maintenanceThe type is in beta and under maintenance.
websocket_disconnectedThe client closed the connection.
websocket_failed_ping_pongThe client did not answer a ping.
websocket_received_inbound_trafficThe client sent a message other than a pong.
websocket_connection_unusedThe client created no subscription in time after connecting.
websocket_internal_errorTwitch had an internal error.
websocket_network_timeoutTwitch timed out writing to the connection.
websocket_network_errorTwitch had a network error writing to the connection.
websocket_failed_to_reconnectThe client did not move to the new connection after a reconnect request.
conduit_deletedThe conduit of the subscription was deleted.

When Twitch ends a subscription on its own, it sends a revocation. The listener drops the subscription, stops calling its handlers and calls the onRevocation listeners with the subscription, whose status says why.

declare function alertOwner(text: string): void;
hooks.onRevocation((subscription) => {
const channel = subscription.condition.broadcaster_user_id;
if (subscription.status === "authorization_revoked") {
alertOwner(`Channel ${channel} disconnected the application`);
} else {
alertOwner(`${subscription.type} was revoked for channel ${channel}: ${subscription.status}`);
}
});

The listener does not subscribe again. Once the cause is fixed, for example after the user authorized your application again, call on() to create a new subscription.

On webhooks and conduits you can also follow authorizations for the whole application with user.authorization.revoke, whose condition is your client ID.

  • The total cost of your subscriptions cannot exceed max_total_cost. Read both numbers from page.raw instead of assuming them.
  • Creating a subscription that already exists with the same transport gives a 409. The webhook and conduit listeners count it as accepted, subscribe() throws it.
  • WebSocket subscriptions have their own limits per user, listed in WebSocket.