Authentication
OneBooks uses OAuth 2.0 authorization code with PKCE. One authorization = one business: the user who consents determines which business’s data your token can see, and the token carries that binding implicitly.
Client types
Section titled “Client types”| Type | Where it runs | Secret | PKCE |
|---|---|---|---|
CONFIDENTIAL |
Your server | Yes — authenticate every token request | Recommended |
PUBLIC |
Browser SPA, mobile, desktop | None (a shipped secret is a leaked secret) | Required |
Discovery
Section titled “Discovery”Fetch the RFC 8414 metadata document once at startup instead of hard-coding endpoints:
curl -s https://api.getonebooks.com/.well-known/oauth-authorization-server{ "issuer": "https://api.getonebooks.com", "authorization_endpoint": "https://app.getonebooks.com/oauth/authorize", "token_endpoint": "https://api.getonebooks.com/oauth/token", "revocation_endpoint": "https://api.getonebooks.com/oauth/revoke", "introspection_endpoint": "https://api.getonebooks.com/oauth/introspect", "api_base_url": "https://api.getonebooks.com", "scopes_supported": ["profile:read", "invoices:read", "…"], "response_types_supported": ["code"], "grant_types_supported": ["authorization_code", "refresh_token"], "code_challenge_methods_supported": ["S256", "plain"], "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "none"]}Note the authorization endpoint is on the app host (it’s a user-facing
consent page), while token/revoke/introspect live on the API host.
api_base_url is a non-standard extension pointing at the REST API root.
Step 1 — redirect to authorize
Section titled “Step 1 — redirect to authorize”Generate a fresh PKCE verifier, challenge and CSRF state per authorization
attempt, then send the user’s browser to the authorization endpoint:
https://app.getonebooks.com/oauth/authorize ?response_type=code &client_id=YOUR_CLIENT_ID &redirect_uri=YOUR_REGISTERED_REDIRECT_URI (URL-encoded) &scope=invoices%3Awrite%20customers%3Aread (space-separated, URL-encoded) &state=RANDOM_STATE &code_challenge=BASE64URL(SHA256(code_verifier)) &code_challenge_method=S256| Parameter | Required | Notes |
|---|---|---|
response_type |
Yes | Always code. |
client_id |
Yes | From the developer console. |
redirect_uri |
Yes | Must exactly match a registered URI — scheme, host, port, path, trailing slash. |
scope |
Yes | Space-separated subset of your app’s registered scopes. |
state |
Recommended | Random CSRF token; echoed back on the callback. |
code_challenge |
Public: required | BASE64URL(SHA256(verifier)) for S256. |
code_challenge_method |
With challenge | S256 (use it) or plain. |
The user signs in to OneBooks (if needed), sees your app name and the requested scopes, and approves or denies. The authorization request is held server-side for 5 minutes — if the user walks away longer than that, restart the flow.
Step 2 — handle the callback
Section titled “Step 2 — handle the callback”On approval:
YOUR_REDIRECT_URI?code=AUTH_CODE&state=RANDOM_STATEOn denial:
YOUR_REDIRECT_URI?error=access_denied&state=RANDOM_STATEBefore continuing:
- Verify
stateequals the value you generated. Reject the callback if not. - Exchange the
codepromptly — it is single-use and expires in 10 minutes.
Step 3 — exchange the code
Section titled “Step 3 — exchange the code”POST /oauth/token is form-encoded. Confidential clients authenticate with
HTTP Basic (client_secret_basic) or by putting client_secret in the body
(client_secret_post); public clients send client_id in the body with no
secret.
curl -s https://api.getonebooks.com/oauth/token \ -u "$CLIENT_ID:$CLIENT_SECRET" \ --data-urlencode "grant_type=authorization_code" \ --data-urlencode "code=$AUTH_CODE" \ --data-urlencode "redirect_uri=https://yourapp.example.com/callback" \ --data-urlencode "code_verifier=$CODE_VERIFIER"curl -s https://api.getonebooks.com/oauth/token \ --data-urlencode "grant_type=authorization_code" \ --data-urlencode "code=$AUTH_CODE" \ --data-urlencode "redirect_uri=https://yourapp.example.com/callback" \ --data-urlencode "client_id=$CLIENT_ID" \ --data-urlencode "code_verifier=$CODE_VERIFIER"redirect_uri must match step 1 exactly; code_verifier is the original
verifier the challenge was derived from.
{ "access_token": "…", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "…", "scope": "invoices:write customers:read"}Store tokens server-side, encrypted at rest. For SPAs, keep the access token in
memory and the refresh token in an HttpOnly cookie scoped to your own backend —
never localStorage. Exchange failures are listed in the
error matrix.
Calling the API
Section titled “Calling the API”curl -s https://api.getonebooks.com/invoices \ -H "Authorization: Bearer $ACCESS_TOKEN"- Tenant is implicit. The token is bound to the consenting business. Never send a business ID — there is no header for it.
- Scopes are enforced per endpoint, deny-by-default. A missing scope gets
403; see Scopes.
Token lifetimes
Section titled “Token lifetimes”| Credential | Lifetime | Notes |
|---|---|---|
| Authorization request | 5 minutes | User must complete consent within this window. |
| Authorization code | 10 minutes | Single-use. |
| Access token | 3600 s (1 hour) | Refresh before expiry, or react to 401. |
| Refresh token | 30 days | Rotated on every use. |
Refreshing
Section titled “Refreshing”Refresh before the access token expires (leave ~60 s of slack for clock skew), or eagerly on a 401:
curl -s https://api.getonebooks.com/oauth/token \ -u "$CLIENT_ID:$CLIENT_SECRET" \ --data-urlencode "grant_type=refresh_token" \ --data-urlencode "refresh_token=$REFRESH_TOKEN"(Public clients: no -u, send client_id in the body.)
The response is a new access token and a new refresh token. Rotation is strict:
- The old refresh token is marked used and cannot be used again.
- The old access token is revoked immediately — not left to age out.
- Persist both new tokens atomically before using either.
If a refresh returns 400 invalid_grant (expired after 30 days, revoked, or
family-burned), send the user through authorization again.
Revocation
Section titled “Revocation”Invalidate a token you no longer need (e.g. the user disconnected your app on your side):
curl -s https://api.getonebooks.com/oauth/revoke \ -u "$CLIENT_ID:$CLIENT_SECRET" \ --data-urlencode "token=$REFRESH_TOKEN" \ --data-urlencode "token_type_hint=refresh_token"Returns 200 even if the token was already invalid (RFC 7009). The business
can also revoke your app’s access from inside OneBooks at any time — your next
API call returns 401; treat that as “needs re-authorization”.
Introspection
Section titled “Introspection”Debugging aid, not a production hot path — just call the API and handle 401:
curl -s https://api.getonebooks.com/oauth/introspect \ -u "$CLIENT_ID:$CLIENT_SECRET" \ --data-urlencode "token=$ACCESS_TOKEN"{ "active": true, "scope": "invoices:write customers:read", "exp": 1765465600, "client_id": "…", "sub": "…" }or { "active": false }.
Security checklist
Section titled “Security checklist”- Never embed a client secret in browser, mobile or desktop builds.
- Generate a fresh
code_verifierandstateper attempt; never reuse them. - Never log raw tokens — log a truncated suffix or hash for correlation.
- One
client_idserves all your customers; each business gets its own token set after its own consent. Don’t conflate your app with their tenant.