---
title: Developer API
description: Build your own integrations - API keys, the REST API, and signed webhooks.
---

{/* Keep in sync with api/src/routes/v1/index.js, api/src/lib/api-key.js
    (scopes) and api/src/lib/dev-webhooks.js (events) - like ../openapi.yaml,
    this page is maintained by hand. */}

TipPage has a full developer API: read your tips, drive the TTS and media
queues, and get events pushed to your own server the moment they happen.
People use it for Discord bots, smart lights that react to tips, VOD
archivers, Stream Deck controls, and custom overlays - if you can code it,
you can wire it to your TipPage.

The complete endpoint-by-endpoint reference lives at **[API reference](/api)**.
This page covers the concepts: keys, scopes, and webhooks. The pattern that
makes the API special - being the thing that plays tips - has its own page:
**[Claim, do your thing, finish](/developer-api/claim-and-finish)**.

The whole API is described by one OpenAPI 3.1 document:
**[download openapi.yaml](/openapi.yaml)** - import it into Postman, Insomnia,
or Bruno, or feed it to a client generator.

## Authentication

Create an API key in **Dashboard → Settings → Developer** (owner and super
admins only). The key is shown **once** at creation - store it somewhere safe.
You can rotate a key from the same page at any time: it keeps its name and
scopes but gets a brand-new key, shown once, and the old key stops
working immediately. Rotation is deliberately dashboard-only - a key can't
mint its own replacement through the API. Send the key as a bearer token on
every request:

```bash
curl https://api.tippage.com/v1/me \
  -H "Authorization: Bearer tp_live_..."
```

The key identifies your TipPage, so there's no account id in any URL. Keys
are server-side credentials: never put one in a browser page, an overlay, or
anything visible on stream.

### Scopes

Each key carries only the scopes you pick (editable later in the dashboard):

| Scope | Grants |
|---|---|
| `tts:read` | The TTS queue and played history, messages and TTS audio URLs included |
| `tips:create` | Create manual tips - no payment attached, same as the dashboard's manual-tip form |
| `tts:control` | Pause, resume, skip, clear, remove, replay, and drive playback (start/finish) of the TTS queue |
| `media:read` | The media queue, played-media history, and live playback status |
| `media:control` | Pause, resume, and skip the media queue, queue videos directly, and show/hide the player on the overlay |
| `tipping:control` | Open and close tipping. Closing stops new checkouts; viewers already mid-payment still complete |
| `viewers:read` | Viewers who signed in to the tip page: first sign-in dates, sub tiers, sub-reward credit balances, and past sub-reward messages |
| `viewers:manage` | Grant and revoke sub-reward credits for signed-in viewers |
| `channel_points:manage` | Resolve channel point redemptions of TipPage-managed rewards - fulfill (keep the points) or cancel (refund the points) |
| `webhooks:manage` | Manage webhook endpoints via the API |
| `ai_voices:manage` | AI voices (closed beta): read the voice catalog and character usage, toggle the feature or individual voices |

Keys deliberately can't touch payments, settings, or team management.
Two reads need no scope at all - any valid key can check the tipping
status and the leaderboard, since both are visible on the public tip page
anyway.

## URLs

| | |
|---|---|
| API base | `https://api.tippage.com/v1` |
| Realtime WebSocket | `wss://realtime.tippage.com` |

The realtime gateway is what your overlay rides on; it isn't part of the
developer API yet - use webhooks (or polling) to react to events.

## Rate limits

240 requests/min per IP and 120 requests/min per key, reported in standard
`RateLimit-*` response headers. Exceeding a limit returns `429`.

## Webhooks

