Skip to content

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.

PurposeEndpoint
Authorize — browser redirecthttps://my.lithospos.com/oauth/authorize
Token — server to serverPOST https://api.lithospos.com/v1/oauth/token
Revoke — server to serverPOST 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.

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) │

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.

RuleDetail
CountUp to five URIs per app
Schemehttps:// — plus http://localhost[:port] and http://127.0.0.1[:port] so you can run the flow on your laptop
FragmentsNot allowed
Length512 characters
MatchingExact string, including port, path, trailing slash and query
  1. 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=8f14e45fceea167a5a36dedd4bea2543
    ParameterRequiredNotes
    response_typeyesAlways code. No other response type is supported
    client_idyeslp_app_pk… or lp_app_sb…. The prefix selects the environment
    redirect_uriyesURL-encoded, and an exact match for one of the app’s registered URIs
    stateyesOpaque, unguessable, single-use. Enforced — a request without it is rejected
    scopenoAccepted and ignored in this version. The granted scope is always your app’s product bundle
    code_challengenoPKCE challenge. Requires code_challenge_method=S256
    code_challenge_methodnoS256 only. plain is rejected

    Generate state per attempt, store it against the user’s session, and compare it on the way back. It is your only defence against a forged callback.

  2. 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.

  3. https://app.example.com/lithospos/callback
    ?code=Yk9sT2xVblJ4c0hqM1BpM3ZDNFhhNmpMd1E4dFI
    &state=8f14e45fceea167a5a36dedd4bea2543
    &merchant_id=1033
    ParameterMeaning
    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_id identifies 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 error before you look for code, and always validate state on both paths. A request whose client_id or redirect_uri does 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.

  4. 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'
    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/"
    }
    FieldMeaning
    access_tokenRS256 JWT. Send as Authorization: Bearer <token>
    token_typeAlways Bearer
    expires_in900 seconds. Always
    refresh_tokenlp_rt_<env>_…. Rotates on every use — see refresh
    refresh_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 and api_base_url against your tenant record. The access token is worth caching for its 900 seconds and nothing longer.

  5. 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.

  6. 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.

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

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.

  1. import { createHash, randomBytes } from 'node:crypto';
    const codeVerifier = randomBytes(32).toString('base64url'); // 43 chars
    const 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.

  2. Send the challenge on the authorize request

    Section titled “Send the challenge on the authorize request”
    &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
    &code_challenge_method=S256

    S256 is the only accepted method. plain is rejected with oauth.pkce_method_unsupported.

  3. 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.

Either side can end a connection.

Your app calls the revoke endpoint — wire it to whatever your product calls “Disconnect”.

Terminal window
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"
}'
200 OK
{ "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_grant with oauth.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”.

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.

  1. 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.

  2. 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.

  3. https://my.lithospos.com/oauth/authorize
    ?response_type=code
    &client_id=lp_app_sbK2m9Qx7Rt4
    &redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback
    &state=e3b0c44298fc1c149afbf4c8996fb924
  4. 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.

  5. 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.

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.

400 Bad Request
{
"error": "invalid_grant",
"error_description": "This authorization code has expired.",
"code": "oauth.code_expired"
}
errorcodeStatusMeaning and fix
invalid_clientdeveloper.invalid_client401Unknown client id, wrong secret or a revoked credential. Deliberately indistinguishable. Response carries WWW-Authenticate: Basic
invalid_requestoauth.invalid_request400A required field is missing or malformed
unsupported_grant_typeoauth.unsupported_grant_type400Only authorization_code, refresh_token and (sandbox) client_credentials exist
invalid_grantoauth.code_invalid400Unknown or already-consumed code
invalid_grantoauth.code_expired400The code is older than five minutes. Restart the flow
invalid_grantoauth.redirect_uri_mismatch400The redirect_uri at exchange differs from the one the code was issued for
invalid_grantoauth.pkce_failed400Missing code_verifier, or it does not match the challenge
invalid_grantoauth.refresh_invalid400Unknown refresh token
invalid_grantoauth.refresh_expired400100 days without use. The merchant must reconnect
invalid_grantoauth.refresh_reused400A rotated-out token was presented after the grace window — the family is revoked. The merchant must reconnect
invalid_grantoauth.grant_revoked400The merchant disconnected the app, or you revoked it
unauthorized_clientdeveloper.use_oauth400client_credentials with a production Full or Online Order credential. Use this flow
unauthorized_clientdeveloper.use_api_key400client_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.

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:

CodeWhat went wrong
oauth.client_not_foundThe client_id does not exist. Error page, never a redirect
oauth.redirect_uri_mismatchThe redirect_uri is not on the app’s registered list. Error page, never a redirect
oauth.state_requiredThe authorize URL was built without state
oauth.pkce_method_unsupportedcode_challenge_method was something other than S256
oauth.app_not_publishedA production client id for an app that is not APPROVED
oauth.company_not_eligibleEnvironment 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
  • Validate state on 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_grant as 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_grant with oauth.grant_revoked, they disconnected you.