Authentication
Your app authenticates as itself, never as a member of the merchant’s staff. How it gets that credential depends on the product it was created with and the environment it is running in.
How each product authenticates
Section titled “How each product authenticates”| Product | Production | Sandbox |
|---|---|---|
| Full API | OAuth 2.0 authorization code — the merchant approves your app on a LithosPOS consent screen | The same OAuth flow against your own sandbox merchants, or the client-credentials quickstart |
| Online Order API | OAuth 2.0 authorization code | Same as Full API |
| ADSR API | API key plus an access request the merchant approves | API key, granted automatically against your own sandbox merchants |
Two of those paths end with a 900-second bearer token; the ADSR key is a long-lived
credential sent on each request. Everything you call as a partner — token minting included —
lives on api.lithospos.com. The one exception is the merchant-facing consent screen, which
lives on my.lithospos.com because it is part of the merchant’s own back office.
OAuth 2.0 — Full API and Online Order API
Section titled “OAuth 2.0 — Full API and Online Order API”The merchant clicks Connect in your product, approves your app on a LithosPOS consent
screen, and lands back on your redirect URI with an authorization code. Your backend exchanges
that code at POST https://api.lithospos.com/v1/oauth/token for a 900-second access token and
a refresh token, and refreshes from then on. The refresh token rotates on every use, so the
newest one must be persisted before the access token is used.
The flow works against sandbox tenants too, so you can build and test the whole connection experience before app review.
→ Connect merchants with OAuth — authorize parameters, the callback, token exchange, PKCE, refresh rotation, revocation and the full error table.
API keys — ADSR
Section titled “API keys — ADSR”An ADSR app creates a named API key in the console and files an access request naming the merchant and the stores it needs. The merchant’s account owner approves it — optionally with fewer stores than you asked for — and your key can read that tenant from then on. Requests expire after 30 days if nobody decides.
curl -s "https://api.lithospos.com/v1/reports/daily-sales?companyId=1033&from=2026-07-01&to=2026-07-31" \ -H 'X-API-Key: lp_ak_pk_7f3c91b2e84a05d6c7b1a394f8e02d5c6a7b9013'companyId is required on every key-authenticated request — a key, unlike a token, does not
carry a merchant inside it.
→ API keys and merchant access — key lifecycle, access requests, store scoping and the error table.
Sandbox quickstart (client credentials)
Section titled “Sandbox quickstart (client credentials)”POST https://api.lithospos.com/v1/tokenThe original client-credentials endpoint still exists and is unchanged. It is the shortest path from a new app to a first API call: two fields, one request, no browser. It only mints tokens for sandbox credentials against your own organization’s sandbox merchants.
This endpoint accepts JSON or application/x-www-form-urlencoded. The form encoding exists
for OAuth2 tooling that insists on grant_type; the value is ignored beyond validation.
curl -s https://api.lithospos.com/v1/token \ -H 'Content-Type: application/json' \ -d '{ "client_id": "lp_app_sbK2m9Qx7Rt4", "client_secret": "lp_sk_sb_5ZkQ5vJ2rN8pXw3TfM6bD1yH4sC7aE0g", "merchant_id": 104271 }'curl -s https://api.lithospos.com/v1/token \ -H 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'grant_type=client_credentials' \ --data-urlencode 'client_id=lp_app_sbK2m9Qx7Rt4' \ --data-urlencode 'client_secret=lp_sk_sb_5ZkQ5vJ2rN8pXw3TfM6bD1yH4sC7aE0g' \ --data-urlencode 'merchant_id=104271'async function mintToken(merchantId) { const response = await fetch('https://api.lithospos.com/v1/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: process.env.LITHOSPOS_CLIENT_ID, client_secret: process.env.LITHOSPOS_CLIENT_SECRET, merchant_id: merchantId, }), });
if (!response.ok) { const { error } = await response.json(); throw new Error(`token mint failed: ${error.code}`); }
return response.json();}Request fields
Section titled “Request fields”| Field | Type | Required | Notes |
|---|---|---|---|
client_id | string | yes | lp_app_sb…. A production lp_app_pk… credential is refused here |
client_secret | string | yes | lp_sk_sb_…. Shown once at creation or rotation |
merchant_id | number | conditional | The sandbox merchant’s company id. Optional only when the organization has exactly one active sandbox merchant |
grant_type | string | no | Accepted as client_credentials for OAuth2 clients; other values are rejected |
Response
Section titled “Response”{ "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjIwMjYtMDQifQ…", "token_type": "Bearer", "expires_in": 900, "scope": "stores.read items.read items.write categories.read categories.write …", "api_base_url": "https://api.lithospos.com/v1/"}| Field | Meaning |
|---|---|
access_token | RS256 JWT. Send as Authorization: Bearer <token> |
token_type | Always Bearer |
expires_in | Lifetime in seconds. Always 900 |
scope | Space-delimited resolved scopes for this app’s product |
api_base_url | Base URL for every partner request, with a trailing slash. Currently https://api.lithospos.com/v1/ |
There is no refresh token on this grant — mint again when the token expires. The OAuth flow
returns the same access token shape plus refresh_token, refresh_token_expires_in and
merchant_id.
One host, every region
Section titled “One host, every region”LithosPOS runs separate clusters for the UK, US, India and UAE, and a merchant’s data lives in exactly one of them. You do not need to know which. Every partner request goes to the same host:
https://api.lithospos.com/v1/…The gateway reads the region claim from your token, routes the request to that merchant’s
origin, and returns the response. Routing happens at the edge, so there is no redirect to
follow and no extra round trip. Key-authenticated ADSR calls are routed the same way, on the
companyId they carry.
const endpoint = new URL('items', api_base_url);// → https://api.lithospos.com/v1/itemsInside the token
Section titled “Inside the token”The access token is a signed JWT, identical whether it came from the OAuth exchange, a
refresh or the sandbox quickstart. Treat it as opaque for authorisation decisions — the
partner API verifies it against the LithosPOS JWKS on every request — but you may read exp
for cache scheduling.
| Claim | Example | Meaning |
|---|---|---|
aud | lithospos-app | Audience. App tokens are rejected on merchant-user endpoints and vice versa |
sub | "412" | Your app id |
orgId | 88 | Owning organization |
companyId | 104271 | The merchant this token may touch — one token, one merchant |
region | in | Cluster holding this merchant’s data. The gateway routes on it; you never send it yourself |
product | FULL | FULL, ONLINE_ORDER or ADSR |
scopes | ["items.read", …] | Resolved scope bundle for the product |
env | SANDBOX | SANDBOX or PRODUCTION |
allowedIps | ["203.0.113.7"] | Present only when the credential has an allowlist |
jti / iat / exp | — | Token id and 900-second window |
Caching and renewal
Section titled “Caching and renewal”Hold one access token per merchant and reuse it for its full 900 seconds. A fresh mint on every request will hit the token endpoint throttle and adds latency for no benefit. This is the same for both mint paths — an OAuth access token is not longer-lived than a quickstart one, it is just renewable without the merchant.
const cache = new Map(); // merchantId → { token, apiBaseUrl, expiresAt }
async function getToken(merchantId) { const hit = cache.get(merchantId); // Renew 60s early so an in-flight request never expires mid-call. if (hit && hit.expiresAt - 60_000 > Date.now()) return hit;
const minted = await mintToken(merchantId); // or refresh(), on the OAuth path const entry = { token: minted.access_token, apiBaseUrl: minted.api_base_url, expiresAt: Date.now() + minted.expires_in * 1000, }; cache.set(merchantId, entry); return entry;}Guard the renewal with a single-flight lock per merchant so a burst of concurrent requests produces one mint, not fifty. Minting a new access token does not invalidate the previous one — it stays valid until it expires. Refresh tokens behave differently: they rotate on every use, and only the newest one is live. See refresh rotation.
Sandbox and production
Section titled “Sandbox and production”Every credential belongs to one environment and the environment is enforced on both ends.
| Sandbox | Production | |
|---|---|---|
| Client id marker | lp_app_sb… | lp_app_pk… |
| Secret prefix | lp_sk_sb_… | lp_sk_pk_… |
| API key prefix | lp_ak_sb_… | lp_ak_pk_… |
| Issued | With the app, immediately | By LithosPOS when app review is approved |
| Merchants reachable | Your organization’s sandbox merchants, granted automatically | Merchants that connected you through OAuth, or approved your ADSR access request |
| Merchant flag | Demo companies only | Live companies only |
client_credentials | Supported | Refused — use OAuth or an API key |
A sandbox credential presented for a live company, or a production credential for a demo
company, fails with developer.env_mismatch at mint time and partner.env_mismatch if it
somehow reaches the partner API. Production additionally requires the app to be APPROVED
(developer.app_not_approved) before a merchant can authorize it at all.
Rotating a secret
Section titled “Rotating a secret”Rotate from Apps → Credentials → Rotate. The new secret is displayed once and works immediately. The previous secret keeps working for 24 hours so you can deploy without a window of downtime, then it is revoked automatically.
Rotating the client secret does not disturb existing merchant connections: refresh tokens belong to the consent, not to the secret. The next refresh simply has to present the new secret.
IP allowlists
Section titled “IP allowlists”A credential can carry an allowlist of source addresses. When set, the addresses are copied
into the token as allowedIps and enforced on every request made with that token; calls from
anywhere else return 403 partner.ip_denied.
Use it when your integration runs from fixed egress IPs. Leave it empty when it does not — an allowlist that drifts out of date is an outage, not a control.
Token endpoint errors
Section titled “Token endpoint errors”The two token endpoints report failures differently, because one of them has to be interoperable with off-the-shelf OAuth clients.
POST /v1/oauth/tokenandPOST /v1/oauth/revokereturn RFC 6749 bodies —{ "error", "error_description", "code" }. The table is in the OAuth guide.POST /v1/tokenreturns the developer API envelope —{ "error": { "code", "message" } }.
| Code | Status | Cause |
|---|---|---|
developer.invalid_client | 401 | Unknown client id, wrong secret, or a revoked credential. Deliberately indistinguishable |
developer.merchant_required | 400 | Sandbox only: merchant_id omitted and the organization has more than one sandbox merchant. A production credential is never asked for merchant_id here — it gets developer.use_oauth or developer.use_api_key |
developer.app_archived | 403 | The app has been archived in the console |
developer.app_not_approved | 403 | Production credential used before app review approval |
developer.grant_missing | 403 | No active grant links this app to that merchant |
developer.env_mismatch | 403 | Sandbox credential against a live merchant, or the reverse |
A production credential on either endpoint is refused with developer.use_oauth (Full and
Online Order apps) or developer.use_api_key (ADSR apps). Both mean the same thing: the
merchant has to authorize you, and the way they do that depends on your product.
See Errors for the complete code table and the partner API envelope.