OAuth 2.0
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.
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 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, excepthttp://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 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) 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
S256is accepted (plainis refused). - Generate a fresh verifier per authorization and keep it with your
stateuntil the callback. Never reuse one.
code_verifier = 43..128 chars from [A-Z a-z 0-9 - . _ ~] (random)
code_challenge = BASE64URL( SHA256( code_verifier ) ) (no padding)
// node
import crypto from "node:crypto";
const verifier = crypto.randomBytes(32).toString("base64url");
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
# python
import base64, hashlib, secrets
verifier = secrets.token_urlsafe(32)
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
// 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
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. 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 unknownclient_idor an unregisteredredirect_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.
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=...
{
"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:
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:
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/revokewithtoken=<access or refresh token>and your client credentials (RFC 7009). 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.
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.
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.