EventSub

EventSub delivers what happens on your channels (chat messages, follows, subs, raids, redemptions, going live) to your app over one WebSocket. It follows Twitch EventSub: the same message envelope, the same subscription type names and the same event field names, so code written for Twitch needs few changes.

Your app doesn't need a public URL. It connects out, and events from every channel that added it arrive on that one connection. If you'd rather receive them over HTTP, add a webhook.

How it differs from Twitch

  • Subscriptions come from consent. On Twitch you create a subscription per type per channel. On Xive, when a creator adds your app, you're subscribed to every type its granted scopes cover on that channel, and removing the app ends them. There is nothing to create or delete. GET /api/apps/eventsub/subscriptions lists what you have.
  • One webhook per app. Twitch's webhook transport is set per subscription. On Xive an app registers one callback URL in the Console and it receives every subscription, alongside the gateway. See Webhooks.
  • The transport is Centrifugo. You connect with a Centrifugo client (JavaScript, Python, Go, Dart, Swift, Java, .NET…) rather than a raw socket. The client handles connecting, keepalive pings and reconnecting, so there are no session_welcome, session_keepalive or session_reconnect messages. Every message you receive is a notification or, when access ends, a revocation.
  • Xive has its own events (gifts, Sparks, super chats, supporter tiers), named in the same channel.* style.

Connect

Ask for a connection token:

curl -X POST https://api.thexive.com/api/apps/@me/gateway \
  -H "Authorization: Bearer $XIVE_APP_SECRET"
{
  "success": true,
  "url": "wss://ws.xivecdn.com/connection/websocket",
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
  "channel": "app:9b1f3c52-6a0e-4d7b-8f4e-2c1d5a7e9b30",
  "expires_at": "2026-10-11T13:00:00Z"
}
  • Connect to url with token. The server subscribes the connection to your app's channel (app:<application id>) for you, so messages arrive on the client's publication event. You can't subscribe to anything else.
  • The token lasts 1 hour. Give your client a token-refresh callback that calls the same route; it runs before the token expires.
  • Staying listed: Xive only sends events to apps that minted a token in the last 2 hours, so an app nobody is running receives nothing. Refreshing on time keeps you listed.
  • Nothing is replayed after a disconnect. If you need to catch up, read recent chat.

The message

Every event is a Twitch EventSub notification:

