---
title: OAuth 2.0
description: Let other streamers connect your app to their TipPage with a Connect button - authorization code + PKCE, refresh tokens, and the same scopes as API keys.
---

{/* Keep in sync with api/src/routes/oauth.js, api/src/lib/oauth.js and
    the OAuth panels in Settings -> Developer. */}

API keys are for **your own** TipPage. OAuth is for when you build something
**other streamers** use: instead of asking each of them to create a key and
paste it into your app, you give them a "Connect TipPage" button. They
approve a consent screen on `auth.tippage.com`, and your app receives an
access token that works everywhere an API key does - every `/v1` endpoint,
the realtime stream, the same scopes, the same rate limits.

It is standard OAuth 2.0: authorization code grant, PKCE (S256), refresh
tokens that rotate on use, [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)
server metadata. Any OAuth client library works.

| | |
|---|---|
| Authorization endpoint | `https://api.tippage.com/oauth/authorize` |
| Token endpoint | `https://api.tippage.com/oauth/token` |
| Revocation endpoint | `https://api.tippage.com/oauth/revoke` |
| Server metadata | `https://api.tippage.com/.well-known/oauth-authorization-server` |
| Access token lifetime | 1 hour |
| Refresh token lifetime | Never expires - rotated on every refresh, ends only when revoked |

## 1. Register your app

**Dashboard -> Settings -> Developer -> OAuth apps -> Register app** (owner
and super admins only). You pick:

- **Name and description** - shown to streamers on the consent screen, next
  to your TipPage account name. Names that could pass for TipPage itself or
  a platform (Twitch, Kick, Stripe, ...) are refused. **Website** is shown
  only in the streamer's Connected apps list, as an unverified claim - the
  consent screen never renders links you control, only the host of your
  `redirect_uri`, because that's the one thing TipPage can vouch for.
  Apps TipPage ships itself carry an **Official TipPage app** badge on the
  consent screen instead; that flag is set by TipPage staff and can't be
  requested through the dashboard.
- **Redirect URIs** - exact-match, one per line. `https://` only, except
  `http://localhost` (any port) for development and reverse-domain custom
  schemes (`com.example.app:/callback`) for native apps. No wildcards, no
  fragments.
