Webhooks
Signed event envelopes POSTed to your server - the catalog, how to verify them, and the operational warnings.
Add endpoints in Settings -> Developer (or the endpoints API) and TipPage POSTs an envelope when things happen:
{
"id": "evt_9f1e2d3c4b5a6978",
"type": "tip.created",
"created": "2026-08-13T20:15:07.000Z",
"actor": null,
"data": { "...": "event-specific payload" }
}
URLs must be public https://. Delivery is at-least-once, unordered, retried with backoff for ~44 hours; an endpoint that keeps failing is disabled (you get a dashboard notification). Verify, answer 2xx fast, work async, and dedupe on id. The same events are on the realtime stream.
Payloads only carry what’s already visible on stream - never donor emails, payment ids, or pre-filter text.
actor
Who caused the event. null when the system did it (a payment landing, an overlay timing out).
actor.type |
Fields | Who |
|---|---|---|
user |
user_id, platform, platform_id, login, display_name |
Someone on the team, in the dashboard |
chat |
same | A mod’s chat command (!pause, !skip). user_id is null unless the account belongs to a TipPage user |
api_key |
id (ak_...), name |
An API key |
oauth_app |
id (oc_...), name, authorization_id (oa_...) |
An OAuth app |
overlay |
id, name |
The overlay (it starts and finishes items) |
bot |
- | The bot, with no chatter attached |
user_id (u_...) is the person and never changes; platform_id is whichever linked account they used.
Verifying signatures
Anyone who finds your URL can POST a well-formed envelope to it. TipPage-Signature is the only proof a request came from TipPage - the other headers and the body are forgeable, and source IPs (Cloudflare’s) aren’t stable.
| Header | Value |
|---|---|
TipPage-Signature |
t=<unix seconds>,v1=<hex HMAC-SHA256> |
TipPage-Event |
event type |
TipPage-Event-Id |
envelope id (evt_...) |
TipPage-Delivery |
delivery id (wd_..., or ping) |
TipPage-Attempt |
1-8; a retry sends the byte-identical body with a fresh t |
The MAC is HMAC-SHA256 of <t>.<raw body> with the endpoint’s whsec_ secret (shown once; rotate in the dashboard or the API).
import crypto from "node:crypto";
function verifyTipPageSignature(secret, header, rawBody, toleranceSec = 300) {
const parts = Object.fromEntries((header || "").split(",").map((p) => p.split("=")));
if (!parts.t || !parts.v1) return false;
if (!(Math.abs(Date.now() / 1000 - Number(parts.t)) <= toleranceSec)) return false;
const mac = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
const got = Buffer.from(parts.v1, "hex");
const want = Buffer.from(mac, "hex");
return got.length === want.length && crypto.timingSafeEqual(got, want);
}
app.post("/webhooks/tippage", express.raw({ type: "*/*" }), (req, res) => {
if (!verifyTipPageSignature(process.env.TIPPAGE_WHSEC, req.get("TipPage-Signature"), req.body)) {
return res.status(401).end();
}
res.status(200).end();
handleEvent(JSON.parse(req.body));
});
- Verify the raw bytes - re-serialized JSON won’t match.
- Keep the timestamp check, or a captured delivery can be replayed forever.
- Compare in constant time, not with
===.
Events
Subscribe each endpoint to the events you want, or *. Twitch and Kick alerts are separate events (twitch.*, kick.*) with the same shape; chat events are one event with platform. Every alert carries platform.
| Event | Fires when | data |
|---|---|---|
tip.created |
A tip enters the TTS queue, from any source | order_id, name, amount, message, source (stripe | paypal | manual | api | replay | sub_reward | test | …), is_paid (real money changed hands - false for manual tips, free integration tips and sub rewards), external_source + external_ref (developer-API tips only: the source label the caller sent and their reference, else null), is_replay, is_sub_reward, platform + platform_user_id (the account the donor was signed in with, both null when they weren’t), filter flags, tts_url (always null here) |
tip.tts_ready |
TTS audio exists for a queued tip. AI-voice tips: at payment. Standard voices: when an alert box first plays the tip, or the streamer previews it (audio is made in the box’s own voice at play time, not at payment) | order_id, tts_url |
tip.filtered |
The filter replaced words in a tip’s name or message | order_id, name, original_name, amount, currency, message, original_message, matched_words, reasoning |
tip.blocked |
The filter wants to block a tip. It is held for a moderator: tip.held follows, and tip.reviewed says whether it was allowed or rejected |
order_id, name, amount, currency, message, blocked_words, reasoning |
tip.held |
A tip was parked in the review queue instead of entering the queues | order_id, name, amount, currency, message, has_media, reason (manual | filter | ai_unavailable | media_lookalike), payment_state (captured | held | none), source |
tip.reviewed |
A moderator decided a held tip, or its payment hold expired. An approval is followed by the usual tip.created / media.created |
order_id, decision (approved | rejected | expired), allow_tts, allow_media, reason, payment_state, payment_released (a Stripe hold was cancelled - the donor was never charged), payment_kept (a blocked tip was rejected but its held payment captured anyway), name, amount, currency |
tipping.opened / closed |
The accept-new-tips switch flips (dashboard or API). Closing stops new checkouts; anyone mid-payment still completes | {} |
queue.tts.started |
A tip starts playing | tip (full tip object) |
queue.tts.finished |
A tip finishes and moves to history | tip |
queue.tts.released |
A consumer releases its claim without finishing - the tip goes back to being claimable | order_id, tip (null if the row was already gone) |
queue.tts.skipped |
The playing tip is skipped | order_id |
queue.tts.removed |
A queued tip is removed before playing | tip |
queue.tts.paused / resumed / cleared |
Queue state changes | {} |
media.created |
A media item enters the media queue | order_id, donor_name, media_url, video_title, video_duration, platform, requested_via, is_replay |
queue.media.started / finished / removed |
A media item starts/finishes, or is removed before playing | media (full media object) |
queue.media.released |
A consumer releases its claim without finishing - the item goes back to being claimable | order_id, media (null if the row was already gone) |
queue.media.skipped |
The playing media item is skipped | order_id |
queue.media.paused / resumed / shown / hidden |
Media queue state and player-visibility changes | {} |
twitch.follow / sub / resub / gift_sub / cheer / raid |
Twitch alerts, after the AI filter (a suppressed alert never emits, and a repeat follow inside the follow cooldown is dropped). Which alerts an overlay shows is set per overlay and does not gate this - every event the filter lets through fires. A replay from the dashboard’s TTS history never fires. A sub start from a viewer TipPage has seen subscribe to the channel before (a lapsed sub returning, a Prime re-up) fires nothing itself: that viewer’s resub share is the event, a twitch.resub carrying sub_started: true (absent when the share arrived before the start) - no twitch.sub fires for it, and none at all if they never share. A first sub fires twitch.sub at once |
event_id (ev_...), platform (twitch), user_name, user_login, message, plus per-type extras: tier, months, sub_message, sub_started, total, gift_recipients, bits, viewers |
twitch.channel_point_redemption |
A viewer redeems any channel point reward - including rewards TipPage has no actions mapped to (after your filters) | user_name, user_login, reward_id, reward_title, reward_cost, user_input, redemption_id, status, redeemed_at, is_managed |
kick.follow / sub / resub / gift_sub |
Kick alerts, same shape as the twitch.* ones (platform is kick; Kick subs have no tier, so tier is always "1"). Replays never fire here either |
event_id, platform, user_name, user_login, message, plus months, total, is_anonymous, gift_recipients where they apply |
kick.kicks |
Someone gifted Kicks (Kick’s paid gift) - the Kick counterpart of a cheer | platform, user_name, user_login, message, kicks (amount), gift_name, kicks_message (the viewer’s note, after your filters), is_anonymous |
chat.command |
A viewer runs one of your custom chat commands, in Twitch or Kick chat (after its permission and cooldown checks pass; built-in commands don’t fire this) | platform (twitch | kick), command, invoked_as, args, user_id (on that platform), user_login, user_name, is_mod, message_id |
counter.updated |
A chat counter changed value - a {count} bump in chat, a dashboard edit, or an API write |
name, value, source (chat, dashboard, or api) |
chat.moderated |
Chat moderation deleted, timed out or banned (one event per step of the response), or only posted messages (action: warn). enforced: false when the platform-side action failed |
platform, action, enforced, duration_seconds, target_user_id, target_login, target_name, feature, rule, matched_term, message, reason |
character.replied |
An AI character answered on stream (closed beta) | event_id, character ({id, name}), source (chat | points | test), platform, user_login, user_name, question, reply, voiced, on_stream, image_shown |
character.held |
A viewer’s picture for an AI character is waiting for a moderator on the Review page | id, character, reason, platform, user_login, user_name |
character.reviewed |
A moderator approved or rejected a held AI-character input | id, character, decision (approved | rejected), platform, user_login, replied |
overlay.connected |
An overlay’s browser source comes online (its realtime connection is up, not just the page loaded) | overlay ({ id, name }), connected_at |
overlay.disconnected |
An overlay’s browser source goes offline (reported after a short grace window, so brief reloads don’t fire it) | overlay ({ id, name }), reason (closed | socket_closed | socket_timeout | heartbeat_timeout | key_rotated | overlay_deleted | tenant_reset), disconnected_at |
ping |
You press “Test” on an endpoint | message |
warning |
Your integration is doing something that’s probably not what you meant - delivered to every endpoint, not subscribable (see Warnings) | code, message, plus per-code extras |
{
"id": "evt_9f1e2d3c4b5a6978",
"type": "tip.created",
"created": "2026-08-13T20:15:07.000Z",
"actor": null,
"data": {
"order_id": "tip_1755115200000_ab12cd",
"name": "GigaChad42",
"amount": 5.00,
"message": "great stream!",
"source": "stripe",
"external_source": null,
"external_ref": null,
"is_paid": true,
"is_replay": false,
"is_sub_reward": false,
"platform": "twitch",
"platform_user_id": "123456789",
"name_was_filtered": false,
"message_was_filtered": false,
"tts_url": null
}
}
A few notes on specific events:
twitch.channel_point_redemptionfires for every reward, mapped or not. Dedupe onredemption_id. Whenis_managedistrue(TipPage created the reward), close it withfulfillorcancel(refunds the points) underchannel_points:manage; other rewards return409 reward_not_managed, because Twitch only lets the creating app resolve them. Make a resolvable reward withPOST /channel-points/rewards, or the streamer can use Create a new reward on the Channel points page.chat.commandfires for your custom commands only, after permission and cooldown pass.commandis the canonical name,invoked_aswhat was typed,argsthe rest of the line (nullif none). Dedupe onmessage_id.
Warnings
warning goes to every endpoint and can’t be unsubscribed: your integration is probably doing something you didn’t mean. data is { code, message, ...extras }. Log or forward message; branch only on code, and treat unknown codes as a log line.
| Code | Meaning |
|---|---|
multiple_tts_consumers |
An overlay and an API consumer both claimed TTS items within 15 minutes - tips are probably playing on both (claim and finish). Extras: queue, consumers |
multiple_media_consumers |
Same, for media |
At most one of each per ~6 hours per streamer.
if (event.type === "warning") notifyDiscord(`TipPage: ${event.data.message}`);