{
  "metadata": {
    "message_id": "6e1f0a9b-2c3d-4e5f-8a7b-9c0d1e2f3a4b",
    "message_type": "notification",
    "message_timestamp": "2026-10-11T12:00:00Z",
    "subscription_type": "channel.follow",
    "subscription_version": "1"
  },
  "payload": {
    "subscription": {
      "id": "1d4b8e2f-0c6a-4f3e-8b91-5a7d2c0e9f13",
      "status": "enabled",
      "type": "channel.follow",
      "version": "1",
      "condition": { "broadcaster_user_id": "3e0b8a51-2f4c-4f8e-9a6d-1b7c5e2d9f04" },
      "transport": { "method": "websocket", "session_id": "app:9b1f3c52-6a0e-4d7b-8f4e-2c1d5a7e9b30" },
      "created_at": "2026-10-11T09:30:00Z",
      "cost": 0
    },
    "event": {
      "broadcaster_user_id": "3e0b8a51-2f4c-4f8e-9a6d-1b7c5e2d9f04",
      "broadcaster_user_login": "nightowl",
      "broadcaster_user_name": "Night Owl",
      "user_id": "1f9a3b6c-7d2e-4c80-b5a4-e6f0d1c2b3a9",
      "user_login": "pixelpanda",
      "user_name": "Pixel Panda",
      "followed_at": "2026-10-11T12:00:00Z",
      …
    }
  }
}
  • Dedupe on metadata.message_id. For chat it's the message id, and for channel activity it's the activity id, so the same event always has the same id.
  • Route on metadata.subscription_type. The channel is payload.subscription.condition.broadcaster_user_id on every type (the creator's profile id, the {channelId} in API URLs); most events also repeat it as broadcaster_user_id.
  • User ids are Xive profile ids (UUIDs). *_login is the lowercase username and *_name the display name. Either can be null for a deleted account, and a bot's login is always null.
  • Ignore types and fields you don't know; more will be added.

Revocation

As on Twitch, when a creator removes your app from their channel, or re-authorizes it with fewer scopes, you get one revocation message for each subscription that ends. It has the subscription with "status": "authorization_revoked" and no event. It arrives over the gateway and, if you have one, your webhook.

{
  "metadata": {
    "message_id": "b1f0c2a4-…",
    "message_type": "revocation",
    "message_timestamp": "2026-10-11T12:00:00Z",
    "subscription_type": "channel.follow",
    "subscription_version": "1"
  },
  "payload": {
    "subscription": {
      "id": "f1c2…",
      "status": "authorization_revoked",
      "type": "channel.follow",
      "version": "1",
      "condition": { "broadcaster_user_id": "8e0d…" },
      "transport": { "method": "websocket", "session_id": "app:…" },
      "created_at": "2026-10-01T09:30:00Z",
      "cost": 0
    }
  }
}
  • Stop work for that channel (or that type): clear its state, leave its queues. The API now refuses calls for it.
  • No reply is needed and it isn't retried. If the creator adds your app again, events resume without any action from you.
  • Check metadata.message_type before reading payload.event.

Subscription types

TypeScopeSent when
channel.chat.messagechat:readAnyone sends a chat message, including bots (and your own).
channel.chat.message_deletechat:readA message is deleted.
channel.banchat:readA viewer is banned, or timed out (is_permanent: false).
channel.unbanchat:readA ban or timeout is lifted.
stream.onlineevents:readThe channel goes live.
stream.offlineevents:readThe channel goes offline.
channel.updateevents:readThe title or category changes.
channel.followevents:readSomeone follows.
channel.subscribeevents:readSomeone subscribes or resubscribes.
channel.subscription.giftevents:readSomeone gifts subscriptions.
channel.raidevents:readAnother channel raids this one.
channel.channel_points_custom_reward_redemption.addevents:readA viewer redeems a channel-point reward.
channel.giftevents:readA viewer sends a gift (Xive).
channel.sparksevents:readA viewer sends Sparks (Xive; like Twitch Bits).
channel.supporter_tierevents:readA viewer reaches a Supporter tier (Xive).
channel.super_chatevents:readA paid highlighted message (Xive).
channel.tts_messageevents:readA paid message read aloud (Xive).
channel.clipevents:readA clip is created.
channel.markerevents:readA stream marker is added.
channel.poll.beginevents:readA poll starts. See Polls & predictions.
channel.poll.progressevents:readVotes come in (at most about once a second).
channel.poll.endevents:readA poll ends: time ran out, it was ended early, or archived.
channel.prediction.beginevents:readA prediction starts.
channel.prediction.progressevents:readViewers put points on outcomes (at most about once a second).
channel.prediction.lockevents:readThe prediction window closes, or it is locked early.
channel.prediction.endevents:readA prediction is resolved or canceled.
channel.activityevents:readAny other channel activity, with activity_type set. Newer kinds appear here before they get their own type.

Event payloads

Every event starts with broadcaster_user_id, broadcaster_user_login and broadcaster_user_name (except channel.raid, which uses from_ and to_).

channel.chat.message

{
  "broadcaster_user_id": "3e0b8a51-…", "broadcaster_user_login": "nightowl", "broadcaster_user_name": "Night Owl",
  "chatter_user_id": "1f9a3b6c-…", "chatter_user_login": "pixelpanda", "chatter_user_name": "Pixel Panda",
  "message_id": "5d2e7c10-4b8a-4f3e-9c61-a0b7d4e2f915",
  "message": { "text": "!uptime", "fragments": [ { "type": "text", "text": "!uptime" } ] },
  "message_type": "text",
  "gif_url": null,
  "color": "#a78bfa",
  "badges": [ { "set_id": "subscriber", "id": "3", "info": "" } ],
  "reply": null,
  "is_bot": false,
  "is_highlighted": false,
  "avatar_url": "https://media.xivecdn.com/…/avatar.jpg",
  "stream_id": "c71d0f2a-8b3e-4a19-b5d6-0e4f9a2c7b18",
  "sent_at": "2026-10-11T11:59:58Z"
}
  • badges[].set_id is broadcaster, moderator, vip, subscriber (id = months), founder or bot.
  • reply is {parent_message_id, parent_message_body, parent_user_id, parent_user_name} or null.
  • message_type is text, or gif with gif_url set.
  • A bot's chatter_user_id is app:<application id>. Ignore messages from your own app, or your bot will answer itself.
  • Use chatter_user_id as user_id for timeouts and bans (bots, whose id starts with app:, can't be timed out), and message_id to reply or delete.

Moderation

  • channel.chat.message_delete: target_message_id.
  • channel.ban: user_id, user_login, user_name, banned_at, is_permanent, ends_at (when a timeout ends, else null).
  • channel.unban: user_id, user_login, user_name.

Stream

  • stream.online: id (the stream id), type: "live", started_at.
  • stream.offline: stream_id.
  • channel.update: title, category_id, category_name, category_slug, stream_id.

Going live with a new title sends both stream.online and channel.update.

Channel activity

Each has user_id, user_login and user_name for the viewer, plus id (the activity id), description (a ready-made line such as "Gifted 5 subs"), stream_id, occurred_at and metadata (extra per-type fields).

TypeFields
channel.followfollowed_at
channel.subscribecumulative_months, is_gift
channel.subscription.gifttotal, is_anonymous
channel.raidfrom_broadcaster_user_*, to_broadcaster_user_*, viewers
channel.channel_points_custom_reward_redemption.addid (the redemption id: use it with Channel points), user_input, status (unfulfilled; fulfilled for an auto-completing reward; canceled if it was refunded at once), reward: {id, title, cost}, redeemed_at. Here id is the redemption, not the activity row.
channel.giftcredits, gift_name
channel.sparkssparks
channel.supporter_tiertier_key, tier_label
channel.super_chatmessage: {text}, credits
channel.tts_messagemessage: {text}, credits, audio_url
channel.clip, channel.marker, channel.activityactivity_type, amount, amount_unit

List your subscriptions

GET /api/apps/eventsub/subscriptions, like Twitch's Get EventSub Subscriptions. Optional filters: type and broadcaster_user_id.

{
  "success": true,
  "data": [ { "id": "1d4b8e2f-…", "status": "enabled", "type": "channel.chat.message", "version": "1", "condition": { "broadcaster_user_id": "3e0b8a51-…" }, "transport": { "method": "websocket", "session_id": "app:9b1f3c52-…" }, "created_at": "2026-10-11T09:30:00Z", "cost": 0 } ],
  "total": 4,
  "gateway_connected": true
}

gateway_connected says whether events are flowing to you right now (you minted a token in the last 2 hours).

With a webhook set, every subscription's transport is {"method": "webhook", "callback": "https://…"} and its status is the webhook's: enabled, webhook_callback_verification_pending, webhook_callback_verification_failed or notification_failures_exceeded. The response also has "webhook": {"callback", "status"} (null with no webhook).

Catch up on missed events

If your connection drops or a webhook delivery gives up, nothing is lost. GET /api/apps/eventsub/events?broadcaster_user_id=…&since=… (events:read) returns the channel's activity since a cursor, oldest first. That covers follows, subs, gift subs, gifts, Sparks, supporter tiers, raids, redemptions, clips and markers. Events come back as the same notification messages the gateway sends, with the same message_id, so dedupe them together.

{
  "success": true,
  "data": [ { "metadata": { "message_id": "d0ab…", "message_type": "notification", "subscription_type": "channel.gift", … }, "payload": { "subscription": { … }, "event": { "user_name": "Elizabeth", "credits": 10, … } } } ],
  "cursor": "2026-10-11T02:48:32.254Z",
  "has_more": false,
  "next_poll_after": 30
}
  • Save cursor and pass it back as since. Without since you get the last hour. limit is 1 to 500 (default 100).
  • When has_more is true, ask again straight away. Otherwise wait next_poll_after seconds: 30 while the channel is live or busy, 300 when it's offline and quiet.
  • It reaches back at most 7 days, and never before the creator added your app. Chat and stream.* messages aren't replayed; read recent chat instead.

Webhooks

Like Twitch's webhook transport, Xive can POST every notification to an https URL you run. Set it on your app's page in the Console, under EventSub webhook. One URL receives every channel and every type your app is subscribed to.

  • It's in addition to the gateway. While your app holds a gateway connection, events arrive there too, with the same message_id. Run both while you move from one to the other, and dedupe on the id. Once you stop minting gateway tokens, the webhook is the only transport.
  • The URL must be https://, on port 443 or 8443, at a public address. Redirects aren't followed.
  • Saving the URL shows the signing secret once (xive_whsec_…). Organization developers can set the URL; only owners and admins see or rotate the secret. Rotating takes effect from the next delivery.

Verification

When you save the URL, Xive sends a webhook_callback_verification message. Nothing is delivered until your endpoint answers within 10 seconds with HTTP 200 and the challenge as the plain-text body (no JSON, no quotes). Check the signature first, as for any message.

{
  "challenge": "x8Pf2kR9aQ1vN4mZ7cB3dT6hJ0sL5wYe",
  "subscription": {
    "id": "8f2a6c1e-4b7d-4e93-8a05-1c3d9e7f2b64",
    "status": "webhook_callback_verification_pending",
    "type": "*",
    "version": "1",
    "condition": { "application_id": "9b1f3c52-6a0e-4d7b-8f4e-2c1d5a7e9b30" },
    "transport": { "method": "webhook", "callback": "https://example.com/xive/eventsub" },
    "created_at": "2026-10-11T09:30:00Z",
    "cost": 0
  }
}

One callback covers every subscription, so it's verified once, with type: "*". If it fails, the Console shows why and the status is webhook_callback_verification_failed; fix it and save again.

Headers

Twitch's headers, with Xive- in place of Twitch-:

HeaderValue
Xive-Eventsub-Message-IdThe message id: metadata.message_id on the gateway. The same on every retry, so dedupe on it.
Xive-Eventsub-Message-Retry0 on the first attempt, then 1, 2…
Xive-Eventsub-Message-Typenotification, revocation or webhook_callback_verification.
Xive-Eventsub-Message-Signaturesha256= and the hex HMAC-SHA256 described below.
Xive-Eventsub-Message-TimestampWhen this attempt was sent (RFC 3339, UTC).
Xive-Eventsub-Subscription-TypeFor example channel.follow (* for verification).
Xive-Eventsub-Subscription-Version1
Xive-Eventsub-Testtrue on a Console test event; absent otherwise.

The body is {"subscription": {…}, "event": {…}}: the gateway's payload, with transport set to {"method": "webhook", "callback": "…"}. Events are the same as on the gateway (Event payloads).

Verify the signature

Every message is signed exactly as Twitch signs: HMAC-SHA256 with your signing secret over the message id, then the timestamp, then the raw request body, prefixed with sha256=. Compute it over the bytes you received (not re-serialized JSON), compare in constant time, and reject anything that doesn't match. Also reject a timestamp more than 10 minutes old; Xive signs each attempt when it's sent, so retries pass that check.

// npm install express
import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.XIVE_EVENTSUB_SECRET;          // xive_whsec_…
const seen = new Set();
const app = express();

app.post("/xive/eventsub", express.raw({ type: "application/json" }), (req, res) => {
  const id = req.get("Xive-Eventsub-Message-Id");
  const timestamp = req.get("Xive-Eventsub-Message-Timestamp");
  const expected = "sha256=" + crypto.createHmac("sha256", SECRET)
    .update(id + timestamp).update(req.body).digest("hex");
  const given = req.get("Xive-Eventsub-Message-Signature") ?? "";
  if (given.length !== expected.length ||
      !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected)) ||
      Date.now() - Date.parse(timestamp) > 10 * 60 * 1000) {
    return res.sendStatus(403);
  }

  const { challenge, subscription, event } = JSON.parse(req.body);
  if (req.get("Xive-Eventsub-Message-Type") === "webhook_callback_verification") {
    return res.status(200).type("text/plain").send(challenge);
  }

  res.sendStatus(204);                                        // answer first, work after
  if (seen.has(id)) return;                                   // a retry you already handled
  seen.add(id);
  if (subscription.type === "channel.follow") {
    console.log(`${event.user_name} followed ${event.broadcaster_user_name}`);
  }
});

