Skip to content
TipPage Docs
Esc
↑↓navigate↵open⌘Jpreview
On this page

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_redemption fires for every reward, mapped or not. Dedupe on redemption_id. When is_managed is true (TipPage created the reward), close it with fulfill or cancel (refunds the points) under channel_points:manage; other rewards return 409 reward_not_managed, because Twitch only lets the creating app resolve them. Make a resolvable reward with POST /channel-points/rewards, or the streamer can use Create a new reward on the Channel points page.
  • chat.command fires for your custom commands only, after permission and cooldown pass. command is the canonical name, invoked_as what was typed, args the rest of the line (null if none). Dedupe on message_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}`);

Was this page helpful?