Developers

Authentication

Markdown

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

  1. The merchant installs your app or opens it from the dashboard, and LARM opens your launch URL with larm_store_id.
  2. Your server generates a random code_verifier and a state, and redirects the browser to /oauth/authorize.
  3. LARM shows the store owner the requested scopes. On approval, the browser returns to your redirect_uri with code and state.
  4. Your server exchanges the code for a token pair at /oauth/token, using the code_verifier.
  5. 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.

HTTP
Authorization: Basic base64(CLIENT_ID:CLIENT_SECRET)
  • client_id is a UUID.
  • The secret starts with lar_cs_ followed by 43 characters.
  • Do not send client_id or 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:

Node.js
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)
ParameterRule
response_typecode only
client_idUUID
store_idUUID, taken from larm_store_id in the launch URL
redirect_uriExact match of one of the redirect URLs registered in the version
state16 to 512 characters, required
code_challenge43 base64url characters
code_challenge_methodS256 only
scopeSpace-separated, up to 2048 characters
version_idOptional, 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:read without orders:read is rejected with SCOPE_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.

The consent page lives in the merchant dashboard, and only the store owner can see it. On approval, the browser returns to:

HTTP
https://app.example.com/larm/callback?code=lar_c_…&state=…

On denial:

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

JSON
{
  "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"
}
FieldMeaning
access_tokenValid for 900 seconds. Starts with lar_at_
refresh_tokenValid for 30 days from the last refresh. Starts with lar_rt_
scopeWhat the merchant actually approved. It can be narrower than what you requested if the merchant declined optional scopes
installation_idThe installation's identity. Store it with the tokens
installation_generationIncreases on reinstall. Any token from an earlier generation is no longer valid
grant_revisionIncreases when the granted scopes change
operation_idUsed for recovery if the response is lost, as described below

4. Refresh the token

cURL
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:

  1. Send the same body to POST /oauth/operations/lookup to get the operation's state.
  2. If the result is RESULT_UNAVAILABLE_REAUTHORIZE, call POST /oauth/operations/recover with the operation_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:

JSON
{ "error": { "code": "PKCE_MISMATCH" }, "meta": { "request_id": "…" } }
CodeHTTPMeaning
INVALID_CLIENT401Client authentication failed or the secret is invalid
INVALID_GRANT_TYPE400Unknown grant_type
INVALID_GRANT400Missing fields or wrong token prefix
INVALID_CODE400The authorization code was not approved or has expired
REDIRECT_URI_MISMATCH400redirect_uri differs from the one used in authorization
PKCE_MISMATCH400code_verifier does not match code_challenge
INVALID_REFRESH_TOKEN400The refresh token is unknown or inactive
REFRESH_ALREADY_USED400The refresh token was already exchanged
TOKEN_REUSE_DETECTED400Resent after success; all tokens were revoked
RECONSENT_REQUIRED403The scopes changed and new consent is required
RESULT_UNAVAILABLE_REAUTHORIZE409The result was lost; recover, then authorize again
RATE_LIMITED429Rate limit exceeded
STORE_MIGRATING503The store is moving; retry shortly

Not supported

  • No client_credentials grant; 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.

    ↑↓ moveEnter openEsc close