app.listen(8443);

Retries

  • Answer with any 2xx, quickly. Do the work after you've answered. A first attempt waits up to 2 seconds and a retry up to 5 seconds; slower counts as a failure.
  • A notification that fails (a non-2xx, a timeout, a refused connection) is retried with backoff: after about 30 seconds, then 1, 2, 4 and 8 minutes. After 6 attempts (about 15 minutes) it's dropped. For anything older, read it from the API, for example recent chat.
  • Retries can arrive out of order and after newer events. Dedupe on Xive-Eventsub-Message-Id, and use the times inside the event when order matters.

When delivery stops

After 20 failed attempts in a row (any success resets the count), Xive stops delivering, as Twitch does. The status becomes notification_failures_exceeded, in the Console and in GET /api/apps/eventsub/subscriptions. Retries still waiting are dropped after an hour. Fix your endpoint, then save the URL again in the Console: it's verified again and delivery resumes.

A Console test event goes to the webhook too, and its result shows on the page, but it never counts towards the 20.

Limits

  • One webhook per app.
  • Up to 3,000 notifications a minute per app, across all its channels. Past that, the rest of the minute's notifications aren't sent to the webhook (the gateway is unaffected). That's well above one busy channel's chat; if your app needs more, use the gateway.
  • Saving the URL (each save sends a verification request) is limited to 10 a minute.
  • Each delivery counts in Events delivered on your app's usage panel, once per notification whether it arrives over the gateway, the webhook or both.

