---
title: Webhooks
description: Signed event envelopes POSTed to your server - the catalog, how to verify them, and the operational warnings.
---

{/* Event catalog kept in sync with api/src/lib/dev-webhooks.js. */}

> **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](/api/reference).

Register endpoint URLs in **Settings -> Developer** (or via the
[endpoints API](/api/reference/webhook-endpoints/listwebhookendpoints)) and TipPage
POSTs a JSON envelope to them when things happen:

```json
{
  "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](/api/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](/api/realtime). 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)).

## 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.

:::warning[Never skip verification]
A forged `tip.created` triggers whatever you wired up: Discord posts, smart
lights, `tts:control` calls against your real queue. The other headers
(`TipPage-Event`, `TipPage-Event-Id`, `TipPage-Delivery`, `TipPage-Attempt`)
and the envelope fields are plain text anyone can set - they identify the
event, they do **not** authenticate it. Source-IP allowlisting doesn't work
either; deliveries originate from Cloudflare's network and the IPs are not
stable. The HMAC check below is the only thing
that counts.
:::

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](/api/reference/webhook-endpoints/rotatewebhooksecret)). 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.

```js
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:

```js
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](/moderation/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](/chat-bot/custom-commands#counters) 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](#warnings)) | `code`, `message`, plus per-code extras |

A full `tip.created` delivery looks like:

```json
{
  "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:

```json
{
  "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:

```bash
# 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:

```json
{
  "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](/api/claim-and-finish)) | `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:

```json
{
  "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:

```js
if (event.type === "warning") notifyDiscord(`TipPage: ${event.data.message}`);
```
