Authentication
The partner API is authenticated with an OAuth2 client credentials grant. Your app holds a client id and secret, exchanges them for a 900-second access token, and sends that token as a bearer credential. There is no user login, no redirect and no refresh token.
Mint a token
Section titled “Mint a token”POST https://api-v2.lithospos.com/v1/tokenEverything you call as a partner — this endpoint included — lives on api-v2.lithospos.com.
The gateway proxies token requests through to the developer token service, so the request
and response bodies are exactly as documented below.
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-v2.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-v2.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-v2.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_ prefix. sb marker for sandbox credentials, pk for production |
client_secret | string | yes | lp_sk_<env>_…. Shown once at creation or rotation |
merchant_id | number | conditional | The LithosPOS company id. Optional only when the credential is sandbox and 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-v2.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-v2.lithospos.com/v1/ |
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-v2.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.
const endpoint = new URL('items', api_base_url);// → https://api-v2.lithospos.com/v1/itemsInside the token
Section titled “Inside the token”The access token is a signed JWT. 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”Mint one token per merchant and reuse it. A fresh mint on every request will hit the token endpoint throttle and adds latency for no benefit.
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); 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 mint with a single-flight lock so a burst of concurrent requests produces one token, not fifty. Tokens are not revoked when you mint a new one — the old one stays valid until it expires.
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_… |
| Issued | With the app, immediately | By LithosPOS when app review is approved |
| Merchants reachable | Your organization’s sandbox merchants only | Merchants that have an active grant for your app |
| Merchant flag | Demo companies only | Live companies only |
A sandbox token presented for a live company, or a production token for a demo company,
fails with developer.env_mismatch at mint time and partner.env_mismatch if it somehow
reaches the partner API. Production tokens additionally require the app to be APPROVED
(developer.app_not_approved) and a grant row linking app to merchant
(developer.grant_missing).
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.
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 partner request; 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”Failures use the developer API error 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 | merchant_id omitted and it could not be inferred |
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 |
See Errors for the complete code table and the partner API envelope.