Test events

You don't need to wait for a real follow or raid to test your handlers. On your app's page in the Console, Send a test event publishes a fake notification of any type above to your app, like the Twitch CLI's twitch event trigger.

  • It is the same envelope as a real notification, with "is_test": true added to metadata. Check it if your app must never act on a test (for example, before paying out or banning someone).
  • The viewer is made up: user_login is xive_test_user and its ids aren't real accounts, so calls that use them (a timeout, a reply to its message_id) fail. The broadcaster is real: your own channel, or a channel that has added your app.
  • It goes to your app's own gateway connection, and to your webhook if one is enabled (with Xive-Eventsub-Test: true; one attempt, never retried). Nothing appears in chat and nobody else receives it. On the gateway your app has to be connected at the time; nothing is queued.
  • Up to 30 test events per minute per app.
{
  "metadata": {
    "message_id": "0c8f4e2a-7b1d-4a39-9e65-3d2f1a0b9c87",
    "message_type": "notification",
    "message_timestamp": "2026-10-11T12:00:00Z",
    "subscription_type": "channel.follow",
    "subscription_version": "1",
    "is_test": true
  },
  "payload": {
    "subscription": { "id": "1d4b8e2f-…", "type": "channel.follow", … },
    "event": {
      "broadcaster_user_id": "3e0b8a51-…", "broadcaster_user_login": "nightowl", "broadcaster_user_name": "Night Owl",
      "user_id": "7e57e57e-0000-4000-8000-000000000001", "user_login": "xive_test_user", "user_name": "Xive Test User",
      "followed_at": "2026-10-11T12:00:00Z",
      …
    }
  }
}

