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/subscriptionslists what you have. - One webhook per app. Twitch's
webhooktransport 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_keepaliveorsession_reconnectmessages. Every message you receive is anotificationor, when access ends, arevocation. - 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
urlwithtoken. The server subscribes the connection to your app's channel (app:<application id>) for you, so messages arrive on the client'spublicationevent. 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 ispayload.subscription.condition.broadcaster_user_idon every type (the creator's profile id, the{channelId}in API URLs); most events also repeat it asbroadcaster_user_id. - User ids are Xive profile ids (UUIDs).
*_loginis the lowercase username and*_namethe display name. Either can benullfor a deleted account, and a bot's login is alwaysnull. - 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_typebefore readingpayload.event.
Subscription types
| Type | Scope | Sent when |
|---|---|---|
channel.chat.message | chat:read | Anyone sends a chat message, including bots (and your own). |
channel.chat.message_delete | chat:read | A message is deleted. |
channel.ban | chat:read | A viewer is banned, or timed out (is_permanent: false). |
channel.unban | chat:read | A ban or timeout is lifted. |
stream.online | events:read | The channel goes live. |
stream.offline | events:read | The channel goes offline. |
channel.update | events:read | The title or category changes. |
channel.follow | events:read | Someone follows. |
channel.subscribe | events:read | Someone subscribes or resubscribes. |
channel.subscription.gift | events:read | Someone gifts subscriptions. |
channel.raid | events:read | Another channel raids this one. |
channel.channel_points_custom_reward_redemption.add | events:read | A viewer redeems a channel-point reward. |
channel.gift | events:read | A viewer sends a gift (Xive). |
channel.sparks | events:read | A viewer sends Sparks (Xive; like Twitch Bits). |
channel.supporter_tier | events:read | A viewer reaches a Supporter tier (Xive). |
channel.super_chat | events:read | A paid highlighted message (Xive). |
channel.tts_message | events:read | A paid message read aloud (Xive). |
channel.clip | events:read | A clip is created. |
channel.marker | events:read | A stream marker is added. |
channel.poll.begin | events:read | A poll starts. See Polls & predictions. |
channel.poll.progress | events:read | Votes come in (at most about once a second). |
channel.poll.end | events:read | A poll ends: time ran out, it was ended early, or archived. |
channel.prediction.begin | events:read | A prediction starts. |
channel.prediction.progress | events:read | Viewers put points on outcomes (at most about once a second). |
channel.prediction.lock | events:read | The prediction window closes, or it is locked early. |
channel.prediction.end | events:read | A prediction is resolved or canceled. |
channel.activity | events:read | Any 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_idisbroadcaster,moderator,vip,subscriber(id= months),founderorbot.replyis{parent_message_id, parent_message_body, parent_user_id, parent_user_name}ornull.message_typeistext, orgifwithgif_urlset.- A bot's
chatter_user_idisapp:<application id>. Ignore messages from your own app, or your bot will answer itself. - Use
chatter_user_idasuser_idfor timeouts and bans (bots, whose id starts withapp:, can't be timed out), andmessage_idto 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, elsenull).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).
| Type | Fields |
|---|---|
channel.follow | followed_at |
channel.subscribe | cumulative_months, is_gift |
channel.subscription.gift | total, is_anonymous |
channel.raid | from_broadcaster_user_*, to_broadcaster_user_*, viewers |
channel.channel_points_custom_reward_redemption.add | id (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.gift | credits, gift_name |
channel.sparks | sparks |
channel.supporter_tier | tier_key, tier_label |
channel.super_chat | message: {text}, credits |
channel.tts_message | message: {text}, credits, audio_url |
channel.clip, channel.marker, channel.activity | activity_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
cursorand pass it back assince. Withoutsinceyou get the last hour.limitis 1 to 500 (default 100). - When
has_moreistrue, ask again straight away. Otherwise waitnext_poll_afterseconds: 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-:
| Header | Value |
|---|---|
Xive-Eventsub-Message-Id | The message id: metadata.message_id on the gateway. The same on every retry, so dedupe on it. |
Xive-Eventsub-Message-Retry | 0 on the first attempt, then 1, 2… |
Xive-Eventsub-Message-Type | notification, revocation or webhook_callback_verification. |
Xive-Eventsub-Message-Signature | sha256= and the hex HMAC-SHA256 described below. |
Xive-Eventsub-Message-Timestamp | When this attempt was sent (RFC 3339, UTC). |
Xive-Eventsub-Subscription-Type | For example channel.follow (* for verification). |
Xive-Eventsub-Subscription-Version | 1 |
Xive-Eventsub-Test | true 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": trueadded tometadata. 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_loginisxive_test_userand its ids aren't real accounts, so calls that use them (a timeout, a reply to itsmessage_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();