Skip to content
TipPage Docs
Esc
navigateopen⌘Jpreview
On this page

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_id field on tip payloads (tip.created, the tip object inside queue.tts.*) is now platform ("twitch" | "kick", null on an unattributed tip) plus platform_user_id - there is no alias. Same rename on every /v1 tip and viewer object; the viewer object gained linked_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:

  1. Capture the raw request body bytes before any JSON/body-parsing middleware touches the request (in Express: express.raw() on the webhook route).
  2. Parse t and v1 out of the TipPage-Signature header.
  3. Reject if t is more than ~5 minutes from your clock. This blocks replays of captured deliveries; the MAC alone can’t.
  4. Compute HMAC-SHA256(secret, "<t>." + rawBody) and compare it to v1 with a constant-time comparison.
  5. 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-Event or the envelope instead of the MAC. Forgeable.
  • Comparing MACs with ===. String comparison leaks timing; use crypto.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 }:

  • code is the stable, machine-readable identifier. Branch on this if you branch at all - never on the message text, which can change.
  • message is 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}`);

Was this page helpful?