Example client

A minimal Node.js bot using the centrifuge npm package:

// npm install centrifuge
import { Centrifuge } from "centrifuge";

const API = "https://api.thexive.com";
const auth = { Authorization: `Bearer ${process.env.XIVE_APP_SECRET}` };
const gateway = async () =>
  (await fetch(`${API}/api/apps/@me/gateway`, { method: "POST", headers: auth })).json();

const me = (await (await fetch(`${API}/api/apps/@me`, { headers: auth })).json()).application;
const { url, token } = await gateway();
const ws = new Centrifuge(url, { token, getToken: async () => (await gateway()).token });

const seen = new Set();
ws.on("publication", async ({ data: { metadata, payload } }) => {
  if (seen.has(metadata.message_id)) return;   // dedupe
  seen.add(metadata.message_id);
  const e = payload.event;

  switch (metadata.subscription_type) {
    case "channel.chat.message":
      if (e.chatter_user_id === `app:${me.id}`) return;   // ignore ourselves
      if (e.message.text.trim() === "!hello") {
        await fetch(`${API}/api/apps/channels/${e.broadcaster_user_id}/chat`, {
          method: "POST",
          headers: { ...auth, "Content-Type": "application/json" },
          body: JSON.stringify({ content: `Hi ${e.chatter_user_name}!`, reply_to_message_id: e.message_id }),
        });
      }
      break;
    case "channel.follow":
      console.log(`${e.user_name} followed ${e.broadcaster_user_name}`);
      break;
  }
});

ws.connect();