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 public https:// 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.

HeaderValue
Xive-Event-IdThe delivery id. Unique per subscription and event, and the same on every retry. Dedupe on this.
Xive-Event-TypeThe event type, e.g. follow, gift, raid.
Xive-Event-TimestampWhen this attempt was sent, RFC 3339 UTC (e.g. 2026-10-11T12:00:01Z). New on every attempt.
Xive-Delivery-Attempt1 for the first try, then 2, 3, …
Xive-Signaturesha256= + hex HMAC-SHA256 of the signing string, keyed with your secret.
User-AgentXive-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 (or event.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:

HeaderValue
Xive-Integration-IdYour subscription id (subscription.id).
Xive-TimestampNow, 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: the cursor from 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 event is identical to the one in the webhook, so dedupe on event.id.
  • Save cursor and send it back as since. It is returned even when events is empty.
  • has_more: true means call again straight away.
  • next_poll_after is how many seconds to wait: 0 while draining, 30 while the creator is live or events just arrived, 300 when 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"
}
FieldMeaning
idThe event's id. Stable: the same event has the same id on the gateway, in webhooks and in the activity feed.
typeSee the table below.
filterThe activity-feed bucket: follows, gifts, sparks, subs, super-chats, channel-points, raids, clips, markers.
actor_idThe viewer's profile id, or null.
display_nameThe viewer's name ("Someone" if unknown).
avatar_urlThe viewer's avatar, or null.
descriptionA ready-made line, e.g. "Followed the channel", "Sent Rose", "Raided with 42 viewers". Doesn't repeat the name.
amount, amount_unitThe quantity and its unit, or null. Meaning depends on type.
amount_labelFormatted amount, e.g. "1 month", "250 Sparks", or null.
metadataExtra, per-type fields (always an object).
created_atWhen it happened, ISO 8601 UTC with milliseconds.
event.typeWhat it is
followSomeone followed the channel.
giftA gift. amount = credits; metadata.gift_name.
sparkSparks bought on the stream. amount = Sparks.
subscriptionA subscription. amount = months subscribed (1 = new).
gifted-subGifted subscriptions. amount = number of subs.
supporter-tierA viewer reached a Supporter tier. metadata.tier_key, metadata.tier_label.
super-chatA highlighted paid message. metadata.content.
ttsA paid message read aloud. amount = credits; metadata.message, metadata.audio_url.
channel-pointsA channel-point redemption (free, not paid). amount = points; metadata.reward_title, metadata.reward_id, metadata.message.
raidA raid. amount = viewers.
clipA clip was created.
markerA stream marker was added.

New types may be added. Ignore types you don't know rather than failing.