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

Developer API

Build your own integrations - API keys, the REST API, and signed webhooks.

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

The whole API is described by one OpenAPI 3.1 document: download 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:

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) and TipPage POSTs a JSON envelope to them when things happen:

{
  "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

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.

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:

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

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

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

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.

{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:

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 - 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 / disable - 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 every few seconds.
  3. When a tip arrives, claim it with POST /v1/tts/{orderId}/start - 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 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.

Was this page helpful?