- **Scopes** - the most your app may ever ask for. Each authorization
  requests a subset. The [scope list](/api/authentication#scopes) is the
  same one API keys use.
- **Client type** - *confidential* (server-side; gets a client secret, shown
  once, rotatable) or *public* (desktop, mobile, browser; no secret, PKCE
  required on every authorization).

You get a `client_id` (`oc_...`) and, for confidential clients, a
`client_secret` (`tpcs_...`). Up to 10 apps per account.

## PKCE in thirty seconds

PKCE ([RFC 7636](https://www.rfc-editor.org/rfc/rfc7636)) stops a stolen
authorization code from being useful. Before you send the streamer off,
you make up a secret (the **verifier**) and send only its hash (the
**challenge**) with the authorize request. When you exchange the code you
send the original verifier, and TipPage checks it hashes to the challenge
it saw. Someone who intercepts the code (a leaky redirect, a malicious app
on the same device) doesn't have the verifier and can't redeem it.

- **Required** for public clients - anything without a client secret.
- **Recommended** for confidential clients too; TipPage verifies it whenever
  a challenge was sent.
- Only `S256` is accepted (`plain` is refused).
- Generate a fresh verifier per authorization and keep it with your `state`
  until the callback. Never reuse one.

```text
code_verifier  = 43..128 chars from [A-Z a-z 0-9 - . _ ~]  (random)
code_challenge = BASE64URL( SHA256( code_verifier ) )       (no padding)
```

```js
// node
import crypto from "node:crypto";
const verifier = crypto.randomBytes(32).toString("base64url");
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
```

```python
# python
import base64, hashlib, secrets
verifier = secrets.token_urlsafe(32)
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
```

```js
// browser
const bytes = crypto.getRandomValues(new Uint8Array(32));
const b64url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const verifier = b64url(bytes);
const challenge = b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
```

Send `code_challenge=<challenge>&code_challenge_method=S256` in step 2 and
`code_verifier=<verifier>` in step 3. A mismatch fails the exchange with
`invalid_grant: PKCE verification failed`. Most OAuth client libraries do
all of this for you when you pass `code_challenge_method: "S256"`.

## 2. Send the streamer to authorize

```text
https://api.tippage.com/oauth/authorize
  ?response_type=code
  &client_id=oc_...
  &redirect_uri=https://yourapp.example/oauth/callback
  &scope=tts:read%20tts:control%20chat:write
  &state=<random, verified on return>
  &code_challenge=<BASE64URL(SHA256(code_verifier))>
  &code_challenge_method=S256
```

| Parameter | |
|---|---|
| `response_type` | Always `code` |
| `client_id` | Your app's id |
| `redirect_uri` | Must exactly match a registered URI. Optional only if the app has exactly one |
| `scope` | Space-separated, a subset of the app's registered scopes |
| `state` | Recommended - opaque value you check when the user comes back (CSRF) |
| `code_challenge` + `code_challenge_method=S256` | See [PKCE](#pkce-in-thirty-seconds). Required for public clients, recommended for all. Only S256 is accepted |

TipPage validates the request, then shows the consent screen on
`auth.tippage.com`. The streamer signs in if they aren't already (Twitch
or Kick), sees your app's name, description and the TipPage account it belongs to, exactly the
scopes you asked for, where they will be sent back to, and Authorize /
Deny. **Only the account owner or a super admin can authorize** - the same
rule as creating an API key.

Back at your `redirect_uri`:

- Approved: `?code=<authorization code>&state=<your state>` - the code is
  single-use and expires in 10 minutes.
- Denied: `?error=access_denied&state=...`.
- Anything wrong with the request itself (`invalid_scope`,
  `unsupported_response_type`, missing PKCE on a public client) is also
  returned as `?error=` - **except** an unknown `client_id` or an
  unregistered `redirect_uri`, which are answered as a JSON 400 without
  redirecting, so a bad link can never bounce someone to an arbitrary site.

## 3. Exchange the code

`POST /oauth/token` with a form body (`application/x-www-form-urlencoded`;
JSON is accepted too). Confidential clients authenticate with HTTP Basic
(`client_id:client_secret`) or `client_secret` in the body; public clients
send just `client_id`.

```bash
curl https://api.tippage.com/oauth/token \
  -u "oc_...:tpcs_..." \
  -d grant_type=authorization_code \
  -d code=... \
  -d redirect_uri=https://yourapp.example/oauth/callback \
  -d code_verifier=...
```

```json
{
  "access_token": "tpat_...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "tprt_...",
  "scope": "tts:read tts:control chat:write"
}
```

Use the access token exactly like an API key:

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

`GET /v1/me` tells you whose TipPage you're acting on (`tenant`) and, for
OAuth tokens, `"credential": "oauth"` plus an `oauth` block with your
`client_id` and the `authorization_id` (`oa_...`).

## 4. Refresh

Access tokens last an hour. Before (or when) one expires:

```bash
curl https://api.tippage.com/oauth/token \
  -u "oc_...:tpcs_..." \
  -d grant_type=refresh_token \
  -d refresh_token=tprt_...
```

You get a **new** access token *and* a **new** refresh token; the old
refresh token is dead the moment the new one is issued. Store the new pair.
Refresh tokens don't expire on their own: a streamer who connects your app
stays connected until they revoke it in Settings -> Developer -> Connected
apps, or your app calls `/oauth/revoke`. There's no periodic re-consent.
If a rotated-out refresh token is ever presented again, TipPage treats it
as a leaked copy and revokes the whole authorization - the streamer has to
reconnect.

## Revocation

Either side can end it:

- **The streamer**: Settings -> Developer -> Connected apps -> Revoke, or by
  disconnecting your app after you disable/delete it. Every token dies and
  any live realtime socket is closed within seconds.
- **Your app**: `POST /oauth/revoke` with `token=<access or refresh token>`
  and your client credentials ([RFC 7009](https://www.rfc-editor.org/rfc/rfc7009)).
  Revoking either kind ends the whole authorization. Unknown tokens still
  return 200.

Rotating your client secret does **not** revoke anything - existing tokens
keep working, but the next refresh needs the new secret. Removing a scope
from your app's registration revokes every current connection (they hold
more than the app may now ask for); adding scopes or changing redirect
URIs keeps them, and a re-authorization replaces the previous one
(one live session per app per streamer).

## The realtime stream

Connect to `wss://ws.tippage.com` and send
`{"data":{"apiKey":"tpat_..."}}` as the first message - the `apiKey` field
takes an access token too. Channels are derived from the authorization's
scopes; the socket stays up while the authorization lives (revocation
closes it), not just while that particular access token is valid.
`POST /v1/realtime/token` handoff tokens work the same as with a key. See
[Realtime](/api/realtime).

## What events say about you

Everything your app does through the API carries an `actor` of
`{ "type": "oauth_app", "id": "oc_...", "name": "<your app>", "authorization_id": "oa_..." }`
on webhooks, the realtime stream and the streamer's dashboard ("TTS paused by
Your App"). See the `actor` table in [Webhooks](/api/webhooks).

## Errors

The token endpoint uses the standard shape: `{ "error": "...", "error_description": "..." }`.
`invalid_client` is a 401; `invalid_grant` (bad/expired/reused code or
refresh token, PKCE mismatch, redirect_uri mismatch),
`unsupported_grant_type` and `invalid_scope` are 400s. A `/v1` call with an
expired or revoked access token gets `401 { "code": "invalid_token" }` -
refresh and retry, or send the streamer through authorize again if the
refresh fails with `invalid_grant`.

## Limits and safety

- Scopes never include payments, settings or team management - the same
  ceiling as API keys.
- Tokens and secrets are stored hashed; a secret is shown once at creation
  or rotation.
- Rate limits are per authorization (120 requests/min), same as per key.
- Authorize requests and codes live 10 minutes and are single-use.
- Don't put a confidential client's secret in a browser or a shipped
  binary - register a public client and use PKCE instead.
