Savee Developers
API

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
NameShown to users on the authorization screen.
DescriptionWhat the integration does and where it's hosted. At least 150 characters — it's what we read when reviewing.
Redirect URLsWhere users return after authorizing. Add one per environment, up to 10.
Website, logoOptional. 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:

https://savee.com/.well-known/oauth-authorization-server

Send the user to authorize

https://savee.com/oauth/authorize/
  ?response_type=code
  &client_id=YOUR_CLIENT_ID
  &redirect_uri=https://yourapp.com/auth/savee/callback
  &scope=profile:read saves:read boards:read
  &state=RANDOM_STRING
  &code_challenge=BASE64URL_SHA256_OF_VERIFIER
  &code_challenge_method=S256
  &resource=https://api.savee.com

Two parameters are easy to miss and both are required:

  • resource must be https://api.savee.com. Tokens are bound to one service, so this is what makes the resulting token work against the API.
  • code_challenge_method must be S256. plain is 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):

curl -X POST https://savee.com/api/oauth/token/ \
  -u 'YOUR_CLIENT_ID:YOUR_CLIENT_SECRET' \
  -H 'Content-Type: application/json' \
  -d '{
    "grant_type": "authorization_code",
    "code": "sv_ac_…",
    "redirect_uri": "https://yourapp.com/auth/savee/callback",
    "code_verifier": "YOUR_PKCE_VERIFIER",
    "resource": "https://api.savee.com"
  }'

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.

{
  "access_token": "sv_at_…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "sv_rt_…",
  "scope": "boards:read profile:read saves:read"
}

Call the API

Exactly like a personal token:

curl https://api.savee.com/v1/saves \
  -H 'Authorization: Bearer sv_at_…'

Scopes

Ask only for what you need. Users see each one on the authorization screen, and a request outside your granted scopes is refused.

ScopeGrants
profile:readUsername, name, avatar — /v1/me
saves:readThe user's saves and home feed
boards:readThe user's boards and their contents
search:readSearching 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:

HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="saves:read"

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.

curl -X POST https://savee.com/api/oauth/token/ \
  -u 'YOUR_CLIENT_ID:YOUR_CLIENT_SECRET' \
  -H 'Content-Type: application/json' \
  -d '{
    "grant_type": "refresh_token",
    "refresh_token": "sv_rt_…"
  }'

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:

curl -X POST https://savee.com/api/oauth/revoke/ \
  -u 'YOUR_CLIENT_ID:YOUR_CLIENT_SECRET' \
  -H 'Content-Type: application/json' \
  -d '{ "token": "sv_rt_…", "token_type_hint": "refresh_token" }'

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.

On this page