Connect merchants with OAuth
A production merchant connects your app themselves. They click Connect LithosPOS in your product, land on a LithosPOS consent screen, approve, and your backend receives an authorization code it exchanges for an access token and a refresh token. Nobody at LithosPOS is in the loop, and the merchant can disconnect from their own back office at any time.
This is the production authentication path for the Full API and the Online Order API. ADSR apps use API keys and access requests instead. The sandbox client-credentials quickstart still exists and is still the fastest way to a first call — it is just sandbox-only.
Endpoints
Section titled “Endpoints”| Purpose | Endpoint |
|---|---|
| Authorize — browser redirect | https://my.lithospos.com/oauth/authorize |
| Token — server to server | POST https://api.lithospos.com/v1/oauth/token |
| Revoke — server to server | POST https://api.lithospos.com/v1/oauth/revoke |
The token endpoint accepts both application/json and application/x-www-form-urlencoded,
and takes client credentials either in the body or as HTTP Basic. It speaks RFC 6749, so a
standard OAuth client library works against it unmodified.
The flow
Section titled “The flow” Your app my.lithospos.com api.lithospos.com │ │ │ 1 │ 302 → /oauth/authorize?client_id&redirect_uri&state │ │───────────────────────────────▶│ │ │ │ owner signs in, reviews │ 2 │ │ the app + scopes, approves │ │ │ │ 3 │ 302 ← {redirect_uri}?code&state&merchant_id │ │◀───────────────────────────────│ │ │ │ │ 4 │ POST /v1/oauth/token grant_type=authorization_code │ │───────────────────────────────────────────────────────────────▶│ │ ◀── access_token (900 s) + refresh_token (100 d) + merchant_id │ │ │ │ 5 │ GET /v1/items Authorization: Bearer <access_token> │ │───────────────────────────────────────────────────────────────▶│ │ │ │ 6 │ POST /v1/oauth/token grant_type=refresh_token │ │───────────────────────────────────────────────────────────────▶│ │ ◀── new access_token + NEW refresh_token (store it now) │Before your first connection
Section titled “Before your first connection”Register your redirect URIs in the console under Apps → your app → OAuth. The authorize
endpoint matches the incoming redirect_uri as an exact string against that list — no
prefix matching, no wildcards, no trailing-slash tolerance.
| Rule | Detail |
|---|---|
| Count | Up to five URIs per app |
| Scheme | https:// — plus http://localhost[:port] and http://127.0.0.1[:port] so you can run the flow on your laptop |
| Fragments | Not allowed |
| Length | 512 characters |
| Matching | Exact string, including port, path, trailing slash and query |
-
Send the merchant to the authorize URL
Section titled “Send the merchant to the authorize URL”A full-page redirect, not an iframe — the consent screen refuses to be framed.
https://my.lithospos.com/oauth/authorize?response_type=code&client_id=lp_app_pkR7n2Vd4Qw8&redirect_uri=https%3A%2F%2Fapp.example.com%2Flithospos%2Fcallback&state=8f14e45fceea167a5a36dedd4bea2543Parameter Required Notes response_typeyes Always code. No other response type is supportedclient_idyes lp_app_pk…orlp_app_sb…. The prefix selects the environmentredirect_uriyes URL-encoded, and an exact match for one of the app’s registered URIs stateyes Opaque, unguessable, single-use. Enforced — a request without it is rejected scopeno Accepted and ignored in this version. The granted scope is always your app’s product bundle code_challengeno PKCE challenge. Requires code_challenge_method=S256code_challenge_methodno S256only.plainis rejectedGenerate
stateper attempt, store it against the user’s session, and compare it on the way back. It is your only defence against a forged callback. -
The merchant approves
Section titled “The merchant approves”The consent screen shows who is asking — your app name and organization — what product bundle it will receive, and which company is being connected. Only the account owner can approve; an employee session gets an explanatory card telling them to ask the owner.
If the merchant is not signed in, LithosPOS asks them to sign in first and returns them to the consent screen afterwards.
-
Handle the callback
Section titled “Handle the callback”https://app.example.com/lithospos/callback?code=Yk9sT2xVblJ4c0hqM1BpM3ZDNFhhNmpMd1E4dFI&state=8f14e45fceea167a5a36dedd4bea2543&merchant_id=1033Parameter Meaning codeSingle-use authorization code. Valid for five minutes stateEcho of what you sent. Compare it before doing anything else merchant_idThe LithosPOS company id that was just connected. Store it against your tenant record merchant_ididentifies the merchant behind the connection so you never have to ask which company you are talking to. You do not pass it back when minting tokens — the code and the refresh token already carry the merchant — but you will want it for your own records, for support conversations, and for the sandbox client-credentials path.If the merchant declines:
https://app.example.com/lithospos/callback?error=access_denied&state=8f14e45f…Always branch on the presence of
errorbefore you look forcode, and always validatestateon both paths. A request whoseclient_idorredirect_uridoes not validate is not redirected at all — LithosPOS renders an error page instead, so an attacker cannot use the authorize endpoint as an open redirect. -
Exchange the code for tokens
Section titled “Exchange the code for tokens”Server-side, within five minutes, exactly once.
Terminal window curl -s https://api.lithospos.com/v1/oauth/token \-H 'Content-Type: application/x-www-form-urlencoded' \--data-urlencode 'grant_type=authorization_code' \--data-urlencode 'code=Yk9sT2xVblJ4c0hqM1BpM3ZDNFhhNmpMd1E4dFI' \--data-urlencode 'redirect_uri=https://app.example.com/lithospos/callback' \--data-urlencode 'client_id=lp_app_pkR7n2Vd4Qw8' \--data-urlencode 'client_secret=lp_sk_pk_3Rw8QhT1nM6vD2xK9bY4sL7cF0aJ5eZg'Terminal window curl -s https://api.lithospos.com/v1/oauth/token \-H 'Content-Type: application/json' \-d '{"grant_type": "authorization_code","code": "Yk9sT2xVblJ4c0hqM1BpM3ZDNFhhNmpMd1E4dFI","redirect_uri": "https://app.example.com/lithospos/callback","client_id": "lp_app_pkR7n2Vd4Qw8","client_secret": "lp_sk_pk_3Rw8QhT1nM6vD2xK9bY4sL7cF0aJ5eZg"}'Terminal window curl -s https://api.lithospos.com/v1/oauth/token \-u 'lp_app_pkR7n2Vd4Qw8:lp_sk_pk_3Rw8QhT1nM6vD2xK9bY4sL7cF0aJ5eZg' \-H 'Content-Type: application/x-www-form-urlencoded' \--data-urlencode 'grant_type=authorization_code' \--data-urlencode 'code=Yk9sT2xVblJ4c0hqM1BpM3ZDNFhhNmpMd1E4dFI' \--data-urlencode 'redirect_uri=https://app.example.com/lithospos/callback'200 OK {"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjIwMjYtMDQifQ…","token_type": "Bearer","expires_in": 900,"refresh_token": "lp_rt_pk_9Sx2QeR7hK4mB1nV6yT3dW8cZ0aL5uJ","refresh_token_expires_in": 8640000,"scope": "stores.read items.read items.write categories.read …","merchant_id": 1033,"api_base_url": "https://api.lithospos.com/v1/"}Field Meaning access_tokenRS256 JWT. Send as Authorization: Bearer <token>token_typeAlways Bearerexpires_in900 seconds. Always refresh_tokenlp_rt_<env>_…. Rotates on every use — see refreshrefresh_token_expires_in8,640,000 seconds — 100 days, rolling scopeSpace-delimited resolved bundle for your app’s product merchant_idThe connected company id api_base_urlBuild every request URL from this. Currently https://api.lithospos.com/v1/Store
merchant_id, the refresh token andapi_base_urlagainst your tenant record. The access token is worth caching for its 900 seconds and nothing longer. -
Call the API
Section titled “Call the API”Identical to every other partner call: one global host, region routing handled by the gateway from the token’s own claims.
Terminal window curl -s "https://api.lithospos.com/v1/items?limit=25" \-H "Authorization: Bearer $ACCESS_TOKEN"The token is scoped to exactly one merchant. A merchant that connects a second company runs the flow again and you get a second token family.
-
Refresh before the token expires
Section titled “Refresh before the token expires”Fifteen minutes is short by design. Refresh on demand — when your cached token is inside about 60 seconds of
exp— rather than on a timer, and hold a single-flight lock per merchant so a burst of work produces one refresh, not fifty.
Refreshing an access token
Section titled “Refreshing an access token”curl -s https://api.lithospos.com/v1/oauth/token \ -H 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'grant_type=refresh_token' \ --data-urlencode 'refresh_token=lp_rt_pk_9Sx2QeR7hK4mB1nV6yT3dW8cZ0aL5uJ' \ --data-urlencode 'client_id=lp_app_pkR7n2Vd4Qw8' \ --data-urlencode 'client_secret=lp_sk_pk_3Rw8QhT1nM6vD2xK9bY4sL7cF0aJ5eZg'curl -s https://api.lithospos.com/v1/oauth/token \ -H 'Content-Type: application/json' \ -d '{ "grant_type": "refresh_token", "refresh_token": "lp_rt_pk_9Sx2QeR7hK4mB1nV6yT3dW8cZ0aL5uJ", "client_id": "lp_app_pkR7n2Vd4Qw8", "client_secret": "lp_sk_pk_3Rw8QhT1nM6vD2xK9bY4sL7cF0aJ5eZg" }'The response has the same shape as the code exchange, including a new refresh_token.
The one you just sent is finished.
Refresh tokens expire 100 days after they were issued, and the clock restarts on every
rotation. An integration that calls the API at least once every 100 days never sees an
expiry; one that goes quiet for longer gets invalid_grant / oauth.refresh_expired and
needs the merchant to reconnect.
// One refresh per merchant, newest token persisted first.async function refresh(tenant) { const response = await fetch('https://api.lithospos.com/v1/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: tenant.refreshToken, client_id: process.env.LITHOSPOS_CLIENT_ID, client_secret: process.env.LITHOSPOS_CLIENT_SECRET, }), });
if (!response.ok) { const { error, code } = await response.json(); // invalid_grant is terminal: the merchant must reconnect. if (error === 'invalid_grant') await markDisconnected(tenant, code); throw new Error(`refresh failed: ${code}`); }
const minted = await response.json(); await saveRefreshToken(tenant, minted.refresh_token); // durable write, first return minted;}PKCE (RFC 7636) binds the authorization code to the client that started the flow. It is optional here — your app is a confidential client and still authenticates with its secret — but it costs three lines and closes the code-interception hole, so use it.
-
Create a verifier and a challenge
Section titled “Create a verifier and a challenge”import { createHash, randomBytes } from 'node:crypto';const codeVerifier = randomBytes(32).toString('base64url'); // 43 charsconst codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');The verifier is a random 43–128 character string you keep in the user’s session. The challenge is its SHA-256 digest, base64url-encoded without padding.
-
Send the challenge on the authorize request
Section titled “Send the challenge on the authorize request”&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256S256is the only accepted method.plainis rejected withoauth.pkce_method_unsupported. -
Send the verifier on the exchange
Section titled “Send the verifier on the exchange”Terminal window --data-urlencode 'code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'A verifier that does not hash to the bound challenge fails with
invalid_grant/oauth.pkce_failed. If you sent a challenge, the verifier is mandatory at exchange time.
Disconnecting
Section titled “Disconnecting”Either side can end a connection.
Your app calls the revoke endpoint — wire it to whatever your product calls “Disconnect”.
curl -s https://api.lithospos.com/v1/oauth/revoke \ -H 'Content-Type: application/json' \ -d '{ "token": "lp_rt_pk_9Sx2QeR7hK4mB1nV6yT3dW8cZ0aL5uJ", "client_id": "lp_app_pkR7n2Vd4Qw8", "client_secret": "lp_sk_pk_3Rw8QhT1nM6vD2xK9bY4sL7cF0aJ5eZg" }'{ "ok": true }Following RFC 7009, revocation always reports success once your client credentials check out
— an unknown, already-revoked or malformed token still returns 200, so you cannot use the
endpoint to probe whether a token exists. Only bad client authentication fails, with 401 invalid_client.
The merchant disconnects from Settings → Connected apps in their back office, which they can do at any time without telling you.
Either way the effect is the same, and it is worth being precise about the timing:
- The refresh token dies immediately. The next refresh returns
invalid_grantwithoauth.grant_revoked. - An access token already in your hands is a signed JWT with at most 15 minutes left on it, and it keeps working until it expires — access tokens are stateless by design, so a disconnect is fully effective within 15 minutes. Treat the disconnect as final and stop calling as soon as you learn of it.
- Reconnecting means running the consent flow again. There is no “reactivate”.
Testing in sandbox
Section titled “Testing in sandbox”The whole flow works against sandbox tenants before your app is anywhere near review — same
authorize URL, same token endpoint, lp_app_sb… credentials and lp_rt_sb_… refresh tokens.
-
Provision a sandbox merchant
Section titled “Provision a sandbox merchant”From Sandbox → Provision merchant. Keep the owner email and password it shows you — they are the login you will use on the consent screen, and they are shown once. See sandbox merchants.
-
Register a loopback redirect URI
Section titled “Register a loopback redirect URI”Add
http://localhost:3000/callback(or whatever your dev server uses) to the app’s redirect URIs. Loopback URIs are accepted so the flow runs end to end on your machine. -
Run the flow with your sandbox client id
Section titled “Run the flow with your sandbox client id”https://my.lithospos.com/oauth/authorize?response_type=code&client_id=lp_app_sbK2m9Qx7Rt4&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&state=e3b0c44298fc1c149afbf4c8996fb924 -
Consent as the sandbox merchant’s owner
Section titled “Consent as the sandbox merchant’s owner”Sign in with the sandbox owner credentials from step 1 — not your developer console account. They are different identities in different systems, and the consent screen is a merchant surface.
-
Exercise the failure paths
Section titled “Exercise the failure paths”Deny the consent and check you handle
error=access_denied. Replay a used code. Let a code sit for six minutes. Refresh twice with the same token, outside the grace window, and confirm your code notices the family was revoked instead of retrying forever.
Errors
Section titled “Errors”Token and revoke endpoint failures use the RFC 6749 body — error, error_description and
our stable code extension. Branch on code; error_description is localised prose that
will change.
{ "error": "invalid_grant", "error_description": "This authorization code has expired.", "code": "oauth.code_expired"}error | code | Status | Meaning and fix |
|---|---|---|---|
invalid_client | developer.invalid_client | 401 | Unknown client id, wrong secret or a revoked credential. Deliberately indistinguishable. Response carries WWW-Authenticate: Basic |
invalid_request | oauth.invalid_request | 400 | A required field is missing or malformed |
unsupported_grant_type | oauth.unsupported_grant_type | 400 | Only authorization_code, refresh_token and (sandbox) client_credentials exist |
invalid_grant | oauth.code_invalid | 400 | Unknown or already-consumed code |
invalid_grant | oauth.code_expired | 400 | The code is older than five minutes. Restart the flow |
invalid_grant | oauth.redirect_uri_mismatch | 400 | The redirect_uri at exchange differs from the one the code was issued for |
invalid_grant | oauth.pkce_failed | 400 | Missing code_verifier, or it does not match the challenge |
invalid_grant | oauth.refresh_invalid | 400 | Unknown refresh token |
invalid_grant | oauth.refresh_expired | 400 | 100 days without use. The merchant must reconnect |
invalid_grant | oauth.refresh_reused | 400 | A rotated-out token was presented after the grace window — the family is revoked. The merchant must reconnect |
invalid_grant | oauth.grant_revoked | 400 | The merchant disconnected the app, or you revoked it |
unauthorized_client | developer.use_oauth | 400 | client_credentials with a production Full or Online Order credential. Use this flow |
unauthorized_client | developer.use_api_key | 400 | client_credentials with a production ADSR credential. Use API keys |
Treat every invalid_grant as terminal for that connection: mark the merchant disconnected,
surface it in your UI and prompt them to reconnect. Retrying will not help, and a retry loop
against the token endpoint will get you throttled.
Consent-time failures
Section titled “Consent-time failures”Problems the merchant hits before a code exists are shown on the consent screen, not sent to your redirect URI. You will hear about them from the merchant, so recognise them:
| Code | What went wrong |
|---|---|
oauth.client_not_found | The client_id does not exist. Error page, never a redirect |
oauth.redirect_uri_mismatch | The redirect_uri is not on the app’s registered list. Error page, never a redirect |
oauth.state_required | The authorize URL was built without state |
oauth.pkce_method_unsupported | code_challenge_method was something other than S256 |
oauth.app_not_published | A production client id for an app that is not APPROVED |
oauth.company_not_eligible | Environment mismatch — a production client id against a demo company, or a sandbox client id against a company that is not one of your organization’s sandbox tenants |
Security checklist
Section titled “Security checklist”- Validate
stateon every callback. Generate it per attempt, bind it to the session, reject anything that does not match, and never reuse a value. - Register exact redirect URIs. No wildcards exist; do not try to work around it by putting a router in front and passing the real destination in the URL.
- Keep the exchange server-side. The client secret, the code and the tokens never touch a browser or a mobile bundle.
- Encrypt refresh tokens at rest, and treat the store as a credential store, not application data. Log neither tokens nor codes.
- Persist the newest refresh token before using the access token, and serialise refreshes per merchant. This is the single most common way integrations lose a connection.
- Handle
invalid_grantas a disconnect, not as an error to retry. - Use PKCE. It is optional and it is nearly free.
- Give the merchant a disconnect button that calls the revoke endpoint, and reconcile with
their side: if a refresh starts returning
invalid_grantwithoauth.grant_revoked, they disconnected you.