# API fundamentals

The response envelope, errors, pagination, ETags and idempotency keys: rules that apply to every endpoint in the API.

## Base URL and authentication

```http
GET /stores/{storeId}/v1/products HTTP/1.1
Host: larm.sa
Authorization: Bearer lar_at_Q2mV0hN8sYc1kP4tRw7Xa.4f0jK2…
```

- Every path starts with `/stores/{storeId}/v1`, where `storeId` is the store the token was issued for.
- `Authorization: Bearer` is required. Any other scheme returns 401 `INVALID_TOKEN`.
- Send requests from a server. Any `Cookie` header returns 400 `INVALID_PUBLIC_REQUEST`.
- Never put tokens or secrets in the URL; `access_token`, `refresh_token`, `client_secret`, `code` and `code_verifier` are rejected in the query string.

## Response envelope

Every successful response has status **200**, including creates. There is no 201 and no 204.

```json
{
  "data": { "id": 311, "name": "Top-up card 100 SAR" },
  "execution": {
    "appId": "…", "versionId": "…", "installationId": "…", "storeId": "…",
    "requestId": "…", "generation": 1, "routeEpoch": 4, "appAuthEpoch": 1, "grantRevision": 1
  },
  "meta": { "request_id": "3f0c2b8e-7a51-4c1e-9d0a-5b6f1e2d4c3a" }
}
```

| Field | Meaning |
|---|---|
| `data` | The resource or list of resources |
| `execution` | Who executed the request. Its keys are camelCase, unlike the rest of the API |
| `meta.request_id` | The request ID. Include it in any support request |

Headers on every response:

| Header | Value |
|---|---|
| `x-larm-api-version` | The dated API version, such as `2026-10-15` |
| `X-Larm-Request-Id` | The same request ID |
| `Cache-Control` | `private, no-store` |
| `ETag` | On products and categories, as described below |

> New fields can be added at any time without notice. Ignore any field you do not recognize, and do not fail when one appears.

## Errors

```json
{ "error": { "code": "SCOPE_REQUIRED" }, "meta": { "request_id": "…" } }
```

An error is a stable code only, with no message and no field details. Branch on `error.code`; the full list is in [Error codes](/errors). Every 429 and 503 from the gateway carries `Retry-After`.

## Pagination

Lists use cursor pagination, not page numbers:

```bash
curl "https://larm.sa/stores/$STORE_ID/v1/products?limit=50" -H "Authorization: Bearer $ACCESS_TOKEN"
# then
curl "https://larm.sa/stores/$STORE_ID/v1/products?limit=50&cursor=$NEXT_CURSOR" -H "Authorization: Bearer $ACCESS_TOKEN"
```

- `limit` is 1 to 100, default 25. Outside that range: 400 `INVALID_FILTER`.
- On the last page, `meta.next_cursor` is `null`.
- A cursor is bound to the filters it was issued with. Changing a filter while reusing an old cursor returns 400 `CURSOR_MISMATCH`.
- **A cursor is valid for 15 minutes from the first page**, not from the latest page. Finish the list within that window; otherwise you get 400 `CURSOR_EXPIRED` and must start over.
- Products, categories and inventory also return `has_more` and `limit` in `meta`.
- An unknown query key returns 422 `UNKNOWN_FIELD`.

```js
async function* all(path) {
  let cursor = null
  do {
    const url = new URL(`https://larm.sa/stores/${storeId}/v1${path}`)
    url.searchParams.set('limit', '100')
    if (cursor) url.searchParams.set('cursor', cursor)
    const response = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } })
    if (!response.ok) throw new Error((await response.json()).error.code)
    const { data, meta } = await response.json()
    yield* data
    cursor = meta.next_cursor
  } while (cursor)
}
```

## ETag and If-Match

Updating a product or category requires proof that you have seen the latest version (optimistic concurrency), so your app never overwrites the merchant's changes:

1. Read the resource. Take the `ETag` header value from the response.
2. Send it in `If-Match` with the `PATCH`.

```bash
curl -X PATCH "https://larm.sa/stores/$STORE_ID/v1/products/311/prices" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'If-Match: "p1.7.Xk2…"' \
  -H "Content-Type: application/json" \
  -d '{"price":"95.00","old_price":"110.00"}'
```

| Case | Response |
|---|---|
| No `If-Match` | 428 `PRECONDITION_REQUIRED` |
| The resource changed after you read it | 412 `RESOURCE_CHANGED`: read it again and rebuild the update |

`If-Match` is required on: product content, product media, product SEO, product prices, and category updates. Inventory, page, coupon and review updates do not use it.

## Idempotency keys

A create can succeed and its response can be lost. Send an `Idempotency-Key` and LARM returns the stored response instead of creating a second copy:

```http
Idempotency-Key: 5f2c1a9e-3b7d-4e60-8a14-c9d2e7f0b358
```

- **Required** on: create product, create category, create page, adjust inventory. Without it: 400 `IDEMPOTENCY_KEY_REQUIRED`.
- Optional on other writes, and not used with webhook subscriptions.
- Up to 128 bytes, no whitespace. Scoped to the store and app, and kept for 24 hours.

| Case | Response |
|---|---|
| Same key, same request, first one completed | The same stored response |
| Same key, first one still running | 409 `OPERATION_IN_PROGRESS`: wait a second and retry |
| Same key, different request | 409 `IDEMPOTENCY_CONFLICT` |

## Request limits

| Limit | Value |
|---|---|
| Body size | 65,536 bytes; otherwise 413 `BODY_TOO_LARGE` |
| Body type | A JSON object as `application/json`; otherwise 415 `CONTENT_TYPE_REQUIRED` |
| Query string | 4096 characters, 32 keys, no repeats |
| Timeout | 10 seconds per request |
| Methods | `GET`, `POST`, `PATCH` and `DELETE`. A wrong method returns 405 with `Allow` |

Rate limits are covered in [Rate limits](/guides/rate-limits).

## Dates and amounts

- Dates are ISO 8601 in UTC, such as `2026-10-15T09:12:44Z`. Analytics use Riyadh time and say so in `meta.timezone`.
- Amounts are strings with two decimal places, such as `"205.00"`, and the currency is `SAR`. Do not convert them to floating-point numbers for arithmetic.
- Phone numbers are in E.164 format, such as `+966512345678`.
