OAuth for apps
Let your users sign in with Savee and read their saves from your own product, without ever handling their token.
There are two ways to authenticate against the API.
A personal access token is the right choice when you're writing something for yourself — a script, a dashboard, your own site. It's one token, it belongs to you, and it grants everything your account can read.
OAuth is for building something other people use. Your users sign in with Savee, approve exactly what your app may read, and your app receives a scoped token per user. You never see their password, they never paste a token into your product, and they can revoke you at any time from their Savee settings.
Registration is invite-only for now
Partner integrations are onboarded one at a time. If you'd like to build one, email hey@savee.com with what you're building and where it'll run. Once your account is enabled, apps are registered under Settings → Developers.
What your users need
Everyone authorizing your app needs an active Savee subscription. That's checked when they authorize and again on every request, so an account that lapses stops working immediately rather than at the end of a token's life.
Registering an app
In Settings → Developers you'll provide:
| Field | |
|---|---|
| Name | Shown to users on the authorization screen. |
| Description | What the integration does and where it's hosted. At least 150 characters — it's what we read when reviewing. |
| Redirect URLs | Where users return after authorizing. Add one per environment, up to 10. |
| Website, logo | Optional. Shown on the authorization screen. |
You'll get a client ID and a client secret. The secret is shown once — store it somewhere safe, and rotate it if it leaks.
You can register up to 6 apps per account. If you need more than that, email hey@savee.com.
Redirect URLs must use https, or http on localhost / 127.0.0.1 for local
development. They're matched exactly at authorization time, so
https://app.example/cb will not accept https://app.example/cb?x=1.
The flow
Standard OAuth 2.1 with PKCE. If you're using an OAuth client library, point it at our metadata document and it will configure itself:
Send the user to authorize
Two parameters are easy to miss and both are required:
resourcemust behttps://api.savee.com. Tokens are bound to one service, so this is what makes the resulting token work against the API.code_challenge_methodmust beS256.plainis not accepted.
The user approves
They see your app's name, exactly which permissions you asked for, and where
they'll be sent back to. If they decline, we redirect with
error=access_denied.
Exchange the code
We redirect to your redirect_uri with code, your state, and iss.
Check state matches what you sent, and check iss is https://savee.com
before using the code — that's what stops another authorization server passing
off a response as ours.
Your app is a confidential client, so this request must be authenticated
with your client secret. -u sends it as HTTP Basic (client_secret_basic):
If your HTTP client can't set Basic auth, send the same two values in the body
instead (client_secret_post) — put client_id and client_secret alongside
the other fields. Both are accepted; pick one.
The secret is required, not optional
Omitting it returns 401 invalid_client. A client that holds a secret must
always present one — otherwise an attacker with a stolen authorization code
could simply leave it out. This is a server-side request only: the secret must
never reach a browser or a mobile binary.
Scopes
Ask only for what you need. Users see each one on the authorization screen, and a request outside your granted scopes is refused.
| Scope | Grants |
|---|---|
profile:read | Username, name, avatar — /v1/me |
saves:read | The user's saves and home feed |
boards:read | The user's boards and their contents |
search:read | Searching Savee's public library |
Requesting no scope grants the three that read the user's own account, but
naming them is better: a narrower ask is more likely to be approved, and it
limits the damage if your token leaks.
search:read must be asked for
It's the one scope that isn't in the default set. Search reads Savee's public
library rather than the user's own data, and draws on a budget shared with
everything else that account has connected — so it's never granted to a client
that didn't request it. Include it in scope if you need search.
A call missing a scope returns 403 with the missing scope named:
That's recoverable — send the user through authorization again asking for the additional scope, rather than treating it as a fatal error.
Refreshing
Access tokens last an hour. Refresh tokens last 30 days and rotate on every use — each refresh returns a new one, and the old one stops working.
Store the new refresh token every time
Because refresh tokens rotate, presenting one that's already been used is treated as a leak: the entire token chain for that user is revoked and they'll have to authorize again. If your app runs multiple instances, make sure two of them can't refresh the same token concurrently.
Disconnecting
Users can revoke your app at any time under Connected apps in their Savee settings, which takes effect immediately.
When you disconnect a user — they delete their account on your side, say — revoke their token rather than letting it expire:
Revoking a refresh token revokes its whole chain, so the matching access tokens
die with it. An unknown or already-revoked token returns 200 — the outcome you
asked for is already true.
Rate limits
OAuth tokens are metered exactly like personal tokens, and quotas are keyed to the Savee account, not to the token — so each of your users gets their own budget, and refreshing a token does not reset it.
One consequence worth designing around: a user's budget is shared across
everything they've connected, including our MCP server and any other integration
they use. You aren't metered in isolation, so back off on 429 rather than
assuming the budget is yours alone.
See rate limits, and note that search is metered far more tightly than everything else.