Creator webhooks
Creator webhooks deliver one creator's stream events to an HTTPS endpoint you run, signed and retried until they land. They are what alert and timer tools connected to a creator's own channel use.
When to use them
- Creator webhooks are per creator. A subscription is created with the creator's own signed-in session (
POST /api/streaming/integrations, usually through your tool's "Connect Xive" flow), sends to a publichttps://URL you own, and carries only that creator's activity events. Good for a tool a creator connects to their own channel, such as a subathon timer or an overlay service. - Channel apps are multi-channel. One app, added by any number of creators, hears chat and events from all of them through EventSub, with no public URL needed. Use those for bots and anything that also needs chat.
A creator can have up to 10 webhook subscriptions. Each one may limit itself to some event types (event_types); without that list it receives every type. Its signing secret is returned once, when the subscription is created or rotated.
Deliveries
Each event is a POST to your URL with a JSON body:
{
"subscription": {
"id": "0d8e4b2a-5c71-4f39-a6e2-9b3c1d7f0e58",
"creator_id": "3e0b8a51-2f4c-4f8e-9a6d-1b7c5e2d9f04"
},
"stream_id": "c71d0f2a-8b3e-4a19-b5d6-0e4f9a2c7b18",
"event": {
"id": "6e1f0a9b-2c3d-4e5f-8a7b-9c0d1e2f3a4b",
"type": "raid",
"filter": "raids",
"actor_id": "1f9a3b6c-7d2e-4c80-b5a4-e6f0d1c2b3a9",
"display_name": "pixelpanda",
"avatar_url": null,
"description": "Raided with 42 viewers",
"amount": 42,
"amount_unit": "viewers",
"amount_label": "42 viewers",
"metadata": {},
"created_at": "2026-10-11T12:00:00.123Z"
}
}
event is an activity row. Route on subscription.creator_id if you serve several creators.
| Header | Value |
|---|---|
Xive-Event-Id | The delivery id. Unique per subscription and event, and the same on every retry. Dedupe on this. |
Xive-Event-Type | The event type, e.g. follow, gift, raid. |
Xive-Event-Timestamp | When this attempt was sent, RFC 3339 UTC (e.g. 2026-10-11T12:00:01Z). New on every attempt. |
Xive-Delivery-Attempt | 1 for the first try, then 2, 3, … |
Xive-Signature | sha256= + hex HMAC-SHA256 of the signing string, keyed with your secret. |
User-Agent | Xive-Integrations/1.0 |
Answer with any 2xx quickly (the first attempt waits about a second, retries up to 10 seconds) and do slow work afterwards. Redirects are not followed. Your URL must be public HTTPS on port 443 or 8443.
Verifying signatures
The signing string is the event id, the timestamp and the raw request body, joined with nothing in between:
Xive-Signature = "sha256=" + hex(HMAC_SHA256(secret, Xive-Event-Id + Xive-Event-Timestamp + rawBody))
Use the body bytes exactly as received; re-encoding parsed JSON will not match. Compare in constant time, and reject timestamps more than about 10 minutes from now. The timestamp is per attempt, so a retry hours later still passes that check.
Node.js (Express)
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.XIVE_WEBHOOK_SECRET;
const seen = new Set(); // use a database in production
app.post("/xive", express.raw({ type: "application/json" }), (req, res) => {
const id = req.get("Xive-Event-Id") ?? "";
const ts = req.get("Xive-Event-Timestamp") ?? "";
const sig = req.get("Xive-Signature") ?? "";
const expected = "sha256=" + crypto.createHmac("sha256", SECRET)
.update(id + ts).update(req.body).digest("hex");
const ok = sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!ok || Math.abs(Date.now() - Date.parse(ts)) > 10 * 60 * 1000) {
return res.sendStatus(403);
}
res.sendStatus(204); // acknowledge first
if (seen.has(id)) return; // a retry we already handled
seen.add(id);
const { subscription, event } = JSON.parse(req.body);
console.log(subscription.creator_id, event.type, event.description);
});
app.listen(3000);
PHP
$raw = file_get_contents('php://input');
$id = $_SERVER['HTTP_XIVE_EVENT_ID'] ?? '';
$ts = $_SERVER['HTTP_XIVE_EVENT_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_XIVE_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $id . $ts . $raw, getenv('XIVE_WEBHOOK_SECRET'));
if (!hash_equals($expected, $sig) || abs(time() - strtotime($ts)) > 600) {
http_response_code(403);
exit;
}
http_response_code(204);
$payload = json_decode($raw, true);
Retries and the breaker
- Anything other than a
2xx(including a timeout or a redirect) is a failed attempt. - Failed deliveries are retried with exponential backoff: 30 s, 60 s, 120 s, … doubling up to a ceiling of 30 minutes between attempts.
- A delivery is abandoned after 12 attempts (a little over three hours).
- Circuit breaker: after 20 consecutive failed deliveries the subscription is switched off and the creator sees it as disabled, with your last error. Any success resets the count. Connecting again re-arms it.
- Deliveries are at least once. Use
Xive-Event-Id(orevent.id) so a retry is never counted twice.
Test deliveries a creator sends from Xive carry "test": true in event.metadata.
Reconciliation read
Webhooks are the main path. As a backstop, your server can poll for anything it missed:
GET /api/streaming/integrations/events?since=CURSOR&limit=200
Authenticate with three headers. There is no Bearer token on this route:
| Header | Value |
|---|---|
Xive-Integration-Id | Your subscription id (subscription.id). |
Xive-Timestamp | Now, RFC 3339 UTC. Must be within 5 minutes of Xive's clock. |
Xive-Signature | "sha256=" + hex(HMAC_SHA256(secret, integrationId + timestamp + rawQueryString)) |
rawQueryString is the query string exactly as you send it, without the leading ? (empty if there is none). Sign the same bytes you put on the wire.
since: thecursorfrom your last read. Without it you get the last hour.limit: 1 – 500, default 200.
{
"success": true,
"events": [
{ "stream_id": "c71d0f2a-…", "event": { "id": "6e1f0a9b-…", "type": "raid", … } }
],
"cursor": "2026-10-11T12:00:00.123Z",
"has_more": false,
"next_poll_after": 30
}
- Events are oldest first, filtered to your subscription's event types. Each
eventis identical to the one in the webhook, so dedupe onevent.id. - Save
cursorand send it back assince. It is returned even wheneventsis empty. has_more: truemeans call again straight away.next_poll_afteris how many seconds to wait:0while draining,30while the creator is live or events just arrived,300when they are offline and quiet. Please follow it.- A bad or expired signature, or a disabled subscription, gets
401"Unauthorized".
// Node.js
const qs = new URLSearchParams({ since: cursor }).toString();
const ts = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
const sig = "sha256=" + crypto.createHmac("sha256", SECRET).update(SUB_ID + ts + qs).digest("hex");
const res = await fetch(`https://api.thexive.com/api/streaming/integrations/events?${qs}`, {
headers: { "Xive-Integration-Id": SUB_ID, "Xive-Timestamp": ts, "Xive-Signature": sig },
});
A receiver that is being disconnected can remove its own subscription with DELETE /api/streaming/integrations/self, signed the same way.
The activity row
Every webhook delivery's event is an activity row: the same row the creator's activity feed and OBS dock show.
{
"id": "6e1f0a9b-2c3d-4e5f-8a7b-9c0d1e2f3a4b",
"type": "gifted-sub",
"filter": "subs",
"actor_id": "1f9a3b6c-7d2e-4c80-b5a4-e6f0d1c2b3a9",
"display_name": "pixelpanda",
"avatar_url": "https://media.xivecdn.com/…/avatar.jpg",
"description": "Gifted 5 subs",
"amount": 5,
"amount_unit": "subs",
"amount_label": "5 subs",
"metadata": {},
"created_at": "2026-10-11T12:00:00.123Z"
}
| Field | Meaning |
|---|---|
id | The event's id. Stable: the same event has the same id on the gateway, in webhooks and in the activity feed. |
type | See the table below. |
filter | The activity-feed bucket: follows, gifts, sparks, subs, super-chats, channel-points, raids, clips, markers. |
actor_id | The viewer's profile id, or null. |
display_name | The viewer's name ("Someone" if unknown). |
avatar_url | The viewer's avatar, or null. |
description | A ready-made line, e.g. "Followed the channel", "Sent Rose", "Raided with 42 viewers". Doesn't repeat the name. |
amount, amount_unit | The quantity and its unit, or null. Meaning depends on type. |
amount_label | Formatted amount, e.g. "1 month", "250 Sparks", or null. |
metadata | Extra, per-type fields (always an object). |
created_at | When it happened, ISO 8601 UTC with milliseconds. |
| event.type | What it is |
|---|---|
follow | Someone followed the channel. |
gift | A gift. amount = credits; metadata.gift_name. |
spark | Sparks bought on the stream. amount = Sparks. |
subscription | A subscription. amount = months subscribed (1 = new). |
gifted-sub | Gifted subscriptions. amount = number of subs. |
supporter-tier | A viewer reached a Supporter tier. metadata.tier_key, metadata.tier_label. |
super-chat | A highlighted paid message. metadata.content. |
tts | A paid message read aloud. amount = credits; metadata.message, metadata.audio_url. |
channel-points | A channel-point redemption (free, not paid). amount = points; metadata.reward_title, metadata.reward_id, metadata.message. |
raid | A raid. amount = viewers. |
clip | A clip was created. |
marker | A stream marker was added. |
New types may be added. Ignore types you don't know rather than failing.