Authentication
OAuth 2.0 with PKCE
How your app gets the merchant's consent and an access token, and how it refreshes the token without losing access.
Overview
- The merchant installs your app or opens it from the dashboard, and LARM opens your launch URL with
larm_store_id. - Your server generates a random
code_verifierand astate, and redirects the browser to/oauth/authorize. - LARM shows the store owner the requested scopes. On approval, the browser returns to your
redirect_uriwithcodeandstate. - Your server exchanges the
codefor a token pair at/oauth/token, using thecode_verifier. - Your server calls the API with
Authorization: Bearer, and refreshes the token before it expires.
Client authentication
/oauth/token, /oauth/launch/verify and /oauth/operations/* accept one method only: client_secret_basic.
Authorization: Basic base64(CLIENT_ID:CLIENT_SECRET)client_idis a UUID.- The secret starts with
lar_cs_followed by 43 characters. - Do not send
client_idor the secret in the request body; a body containing unknown keys is rejected.
When you rotate the secret in the portal, the previous secret stays valid for 24 hours, so deploy the new secret to your servers within that window. Revocation takes effect immediately.
1. Start authorization
Generate a random code_verifier of 43 to 128 characters and derive the code_challenge from it:
import crypto from 'node:crypto'
const verifier = crypto.randomBytes(32).toString('base64url') // 43 characters
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url')
const state = crypto.randomBytes(24).toString('base64url')
// Store verifier and state in your server session, bound to the store, then redirect the browser:
const url = new URL('https://larm.sa/oauth/authorize')
url.search = new URLSearchParams({
response_type: 'code',
client_id: process.env.LARM_CLIENT_ID,
store_id: storeId, // from larm_store_id
redirect_uri: 'https://app.example.com/larm/callback',
state,
code_challenge: challenge,
code_challenge_method: 'S256',
scope: 'products:read orders:read',
})
res.redirect(url.href)| Parameter | Rule |
|---|---|
response_type | code only |
client_id | UUID |
store_id | UUID, taken from larm_store_id in the launch URL |
redirect_uri | Exact match of one of the redirect URLs registered in the version |
state | 16 to 512 characters, required |
code_challenge | 43 base64url characters |
code_challenge_method | S256 only |
scope | Space-separated, up to 2048 characters |
version_id | Optional, UUID of a specific version |
Rules for the requested scopes:
- Include every required scope of the version.
- Request nothing beyond the required and optional scopes combined.
- Include each scope's dependencies. Requesting
orders.digital:readwithoutorders:readis rejected withSCOPE_DEPENDENCIES_MUST_BE_VISIBLE.
Parameters are a closed set: any unknown or repeated parameter is rejected. Errors at this step are returned as JSON from the authorization endpoint itself, and are never sent to redirect_uri.
2. Merchant consent
The consent page lives in the merchant dashboard, and only the store owner can see it. On approval, the browser returns to:
https://app.example.com/larm/callback?code=lar_c_…&state=…On denial:
https://app.example.com/larm/callback?error=access_denied&state=…Compare state with the value you stored before doing anything else. The authorization code is valid for at most two minutes and can be used once.
3. Exchange the code for tokens
curl -X POST "https://larm.sa/oauth/token" \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=$CODE" \
-d "redirect_uri=https://app.example.com/larm/callback" \
-d "code_verifier=$VERIFIER"The body accepts application/x-www-form-urlencoded or application/json, and the only allowed keys are grant_type, code, redirect_uri, code_verifier and refresh_token.
{
"access_token": "lar_at_Q2mV0hN8sYc1kP4tRw7Xa.4f0jK2…",
"refresh_token": "lar_rt_Hc8nW1qLp5sVz0bTy3Ue.9aQm…",
"token_type": "Bearer",
"expires_in": 900,
"scope": "products:read orders:read",
"store_id": "5b1d7a62-0c4e-4f38-9a51-2e7b3c9d8f10",
"installation_id": "c7e4a1f2-6b3d-4e8a-9c05-1d2f3a4b5c6d",
"installation_generation": 1,
"grant_revision": 1,
"operation_id": "0e9d8c7b-6a5f-4e3d-2c1b-0a9f8e7d6c5b"
}| Field | Meaning |
|---|---|
access_token | Valid for 900 seconds. Starts with lar_at_ |
refresh_token | Valid for 30 days from the last refresh. Starts with lar_rt_ |
scope | What the merchant actually approved. It can be narrower than what you requested if the merchant declined optional scopes |
installation_id | The installation's identity. Store it with the tokens |
installation_generation | Increases on reinstall. Any token from an earlier generation is no longer valid |
grant_revision | Increases when the granted scopes change |
operation_id | Used for recovery if the response is lost, as described below |
4. Refresh the token
curl -X POST "https://larm.sa/oauth/token" \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d "grant_type=refresh_token" \
-d "refresh_token=$REFRESH_TOKEN"Refresh tokens rotate, and rotation is mandatory:
- Every refresh issues a new pair and revokes the previous access token immediately.
- A refresh token can be used once. Persist the new pair before doing anything else.
- The 30-day window restarts on every refresh, so an active app never needs the merchant's consent again.
Two identical concurrent requests are treated as one, so a double submit is safe. The risk is retrying after a success.
If the response is lost
If the connection drops and you do not know whether the exchange succeeded:
- Send the same body to
POST /oauth/operations/lookupto get the operation's state. - If the result is
RESULT_UNAVAILABLE_REAUTHORIZE, callPOST /oauth/operations/recoverwith theoperation_id. Any tokens that were issued are revoked; then authorize again.
LARM never resends tokens that were already issued; recovery guarantees that no orphaned token pair stays valid.
Error format
LARM's OAuth errors use the same unified format as the rest of the API, not the RFC 6749 text format:
{ "error": { "code": "PKCE_MISMATCH" }, "meta": { "request_id": "…" } }| Code | HTTP | Meaning |
|---|---|---|
INVALID_CLIENT | 401 | Client authentication failed or the secret is invalid |
INVALID_GRANT_TYPE | 400 | Unknown grant_type |
INVALID_GRANT | 400 | Missing fields or wrong token prefix |
INVALID_CODE | 400 | The authorization code was not approved or has expired |
REDIRECT_URI_MISMATCH | 400 | redirect_uri differs from the one used in authorization |
PKCE_MISMATCH | 400 | code_verifier does not match code_challenge |
INVALID_REFRESH_TOKEN | 400 | The refresh token is unknown or inactive |
REFRESH_ALREADY_USED | 400 | The refresh token was already exchanged |
TOKEN_REUSE_DETECTED | 400 | Resent after success; all tokens were revoked |
RECONSENT_REQUIRED | 403 | The scopes changed and new consent is required |
RESULT_UNAVAILABLE_REAUTHORIZE | 409 | The result was lost; recover, then authorize again |
RATE_LIMITED | 429 | Rate limit exceeded |
STORE_MIGRATING | 503 | The store is moving; retry shortly |
Not supported
- No
client_credentialsgrant; every token is bound to a store and its owner's consent. - No OpenID Connect and no
id_token. - No token revocation or introspection endpoint. The merchant uninstalls the app from the dashboard, and you receive the [
app.uninstalled](/guides/app-uninstall) webhook.