Register endpoint URLs in **Settings → Developer** (or via the
[endpoints API](/api/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",
  "data": { "...": "event-specific payload" }
}
```

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; delivery 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/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.

| Event | Fires when | `data` |
|---|---|---|
| `tip.created` | A tip enters the TTS queue, from any source | `order_id`, `name`, `amount`, `message`, `source` (`stripe` \| `paypal` \| `manual` \| `replay` \| `sub_reward` \| `test` \| ...), `is_replay`, `is_sub_reward`, `twitch_user_id`, 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` |
| `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) | `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` |
| `chat.command` | A viewer runs one of your custom chat commands (after its permission and cooldown checks pass; built-in commands don't fire this) | `command`, `invoked_as`, `args`, `user_id`, `user_login`, `user_name`, `is_mod`, `message_id` |
| `chat.moderated` | Chat moderation warned, deleted, timed out, or banned (`enforced: false` when the Twitch-side action failed) | `action`, `enforced`, `duration_seconds`, `target_user_id`, `target_login`, `target_name`, `feature`, `matched_term`, `message`, `reason` |
| `ping` | You press "Test" on an endpoint | `message` |

A full `tip.created` delivery looks like:

```json
{
  "id": "evt_9f1e2d3c4b5a6978",
  "type": "tip.created",
  "created": "2026-08-13T20:15:07.000Z",
  "data": {
    "order_id": "tip_1755115200000_ab12cd",
    "name": "GigaChad42",
    "amount": 5.00,
    "message": "great stream!",
    "source": "stripe",
    "is_replay": false,
    "is_sub_reward": false,
    "twitch_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",
  "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.

## Viewers and sub-reward credits

Streamers with sub rewards enabled give subscribers free TTS/media credits;
viewers sign in to the tip page with Twitch to see and spend them. The
`viewers:read` and `viewers:manage` scopes put that whole system under
automation - award bonus credits from your own loyalty bot, sync balances
into Discord, or audit who's actually using their rewards.

- [`GET /v1/viewers`](/api/viewers/listviewers) - everyone who has signed
  in, most recent first, with first-sign-in dates and credit balances.
- [`GET /v1/viewers/{user}`](/api/viewers/getviewer) - one viewer in full:
  profile, sign-in date, balance (including gift-sub credits still pending
  sign-in), the grant-by-grant ledger, and usage totals.
- [`GET /v1/viewers/{user}/rewards`](/api/viewers/listviewerrewards) -
  their played sub-reward messages, paginated like `/tts/history`.
- [`POST /v1/viewers/{user}/credits/grant`](/api/viewers/grantviewercredits) /
  [`revoke`](/api/viewers/revokeviewercredits) - move credits, same as the
  dashboard's Viewers page. Revoking only ever touches *unused* credits.

`{user}` takes either the Twitch user id or the viewer's name (login or
display name, case-insensitive) - so both of these grant GigaChad42 three
credits:

```bash
curl -X POST https://api.tippage.com/v1/viewers/123456789/credits/grant \
  -H "Authorization: Bearer tp_live_..." \
  -H "Content-Type: application/json" -d '{"amount": 3}'

curl -X POST https://api.tippage.com/v1/viewers/GigaChad42/credits/grant \
  -H "Authorization: Bearer tp_live_..." \
  -H "Content-Type: application/json" -d '{"amount": 3}'
```

Prefer the user id in automation - it survives renames and can never be
ambiguous (in the rare case two viewers share a display name, name lookups
answer `409 ambiguous_viewer` instead of guessing).

One rule to design around: **a viewer exists only once they've signed in to
the tip page with Twitch**. Granting (or looking up) anyone who never has
answers `404` with code `viewer_not_signed_in` and grants nothing - there's
no account to attach the credits to yet. Have the viewer sign in first, then
grant. (Gift-sub credits from Twitch events are the one exception: those
wait in a pending pool and attach automatically on first sign-in - you'll
see them as `credits.pending` on the viewer detail.)

## AI voices (closed beta)

Accounts in the AI voices beta can let viewers have their tip read out in
AI voices - one voice for the whole message, or a script where different
lines are spoken by different voices. The `ai_voices:manage` scope exposes
that feature to automation:

- [`GET /v1/ai-voices`](/api/ai-voices/listaivoices) - the feature's state
  (on/off, mode, minimum tip), the full voice catalog with per-voice
  `enabled` flags and sample clips, and this month's character usage.
  AI generation spends characters from a monthly allowance; when it runs
  out, AI voices pause until the month rolls over.
- [`POST /v1/ai-voices/enable`](/api/ai-voices/enableaivoices) /
  [`disable`](/api/ai-voices/disableaivoices) - the master switch, same as
  the dashboard toggle. Handy for turning AI voices on only while live.
- `POST /v1/ai-voices/{voiceId}/enable` / `disable` - curate which voices
  viewers can pick, by the `id` slugs from the catalog. The last enabled
  voice can't be disabled - use the master switch instead.

While the feature is in closed beta, every `/v1/ai-voices` endpoint
answers `404` for accounts that aren't flagged in - the scope won't appear
in your dashboard's key editor either.

## A worked example: react to tips from your own server

1. Create a key with `tts:read` + `tts:control`.
2. Add a webhook endpoint subscribed to `tip.created` - or skip webhooks and
   poll [`GET /v1/tts/queue`](/api/tts-queue/getttsqueue) every few seconds.
3. When a tip arrives, claim it with
   [`POST /v1/tts/{orderId}/start`](/api/tts-queue/startttsitem) - the same
   call the overlay makes, so the dashboard shows it as playing and nothing
   else can double-play it.
4. Do your thing - light the lights, post to Discord, play the audio from
   `tts_url` on your own device.
5. Finish with [`POST /v1/tts/{orderId}/finish`](/api/tts-queue/finishttsitem)
   to promote it to history and free the queue for the next tip.

If your integration should *be* the alert experience, turn off the overlay's
TTS so the queue has one consumer. The full protocol - the gates, crash
recovery, and the do's and don'ts - is in
[Claim, do your thing, finish](/developer-api/claim-and-finish).
