OAuth for apps
Let other collectors connect your app: authorization code with PKCE, tokens, refresh and review.
When to use OAuth
API keys are for your own scripts. When other collectors use your app, use OAuth: they sign in at collection.id, see what your app asks for, and allow it. You never see their password, your app gets only the scopes they allowed, and they can remove it at any time in Settings → Connected apps.
It’s OAuth 2.1’s authorization code flow with PKCE (S256), for every app. There’s no implicit or password grant. Endpoints are in beta while the first apps are built.
Registering your app
Register it in Settings → Your apps (or Register an OAuth app): its name, logo, website, privacy policy, redirect URIs, and the most it may ask for. You get a client id (app_…).
- Public: a phone, desktop or single-page app. It can’t keep a secret, so it has none: PKCE proves the code is its own.
- Confidential: an app with a server. It also has a client secret (
ccs_live_…), shown once; rotating it can keep the old one working for 24 hours.
Redirect URIs match exactly: https only, no wildcards, no extra paths or query. The one exception is loopback for native apps and development: http://127.0.0.1/…, http://[::1]/… or http://localhost/…, where the port may differ from the one you registered.
Sending the user to authorize
Make a PKCE verifier (43–128 random characters) and keep it with the user’s session, with a random state. Send the user to:
https://collection.id/oauth/authorize
?response_type=code
&client_id=app_k3v9q2m7x1c8d4f6h0j5
&redirect_uri=https://shelfie.example/callback
&scope=catalog:read collection:read
&state=<random, checked on the way back>
&code_challenge=<BASE64URL(SHA-256(code_verifier))>
&code_challenge_method=S256- Signed out, they sign in first. Then they see the consent screen: your app, each scope in plain words, and where they’ll be sent back to.
- Allowed: back to your redirect URI with
code, yourstateandiss=https://collection.id. Check both before using the code. - Denied, or a request we can’t serve: back with
error(access_denied,invalid_scope,invalid_request…). An unknown app or redirect URI is never sent back: the user sees an error on collection.id. - Someone who allowed these scopes before goes straight back, without the consent screen, except for a public app on a loopback address. Add
prompt=consentto always ask.
Getting tokens
Exchange the code within 2 minutes, once, at the token endpoint. It’s form-encoded. Confidential apps authenticate with HTTP Basic (or client_secret in the form); public apps send client_id.
curl https://api.collection.id/v1/oauth/token \
-u "app_k3v9q2m7x1c8d4f6h0j5:$CLIENT_SECRET" \
-d grant_type=authorization_code \
-d code=cac_… \
-d redirect_uri=https://shelfie.example/callback \
-d code_verifier=<the verifier you kept>{
"access_token": "cat_live_…",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "crt_live_…",
"scope": "catalog:read collection:read",
"store_id": null
}Send the access token as Authorization: Bearer cat_live_…. It lives 1 hour.
A code works once
A code sent a second time revokes every token it issued. A wrong verifier or redirect URI uses the code up too: start the authorization again.
Refreshing
Before the hour is up, send grant_type=refresh_token with the latest refresh token. The answer has a new access token and a new refresh token: the one you sent stops working. Refresh tokens last 60 days from their last use.
Store the new refresh token before you use it
Sending a used refresh token again means someone else may hold it, so every token from that authorization is revoked and the user must connect again. Refresh one at a time, and keep the new token before anything else.
A refresh may ask for fewer scopes (scope=…) for the new access token, never more.
Scopes and review
Apps use the same scopes as API keys, and the API checks a token exactly as it checks a key:
- Personal scopes act as the user, on their own data.
- Store scopes act for one store the user owns or manages, chosen on the consent screen (
store_idin the token answer). Ask for them on their own, withcatalog:readat most beside them. - Admin scopes are never available to apps, and an app never gets staff powers.
Write and store scopes need review. Until an admin approves them, they work for you alone (your own account and stores), so you can build and test. Ask for review in Settings → Your apps (Ask for review). Read-only personal scopes need none.
With the TypeScript SDK
The SDK makes the PKCE pair and the URL, checks the redirect, exchanges the code, and keeps the tokens fresh, one refresh at a time:
import {
authorizeUrl,
createCollectorClient,
createPkce,
createState,
exchangeCode,
oauthSession,
parseCallback,
type OAuthSession,
type TokenSet,
} from '@collector/sdk';
// A server-side app (confidential: it keeps a client secret). A phone, desktop or single-page app
// is public: leave clientSecret out, and PKCE alone protects the code.
const app = {
clientId: process.env.COLLECTOR_CLIENT_ID ?? '',
clientSecret: process.env.COLLECTOR_CLIENT_SECRET,
};
const redirectUri = 'https://shelfie.example/callback';
// What you keep per sign-in attempt and per user: your session store and database.
const pending = new Map<string, { verifier: string }>();
const saved = new Map<string, TokenSet>();
// One session per user in this process (see step 3).
const sessions = new Map<string, OAuthSession>();
// 1. "Connect The Collector": send the user to authorize your app.
export async function connect(): Promise<Response> {
const pkce = await createPkce();
const state = createState();
pending.set(state, { verifier: pkce.verifier });
const url = authorizeUrl({
clientId: app.clientId,
redirectUri,
scopes: ['catalog:read', 'collection:read'],
state,
codeChallenge: pkce.challenge,
});
return Response.redirect(url, 302);
}
// 2. Your redirect URI: check the answer, and exchange the code (once, within two minutes).
export async function callback(request: Request, userId: string): Promise<Response> {
const state = new URL(request.url).searchParams.get('state') ?? '';
const attempt = pending.get(state);
if (!attempt) return new Response('Start again.', { status: 400 });
pending.delete(state);
const { code } = parseCallback(request.url, { state, issuer: 'https://collection.id' });
const tokens = await exchangeCode({ ...app, code, redirectUri, codeVerifier: attempt.verifier });
saved.set(userId, tokens);
// Connected again: the next call starts a session from these tokens.
sessions.delete(userId);
return new Response('Connected.');
}
// 3. Call the API as the user. One session per user (in one process): it refreshes the access
// token before its hour is up, one refresh at a time, and stores each new refresh token before
// using it (each works once; two refreshes with the same one revoke everything).
function sessionFor(userId: string): OAuthSession {
let session = sessions.get(userId);
if (!session) {
session = oauthSession({
...app,
tokens: saved.get(userId)!,
onTokens: (tokens) => {
saved.set(userId, tokens);
},
});
sessions.set(userId, session);
}
return session;
}
export async function collection(userId: string) {
const client = createCollectorClient({ token: sessionFor(userId).token });
return client.listCopies({ query: { limit: 10 } });
}Revoking and disconnecting
- Revoke a token (RFC 7009): a refresh token takes every token from its authorization, an access token itself. It answers 200 either way.
- The user can remove your app in Settings → Connected apps: its tokens stop at once, and your next call answers
401 invalid_token. - Deleting your app, or an admin suspending it, stops every token it holds.
Errors
The token and revocation endpoints answer OAuth’s own errors, { "error": …, "error_description": … }:
| error | Status | Meaning |
|---|---|---|
invalid_request | 400 | Not form-encoded, or a parameter missing or sent twice. |
invalid_client | 401 | An unknown or suspended app, a wrong secret, or a secret from a public app. |
invalid_grant | 400 | The code or refresh token is unknown, expired, used, revoked or another app’s; or the redirect URI or verifier doesn’t match. |
unsupported_grant_type | 400 | Anything but authorization_code and refresh_token. |
invalid_scope | 400 | A refresh asking for more than was granted. |
rate_limited | 429 | Over your app’s limits: wait Retry-After seconds. |
Calls with an access token answer the API’s usual problems: 401 invalid_token (expired, revoked or removed: refresh, or connect again), 403 insufficient_scope (the user didn’t allow what the operation needs) and 403 key_not_allowed (not available to apps).
Limits and security
- Each app is limited per user (300 a minute, 50,000 a day) and across all its users (3,000 a minute): the RateLimit headers say what’s left. The token endpoint counts against your app, not your network.
- Tokens are opaque: don’t parse them. Keep them, and any client secret, out of logs and URLs.
- Never put a client secret in a phone, desktop or browser app: register those as public.
- The consent screen can’t be framed. Open it in the browser (or an in-app browser tab), never a webview you control.