Skip to content

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.

TypeWhere it runsSecretPKCE
CONFIDENTIALYour serverYes — authenticate every token requestRecommended
PUBLICBrowser SPA, mobile, desktop, CLI (device flow)None (a shipped secret is a leaked secret)Required

Fetch the RFC 8414 metadata document once at startup instead of hard-coding endpoints:

Terminal window
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.

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
ParameterRequiredNotes
response_typeYesAlways code.
client_idYesFrom the developer console.
redirect_uriYesMust exactly match a registered URI — scheme, host, port, path, trailing slash.
scopeYesSpace-separated subset of your app’s registered scopes.
stateRecommendedRandom CSRF token; echoed back on the callback.
code_challengePublic: requiredBASE64URL(SHA256(verifier)) for S256.
code_challenge_methodWith challengeS256 — 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.

On approval:

YOUR_REDIRECT_URI?code=AUTH_CODE&state=RANDOM_STATE

On denial:

YOUR_REDIRECT_URI?error=access_denied&state=RANDOM_STATE

Before continuing:

  1. Verify state equals the value you generated. Reject the callback if not.
  2. Exchange the code promptly — it is single-use and expires in 10 minutes.

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.

Terminal window
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"

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.

Terminal window
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.
CredentialLifetimeNotes
Authorization request5 minutesUser must complete consent within this window.
Authorization code10 minutesSingle-use.
Access token3600 s (1 hour)Refresh before expiry, or react to 401.
Refresh token30 daysRotated on every use.

Refresh before the access token expires (leave ~60 s of slack for clock skew), or eagerly on a 401:

Terminal window
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.

POST /oauth/device/authorize accepts JSON or form encoding. The client authenticates as at the token endpoint (RFC 8628 §3.1):

Terminal window
curl -s https://api.getonebooks.com/oauth/device/authorize \
--data-urlencode "client_id=$CLIENT_ID" \
--data-urlencode "scope=invoices:write customers:read"
{
"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
}
ParameterRequiredNotes
client_idUnless sent with HTTP BasicFrom the developer console.
client_secretCONFIDENTIAL clients, unless sent with HTTP BasicNever in a PUBLIC app’s build.
scopeNoSpace-separated; every scope must be one your app registered in the console.
resourceNoRFC 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:

  • 401 invalid_client — the client didn’t authenticate: an unknown client_id or none at all, an app OneBooks has suspended or that’s been deactivated, a CONFIDENTIAL client without its secret, or a wrong secret. A secret you do send must be correct whatever the client type, so a PUBLIC client presenting a stale one fails too. The response carries WWW-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.
  • 400 invalid_scope — a scope your app didn’t register; error_description names the offenders.
  • 400 invalid_target — a resource that isn’t one absolute URI on this API’s origin.

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.

Poll POST /oauth/token with the device-code grant, never faster than interval seconds:

Terminal window
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.)

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" }
errorMeaningWhat your client should do
authorization_pendingThe user hasn’t approved yet.Keep polling, no faster than interval.
slow_downYou polled faster than interval.Back off; wait at least interval seconds before the next poll.
access_deniedThe user denied the request. The device code is consumed.Stop polling. Start a fresh device request if the user wants to try again.
expired_tokenThe 10-minute window elapsed.Stop polling, request a new device code, show the new user_code.
invalid_grantDevice 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.

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:

Terminal window
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"
  • A CONFIDENTIAL client must present its secret (HTTP Basic, or client_secret in the body); its client_id alone gets 401 invalid_client.
  • A PUBLIC client identifies itself by client_id alone — 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 same 200 as 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_client and counts toward the failed-authentication lockout on /oauth/revoke — as does an unknown, suspended or deactivated client_id.
  • A request with no client_id at 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”.

Debugging aid, not a production hot path — just call the API and handle 401:

Terminal window
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.

  • Never embed a client secret in browser, mobile or desktop builds.
  • Generate a fresh code_verifier and state per attempt; never reuse them.
  • Never log raw tokens — log a truncated suffix or hash for correlation.
  • One client_id serves all your customers; each business gets its own token set after its own consent. Don’t conflate your app with their tenant.