Webhooks
Signed event envelopes POSTed to your server - the catalog, how to verify them, and the operational warnings.
Changed 2026-09-06 - viewers are platform-neutral. A viewer can sign in to the tip page with Twitch or Kick and link the other account, so the
twitch_user_idfield on tip payloads (tip.created, thetipobject insidequeue.tts.*) is nowplatform("twitch"|"kick",nullon an unattributed tip) plusplatform_user_id- there is no alias. Same rename on every/v1tip and viewer object; the viewer object gainedlinked_accounts. See the API reference.
Register endpoint URLs in Settings -> Developer (or via the endpoints API) and TipPage POSTs a JSON envelope to them when things happen:
{
"id": "evt_9f1e2d3c4b5a6978",
"type": "tip.created",
"created": "2026-08-13T20:15:07.000Z",
"actor": null,
"data": { "...": "event-specific payload" }
}
actor says who caused the event - null for anything the system did
on its own (a payment landing, an overlay heartbeat lapsing), otherwise one
of:
actor.type |
Fields | Meaning |
|---|---|---|
user |
user_id, platform, platform_id, login, display_name |
A team member in the dashboard (the streamer or someone on their team). user_id is their TipPage user id (u_...); platform + platform_id are the linked account they signed in with |
chat |
user_id, platform, platform_id, login, display_name |
A moderator’s chat command (!pause, !skip, …) via the TipPage bot. platform/platform_id are the chatter’s account on the platform the message came from; user_id is set when that account is linked to a TipPage user, otherwise null |
api_key |
id (ak_...), name |
A developer API key - your own or another integration’s |
oauth_app |
id (oc_...), name, authorization_id (oa_...) |
A third-party app connected through OAuth, acting with the streamer’s authorization |
overlay |
id, name |
The overlay itself (it reports when a TTS or media item starts and finishes) |
bot |
- | The chat bot with no chatter attached (rare) |
So a queue.tts.paused with "actor": { "type": "chat", "login": "modname", ... }
means a mod typed !pause in chat, while the same event with
"actor": { "type": "api_key", "id": "ak_..." } means an integration paused
the queue. Key on user_id when you want “the same person” - it never changes,
while platform_id is one of their linked accounts. Platform ids and logins
are public platform identity; user ids are opaque.
The same events are also available live over a WebSocket - same envelope, same event ids, no HTTP endpoint to host - see Realtime WebSocket. Webhooks are the durable transport; the stream is for reacting in the moment.
Endpoint URLs must be public https://. Delivery is at-least-once with
no ordering guarantee; failed deliveries retry with backoff for up to ~44
hours, and an endpoint that keeps failing is disabled automatically (you get
a dashboard notification). Answer with any 2xx as fast as you can and do real
work asynchronously - but verify the signature first, before you trust a
single byte of the body (see
Verifying that events come from TipPage).
Verifying that events come from TipPage
Your endpoint URL is an ordinary public https:// address. Anyone who
discovers it - a leaked log line, a guessed path, a nosy viewer - can POST a
perfectly-shaped envelope to it. The TipPage-Signature header is the only
proof that a request actually came from TipPage. An endpoint that skips
verification will happily act on forged events.
Every delivery carries these headers:
| Header | Value |
|---|---|
TipPage-Signature |
t=<unix seconds>,v1=<hex HMAC-SHA256> - the proof of origin |
TipPage-Event |
event type, e.g. tip.created (informational) |
TipPage-Event-Id |
envelope id (evt_...) (informational) |
TipPage-Delivery |
delivery id (wd_..., or ping for tests) (informational) |
TipPage-Attempt |
attempt number (1-8) - above 1 means a retry of the same event, byte-identical body (informational) |
The MAC is HMAC-SHA256 over <t>.<raw request body> using your endpoint’s
signing secret (whsec_..., shown once when the endpoint is created;
rotate it any time from the dashboard or
the API). To verify a delivery:
- Capture the raw request body bytes before any JSON/body-parsing
middleware touches the request (in Express:
express.raw()on the webhook route). - Parse
tandv1out of theTipPage-Signatureheader. - Reject if
tis more than ~5 minutes from your clock. This blocks replays of captured deliveries; the MAC alone can’t. - Compute
HMAC-SHA256(secret, "<t>." + rawBody)and compare it tov1with a constant-time comparison. - Only now parse the JSON and act on it. Respond 2xx fast, work async.
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;
// NaN-safe: anything that isn't a fresh unix timestamp fails.
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);
}
Wired into an Express route:
app.post("/webhooks/tippage", express.raw({ type: "*/*" }), (req, res) => {
const sig = req.get("TipPage-Signature");
if (!verifyTipPageSignature(process.env.TIPPAGE_WHSEC, sig, req.body)) {
return res.status(401).end(); // not from TipPage - do nothing else with it
}
res.status(200).end(); // ack fast...
const event = JSON.parse(req.body);
handleEvent(event); // ...then do the real work
});
Mistakes that break - or silently defeat - verification:
- Re-serializing the body.
JSON.stringify(req.body)almost never reproduces the exact bytes TipPage signed (key order, whitespace, unicode escapes all differ), so verification fails - and the usual “fix” people reach for is deleting the check. Verify the raw bytes. - Trusting
TipPage-Eventor the envelope instead of the MAC. Forgeable. - Comparing MACs with
===. String comparison leaks timing; usecrypto.timingSafeEqual(or your language’s constant-time equivalent). - Skipping the timestamp check. A captured delivery would then verify forever and can be replayed at you at any time.
- Verifying once per event instead of once per request. Retries re-sign
the same byte-identical body with a fresh
t; every attempt must pass on its own.
When verification fails, respond 401 and drop the request - don’t parse it,
don’t log the body anywhere your automation reads. And since delivery is
at-least-once, dedupe verified events by the envelope id before acting.
Events
Subscribe per endpoint to exactly the events you want, or * for everything.
Platforms. A streamer can have Twitch, Kick, or both connected. Alerts are split by platform - twitch.* and kick.* are separate events with the same payload shape - while chat events (chat.command, chat.moderated) are one event with a platform field saying which chat it happened in. user_id on a chat event is the viewer’s id on that platform. Every alert payload also carries platform, so a handler that switches on it works for both catalogs.
| 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 |
Pre-rendered TTS audio is ready | 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 rejected a tip outright - it never reached the queue | 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), 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), 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.paused / resumed / skipped / shown / hidden |
Media queue state and player-visibility changes | {} |
twitch.follow / sub / resub / gift_sub / cheer / raid |
Twitch alerts (after your alert settings and filters) | event_id (ev_...), platform (twitch), user_name, user_login, message, plus per-type extras: tier, months, sub_message, 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") |
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 warned, deleted, timed out, or banned (enforced: false when the Twitch-side action failed; Twitch only for now) |
platform, action, enforced, duration_seconds, target_user_id, target_login, target_name, feature, matched_term, message, reason |
overlay.connected |
An overlay’s browser source comes online | 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 | 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 |
A full tip.created delivery looks like:
{
"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
}
}
And a twitch.channel_point_redemption delivery:
{
"id": "evt_2c4d6e8f0a1b3c5d",
"type": "twitch.channel_point_redemption",
"created": "2026-08-13T20:16:42.000Z",
"actor": null,
"data": {
"user_name": "GigaChad42",
"user_login": "gigachad42",
"reward_id": "9db08b8f-01c9-4b12-a3c2-8e5a4f7d6b21",
"reward_title": "Hydrate!",
"reward_cost": 500,
"user_input": "drink the whole bottle",
"redemption_id": "5b8f2c1e-7a3d-4e9f-b6c0-1d2e3f4a5b6c",
"status": "unfulfilled",
"redeemed_at": "2026-08-13T20:16:41.000Z",
"is_managed": true
}
}
This fires for every reward on the channel, not just ones with TipPage
actions mapped - it’s the hook for building your own redemption automations.
user_input is null when the reward doesn’t ask for text. Dedupe on
data.redemption_id - it’s the Twitch redemption id, stable even when the
same redemption is redelivered under a fresh envelope id.
When is_managed is true (the reward was created by TipPage), you can
close the loop with a channel_points:manage key once your automation has
run:
# it worked - the viewer's points stay spent
curl -X POST https://api.tippage.com/v1/channel-points/redemptions/5b8f2c1e-.../fulfill \
-H "Authorization: Bearer tp_live_..."
# it failed - refund the viewer's points
curl -X POST https://api.tippage.com/v1/channel-points/redemptions/5b8f2c1e-.../cancel \
-H "Authorization: Bearer tp_live_..."
Twitch only lets the app that created a reward resolve its redemptions, so
rewards the streamer made in the Twitch dashboard (is_managed: false)
can’t be resolved this way - those return 409 reward_not_managed and stay
in the Twitch rewards queue for manual resolution.
chat.command turns custom commands into real-world triggers: create a
command in Chat bot -> Custom commands, subscribe an endpoint to
chat.command, and every time a viewer runs it your server gets a signed
POST - !explode can fire a confetti cannon. A delivery looks like:
{
"id": "evt_7a9b1c3d5e2f4a6b",
"type": "chat.command",
"created": "2026-08-13T20:18:03.000Z",
"data": {
"command": "explode",
"invoked_as": "explode",
"args": "3 times",
"user_id": "123456789",
"user_login": "gigachad42",
"user_name": "GigaChad42",
"is_mod": false,
"message_id": "d2e1f3a4-b5c6-4d7e-8f90-a1b2c3d4e5f6"
}
}
command is the command’s canonical name; invoked_as is what the viewer
actually typed (they differ when an alias was used). args is the rest of
the chat line after the command, original case, null when there was
none. It fires only after the command’s permission and cooldown checks
pass - a viewer spamming !explode inside its cooldown window doesn’t
reach your server - and only for your custom commands, never built-ins
like !queue. Dedupe on data.message_id (the Twitch chat message id).
Webhook payloads only ever contain what’s already visible on stream - never donor emails, payment identifiers, or pre-filter message text.
Warnings
One event type sits outside the subscription list: warning is delivered
to every active endpoint, always, and can’t be unsubscribed. Warnings
are TipPage telling you your integration is doing something that’s
probably not what you meant - and the setups that trigger them are
exactly the ones whose authors wouldn’t have opted in.
data is always { code, message, ...extras }:
codeis the stable, machine-readable identifier. Branch on this if you branch at all - never on themessagetext, which can change.messageis prose written to be piped somewhere a human reads.
You don’t need per-code handling. The recommended minimum is: log
data.message to a log you actually look at, or forward it to a Discord
channel. New codes are added over time, so an unknown code must be a
log line, never an error - and as with any event type you don’t
recognize, still answer 2xx.
Current codes:
| Code | Meaning | extras |
|---|---|---|
multiple_tts_consumers |
A connected overlay and an API consumer both claimed TTS queue items within a 15-minute window - tips are likely playing on both. Turn the overlay’s TTS off, or stop your consumer (see the consumer guide) | queue ("tts"), consumers |
multiple_media_consumers |
Same, for the media queue | queue ("media"), consumers |
The same warning fires at most once per ~6 hours per streamer, so forwarding them raw won’t flood anything. A delivery looks like:
{
"id": "evt_5e7a9c1b3d2f4680",
"type": "warning",
"created": "2026-08-30T18:42:11.000Z",
"data": {
"code": "multiple_tts_consumers",
"message": "Two different consumers are driving the TTS queue: a connected overlay and an API consumer both claimed items within the last 15 minutes, so items are likely playing on both. Turn the TTS widget off on the overlay, or stop the API consumer.",
"queue": "tts",
"consumers": ["overlay", "api"]
}
}
And the whole recommended handler is one line in your existing verified webhook route:
if (event.type === "warning") notifyDiscord(`TipPage: ${event.data.message}`);