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, CLI (device flow) | 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", "device_authorization_endpoint": "https://api.getonebooks.com/oauth/device/authorize", "registration_endpoint": "https://api.getonebooks.com/oauth/register", "revocation_endpoint": "https://api.getonebooks.com/oauth/revoke", "introspection_endpoint": "https://api.getonebooks.com/oauth/introspect", "jwks_uri": "https://api.getonebooks.com/.well-known/jwks.json", "api_base_url": "https://api.getonebooks.com", "scopes_supported": ["profile:read", "invoices:read", "…"], "response_types_supported": ["code"], "grant_types_supported": ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code", "urn:ietf:params:oauth:grant-type:token-exchange"], "code_challenge_methods_supported": ["S256"], "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "none"], "client_id_metadata_document_supported": true}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.
scopes_supported lists every scope the authorization server knows, which is a
superset of what a partner app may request — see Scopes for the
catalog the console actually offers.
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 — required for public clients, which are refused plain. Confidential clients should use S256 too. |
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.
For an app OneBooks has suspended, or that’s been deactivated, the consent
screen doesn’t open at all: it reports Invalid client_id.
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.
A failed exchange answers with a standard OAuth error body — RFC 6749 §5.2, the same on every token, revocation and introspection error — so any OAuth library can read it:
{ "error": "invalid_grant", "error_description": "code already used" }Branch on error; error_description is optional and only for your logs.
Every code and status is listed under
OAuth endpoint errors.
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,
family-burned, or the business revoked your app’s access or uninstalled it),
send the user through authorization again. Every grant re-checks at the moment
it issues tokens that the business’s installation of your app is still
ACTIVE — and code, device-code and refresh grants also that the user’s
consent hasn’t been revoked — so a code, device code or refresh token redeemed
after an uninstall or revocation gets invalid_grant, never a fresh pair.
Device authorization — native apps without a redirect URI
Section titled “Device authorization — native apps without a redirect URI”Some apps have nowhere to send a redirect: desktop apps and CLIs, POS
terminals, kiosks, and native apps that can’t host an https callback or open
a local listener. For those, OneBooks implements the RFC 8628 device
authorization grant — your app shows a short code, the user approves it in a
browser on any device, and your app polls until tokens come back.
Both client types work here, and each authenticates at both steps — the device
request and every token poll — exactly as it does at the token endpoint: a
CONFIDENTIAL client with its secret; a PUBLIC client with its client_id
alone, needing no secret at any point in this flow. A desktop or device build
can’t keep a secret, so register such an app as PUBLIC.
Step 1 — request a device code
Section titled “Step 1 — request a device code”POST /oauth/device/authorize accepts JSON or form encoding. The client
authenticates as at the token endpoint (RFC 8628 §3.1):
curl -s https://api.getonebooks.com/oauth/device/authorize \ --data-urlencode "client_id=$CLIENT_ID" \ --data-urlencode "scope=invoices:write customers:read"curl -s https://api.getonebooks.com/oauth/device/authorize \ -u "$CLIENT_ID:$CLIENT_SECRET" \ --data-urlencode "scope=invoices:write customers:read"(client_id + client_secret in the body works too. With HTTP Basic the
body needs no client_id.)
{ "device_code": "…", "user_code": "ABCD-EFGH", "verification_uri": "https://app.getonebooks.com/device", "verification_uri_complete": "https://app.getonebooks.com/device?user_code=ABCD-EFGH", "expires_in": 600, "interval": 5}| Parameter | Required | Notes |
|---|---|---|
client_id | Unless sent with HTTP Basic | From the developer console. |
client_secret | CONFIDENTIAL clients, unless sent with HTTP Basic | Never in a PUBLIC app’s build. |
scope | No | Space-separated; every scope must be one your app registered in the console. |
resource | No | RFC 8707 resource indicator — the same rule as on /oauth/token: it must be on this API’s origin (https://api.getonebooks.com, any path). |
Every failure is a standard OAuth error body, like every token endpoint error, and mints no code:
401invalid_client— the client didn’t authenticate: an unknownclient_idor none at all, an app OneBooks has suspended or that’s been deactivated, aCONFIDENTIALclient without its secret, or a wrong secret. A secret you do send must be correct whatever the client type, so aPUBLICclient presenting a stale one fails too. The response carriesWWW-Authenticate: Basic realm="OneBooks"when you used HTTP Basic, and each failure counts toward this endpoint’s failed-authentication lockout — kept apart from/oauth/token’s, so it never costs you token refreshes.400invalid_scope— a scope your app didn’t register;error_descriptionnames the offenders.400invalid_target— aresourcethat isn’t one absolute URI on this API’s origin.
Step 2 — the user approves
Section titled “Step 2 — the user approves”Display user_code (eight characters in two groups, so it reads off a terminal
or a receipt printer) together with verification_uri. Where you can render a
link or a QR code, use verification_uri_complete instead — it pre-fills the
code.
The user opens that URI in their own browser, signs in to OneBooks, sees your app’s name and the scopes you asked for, and approves or denies. Approval binds the resulting tokens to the business they’re signed into, exactly like the consent screen of the code flow. The review gate applies unchanged too: an app that hasn’t completed review can only be approved against its own organization’s sandbox businesses.
Step 3 — poll for tokens
Section titled “Step 3 — poll for tokens”Poll POST /oauth/token with the device-code grant, never faster than
interval seconds:
curl -s https://api.getonebooks.com/oauth/token \ -u "$CLIENT_ID:$CLIENT_SECRET" \ --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:device_code" \ --data-urlencode "device_code=$DEVICE_CODE"(client_id + client_secret in the body works too — HTTP Basic is just the
tidier option.)
curl -s https://api.getonebooks.com/oauth/token \ --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:device_code" \ --data-urlencode "device_code=$DEVICE_CODE" \ --data-urlencode "client_id=$CLIENT_ID"Until the user decides, the endpoint answers 400 with a standard RFC 8628
§3.5 error body — the code is in error, as on every
token endpoint error:
{ "error": "authorization_pending" }error | Meaning | What your client should do |
|---|---|---|
authorization_pending | The user hasn’t approved yet. | Keep polling, no faster than interval. |
slow_down | You polled faster than interval. | Back off; wait at least interval seconds before the next poll. |
access_denied | The user denied the request. The device code is consumed. | Stop polling. Start a fresh device request if the user wants to try again. |
expired_token | The 10-minute window elapsed. | Stop polling, request a new device code, show the new user_code. |
invalid_grant | Device code not found or issued to a different client — or the approval no longer stands: the business uninstalled your app (which cancels an approved code you haven’t collected) or revoked its access. | Stop polling — a mismatched client_id, a bug on your side or a withdrawn approval, not a transient state. |
At the default 5-second interval you make 12 requests a minute, comfortably
inside even a public client’s token-endpoint budget
of 300 a minute per IP and client; a tighter loop earns slow_down, and past that budget a
429 (temporarily_unavailable, with Retry-After).
On approval the poll returns exactly the payload the authorization-code
exchange returns (access_token, refresh_token, token_type: "Bearer",
expires_in, scope), and the device code is consumed — it is single-use, so
that successful poll is your last one. Everything after this point is identical
to the code flow: calling the API, token lifetimes,
refreshing, revocation and
introspection all behave the same way.
Revocation
Section titled “Revocation”Invalidate a token you no longer need (e.g. the user disconnected your app on your side). Authenticate the way you do at the token endpoint:
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"curl -s https://api.getonebooks.com/oauth/revoke \ --data-urlencode "client_id=$CLIENT_ID" \ --data-urlencode "token=$REFRESH_TOKEN" \ --data-urlencode "token_type_hint=refresh_token"- A
CONFIDENTIALclient must present its secret (HTTP Basic, orclient_secretin the body); itsclient_idalone gets401 invalid_client. - A
PUBLICclient identifies itself byclient_idalone — in the body, or as HTTP Basic with an empty secret (client_id:) — and can revoke only tokens issued to it: another client’s token gets the same200as an unknown one, and nothing is revoked. - A secret you do present must be correct, whatever the client type. A wrong
one is
401 invalid_clientand counts toward the failed-authentication lockout on/oauth/revoke— as does an unknown, suspended or deactivatedclient_id. - A request with no
client_idat all is still accepted: holding the token is enough to revoke it.
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, and a refresh answers invalid_grant; 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 } — for a token that’s expired, revoked or unknown, one
issued to a different client (only the issuing client sees details), and any
token the API would refuse anyway: your app is suspended or deactivated, or
the user is suspended or under a processing restriction.
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.