# Salaf Commerce API The public API for merchants and their integrations. Authenticate with `Authorization: Bearer salaf_sk_…`. Keys are issued from the dashboard under Settings → Developer, and their secret is shown exactly once. Conventions: fields are snake_case; timestamps are ISO-8601 in UTC; monetary amounts are strings with two decimals ("1250.00") so decimal precision survives JSON. Lists are cursor-paginated — pass the previous page's `meta.next_cursor` as `starting_after`. Every operation declares the scope it needs as `x-required-scope`. A key holding `:write` also satisfies `:read`. ## Introduction - [Getting started](https://www.salafems.com/developers/getting-started.md): From no key to your first response, in five minutes. - [Authentication](https://www.salafems.com/developers/authentication.md): Key format, store vs company keys, rotation, revocation. - [Build with AI](https://www.salafems.com/developers/ai.md): Markdown twins, llms.txt, the spec URL, and the two MCP servers. ## Core concepts - [Errors](https://www.salafems.com/developers/errors.md): The error envelope, every code, and what to do with each. - [Pagination](https://www.salafems.com/developers/pagination.md): Cursor paging, the sync pattern, and why sorting is limited. - [Writes & idempotency](https://www.salafems.com/developers/idempotency.md): Every write endpoint, and the Idempotency-Key contract behind them. - [Rate limits](https://www.salafems.com/developers/rate-limits.md): Per-key buckets, headers, and handling 429. - [Versioning](https://www.salafems.com/developers/versioning.md): What we may change without warning, and what we may not. ## API reference - [Overview](https://www.salafems.com/developers/reference.md): Generated from the published OpenAPI description. - [Products](https://www.salafems.com/developers/reference/products.md): 6 Products endpoints (GET, POST, PATCH), generated from the OpenAPI description. - [Categories](https://www.salafems.com/developers/reference/categories.md): 2 Categories endpoints (GET), generated from the OpenAPI description. - [Brands](https://www.salafems.com/developers/reference/brands.md): 2 Brands endpoints (GET), generated from the OpenAPI description. - [Orders](https://www.salafems.com/developers/reference/orders.md): 7 Orders endpoints (GET, POST, PATCH), generated from the OpenAPI description. - [Customers](https://www.salafems.com/developers/reference/customers.md): 4 Customers endpoints (GET, POST, PATCH), generated from the OpenAPI description. - [Inventory](https://www.salafems.com/developers/reference/inventory.md): 3 Inventory endpoints (POST, GET), generated from the OpenAPI description. - [Locations](https://www.salafems.com/developers/reference/locations.md): 2 Locations endpoints (GET), generated from the OpenAPI description. - [Channels](https://www.salafems.com/developers/reference/channels.md): 1 Channels endpoint (GET), generated from the OpenAPI description. ## Webhooks - [Overview](https://www.salafems.com/developers/webhooks.md): Outbound events: the envelope, the guarantees, the rules. - [Event catalog](https://www.salafems.com/developers/webhook-events.md): Every event type we send, and what each one carries. - [Verifying signatures](https://www.salafems.com/developers/webhook-signatures.md): The signing scheme, code in three languages, and test vectors. - [Retries & ordering](https://www.salafems.com/developers/webhook-retries.md): The retry ladder, dead deliveries, and auto-disable. - [Testing](https://www.salafems.com/developers/webhook-testing.md): The salaf.ping event and how to develop against it. - [Headers](https://www.salafems.com/developers/webhook-headers.md): Every header on a delivery, and what to do with it. - [Rotating a secret](https://www.salafems.com/developers/webhook-rotation.md): The dual-signing window, and what a receiver does during it. ## Guides - [Overview](https://www.salafems.com/developers/guides.md): End-to-end recipes for the four common integrations. - [Create an order](https://www.salafems.com/developers/guide-create-an-order.md): Customer, variants, idempotency key, and what comes back. - [Sync inventory to an ERP](https://www.salafems.com/developers/guide-sync-inventory.md): Cursors, watermarks and checkpoints that survive a crash. - [Handle webhooks idempotently](https://www.salafems.com/developers/guide-handle-webhooks.md): Verify, dedupe, upsert, and answer fast. - [Build a custom storefront](https://www.salafems.com/developers/guide-custom-storefront.md): The read path, checkout, and what the API does not give you. ## Reference material - [Changelog](https://www.salafems.com/developers/changelog.md): Every change to the public API, newest first. ## Machine-readable - [OpenAPI 3.0 description](https://www.salafems.com/openapi/public-v1.json): every endpoint, parameter, schema and scope — the artifact this documentation is generated from. - [llms.txt](https://www.salafems.com/llms.txt): this documentation as an index for AI tools. - [llms-full.txt](https://www.salafems.com/llms-full.txt): every page in one markdown document. --- # Getting started From no key to your first response, in five minutes. The Salaf Commerce API is a REST API over your store's real data — the same products, orders, customers and stock your staff see in the dashboard. It speaks JSON, authenticates with a single header, and needs nothing installed. This page takes you from no key to a real response. It should take about five minutes. > **Note — What ships today** > > Reading, writing and webhooks are all live: 17 read endpoints, 10 write > endpoints, and 24 outbound event types. What is *not* here is listed plainly > under [what is not here yet](#what-is-not-here-yet). ## Before you start You need three things, and all three are things a store owner already has or can grant in a minute: 1. **A Salaf EMS account** with access to the store you want to integrate. 2. **A plan that includes API access.** The `api_access` feature is checked on every single request, not just when the key is made — so a plan change takes effect on the next call in either direction. Without it every request answers `401 UNAUTHORIZED` with the message *"API access is not included in your current plan."* 3. **The `developer` permission** on your user (`developer.read` to see keys, `developer.manage` to create them). Company owners have it already. ## Create an API key Keys are created in the dashboard, never through the API. 1. Open **Settings → Developer** and choose **Create API key**. 2. Pick the key type: - **Store key** — reads one store. This is the right default. - **Company key** — reads every live store in the company. Only a company owner can create one. 3. Give it a name you will recognise in six months (*"NetSuite nightly sync"* beats *"test"*). 4. Tick the scopes it needs. You can only grant scopes your own role can back — a user who cannot read orders in the dashboard cannot mint a key that reads them over the API. 5. Optionally set an expiry date. > **Warning — The secret is shown exactly once** > > Salaf stores only a SHA-256 hash of your key, so there is no "show it again" > button — not for you, and not for our support team. Copy it into your secret > manager on the spot. If you lose it, rotate the key. A key looks like this — the literal prefix `salaf_sk_` followed by 40 characters: ```text salaf_sk_7f3kq9p2xR4mVnB8tLcYwZ1sD6hJ0gKeQaUiOpNm ``` ## Make your first request Authenticate by putting the key in an `Authorization: Bearer` header. That is the whole authentication story — no signing, no token exchange, no expiry to refresh. Ask for one product. If the key works, this returns data; if it does not, the error tells you precisely why. **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS "https://api.salafems.com/ext/v1/products?limit=1" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/products"); Object.entries({ limit: "1", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.salafems.com/ext/v1/products", params={ "limit": "1", }, headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` ## Read the response A list endpoint answers with the rows under `data` and a cursor under `meta`: ```json { "data": [ { "id": "7c4e1d90-3b2a-4f58-9e6d-1a5c8b30f2d4", "store_id": "b21c5f04-9d7e-4c11-8f3a-6e0d2b915a77", "name": "Premium Cotton T-Shirt", "slug": "premium-cotton-t-shirt", "status": "active", "delivery_charge": "60.00", "variants": [ { "id": "0f9c1b8e-5f21-4a3d-9b6c-2d7e8a41c503", "sku": "TSHIRT-BLK-M", "name": "Black / M", "price": "1250.00", "discount_price": "1100.00", "status": "active" } ], "created_at": "2026-07-14T09:12:44.000Z", "updated_at": "2026-08-09T17:31:02.000Z" } ], "meta": { "has_more": true, "next_cursor": "7c4e1d90-3b2a-4f58-9e6d-1a5c8b30f2d4" } } ``` Four conventions hold everywhere, so learning them once is enough: | Convention | Looks like | Why | | ------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | Field names are `snake_case` | `payment_status` | Stable public names, decoupled from our internal columns. | | Money is a **string** with 2 decimals | `"1250.00"` | JSON numbers are IEEE-754 doubles and cannot hold every `Decimal(12,2)` exactly. `1250.10` must not read back as `1250.1`. | | Timestamps are ISO-8601 **UTC** | `"2026-08-09T17:31:02.000Z"` | Always with the `Z`. No local time, ever. | | Lists are cursor-paginated | `meta.next_cursor` | Pages stay stable while the store keeps taking orders. See [Pagination](https://www.salafems.com/developers/pagination.md). | Fetching a single object returns that object directly — there is no envelope around it, and no `success` field to check. The HTTP status is the answer. ## The base URL > **Warning — Use /ext/v1 — it is the path that works today** > > The production base URL is: > > `https://api.salafems.com/ext/v1` > > A shorter `https://api.salafems.com/v1` alias is planned and the proxy rule > for it is written, but it has **not been deployed** — it will not resolve > yet. Build against `/ext/v1`. When the alias goes live it will be announced > in the [changelog](https://www.salafems.com/developers/changelog.md), both paths will serve the same > routes, and nothing you have written will break. Running the backend locally, the same routes are on your own port with no proxy in the way: ```bash # Default local backend port is 5000 (6003 under Docker Compose). curl -sS "http://localhost:5000/ext/v1/products?limit=1" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` ## When something goes wrong Errors are always the same shape: one `error` object with a stable machine `code`, a human `message`, and a `request_id`. ```json { "error": { "code": "FORBIDDEN_SCOPE", "message": "This API key is missing the `products:read` scope.", "request_id": "4f1c9b02-7d3e-4a85-9c16-b0e2f7a34d59" } } ``` The three you are most likely to hit in the first five minutes: - **`401 UNAUTHORIZED`** — the header is missing or malformed, the key is unknown, or your plan does not include API access. - **`403 FORBIDDEN_SCOPE`** — the key is valid but was not given this scope. Scopes are fixed when a key is created, so make a new key. - **`404 NOT_FOUND`** — the id does not exist *in the store this key can see*. A record belonging to another tenant returns 404 rather than 403, on purpose: a 403 would confirm the id exists. Keep the `request_id`. It is echoed in the `X-Request-Id` response header and is what support needs to find your exact request in our logs. Full list on the [Errors](https://www.salafems.com/developers/errors.md) page. ## What is not here yet Being straight about the boundaries, so you can plan around them: | Capability | Status | | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | | Reading products, orders, customers, stock, locations, channels | **Available** | | Creating orders and payments, adjusting stock, writing products and customers | **Available** — see [Writes & idempotency](https://www.salafems.com/developers/idempotency.md) | | Webhooks (outbound events) | **Available** — see [Webhooks](https://www.salafems.com/developers/webhooks.md) | | Managing webhook endpoints over the API | Dashboard only. The `webhooks:manage` scope is declared but not grantable | | Reading or writing returns, shipments and POS sessions | Webhook events only — there is no REST endpoint for them yet | | SDKs for any language | Not planned for v1 — the four sample languages are the support level | | A sandbox or test-mode key | Not planned for v1. There is no `salaf_sk_test_`; keys act on live data | ## Next steps - [Authentication](https://www.salafems.com/developers/authentication.md) — store vs company keys, rotation without downtime, and revocation. - [Pagination](https://www.salafems.com/developers/pagination.md) — the `updated_after` pattern every sync integration ends up needing. - [Writes & idempotency](https://www.salafems.com/developers/idempotency.md) — every write endpoint and the `Idempotency-Key` contract. - [Webhooks](https://www.salafems.com/developers/webhooks.md) — stop polling for the things you need to know about quickly. - [Guides](https://www.salafems.com/developers/guides.md) — four end-to-end recipes. - [API reference](https://www.salafems.com/developers/reference.md) — every endpoint, generated from the published OpenAPI description. --- # Authentication Key format, store vs company keys, rotation, revocation. Every request carries an API key in an `Authorization` header. There is no token exchange, no refresh, and nothing expires unless you ask it to. ```http Authorization: Bearer salaf_sk_7f3kq9p2xR4mVnB8tLcYwZ1sD6hJ0gKeQaUiOpNm ``` ## Key format A key is the literal prefix `salaf_sk_` followed by 40 random base62 characters — around 238 bits of entropy. The prefix is not decoration. It earns its place three times: secret scanners (GitHub and GitLab push protection among them) match on exactly this shape, so a key committed by accident gets caught; it tells you at a glance which of the tokens in a config file is ours; and it makes a Salaf key structurally impossible to confuse with a JWT. Anything that does not start with `salaf_sk_` is rejected before we even look in the database, so a stolen dashboard session token can never be replayed here. > **Danger — We cannot recover a key for you** > > Only a SHA-256 hash of your key is stored. Not encrypted — hashed. There is > no route, no admin screen and no support process that can return the > plaintext, because it does not exist anywhere after the response that created > it. Lose it and you rotate. In the dashboard and in our logs a key appears masked, as its prefix sample and last four characters: ```text salaf_sk_7f3kq9p2…OpNm ``` ## Store keys and company keys The key type is chosen at creation and can never change. It decides how wide the tenant fence is — never how the fence works. | | Store key | Company key | | ------------------- | ------------------------------------------------- | ------------------------------------- | | Sees | One store | Every live store in the company | | Created by | Anyone with `developer.manage` | Company owners only | | `store_id` on rows | The key's own store | Varies per row — partition on it | | `?store_id=` filter | Must match the key's own store | Narrows to one store in the company | | Best for | A single storefront, a POS bridge, one shop's ERP | A head office reading across branches | **Start with a store key.** A company key is a wider blast radius if it leaks, and most integrations only ever touch one store. Reach for a company key when you genuinely need one credential to read several stores. **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS "https://api.salafems.com/ext/v1/locations" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/locations"); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.salafems.com/ext/v1/locations", headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` ### Narrowing a company key Pass `store_id` to restrict a read to one store. The value is validated against your company before any query runs — an id belonging to someone else's company returns `404`, not `403`, so the parameter can never be used to probe for which store ids exist. **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS "https://api.salafems.com/ext/v1/orders?store_id=REPLACE_WITH_STORE_ID&limit=50" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/orders"); Object.entries({ store_id: "REPLACE_WITH_STORE_ID", limit: "50", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.salafems.com/ext/v1/orders", params={ "store_id": "REPLACE_WITH_STORE_ID", "limit": "50", }, headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` > **Note — Locations are the exception** > > Locations belong to the **company**, not to a store: several stores share one > warehouse. So a location carries `company_id` instead of `store_id`, and > passing `store_id` does not narrow the list. This is the one resource where a > store key and a company key see the same rows. ## Scopes A key carries a set of scopes, fixed when it is created. Scopes are named `resource:action` and there are only two verbs — `read` and `write`. | Scope | Covers | Endpoints today | | ----------------- | -------------------------------------- | --------------------------- | | `products:read` | Products, variants, categories, brands | Yes | | `orders:read` | Orders, their items and payment state | Yes | | `customers:read` | Customers and their addresses | Yes | | `inventory:read` | Stock levels and the movement ledger | Yes | | `locations:read` | Warehouses and stock locations | Yes | | `channels:read` | Sales channels | Yes | | `payments:read` | Payments recorded against an order | Yes | | `products:write` | Create, update and archive products | Yes | | `orders:write` | Create orders, change status, cancel | Yes | | `customers:write` | Create and update customers | Yes | | `inventory:write` | Stock adjustments | Yes | | `payments:write` | Record a payment against an order | Yes | | `webhooks:manage` | Manage webhook endpoints via the API | Declared, **not grantable** | > **Note — Why webhooks:manage cannot be granted** > > Webhook endpoints are created and edited in the dashboard only. A leaked API > key must not be able to point a merchant's event stream at somebody else's > server, and no scope granularity makes that risk acceptable. The scope exists > in the catalogue so the day it becomes grantable is an additive change rather > than a new name. Three rules govern them: - **Write implies read of the same resource.** A key with `orders:write` also satisfies `orders:read`. The reverse is never true, which is the direction that actually matters. - **Scopes are immutable.** No endpoint changes a key's scopes, and the dashboard does not offer it either. Need different scopes? Create a new key and revoke the old one. - **You can only grant what you hold.** A staff user can only tick scopes their own role can back. A cashier who cannot read orders in the dashboard cannot mint a key that reads them over the API. Every operation in the [reference](https://www.salafems.com/developers/reference.md) shows the scope it requires. A key missing it gets `403 FORBIDDEN_SCOPE` — the endpoint is never silently reduced to an empty result. ## Rotation Rotating replaces a key without an outage. Salaf issues a **new** key and sets an expiry on the old one instead of killing it, so both work during the overlap: 1. Rotate in the dashboard and copy the new secret. 2. Both keys are valid for the grace window — **24 hours by default**. 3. Deploy the new key wherever the old one lives. 4. The old key stops working when the window closes. Nothing to remember, no second visit to the dashboard. If you rotate because a key **leaked**, choose the immediate option instead: the old key dies at once and the overlap is skipped. A brief outage is the correct trade when the alternative is a live credential in someone else's hands. ## Revocation Revoking is permanent — a revoked key never comes back, and there is no "delete" at all, because the row is audit history. **Revocation takes effect immediately.** Authentication is cached for speed, but every revocation and rotation explicitly evicts that cache rather than waiting for it to expire, so there is no window in which a killed key still works. ## Why a request was rejected All authentication failures return `401` and are told apart by their `code`: | Code | Meaning | | -------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `UNAUTHORIZED` | The header is missing or malformed, the key is unknown, your plan does not include API access, or the store or company is not live. | | `KEY_REVOKED` | The key existed and was deliberately killed. | | `KEY_EXPIRED` | The key's clock ran out — either its own expiry date, or the end of a rotation grace window. | > **Note — Only revoked and expired are distinguished** > > Everything else — an unknown key, a plan without API access, a suspended > store — answers plain `UNAUTHORIZED`. The two that are named are named > deliberately: someone at the merchant chose to kill that key, so telling the > caller why is a kindness rather than a leak. The rest stay > indistinguishable, so the endpoint cannot be used to work out whether a > guessed key exists. Two more you will see: - `403 FORBIDDEN_SCOPE` — the key is fine, but it was not granted this scope. - `404 NOT_FOUND` — for an id in another tenant. Never `403`; a `403` would confirm the record exists. > **Warning — Plan and store changes take about a minute** > > Revocation and rotation are instant. Plan changes and store suspensions are > read through a short-lived cache, so those propagate within roughly a > minute rather than on the very next request. If you are testing an upgrade, > give it a moment before concluding it did not work. ## Keeping keys safe - **Server-side only.** A key grants everything its scopes allow across a whole store. It has no business in a browser, a mobile app, or anything else a customer can open. - **Environment variables, not source.** Every sample in these docs reads `SALAF_API_KEY` from the environment for exactly this reason. - **One key per integration.** Separate keys mean you can revoke the one that leaked without taking down the other four, and `last_used_at` in the dashboard tells you which is which. - **Only the scopes it needs.** A reporting tool that reads orders should not hold `customers:read` as well. --- # Build with AI Markdown twins, llms.txt, the spec URL, and the two MCP servers. These docs are published for two readers: you, and whatever agent is helping you integrate. Everything the human pages say also exists in a machine-shaped form — a markdown twin of every page, an index and a single-file corpus for LLMs, the OpenAPI description the reference is generated from, and two MCP servers: one that answers from the real pages, and one that reads — and, scope permitting, operates — a store's live data through the API itself. This page is where each one lives and how to point your tools at it. None of it is required to use the API. It exists so an agent reads what these docs actually say instead of guessing from APIs that merely resemble ours. ## Markdown for every page Append `.md` to any docs URL and you get that page as raw markdown — no nav chrome, no highlighting markup, just the content: | Human page | Markdown twin | | ------------------------------------------------ | --------------------------------------------------- | | `https://www.salafems.com/developers/pagination` | `https://www.salafems.com/developers/pagination.md` | The overview's twin is `/developers.md`, and the generated reference follows the same rule — `/developers/reference/orders.md` is the orders reference as markdown. Two files cover the corpus as a whole: - [`/llms.txt`](https://www.salafems.com/llms.txt) — the index, per the llms.txt convention: every page as an absolute `.md` link with a one-line summary. Give this to tools that crawl and follow links. - [`/llms-full.txt`](https://www.salafems.com/llms-full.txt) — every page concatenated in sidebar reading order. Give this to tools that read: it fits comfortably in a modern context window, so pasting it into a chat hands the model the entire documentation at once. Every page header also carries a **Copy page** button that copies the page's markdown twin, and a menu that opens the page in Claude or ChatGPT with the twin's URL already in the prompt. ## The OpenAPI description The API is described by one published OpenAPI document: ```text https://www.salafems.com/openapi/public-v1.json ``` It is the same artifact the [reference](https://www.salafems.com/developers/reference.md) pages are generated from, so the two cannot disagree — anything the spec declares, the reference shows, and nothing in the reference was written by hand. One URL unlocks the usual tooling: generate a typed client, import the collection into Postman or Insomnia, or hand it to a ChatGPT GPT Action, which takes an OpenAPI URL directly. ## Add the docs to your tools **Cursor** — Settings → Indexing & Docs → Add Doc, then paste `https://www.salafems.com/llms.txt`. Cursor indexes the linked pages and cites them in chat. **Claude Code** — connect the MCP server with the one-liner in the next section. Without MCP, telling it to fetch `https://www.salafems.com/llms.txt` and follow the links it needs works nearly as well. **ChatGPT** — a GPT Action takes the OpenAPI URL above directly; for questions rather than calls, paste `/llms-full.txt` into the conversation. **Everything else** — any agent that can fetch a URL can read the `.md` twins, and any tool that accepts a document can take `/llms-full.txt`. ## The docs MCP server The docs run a Model Context Protocol server at: ```text https://www.salafems.com/developers/mcp ``` Transport is Streamable HTTP, and there is no authentication — it serves the same public pages you are reading and nothing else. | Tool | Answers | | ---------------- | ------------------------------------------------------ | | `search_docs` | Where in the docs a term or topic is covered. | | `get_page` | One page in full, as markdown, by slug. | | `list_endpoints` | Every operation, optionally narrowed to one resource. | | `get_endpoint` | One operation in detail — parameters, scope, examples. | For Claude Code, one command: ```bash claude mcp add --transport http salaf-docs https://www.salafems.com/developers/mcp ``` For other MCP clients, the generic configuration block: ```json { "mcpServers": { "salaf-docs": { "type": "http", "url": "https://www.salafems.com/developers/mcp" } } } ``` > **Note — Docs only — it cannot touch a store** > > This server takes no API key and makes no API calls. It answers questions > about the documentation; creating an order still means calling the API, > with your key, under the same > [idempotency contract](https://www.salafems.com/developers/idempotency.md) as any other client. ## The store MCP server The API itself also speaks MCP. A second server exposes a store's live data as curated tools, at: ```text https://api.salafems.com/ext/mcp/v1 ``` Transport is Streamable HTTP, and it authenticates exactly like the REST API: the same `Authorization: Bearer` header, the same [keys](https://www.salafems.com/developers/authentication.md), the same tenant boundary. The key the connection carries decides which company — and, for a store key, which store — every tool can see, and each tool requires the same [scope](https://www.salafems.com/developers/authentication.md#scopes) as its REST counterpart. A call made with a key that lacks the scope comes back as a readable refusal naming the missing scope, so an agent can tell its human exactly which key to ask for. Twelve tools read: | Tool | Answers | | ---------------------------------- | ------------------------------------------------------------------- | | `list_products` / `get_product` | Products with their variants nested — the sellable units. | | `list_categories` / `list_brands` | Catalog metadata; ids feed the product filters. | | `list_orders` / `get_order` | Orders, compact by default, expandable with `customer` and `items`. | | `list_order_payments` | Every payment row recorded against one order. | | `list_customers` / `get_customer` | Customer records; the detail includes addresses. | | `get_inventory_levels` | Stock per variant per location, with a `low_stock` filter. | | `list_locations` / `list_channels` | Warehouses and sales channels. | And ten write — one per [write endpoint](https://www.salafems.com/developers/idempotency.md), running the same engine the merchant's own staff use, so a tool call cannot do anything the dashboard could not: | Tool | Does | | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | `create_order` | Places an order: stock reserved, totals computed server-side, coupons priced by the real engine. | | `update_order_status` / `cancel_order` | Status moves under the same strict transition map as the desk. | | `record_order_payment` | Records a settled payment, capped at the order's net due. | | `create_customer` / `update_customer` | Customer records, with per-store phone/email uniqueness. | | `adjust_inventory` | A signed, audited stock change — `reason` required, on-hand can never go negative. | | `create_product` / `update_product` / `archive_product` | Catalog writes, variants included. | The write tools are opt-in, and the opt-in is the key: each tool requires the same write scope as its REST twin, so a key minted with read scopes simply cannot write, whatever an agent asks of it. Writes carry the [idempotency contract](https://www.salafems.com/developers/idempotency.md) as an `idempotency_key` argument — required on `create_order`, `record_order_payment` and `adjust_inventory`, honoured everywhere else. The rules are the header's, verbatim: one unique value per logical operation, the same value on every retry of it, replays answer with the original result. List tools page exactly like the REST API — pass `meta.next_cursor` back as `starting_after` until it comes back null — and results are byte-equivalent to the JSON their REST twins return, because each tool calls the same service its twin calls. That holds for writes too: a write answers with what its REST twin answers — the object re-read through the public serializer (or, for `archive_product`, the same small `{ id, archived }` receipt). For Claude Code, one command (the key travels as a header): ```bash claude mcp add --transport http salaf-store https://api.salafems.com/ext/mcp/v1 --header "Authorization: Bearer salaf_sk_your_key_here" ``` For other MCP clients: ```json { "mcpServers": { "salaf-store": { "type": "http", "url": "https://api.salafems.com/ext/mcp/v1", "headers": { "Authorization": "Bearer salaf_sk_your_key_here" } } } } ``` > **Warning — The key in that config is a real credential** > > Anything the agent can do, that config file can do. Create a dedicated key > holding only the scopes you intend the agent to use — read scopes for an > agent that answers questions, write scopes only when you mean for it to > operate the store — keep the config out of version control, and > [revoke](https://www.salafems.com/developers/authentication.md#rotation) the key if the machine or > the config leaks. ## Drop this in your repo If a coding agent works on your integration, put a short description of this API where your agent reads instructions — an `AGENTS.md`, a `CLAUDE.md`, a system prompt. The block below is deliberately compact: enough to make correct calls, with links to where everything else lives. ```markdown ## Salaf Commerce API - REST API over a Salaf EMS store's live data: products, orders, customers, inventory, locations and sales channels. JSON in, JSON out. - Base URL: `https://api.salafems.com/ext/v1` - Auth: `Authorization: Bearer `. Keys start with `salaf_sk_` and are created in the dashboard under Settings → Developer. Server-side only. - Errors: every failure is `{ error: { code, message, request_id } }`. Branch on `code` — it is stable. `message` is human copy and may be reworded at any time. Validation failures add a `fields` map. - Writes: send an `Idempotency-Key` header (any unique value up to 255 characters; a UUID is ideal) and reuse the SAME key when retrying the same operation. Required on `POST /orders`, `POST /orders/{id}/payments` and `POST /inventory/adjustments`; honoured on every other write. - Pagination: cursor-based. Pass `limit` (max 100) and `starting_after` = the previous page's `meta.next_cursor`; stop when `next_cursor` is null. - Machine-readable docs: - OpenAPI: https://www.salafems.com/openapi/public-v1.json - Docs index: https://www.salafems.com/llms.txt (whole corpus in one file: https://www.salafems.com/llms-full.txt) - Docs MCP, Streamable HTTP, no auth: https://www.salafems.com/developers/mcp - Store MCP (live store data through curated read + write tools, gated by the key's scopes, same Bearer key): https://api.salafems.com/ext/mcp/v1 ``` The facts in that block are the same ones documented on [Getting started](https://www.salafems.com/developers/getting-started.md), [Errors](https://www.salafems.com/developers/errors.md), [Writes & idempotency](https://www.salafems.com/developers/idempotency.md) and [Pagination](https://www.salafems.com/developers/pagination.md) — when in doubt, those pages are the authority, and an agent with the MCP server attached can check them itself. --- # Errors The error envelope, every code, and what to do with each. Every failure — from a malformed key to a database conflict — comes back in one shape. Write one error handler, not twelve. ```json { "error": { "code": "NOT_FOUND", "message": "Order not found.", "request_id": "4f1c9b02-7d3e-4a85-9c16-b0e2f7a34d59" } } ``` ## The error object | Field | Always present | What it is for | | ------------ | --------------------------------- | ------------------------------------------------------------------ | | `code` | Yes | A stable machine-readable identifier. **Branch on this.** | | `message` | Yes | A human-readable sentence. Show it to a developer; never parse it. | | `fields` | Only on validation failures | A map of field name to the list of things wrong with it. | | `request_id` | Whenever a request id was stamped | Correlates with our logs. Echoed in the `X-Request-Id` header. | > **Warning — Branch on code, never on message** > > `code` is part of the contract and will not change without a version bump. > `message` is human copy and may be reworded at any time, in any release. Code > that does `if (error.message === "Order not found.")` is code that breaks on > a typo fix. There is no `success` field and no envelope around the error. The HTTP status and the `code` are the answer. > **Note — This differs from the dashboard API on purpose** > > If you have seen our internal API, it wraps everything in > `{ success, message, data, meta }`. The public API deliberately does not. > That `message` is UI copy — it gets reworded whenever a designer asks, which > is fine for a screen and fatal for something your code branches on. Here the > machine-readable code leads and the message is strictly human-facing. ## Every error code ### Authentication and authorization | HTTP | Code | When | | ---- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | 401 | `UNAUTHORIZED` | No `Authorization` header, a token that is not `salaf_sk_…`, an unknown key, a plan without API access, or a store or company that is not live. | | 401 | `KEY_REVOKED` | The key exists and was deliberately revoked. | | 401 | `KEY_EXPIRED` | The key's expiry date passed, or its rotation grace window closed. | | 403 | `FORBIDDEN_SCOPE` | The key is valid but was not granted the scope this endpoint requires. Scopes are fixed at creation — make a new key. | ### Request problems | HTTP | Code | When | | ---- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------- | | 404 | `NOT_FOUND` | No such record **in the data this key can see**. Also what a record belonging to another tenant returns. | | 422 | `VALIDATION_ERROR` | A parameter or body field was missing, the wrong type, out of range, or not in the allowed set. Carries `fields`. | | 400 | `VALIDATION_ERROR` | The one case that stays `400`: a required `Idempotency-Key` header is absent. `fields` names the header. | | 409 | `CONFLICT` | The write collides with an existing record — a duplicate unique value, or a reference to something that is not there. | | 429 | `RATE_LIMITED` | Too many requests for this key in the current window. See [Rate limits](https://www.salafems.com/developers/rate-limits.md). | > **Note — 404 rather than 403 for another tenant's data** > > Asking for an id that belongs to a different store or company returns `404`, > identical to an id that does not exist at all. A `403` would confirm the > record is real, which turns the endpoint into an oracle for enumerating other > merchants' ids. Every repository query is scoped by tenant before it runs, so > there is nothing to leak in the first place. ### Ours, not yours | HTTP | Code | When | | ---- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 500 | `INTERNAL` | Something failed on our side. The message is always *"Something went wrong on our end."* — deliberately generic, because the alternative is leaking our internals into your logs. The real cause is recorded against the `request_id`. | Retry a `500` once, with a short delay. If it persists, send us the `request_id`. ### On the write endpoints | HTTP | Code | When | | ---- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | 400 | `INSUFFICIENT_STOCK` | Not enough stock to satisfy the request. `fields.items` names the offending line, with what was requested and what is available. | | 400 | `INVALID_STATUS_TRANSITION` | The order cannot move from its current status to the requested one. `fields.status` says which move was refused. | | 422 | `IDEMPOTENCY_CONFLICT` | The `Idempotency-Key` was reused with a different request body. | | 409 | `IDEMPOTENCY_IN_FLIGHT` | The same `Idempotency-Key` is still being processed. Retry shortly, **with the same key**. | > **Note — These two are 400, not 422** > > `INSUFFICIENT_STOCK` and `INVALID_STATUS_TRANSITION` come from the domain > services with their own status, and it is deliberately not rewritten on the > way out. Branch on the `code`, which is stable, rather than on the status — > which is the advice for every error here, and the reason `code` exists. Full detail on [Writes & idempotency](https://www.salafems.com/developers/idempotency.md). ## Validation errors `fields` maps each parameter to everything wrong with it. Ordinary validation failures are normalised to `422`, so there is one status meaning *"your request was wrong"* — the exceptions are the three `400`s named above, which carry their own codes. ```json { "error": { "code": "VALIDATION_ERROR", "message": "The request was not valid.", "fields": { "limit": [ "limit must not be greater than 100", "limit must be an integer number" ] }, "request_id": "4f1c9b02-7d3e-4a85-9c16-b0e2f7a34d59" } } ``` ## Request ids Every request through the public API is stamped with an id, returned in the `X-Request-Id` response header and included in the body of any error. ```http HTTP/1.1 404 Not Found X-Request-Id: 4f1c9b02-7d3e-4a85-9c16-b0e2f7a34d59 Content-Type: application/json ``` **Log it on every failure.** It is the only handle that finds your exact request in our logs — far more useful than a timestamp and an endpoint name, particularly for a sync that makes thousands of calls an hour. You can also **supply your own**: send an `X-Request-Id` header and we will use your value instead of generating one. That lets you carry a trace id from your own system straight through ours, so one identifier spans both sides. ## Handling errors well - **Switch on `code`, with a default branch.** New codes may be added in v1 — treat the list as open, and let anything unrecognised fall through to "unexpected error, log and alert". - **Do not retry `4xx`.** A `401`, `403`, `404` or `422` will fail exactly the same way the second time. The only retryable statuses are `429` (wait for `Retry-After`) and `500` (once, briefly). - **Distinguish `KEY_REVOKED` from `UNAUTHORIZED` in your alerting.** A revoked key means a human deliberately turned your integration off; it should page someone, not silently retry forever. - **Keep `request_id` in your own error records**, not just in a log line that rotates out after a week. --- # Pagination Cursor paging, the sync pattern, and why sorting is limited. Lists are cursor-paginated. You ask for a page size and a position; you get rows and a cursor for the next page. ```json { "data": ["…25 objects…"], "meta": { "has_more": true, "next_cursor": "c9d8e7f6-1a2b-4c3d-8e9f-0a1b2c3d4e5f" } } ``` ## Parameters | Parameter | Type | Default | Notes | | ---------------- | ----------------------------- | ------------- | --------------------------------------------------------------------------------------------------- | | `limit` | integer | `25` | Maximum `100`. Values outside the range are **clamped**, not rejected — `limit=5000` gives you 100. | | `starting_after` | object id | – | Return the page *after* this object. Pass the previous page's `next_cursor`. | | `ending_before` | object id | – | Return the page *before* this object. Paging backwards. | | `sort` | `created_at` or `-created_at` | `-created_at` | Newest first by default. | `starting_after` and `ending_before` are mutually exclusive — sending both is a `VALIDATION_ERROR` rather than a silently ignored parameter. ## The `meta` block | Field | Type | Meaning | | ------------- | ---------------- | ---------------------------------------------------------------------------------- | | `has_more` | boolean | There is at least one more row after this page. | | `next_cursor` | string or `null` | The id to pass as `starting_after` for the next page. **`null` on the last page.** | `next_cursor` is deliberately `null` rather than the last row's id when the list is exhausted, so `while (cursor)` terminates on its own and nobody has to remember to check `has_more` as well. ## Walking every page **cURL** ```bash # Page 1 — no cursor. curl -sS "https://api.salafems.com/ext/v1/orders?limit=100" \ -H "Authorization: Bearer $SALAF_API_KEY" # The response ends with: # "meta": { "has_more": true, "next_cursor": "0f0b…c31" } # Feed that cursor back as starting_after, and repeat until # has_more is false. curl -sS "https://api.salafems.com/ext/v1/orders?limit=100&starting_after=0f0b…c31" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` **JavaScript** ```javascript async function* everyOrder(params = {}) { let cursor = null; do { const url = new URL("https://api.salafems.com/ext/v1/orders"); url.searchParams.set("limit", "100"); for (const [key, value] of Object.entries(params)) { url.searchParams.set(key, value); } if (cursor) { url.searchParams.set("starting_after", cursor); } const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}` }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); yield* data; // next_cursor is null on the last page, which ends the loop. cursor = meta.has_more ? meta.next_cursor : null; } while (cursor); } for await (const order of everyOrder({ status: "confirmed" })) { console.log(order.number, order.total_amount); } ``` **Python** ```python import os import requests def every_order(**params): cursor = None headers = {"Authorization": f"Bearer {os.environ['SALAF_API_KEY']}"} while True: query = {"limit": 100, **params} if cursor: query["starting_after"] = cursor response = requests.get( "https://api.salafems.com/ext/v1/orders", params=query, headers=headers, timeout=30, ) response.raise_for_status() page = response.json() yield from page["data"] if not page["meta"]["has_more"]: return cursor = page["meta"]["next_cursor"] for order in every_order(status="confirmed"): print(order["number"], order["total_amount"]) ``` **PHP** ```php 100], $params); if ($cursor !== null) { $query['starting_after'] = $cursor; } $ch = curl_init('https://api.salafems.com/ext/v1/orders?' . http_build_query($query)); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $page = json_decode(curl_exec($ch), true); curl_close($ch); yield from $page['data']; $cursor = $page['meta']['has_more'] ? $page['meta']['next_cursor'] : null; } while ($cursor !== null); } foreach (every_order(['status' => 'confirmed']) as $order) { echo $order['number'] . ' ' . $order['total_amount'] . PHP_EOL; } ``` ## Why cursors instead of page numbers Because the primary consumer of this API is a sync, and a store keeps taking orders while your sync runs. With offset pages, one order placed while you are between page 1 and page 2 shifts every row down by one. Page 2 then re-serves a row you already had and **skips one entirely** — silently. The merchant discovers it weeks later as a missing order in their accounts. A cursor is anchored to a row, not to a count. New rows arriving at the head do not move the rows behind it, so the walk stays correct no matter what the store does while you are reading. Deep pages are cheap for the same reason — there is no `OFFSET 40000` for the database to count through. ## Why you can only sort by `created_at` This is the constraint people ask about most, so here is the actual reason. A cursor works by remembering *where you were* in a sorted order. That only holds if the sort key does not move. `created_at` never changes after a row is written, so a cursor over it is stable for as long as you care to page. `updated_at` moves constantly — every status change, every payment, every edit. Paging over it would mean a row you already read gets touched, jumps ahead of your cursor, and is served to you again while another row slips behind it and is never served at all. That is a data-loss bug wearing a feature's clothes, so the sort allow-list contains `created_at` and nothing else. > **Note — What to use instead** > > You almost certainly wanted `updated_after`, not `sort=updated_at`. It gives > you exactly what you were reaching for — everything that changed — without > the instability, because it **filters** on the moving column while still > **paging** on the stable one. ## Syncing changes with `updated_after` `updated_after` is the workhorse of any ongoing integration. It returns everything modified since an instant — including rows created long before it. An order placed last month and confirmed this morning is precisely the row a `created_after` poll misses and an `updated_after` poll catches. **cURL** ```bash # Everything that changed since the last successful sync. # Store the timestamp you STARTED the run at, not the one you finished at. SINCE="2026-08-10T00:00:00Z" curl -sS "https://api.salafems.com/ext/v1/orders?updated_after=$SINCE&limit=100&expand=items,customer" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` **JavaScript** ```javascript // Read the watermark BEFORE the run: anything modified while the run is // in flight is then picked up next time instead of being skipped. const runStartedAt = new Date().toISOString(); const since = await loadWatermark(); // your storage let cursor = null; do { const url = new URL("https://api.salafems.com/ext/v1/orders"); url.searchParams.set("updated_after", since); url.searchParams.set("limit", "100"); url.searchParams.set("expand", "items,customer"); if (cursor) { url.searchParams.set("starting_after", cursor); } const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}` }, }); const { data, meta } = await response.json(); for (const order of data) { // Upsert on id — an order can appear in several runs as it moves // through its statuses, and the newest snapshot always wins. await upsertOrder(order); } cursor = meta.has_more ? meta.next_cursor : null; } while (cursor); await saveWatermark(runStartedAt); ``` **Python** ```python import os from datetime import datetime, timezone import requests # Read the watermark BEFORE the run, so rows changed mid-run are caught # by the next one rather than skipped by this one. run_started_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") since = load_watermark() # your storage headers = {"Authorization": f"Bearer {os.environ['SALAF_API_KEY']}"} cursor = None while True: query = { "updated_after": since, "limit": 100, "expand": "items,customer", } if cursor: query["starting_after"] = cursor page = requests.get( "https://api.salafems.com/ext/v1/orders", params=query, headers=headers, timeout=30, ).json() for order in page["data"]: # Upsert on id: the same order reappears as its status changes. upsert_order(order) if not page["meta"]["has_more"]: break cursor = page["meta"]["next_cursor"] save_watermark(run_started_at) ``` **PHP** ```php $since, 'limit' => 100, 'expand' => 'items,customer', ]; if ($cursor !== null) { $query['starting_after'] = $cursor; } $ch = curl_init('https://api.salafems.com/ext/v1/orders?' . http_build_query($query)); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $page = json_decode(curl_exec($ch), true); curl_close($ch); foreach ($page['data'] as $order) { upsert_order($order); // upsert on id } $cursor = $page['meta']['has_more'] ? $page['meta']['next_cursor'] : null; } while ($cursor !== null); save_watermark($runStartedAt); ``` Three rules make an `updated_after` sync correct: 1. **Record the watermark before the run, not after.** Anything modified while your run is in flight then gets picked up by the next run rather than falling into the gap between them. 2. **Upsert on `id`.** The same order legitimately appears in many runs as it moves through its statuses. The newest snapshot always wins. 3. **Overlap slightly.** Subtracting a minute from your stored watermark costs a few duplicate rows — which rule 2 already handles — and protects you from clock skew. > **Warning — Not every resource has updated\_after yet** > > Today it exists on **orders** and **products** only. > > Customers, categories, brands, inventory levels, movements, locations and > channels do not have it. For those, re-read the full list on a schedule — > they are small and change rarely, so a nightly full read is entirely > reasonable. The filter is planned for the remaining resources; it will be > additive, and announced in the [changelog](https://www.salafems.com/developers/changelog.md). [Webhooks](https://www.salafems.com/developers/webhooks.md) let you stop polling *frequently*, but not stop polling: they are at-least-once and your receiver can be down, so a periodic `updated_after` pass stays the reconciliation path. Poll hourly or daily instead of every minute, and let events carry the urgency. ## Filtering and search Filters are an explicit allow-list per resource, not a generic query language. That is a deliberate limit: a generic filter syntax would expose our internal schema as a public contract and freeze it there. The exact set for each endpoint is in the [reference](https://www.salafems.com/developers/reference.md). - **Orders** — `status`, `payment_status`, `fulfillment_status`, `channel` (the slug, not the id), `created_after`, `created_before`, `updated_after`, `search` - **Products** — `status`, `category_id`, `updated_after`, `search` - **Categories** and **Brands** — `status` - **Inventory levels** — `warehouse_id`, `variant_id`, `low_stock` - **Inventory movements** — `warehouse_id`, `variant_id` `search` matches order number, customer name and customer phone on orders; name and SKU on products. It is only offered where an index already supports it. ## Expanding related objects Orders are compact by default — related objects are referenced by id. Ask for them with `expand`: ```bash curl -sS "https://api.salafems.com/ext/v1/orders?expand=customer,items&limit=50" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` `expand` accepts `customer` and `items`, comma-separated, one level deep — no nesting. It is opt-in rather than always-on because a sync walking ten thousand orders should not pay for line items it is going to throw away. ## The last page ```json { "data": ["…7 objects…"], "meta": { "has_more": false, "next_cursor": null } } ``` An empty list is `data: []` with `has_more: false` — not a `404`. A search that matches nothing is a successful request with no results. --- # Writes & idempotency Every write endpoint, and the Idempotency-Key contract behind them. Ten endpoints write. They run the same code the dashboard runs — the same stock reservation, the same coupon engine, the same totals math, the same status-transition rules — so the API cannot do anything the merchant's own staff could not, and cannot skip anything the desk enforces. Because a write can be lost on the way back, every one of them takes an `Idempotency-Key`, and three of them require it. ## The write endpoints | Endpoint | Scope | `Idempotency-Key` | Answers | | ----------------------------- | ----------------- | ----------------- | --------------------------------- | | `POST /orders` | `orders:write` | **Required** | `201` + the created order | | `PATCH /orders/{id}/status` | `orders:write` | Honoured | `200` + the updated order | | `POST /orders/{id}/cancel` | `orders:write` | Honoured | `200` + the cancelled order | | `POST /orders/{id}/payments` | `payments:write` | **Required** | `201` + the updated order | | `POST /customers` | `customers:write` | Honoured | `201` + the customer | | `PATCH /customers/{id}` | `customers:write` | Honoured | `200` + the customer | | `POST /inventory/adjustments` | `inventory:write` | **Required** | `201` + the resulting stock level | | `POST /products` | `products:write` | Honoured | `201` + the product | | `PATCH /products/{id}` | `products:write` | Honoured | `200` + the product | | `POST /products/{id}/archive` | `products:write` | Honoured | `201` + `{ id, archived }` | Request fields, validation rules and every declared status are in the [reference](https://www.salafems.com/developers/reference.md) — generated from the same decorators that validate the request, so they cannot drift. This page covers the rules that span all of them. > **Note — Write responses are read responses** > > A write answers with the object re-read through the **public serializer** — > the same shape the corresponding `GET` publishes, with orders and customers > fully expanded. You never have to follow a write with a read to learn what > you just created, and you can feed a write response into the same parser as a > `GET`. ## `Idempotency-Key` Send a unique value per **logical operation** — not per HTTP attempt. Every retry of the same operation sends the same key. ```http POST /ext/v1/orders HTTP/1.1 Authorization: Bearer salaf_sk_… Idempotency-Key: 3f1a5c8e-0b2d-4f77-9a13-5e6c7d8f9a10 Content-Type: application/json ``` A UUID is ideal. Anything non-empty up to **255 characters** is accepted — an ERP that emits its own request ids should not have to be RFC 4122 about it. **cURL** ```bash # The key identifies the ORDER you are placing, not this HTTP attempt. # Generate it once, store it with your own record, and reuse it on retries. IDEMPOTENCY_KEY=$(uuidgen) curl -sS -X POST "https://api.salafems.com/ext/v1/orders" \ -H "Authorization: Bearer $SALAF_API_KEY" \ -H "Idempotency-Key: $IDEMPOTENCY_KEY" \ -H "Content-Type: application/json" \ -d '{ "channel": "facebook", "customer": { "phone": "01712345678", "full_name": "Rafiqul Islam" }, "items": [ { "sku": "TSHIRT-RED-M", "quantity": 2 } ], "shipping_charge": 120, "note": "Deliver after 6pm." }' ``` **JavaScript** ```javascript import { randomUUID } from "node:crypto"; // One key per logical order. Persist it next to your own order record BEFORE // the call, so a retry after a process restart still sends the same one. const idempotencyKey = randomUUID(); const response = await fetch("https://api.salafems.com/ext/v1/orders", { method: "POST", headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, "Idempotency-Key": idempotencyKey, "Content-Type": "application/json", }, body: JSON.stringify({ channel: "facebook", customer: { phone: "01712345678", full_name: "Rafiqul Islam" }, items: [{ sku: "TSHIRT-RED-M", quantity: 2 }], shipping_charge: 120, note: "Deliver after 6pm.", }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } // 201 with the full order: items and customer expanded, totals computed, // stock already reserved. const order = await response.json(); console.log(order.number, order.total_amount, order.payment_status); // true when this was a replay of an earlier identical call. console.log(response.headers.get("Idempotent-Replayed") === "true"); ``` **Python** ```python import os import uuid import requests # One key per logical order — generated where the order is DECIDED, not # inside the retry loop, and stored with your own record. idempotency_key = str(uuid.uuid4()) response = requests.post( "https://api.salafems.com/ext/v1/orders", json={ "channel": "facebook", "customer": {"phone": "01712345678", "full_name": "Rafiqul Islam"}, "items": [{"sku": "TSHIRT-RED-M", "quantity": 2}], "shipping_charge": 120, "note": "Deliver after 6pm.", }, headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", "Idempotency-Key": idempotency_key, }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") order = response.json() print(order["number"], order["total_amount"], order["payment_status"]) print(response.headers.get("Idempotent-Replayed") == "true") ``` **PHP** ```php 'facebook', 'customer' => ['phone' => '01712345678', 'full_name' => 'Rafiqul Islam'], 'items' => [ ['sku' => 'TSHIRT-RED-M', 'quantity' => 2], ], 'shipping_charge' => 120, 'note' => 'Deliver after 6pm.', ]); $ch = curl_init('https://api.salafems.com/ext/v1/orders'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('SALAF_API_KEY'), 'Idempotency-Key: ' . $idempotencyKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } echo $response['number'] . ' ' . $response['total_amount'] . PHP_EOL; ``` ### What happens on a repeat | Situation | Result | | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | First time this key is seen | The operation runs normally. | | Same key, same body, already finished | The **stored response is replayed** — original status code, byte-identical body, plus `Idempotent-Replayed: true`. The operation does not run again. | | Same key, **different** body | `422 IDEMPOTENCY_CONFLICT`. A key identifies one specific request; reusing it for another is a bug worth surfacing rather than guessing at. | | Same key, original still running | `409 IDEMPOTENCY_IN_FLIGHT`. Retry in a few seconds. The operation never runs twice concurrently. | | Same key, original **failed** | Runs fresh. Failures are never replayable — you saw the error, and a retry deserves a real attempt. | | Same key, more than 24 hours later | Treated as new. | **cURL** ```bash # Retrying is just sending the SAME key again. KEY="3f1a5c8e-0b2d-4f77-9a13-5e6c7d8f9a10" send() { curl -sS -o body.json -w '%{http_code}' -D headers.txt \ -X POST "https://api.salafems.com/ext/v1/orders" \ -H "Authorization: Bearer $SALAF_API_KEY" \ -H "Idempotency-Key: $KEY" \ -H "Content-Type: application/json" \ -d @order.json } STATUS=$(send) # 409 means the first attempt is still running. Wait, then ask again — # do NOT generate a new key, that would create a second order. while [ "$STATUS" = "409" ]; do sleep 2 STATUS=$(send) done # On a replay the body is byte-identical to the original response and this # header is present: grep -i '^idempotent-replayed' headers.txt ``` **JavaScript** ```javascript async function createOrderOnce(order, idempotencyKey, attempt = 0) { const response = await fetch("https://api.salafems.com/ext/v1/orders", { method: "POST", headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, "Idempotency-Key": idempotencyKey, "Content-Type": "application/json", }, body: JSON.stringify(order), }); // 201 first time, 201 again on every replay — with the original body. if (response.ok) { return response.json(); } const { error } = await response.json(); // The original request is still in flight. Wait for it; never re-key. if (error.code === "IDEMPOTENCY_IN_FLIGHT" && attempt < 5) { await new Promise((resolve) => setTimeout(resolve, 2000)); return createOrderOnce(order, idempotencyKey, attempt + 1); } // Same key, different body: a bug in YOUR code, not a transient failure. // Retrying cannot fix it — the key is already bound to another request. if (error.code === "IDEMPOTENCY_CONFLICT") { throw new Error( `Key ${idempotencyKey} was already used for a different order.`, ); } throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } ``` **Python** ```python import os import time import requests HEADERS = {"Authorization": f"Bearer {os.environ['SALAF_API_KEY']}"} def create_order_once(order, idempotency_key, attempts=5): for _ in range(attempts): response = requests.post( "https://api.salafems.com/ext/v1/orders", json=order, headers={**HEADERS, "Idempotency-Key": idempotency_key}, timeout=30, ) if response.ok: # A replay returns the ORIGINAL response, marked as one. replayed = response.headers.get("Idempotent-Replayed") == "true" return response.json(), replayed error = response.json()["error"] if error["code"] == "IDEMPOTENCY_IN_FLIGHT": # The first attempt is still running. Wait — do not re-key. time.sleep(2) continue if error["code"] == "IDEMPOTENCY_CONFLICT": raise RuntimeError( f"Key {idempotency_key} was already used for a different order." ) raise RuntimeError(f"{error['code']}: {error['message']}") raise RuntimeError("The original request is still in flight.") ``` **PHP** ```php true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('SALAF_API_KEY'), 'Idempotency-Key: ' . $idempotencyKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode($order), CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status < 400) { return $response; } // Still running: wait for the original, never mint a new key. if ($response['error']['code'] === 'IDEMPOTENCY_IN_FLIGHT') { sleep(2); continue; } if ($response['error']['code'] === 'IDEMPOTENCY_CONFLICT') { throw new RuntimeException("Key {$idempotencyKey} was already used for a different order."); } throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } throw new RuntimeException('The original request is still in flight.'); } ``` ### The rules behind that table - **The key is scoped to your API key.** Two of your systems holding different Salaf keys cannot collide on the same UUID, and one system's keys cannot be guessed or hijacked by another. - **The body is fingerprinted, not compared literally.** The fingerprint is a hash of the method, the path and a *canonical* form of the body — so re-serialising the same structure with different key order still counts as the same request, while any value change counts as a different one. - **Rows live 24 hours**, then they are purged and the key becomes reusable. - **A crashed request unblocks itself.** If the original process died mid-flight, the row is claimed by a retry after 90 seconds and the operation runs fresh. Until then, duplicates get `409`. - **`GET` requests ignore the header entirely.** Reads are already idempotent. > **Warning — Generate the key where the operation is decided** > > The most common way to defeat idempotency is to generate the key inside the > retry loop — that is a new key every attempt, which is exactly the duplicate > orders the mechanism exists to prevent. > > Generate it when you decide to place the order, **persist it with your own > record**, and reuse it on every attempt including after a process restart. > **Note — Order creation is protected twice** > > `POST /orders` additionally derives a deterministic request id from your API > key and idempotency key and hands it to the order core. So even in the narrow > window where the idempotency row was written but the response never stored — > a crash between the two — a retry converges on the *same order* rather than > creating a second one. Nothing is required of you to get this; it is why > order creation is the one write with belt and braces. ## Writing with a company key A [company key](https://www.salafems.com/developers/authentication.md#store-keys-and-company-keys) can read every store in the company, but a write has to land in exactly one. - **`store_id` in the body is required.** There is no such thing as a company order. Omitting it is `422` naming the field: *"store\_id is required in the body when writing with a company-scoped API key."* - **It is validated before anything runs.** A store belonging to another company, or one that is suspended, answers `404` — never `403`, so the field cannot be used to discover which store ids exist. - **A store key may repeat its own `store_id`, and may not contradict it.** Naming a different store with a store key is `422`: you filled the field in and got it wrong, which is a fact about your request rather than a hint about someone else's data. Liveness is re-checked on every write either way, so a suspended store stops accepting writes at the same moment it stops serving shoppers. ## Creating orders `POST /orders` is the endpoint with the most surface, and four of its behaviours surprise people. ### Prices are trusted `unit_price`, `discount_price`, `shipping_charge` and `tax_amount` are accepted as your system computed them. This is a **merchant-trusted surface** — the key belongs to the store owner and runs the same path as the admin desk, where staff also override prices. A shopper-facing API would never accept them. Omit `unit_price` and the variant's current catalogue price is used, exactly as at the desk. A `discount_price` is only honoured when it is a *real* discount: above zero and below the regular price. ### `shipping_charge` is never calculated for you Default `0`. If your system charges for delivery, send the number; nothing computes it on this path. (A free-shipping coupon still zeroes it.) ### The customer is found or created by phone Send `customer_id` for an existing customer, or a `customer` object to find-or-create. Never both — that is a `422`. The match key is the **canonical Bangladeshi phone number**: any accepted spelling is normalised first, so `+8801712345678`, `8801712345678` and `01712345678` are one customer, not three. > **Warning — An existing customer is never updated by an order** > > If the phone matches, that customer is used **as-is**. The `full_name` and > `email` you sent are ignored — a sale is not a CRM edit, and silently > renaming a customer because a checkout form was filled in carelessly is worse > than doing nothing. > > `full_name` is required only when the phone matches nobody, because a new > record needs a name. To change an existing customer, call > `PATCH /customers/{id}` deliberately. ### Attribution - **`channel`** takes a channel *slug* (`facebook`, `online-store`), never an id. Omitted, it defaults to the store's `manual` channel. An unknown or inactive slug fails the order rather than mis-attributing it. - **`source` is always `api`** on the resulting order, and you cannot set it. That is how a merchant tells API orders from POS and storefront ones in their own reports. ### Everything else is the desk's behaviour, inherited Stock is reserved with an availability check (`INSUFFICIENT_STOCK` names the line that failed), coupons are priced and redeemed by the same engine, the plan order quota is metered, and the order appears in the dashboard timeline with *"Order placed via API"*. ## Endpoint-specific rules worth knowing **`POST /inventory/adjustments`** — `reason` is always required (the ledger has no unexplained movements). `quantity_change` is a signed delta and may not be zero, may not take on-hand below zero, and may not take it below what is already reserved. `type: "purchase"` requires `unit_cost` — stock cannot enter at no cost — and `type: "damage"` must remove stock. `unit_cost` is **write-only**: it is accepted here and never appears in any response. **`POST /orders/{id}/payments`** — the amount is capped at the order's *net* due (refunds counted). `transaction_id` is unique per store, so recording the same provider TrxID twice is a `409`. The paid-in-full events fire exactly once, on the transition. **`PATCH /orders/{id}/status`** — validated against the same strict transition map staff use. An illegal move is refused with `INVALID_STATUS_TRANSITION` rather than being quietly applied. Setting the status it already has is a no-op, not an error. `POST /orders/{id}/cancel` is sugar for the `cancelled` transition, with the same stock unwind. **Product writes** are deliberately narrower than the dashboard: no images (binary upload is not a JSON API's job), no attributes or specifications, and no initial stock — stock enters through `POST /inventory/adjustments`, where the ledger rules live. `cost_price` on a variant is accepted and never returned. **`PATCH /products/{id}` reconciles the whole variant set** when `variants` is present: entries with an `id` are patched, entries without one are created, and existing variants missing from the list are **removed** (refused if they carry stock history). Send the full set, not a partial one. The same applies to `categories`, which replaces the product's category links. **Customer writes** always produce a `manual` customer — there is no `type` field — and the store's walk-in system record cannot be created or edited. Phone and email are unique per store, so a collision is a `409`. ## Errors specific to writes | HTTP | Code | Means | | ---- | --------------------------- | ------------------------------------------------------------------------ | | 400 | `VALIDATION_ERROR` | A required `Idempotency-Key` is missing. `fields` names the header. | | 400 | `INSUFFICIENT_STOCK` | Not enough stock for a line. `fields.items` says which. | | 400 | `INVALID_STATUS_TRANSITION` | The order cannot move from its current status to the requested one. | | 409 | `IDEMPOTENCY_IN_FLIGHT` | The same key is still being processed. Wait and retry with the same key. | | 409 | `CONFLICT` | A duplicate unique value — a SKU, a phone, a provider transaction id. | | 422 | `IDEMPOTENCY_CONFLICT` | The same key was used for a different body. | | 422 | `VALIDATION_ERROR` | Anything else about the request that is wrong, with `fields`. | Full list on the [Errors](https://www.salafems.com/developers/errors.md) page. ## Rate limits Writes count against a **separate, smaller bucket**: 30 per minute per key by default, against 120 for reads. A bulk catalogue import should pace itself accordingly — or run on its own key, since buckets are per key. See [Rate limits](https://www.salafems.com/developers/rate-limits.md). --- # Rate limits Per-key buckets, headers, and handling 429. Requests are counted **per API key**, in a fixed 60-second window, across two separate buckets. | Bucket | Counts | Default | | ------ | ------------------------ | ------------------ | | Reads | `GET`, `HEAD`, `OPTIONS` | **120 per minute** | | Writes | Everything else | **30 per minute** | The two buckets are counted separately, so a bulk catalogue import cannot starve the reads a dashboard is making with the same key — and neither can exhaust the other's allowance. The write bucket is the smaller one on purpose: writes cost far more work per request, and 30 a minute is well above what a correctly batched integration needs. ## Headers on every response You never have to guess where you stand — the numbers are on every response, not just the ones that fail. ```http HTTP/1.1 200 OK X-RateLimit-Limit: 120 X-RateLimit-Remaining: 117 X-RateLimit-Reset: 1786521600 ``` | Header | Meaning | | ----------------------- | -------------------------------------------------------- | | `X-RateLimit-Limit` | Requests allowed in the current window, for this bucket. | | `X-RateLimit-Remaining` | How many are left. Floors at `0`. | | `X-RateLimit-Reset` | Unix timestamp, in **seconds**, when the window resets. | > **Note — You get these headers even on a 403** > > The rate-limit check runs before the scope check, deliberately. A caller > hammering an endpoint their key is not allowed to touch is still consuming > our capacity, so they are still counted — and they still get the headers > telling them so. ## When you exceed it ```http HTTP/1.1 429 Too Many Requests Retry-After: 24 X-RateLimit-Limit: 120 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1786521600 ``` ```json { "error": { "code": "RATE_LIMITED", "message": "Rate limit exceeded: 120 read requests per minute. Retry in 24s.", "request_id": "4f1c9b02-7d3e-4a85-9c16-b0e2f7a34d59" } } ``` **`Retry-After` is exact.** It is the number of seconds left in your window, not a suggestion and not a rough backoff hint. Sleeping for less means spending another request to be told the same thing; sleeping for more just wastes your own time. **cURL** ```bash # --retry 5 makes curl honour Retry-After on a 429 by itself. curl -sS --retry 5 --retry-all-errors \ "https://api.salafems.com/ext/v1/orders?limit=100" \ -H "Authorization: Bearer $SALAF_API_KEY" \ -D headers.txt # The headers on EVERY response tell you how much room is left: # X-RateLimit-Limit: 120 # X-RateLimit-Remaining: 3 # X-RateLimit-Reset: 1786521600 grep -i '^x-ratelimit' headers.txt ``` **JavaScript** ```javascript async function call(url, attempt = 0) { const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}` }, }); if (response.status === 429 && attempt < 5) { // Retry-After is authoritative — it is the exact number of seconds // until this key's window resets. Guessing a backoff just means // retrying too early and burning another request on a 429. const wait = Number(response.headers.get("Retry-After") ?? 1); await new Promise((resolve) => setTimeout(resolve, wait * 1000)); return call(url, attempt + 1); } if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } return response.json(); } ``` **Python** ```python import os import time import requests HEADERS = {"Authorization": f"Bearer {os.environ['SALAF_API_KEY']}"} def call(url, params=None, attempts=5): for attempt in range(attempts): response = requests.get(url, params=params, headers=HEADERS, timeout=30) if response.status_code == 429: # Retry-After is exact — it is the seconds left in this key's # fixed window. Sleeping less just spends another request. time.sleep(int(response.headers.get("Retry-After", "1"))) continue if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") return response.json() raise RuntimeError("Still rate limited after retrying.") ``` **PHP** ```php true, CURLOPT_HEADER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $raw = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); curl_close($ch); $headers = substr($raw, 0, $headerSize); $payload = json_decode(substr($raw, $headerSize), true); if ($status === 429) { // Retry-After is the exact seconds left in the window. preg_match('/retry-after:\s*(\d+)/i', $headers, $m); sleep((int) ($m[1] ?? 1)); continue; } if ($status >= 400) { throw new RuntimeException("{$payload['error']['code']}: {$payload['error']['message']}"); } return $payload; } throw new RuntimeException('Still rate limited after retrying.'); } ``` ## How the window works The window is **fixed**, not sliding: the minute is divided into fixed blocks and each one starts your count at zero. One consequence is worth knowing — you can briefly do up to twice the limit across a boundary, by spending your full allowance at the end of one window and again at the start of the next. That forgiveness is intentional. A sliding window would be stricter and would cost more to run, and nobody has yet demonstrated that the edge behaviour matters here. If that changes, tightening it is a change to our side only. ## Per key, not per company Each key gets its own buckets. A company key counts as one key, exactly like a store key. This has a practical implication worth planning around: **splitting one integration across several keys multiplies its throughput.** That is a legitimate way to run a nightly bulk sync and a real-time listener side by side without one starving the other — and separate keys also mean you can revoke one without touching the other. There is also a per-IP ceiling in front of everything as a denial-of-service backstop. It is set high enough that ordinary integration traffic never approaches it, including several tenants sharing one office NAT. ## Plan limits The defaults above are floors, not ceilings. Both limits are plan features (`api_rate_limit_reads` and `api_rate_limit_writes`), so a higher tier raises them with no change on your side — your key simply starts reporting a larger `X-RateLimit-Limit`. Read the limit from the header rather than hard-coding `120`. Your plan may already be higher than the default, and it can change without a release on either side. ## Staying under the limit - **Ask for bigger pages.** `limit=100` fetches the same 1,000 rows in 10 requests instead of 40. The rate limit counts requests, not rows. - **Filter with `updated_after`.** A sync that reads only what changed makes a fraction of the calls of one that re-reads everything. - **Do not poll tightly.** Once a minute is plenty for almost every integration; once every few minutes is plenty for most. - **Watch `X-RateLimit-Remaining` and slow down before you hit zero.** Backing off at 10 remaining is cheaper than a `429` and a retry. - **Do not expand what you will not read.** `expand=items` on a page you only need totals from is work for both of us. --- # Versioning What we may change without warning, and what we may not. The version is in the path. Everything served under `/ext/v1` keeps its promises for as long as v1 exists. ```text https://api.salafems.com/ext/v1/orders ``` There is no version header, no date pinning per request, and no account-level version. One product team and a modest surface do not justify that machinery — a path segment is the whole strategy, and it is legible from a log line. ## What we may change inside v1 These are **additive** and can ship any time. Your integration must tolerate them. | Change | Example | | --------------------------------- | ------------------------------------------------- | | A new field on an existing object | An order gains `delivery_window` | | A new endpoint | `GET /ext/v1/returns` | | A new query parameter or filter | `updated_after` arriving on customers | | A new value in an existing enum | An order status we do not have today | | A new error `code` | A more specific failure than the one you get now | | A new webhook event type | An endpoint subscribed to `*` starts receiving it | > **Warning — Treat every enum as an open set** > > This is the single most common way an integration breaks on an additive > change. Code that switches exhaustively on `order.status` — or worse, throws > on an unrecognised value — will fail the day a new status ships, and that day > is not a breaking change on our side. > > Handle the values you care about; let everything else fall through a default > branch. ## What we will not change inside v1 These are **breaking** and require a new major version: - Removing a field, or renaming one - Changing a field's type — a money string will never become a number - Changing what a field means while keeping its name - Removing an endpoint or a query parameter - Removing an enum value - Making an optional parameter required - Changing the meaning of an HTTP status or an error `code` A v2 would mount **beside** v1 at `/ext/v2`, never over it. v1 does not change underneath you the day v2 appears. ## Writing an integration that survives - **Ignore fields you do not recognise.** Never validate a response against a closed schema that rejects unknown keys. - **Default-branch every enum.** See the warning above. - **Do not depend on field order**, or on the absence of a field. - **Do not depend on the exact wording of `message`.** Branch on `code`. - **Do not parse ids.** They are opaque strings. Their format is not a contract, and code that assumes a shape will break when a resource adopts a different one. - **Do not assume a field is missing forever** because it is `null` today. ## Deprecation If something in v1 ever has to go, it goes slowly and loudly: 1. **Announced in the [changelog](https://www.salafems.com/developers/changelog.md)** with the replacement and a date. 2. **`Deprecation` and `Sunset` headers** appear on the affected endpoints, so your logs can tell you that you are using something on the way out even if nobody read the changelog. 3. **At least six months** between the announcement and the shutdown. ```http HTTP/1.1 200 OK Deprecation: true Sunset: Sat, 11 Aug 2027 00:00:00 GMT ``` Nothing is deprecated today. ## The version tag Responses and this documentation carry a date-based version tag — currently **2026-08**. For the REST endpoints it is informational: the path segment is what governs compatibility. For [webhooks](https://www.salafems.com/developers/webhooks.md) it is load-bearing. Each webhook endpoint is pinned to a version **when it is created**, and nothing afterwards changes it — not editing the endpoint, not rotating its secret. A change to payload shape mints a new date tag, and existing endpoints keep receiving the shape they were built against. Every delivery states its version twice: in the `X-Salaf-Api-Version` header and as `api_version` in the body. Event *names* are governed by a stricter rule still: they are additive forever. A new event type is a new name, and an existing name is never renamed and never changes meaning — a rename would be a silent outage that no deprecation header could reach, because merchants subscribe by string. See the [event catalog](https://www.salafems.com/developers/webhook-events.md). ## What is not covered by any of this **Data is not a contract.** A store can rename a product, archive a category or change a price at any moment; a status your integration has never seen may appear because a merchant started using a workflow they had not used before. Versioning governs the *shape* of the API, not the values flowing through it. Rate limits are also outside the versioning promise — they are plan configuration, and a higher plan changing your quota is not a breaking change. Read the limit from `X-RateLimit-Limit` rather than hard-coding it. --- # Salaf Commerce API — reference Version `2026-08`, generated from the [published OpenAPI description](https://www.salafems.com/openapi/public-v1.json). 27 endpoints — 17 reads and 10 writes. Paths are shown exactly as the server routes them — prefix them with `https://api.salafems.com`. Authenticate with two headers, no handshake: ```http Authorization: Bearer salaf_sk_YOUR_API_KEY Accept: application/json ``` A key with `:write` also satisfies `:read`; the reverse is never true. Writes take an `Idempotency-Key` — see [Writes & idempotency](https://www.salafems.com/developers/idempotency.md). ## Resources - **[Products](https://www.salafems.com/developers/reference/products.md)** — 6 endpoints - `GET /ext/v1/products` — [List products](https://www.salafems.com/developers/reference/products.md#list-products) · scope `products:read` - `POST /ext/v1/products` — [Create a product](https://www.salafems.com/developers/reference/products.md#create-a-product) · scope `products:write` - `GET /ext/v1/products/{id}` — [Get a product by id](https://www.salafems.com/developers/reference/products.md#get-a-product-by-id) · scope `products:read` - `PATCH /ext/v1/products/{id}` — [Update a product](https://www.salafems.com/developers/reference/products.md#update-a-product) · scope `products:write` - `POST /ext/v1/products/{id}/archive` — [Archive a product](https://www.salafems.com/developers/reference/products.md#archive-a-product) · scope `products:write` - `GET /ext/v1/variants/{identifier}` — [Get a variant by id, SKU or barcode](https://www.salafems.com/developers/reference/products.md#get-a-variant-by-id-sku-or-barcode) · scope `products:read` - **[Categories](https://www.salafems.com/developers/reference/categories.md)** — 2 endpoints - `GET /ext/v1/categories` — [List categories](https://www.salafems.com/developers/reference/categories.md#list-categories) · scope `products:read` - `GET /ext/v1/categories/{id}` — [Get a category by id](https://www.salafems.com/developers/reference/categories.md#get-a-category-by-id) · scope `products:read` - **[Brands](https://www.salafems.com/developers/reference/brands.md)** — 2 endpoints - `GET /ext/v1/brands` — [List brands](https://www.salafems.com/developers/reference/brands.md#list-brands) · scope `products:read` - `GET /ext/v1/brands/{id}` — [Get a brand by id](https://www.salafems.com/developers/reference/brands.md#get-a-brand-by-id) · scope `products:read` - **[Orders](https://www.salafems.com/developers/reference/orders.md)** — 7 endpoints - `GET /ext/v1/orders` — [List orders](https://www.salafems.com/developers/reference/orders.md#list-orders) · scope `orders:read` - `POST /ext/v1/orders` — [Create an order](https://www.salafems.com/developers/reference/orders.md#create-an-order) · scope `orders:write` - `GET /ext/v1/orders/{id}` — [Get an order by id](https://www.salafems.com/developers/reference/orders.md#get-an-order-by-id) · scope `orders:read` - `POST /ext/v1/orders/{id}/cancel` — [Cancel an order](https://www.salafems.com/developers/reference/orders.md#cancel-an-order) · scope `orders:write` - `GET /ext/v1/orders/{id}/payments` — [List an order's payments](https://www.salafems.com/developers/reference/orders.md#list-an-orders-payments) · scope `payments:read` - `POST /ext/v1/orders/{id}/payments` — [Record a payment against an order](https://www.salafems.com/developers/reference/orders.md#record-a-payment-against-an-order) · scope `payments:write` - `PATCH /ext/v1/orders/{id}/status` — [Change an order status](https://www.salafems.com/developers/reference/orders.md#change-an-order-status) · scope `orders:write` - **[Customers](https://www.salafems.com/developers/reference/customers.md)** — 4 endpoints - `GET /ext/v1/customers` — [List customers](https://www.salafems.com/developers/reference/customers.md#list-customers) · scope `customers:read` - `POST /ext/v1/customers` — [Create a customer](https://www.salafems.com/developers/reference/customers.md#create-a-customer) · scope `customers:write` - `GET /ext/v1/customers/{id}` — [Get a customer by id, with their addresses](https://www.salafems.com/developers/reference/customers.md#get-a-customer-by-id-with-their-addresses) · scope `customers:read` - `PATCH /ext/v1/customers/{id}` — [Update a customer](https://www.salafems.com/developers/reference/customers.md#update-a-customer) · scope `customers:write` - **[Inventory](https://www.salafems.com/developers/reference/inventory.md)** — 3 endpoints - `POST /ext/v1/inventory/adjustments` — [Adjust stock at a location](https://www.salafems.com/developers/reference/inventory.md#adjust-stock-at-a-location) · scope `inventory:write` - `GET /ext/v1/inventory/levels` — [List stock levels (one row per variant per location)](https://www.salafems.com/developers/reference/inventory.md#list-stock-levels-one-row-per-variant-per-location) · scope `inventory:read` - `GET /ext/v1/inventory/movements` — [List stock movements (the append-only ledger behind the levels)](https://www.salafems.com/developers/reference/inventory.md#list-stock-movements-the-append-only-ledger-behind-the-levels) · scope `inventory:read` - **[Locations](https://www.salafems.com/developers/reference/locations.md)** — 2 endpoints - `GET /ext/v1/locations` — [List locations](https://www.salafems.com/developers/reference/locations.md#list-locations) · scope `locations:read` - `GET /ext/v1/locations/{id}` — [Get a location by id](https://www.salafems.com/developers/reference/locations.md#get-a-location-by-id) · scope `locations:read` - **[Channels](https://www.salafems.com/developers/reference/channels.md)** — 1 endpoint - `GET /ext/v1/channels` — [List sales channels](https://www.salafems.com/developers/reference/channels.md#list-sales-channels) · scope `channels:read` --- # Products Part of the [Salaf Commerce API reference](https://www.salafems.com/developers/reference.md). Paths below are shown exactly as the server routes them — prefix them with `https://api.salafems.com`. Authenticate every request with `Authorization: Bearer salaf_sk_YOUR_API_KEY`; each operation names the scope its key must hold. Every failure uses the one error envelope — see [Errors](https://www.salafems.com/developers/errors.md). ## List products `GET /ext/v1/products` · Requires scope `products:read` Variants are nested in each product — they are the commercial unit. Use `updated_after` to sync only what changed. ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | `number` (default `25`, max 100) | No | Page size. | | `starting_after` | `string` | No | Return the page AFTER this object id (the previous page's `next_cursor`). | | `ending_before` | `string` | No | Return the page BEFORE this object id. | | `sort` | `string` (default `-created_at`) | No | Newest first by default. Only `created_at` is sortable — a cursor over a mutable key (like `updated_at`) cannot page reliably. Use `updated_after` to sync changes. One of: `created_at`, `-created_at`. | | `store_id` | `string` | No | Restrict to one store. Required breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | | `status` | `string` | No | Filter to one of the values below. One of: `draft`, `active`, `archived`. | | `category_id` | `string` | No | Only products in this category. | | `updated_after` | `date-time` | No | Only products changed since this instant — the sync filter. Pair it with the default `-created_at` order and page with the cursor. | | `search` | `string` | No | Match on product name or variant SKU. | ### Request **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS "https://api.salafems.com/ext/v1/products?limit=25&updated_after=2026-08-01T00%3A00%3A00Z" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/products"); Object.entries({ limit: "25", updated_after: "2026-08-01T00:00:00Z", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.salafems.com/ext/v1/products", params={ "limit": "25", "updated_after": "2026-08-01T00:00:00Z", }, headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` ### Responses - `200` — A cursor page of products. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "data": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Classic Cotton T-Shirt", "slug": "classic-cotton-t-shirt", "short_description": null, "description": null, "thumbnail": null, "status": "active", "delivery_charge": "1250.00", "rating_average": "4.50", "rating_count": 12, "brand": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Acme", "slug": "acme" }, "categories": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "T-Shirts", "slug": "t-shirts", "is_primary": true } ], "images": [ { "url": "https://cdn.example.com/p/1.webp", "position": 0 } ], "variants": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "sku": "TSHIRT-RED-M", "barcode": "0123456789012", "name": "Red / M", "price": "1250.00", "discount_price": "1250.00", "weight": "0.25", "dimensions": null, "low_stock_threshold": 5, "status": "active", "image": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "url": "https://cdn.example.com/p/1.webp" }, "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ], "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ], "meta": { "has_more": true, "next_cursor": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001" } } ``` ## Create a product `POST /ext/v1/products` · Requires scope `products:write` At least one variant (a simple product has exactly one). Slugs auto-generate from the name and auto-suffix when taken; a duplicate SKU is a 409. Media, attribute wiring and initial stock are dashboard concerns — stock enters through `POST /v1/inventory/adjustments`. ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `name` | `string` | Yes | — | | `slug` | `string` | No | URL-safe slug. Auto-generated from the name when omitted; auto-suffixed when taken. | | `brand_id` | `uuid` | No | — | | `short_description` | `string` | No | — | | `description` | `string` | No | — | | `status` | `string` (default `draft`) | No | — One of: `draft`, `active`, `archived`. | | `delivery_charge` | `number` | No | Per-product delivery-charge override: 0 = free delivery, N = flat charge; omit for the standard shipping rules. | | `categories` | `ProductCategoryLinkDto[]` | No | — | | `variants` | `ProductVariantWriteDto[]` | Yes | — | ### `categories[]` fields | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | — | | `is_primary` | `boolean` (default `false`) | No | — | ### `variants[]` fields | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | No | Update only: patches this existing variant. Omit to create a new one. | | `sku` | `string` | Yes | — | | `barcode` | `string` | No | — | | `name` | `string` | Yes | — | | `price` | `number` | Yes | — | | `discount_price` | `number` | No | Sale price — must be strictly below `price`. | | `cost_price` | `number` | No | What the unit costs the store. Write-only (never serialized, Q6); defaults to 0 when omitted. | | `weight` | `number` | No | — | | `low_stock_threshold` | `number` (default `0`) | No | — | | `status` | `string` (default `active`) | No | — One of: `active`, `inactive`. | ### Request **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS -X POST "https://api.salafems.com/ext/v1/products" \ -H "Authorization: Bearer $SALAF_API_KEY" \ -H "Idempotency-Key: e7a1…-your-uuid" \ -H "Content-Type: application/json" \ -d '{ "name": "Classic Cotton T-Shirt", "status": "active", "variants": [ { "sku": "TSHIRT-RED-M", "name": "Red / M", "price": 1250, "cost_price": 850 } ] }' ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/products"); const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, "Idempotency-Key": "e7a1…-your-uuid", "Content-Type": "application/json", }, body: JSON.stringify({ "name": "Classic Cotton T-Shirt", "status": "active", "variants": [ { "sku": "TSHIRT-RED-M", "name": "Red / M", "price": 1250, "cost_price": 850 } ] }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "name": "Classic Cotton T-Shirt", "status": "active", "variants": [ { "sku": "TSHIRT-RED-M", "name": "Red / M", "price": 1250, "cost_price": 850, }, ], } response = requests.post( "https://api.salafems.com/ext/v1/products", json=payload, headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", "Idempotency-Key": "e7a1…-your-uuid", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 'Classic Cotton T-Shirt', 'status' => 'active', 'variants' => [ [ 'sku' => 'TSHIRT-RED-M', 'name' => 'Red / M', 'price' => 1250, 'cost_price' => 850, ], ], ]); $ch = curl_init('https://api.salafems.com/ext/v1/products'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: e7a1…-your-uuid', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `201` — The created product, variants nested. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `409` — A variant SKU is already in use in this store. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Classic Cotton T-Shirt", "slug": "classic-cotton-t-shirt", "short_description": null, "description": null, "thumbnail": null, "status": "active", "delivery_charge": "1250.00", "rating_average": "4.50", "rating_count": 12, "brand": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Acme", "slug": "acme" }, "categories": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "T-Shirts", "slug": "t-shirts", "is_primary": true } ], "images": [ { "url": "https://cdn.example.com/p/1.webp", "position": 0 } ], "variants": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "sku": "TSHIRT-RED-M", "barcode": "0123456789012", "name": "Red / M", "price": "1250.00", "discount_price": "1250.00", "weight": "0.25", "dimensions": null, "low_stock_threshold": 5, "status": "active", "image": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "url": "https://cdn.example.com/p/1.webp" }, "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ], "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ``` ## Get a product by id `GET /ext/v1/products/{id}` · Requires scope `products:read` The full product object, variants nested. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The product id. | ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Restrict the lookup to one store. Breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | ### Request **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS "https://api.salafems.com/ext/v1/products/REPLACE_WITH_ID" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/products/REPLACE_WITH_ID"); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.salafems.com/ext/v1/products/REPLACE_WITH_ID", headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The product. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Product not found. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Classic Cotton T-Shirt", "slug": "classic-cotton-t-shirt", "short_description": null, "description": null, "thumbnail": null, "status": "active", "delivery_charge": "1250.00", "rating_average": "4.50", "rating_count": 12, "brand": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Acme", "slug": "acme" }, "categories": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "T-Shirts", "slug": "t-shirts", "is_primary": true } ], "images": [ { "url": "https://cdn.example.com/p/1.webp", "position": 0 } ], "variants": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "sku": "TSHIRT-RED-M", "barcode": "0123456789012", "name": "Red / M", "price": "1250.00", "discount_price": "1250.00", "weight": "0.25", "dimensions": null, "low_stock_threshold": 5, "status": "active", "image": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "url": "https://cdn.example.com/p/1.webp" }, "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ], "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ``` ## Update a product `PATCH /ext/v1/products/{id}` · Requires scope `products:write` Partial update of the product fields. `categories` (when present) replaces the link set; `variants` (when present) reconciles the FULL variant set — patch by id, create without one, delete the rest unless stock history forbids it. Same semantics as the dashboard. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The product id. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `name` | `string` | No | — | | `slug` | `string` | No | — | | `brand_id` | `uuid` | No | Set null to detach the brand. | | `short_description` | `string` | No | — | | `description` | `string` | No | — | | `status` | `string` | No | — One of: `draft`, `active`, `archived`. | | `delivery_charge` | `object` | No | Set null to return to the standard shipping rules. | | `categories` | `ProductCategoryLinkDto[]` | No | When present, REPLACES the product's category links. | | `variants` | `ProductVariantWriteDto[]` | No | When present, reconciles the FULL variant set in one transaction — entries with an `id` are patched, entries without one are created, and existing variants missing from the list are removed (refused when they carry stock history). Same semantics as the dashboard. | ### `categories[]` fields | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | — | | `is_primary` | `boolean` (default `false`) | No | — | ### `variants[]` fields | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | No | Update only: patches this existing variant. Omit to create a new one. | | `sku` | `string` | Yes | — | | `barcode` | `string` | No | — | | `name` | `string` | Yes | — | | `price` | `number` | Yes | — | | `discount_price` | `number` | No | Sale price — must be strictly below `price`. | | `cost_price` | `number` | No | What the unit costs the store. Write-only (never serialized, Q6); defaults to 0 when omitted. | | `weight` | `number` | No | — | | `low_stock_threshold` | `number` (default `0`) | No | — | | `status` | `string` (default `active`) | No | — One of: `active`, `inactive`. | ### Request **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS -X PATCH "https://api.salafems.com/ext/v1/products/REPLACE_WITH_ID" \ -H "Authorization: Bearer $SALAF_API_KEY" \ -H "Idempotency-Key: e7a1…-your-uuid" \ -H "Content-Type: application/json" \ -d '{ "status": "active" }' ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/products/REPLACE_WITH_ID"); const response = await fetch(url, { method: "PATCH", headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, "Idempotency-Key": "e7a1…-your-uuid", "Content-Type": "application/json", }, body: JSON.stringify({ "status": "active" }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "status": "active", } response = requests.patch( "https://api.salafems.com/ext/v1/products/REPLACE_WITH_ID", json=payload, headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", "Idempotency-Key": "e7a1…-your-uuid", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 'active', ]); $ch = curl_init('https://api.salafems.com/ext/v1/products/REPLACE_WITH_ID'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: e7a1…-your-uuid', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The updated product, variants nested. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Product not found. - `409` — A variant SKU is already in use in this store. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Classic Cotton T-Shirt", "slug": "classic-cotton-t-shirt", "short_description": null, "description": null, "thumbnail": null, "status": "active", "delivery_charge": "1250.00", "rating_average": "4.50", "rating_count": 12, "brand": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Acme", "slug": "acme" }, "categories": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "T-Shirts", "slug": "t-shirts", "is_primary": true } ], "images": [ { "url": "https://cdn.example.com/p/1.webp", "position": 0 } ], "variants": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "sku": "TSHIRT-RED-M", "barcode": "0123456789012", "name": "Red / M", "price": "1250.00", "discount_price": "1250.00", "weight": "0.25", "dimensions": null, "low_stock_threshold": 5, "status": "active", "image": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "url": "https://cdn.example.com/p/1.webp" }, "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ], "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ``` ## Archive a product `POST /ext/v1/products/{id}/archive` · Requires scope `products:write` Non-destructive: variants, images and inventory survive with their status parked, and the dashboard can restore. Archived products drop out of `GET /v1/products`. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The product id. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | ### Request **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS -X POST "https://api.salafems.com/ext/v1/products/REPLACE_WITH_ID/archive" \ -H "Authorization: Bearer $SALAF_API_KEY" \ -H "Idempotency-Key: e7a1…-your-uuid" \ -H "Content-Type: application/json" \ -d '{}' ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/products/REPLACE_WITH_ID/archive"); const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, "Idempotency-Key": "e7a1…-your-uuid", "Content-Type": "application/json", }, body: JSON.stringify({}), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = {} response = requests.post( "https://api.salafems.com/ext/v1/products/REPLACE_WITH_ID/archive", json=payload, headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", "Idempotency-Key": "e7a1…-your-uuid", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: e7a1…-your-uuid', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `201` — Archival confirmed. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Product not found. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "archived": true } ``` ## Get a variant by id, SKU or barcode `GET /ext/v1/variants/{identifier}` · Requires scope `products:read` Answers "what is this thing in my hand?" for a scanned barcode or a SKU from a spreadsheet. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `identifier` | `string` | Yes | A variant id, SKU or barcode. All three resolve here. | ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Restrict the lookup to one store. Breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | ### Request **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS "https://api.salafems.com/ext/v1/variants/SKU-001" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/variants/SKU-001"); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.salafems.com/ext/v1/variants/SKU-001", headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The variant. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Variant not found. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "sku": "TSHIRT-RED-M", "barcode": "0123456789012", "name": "Red / M", "price": "1250.00", "discount_price": "1250.00", "weight": "0.25", "dimensions": null, "low_stock_threshold": 5, "status": "active", "image": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "url": "https://cdn.example.com/p/1.webp" }, "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ``` --- # Categories Part of the [Salaf Commerce API reference](https://www.salafems.com/developers/reference.md). Paths below are shown exactly as the server routes them — prefix them with `https://api.salafems.com`. Authenticate every request with `Authorization: Bearer salaf_sk_YOUR_API_KEY`; each operation names the scope its key must hold. Every failure uses the one error envelope — see [Errors](https://www.salafems.com/developers/errors.md). ## List categories `GET /ext/v1/categories` · Requires scope `products:read` Catalog structure; `parent_id` builds the tree client-side. ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | `number` (default `25`, max 100) | No | Page size. | | `starting_after` | `string` | No | Return the page AFTER this object id (the previous page's `next_cursor`). | | `ending_before` | `string` | No | Return the page BEFORE this object id. | | `sort` | `string` (default `-created_at`) | No | Newest first by default. Only `created_at` is sortable — a cursor over a mutable key (like `updated_at`) cannot page reliably. Use `updated_after` to sync changes. One of: `created_at`, `-created_at`. | | `store_id` | `string` | No | Restrict to one store. Required breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | | `status` | `string` | No | Filter to one of the values below. One of: `active`, `inactive`. | ### Request **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS "https://api.salafems.com/ext/v1/categories?limit=25" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/categories"); Object.entries({ limit: "25", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.salafems.com/ext/v1/categories", params={ "limit": "25", }, headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` ### Responses - `200` — A cursor page of categories. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "data": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "parent_id": null, "name": "T-Shirts", "slug": "t-shirts", "image": null, "description": null, "status": "active", "position": 0, "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ], "meta": { "has_more": true, "next_cursor": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001" } } ``` ## Get a category by id `GET /ext/v1/categories/{id}` · Requires scope `products:read` One category node; follow `parent_id` for its ancestors. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The category id. | ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Restrict the lookup to one store. Breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | ### Request **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS "https://api.salafems.com/ext/v1/categories/REPLACE_WITH_ID" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/categories/REPLACE_WITH_ID"); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.salafems.com/ext/v1/categories/REPLACE_WITH_ID", headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The category. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Category not found. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "parent_id": null, "name": "T-Shirts", "slug": "t-shirts", "image": null, "description": null, "status": "active", "position": 0, "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ``` --- # Brands Part of the [Salaf Commerce API reference](https://www.salafems.com/developers/reference.md). Paths below are shown exactly as the server routes them — prefix them with `https://api.salafems.com`. Authenticate every request with `Authorization: Bearer salaf_sk_YOUR_API_KEY`; each operation names the scope its key must hold. Every failure uses the one error envelope — see [Errors](https://www.salafems.com/developers/errors.md). ## List brands `GET /ext/v1/brands` · Requires scope `products:read` Read-only in v1; brand management stays on the dashboard. ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | `number` (default `25`, max 100) | No | Page size. | | `starting_after` | `string` | No | Return the page AFTER this object id (the previous page's `next_cursor`). | | `ending_before` | `string` | No | Return the page BEFORE this object id. | | `sort` | `string` (default `-created_at`) | No | Newest first by default. Only `created_at` is sortable — a cursor over a mutable key (like `updated_at`) cannot page reliably. Use `updated_after` to sync changes. One of: `created_at`, `-created_at`. | | `store_id` | `string` | No | Restrict to one store. Required breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | | `status` | `string` | No | Filter to one of the values below. One of: `active`, `inactive`. | ### Request **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS "https://api.salafems.com/ext/v1/brands?limit=25" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/brands"); Object.entries({ limit: "25", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.salafems.com/ext/v1/brands", params={ "limit": "25", }, headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` ### Responses - `200` — A cursor page of brands. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "data": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Acme", "slug": "acme", "logo": null, "description": null, "status": "active", "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ], "meta": { "has_more": true, "next_cursor": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001" } } ``` ## Get a brand by id `GET /ext/v1/brands/{id}` · Requires scope `products:read` One brand record. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The brand id. | ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Restrict the lookup to one store. Breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | ### Request **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS "https://api.salafems.com/ext/v1/brands/REPLACE_WITH_ID" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/brands/REPLACE_WITH_ID"); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.salafems.com/ext/v1/brands/REPLACE_WITH_ID", headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The brand. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Brand not found. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Acme", "slug": "acme", "logo": null, "description": null, "status": "active", "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ``` --- # Orders Part of the [Salaf Commerce API reference](https://www.salafems.com/developers/reference.md). Paths below are shown exactly as the server routes them — prefix them with `https://api.salafems.com`. Authenticate every request with `Authorization: Bearer salaf_sk_YOUR_API_KEY`; each operation names the scope its key must hold. Every failure uses the one error envelope — see [Errors](https://www.salafems.com/developers/errors.md). ## List orders `GET /ext/v1/orders` · Requires scope `orders:read` Compact by default. Add `?expand=customer,items` for the full object. `updated_after` is the sync filter — it catches orders placed before your last poll but changed since. ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | `number` (default `25`, max 100) | No | Page size. | | `starting_after` | `string` | No | Return the page AFTER this object id (the previous page's `next_cursor`). | | `ending_before` | `string` | No | Return the page BEFORE this object id. | | `sort` | `string` (default `-created_at`) | No | Newest first by default. Only `created_at` is sortable — a cursor over a mutable key (like `updated_at`) cannot page reliably. Use `updated_after` to sync changes. One of: `created_at`, `-created_at`. | | `store_id` | `string` | No | Restrict to one store. Required breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | | `status` | `string` | No | Filter to one of the values below. One of: `pending`, `follow_up`, `confirmed`, `processing`, `ready_to_ship`, `in_transit`, `delivered`, `returned`, `failed_delivery`, `cancelled`, `hold`, `spam`. | | `payment_status` | `string` | No | Filter to one of the values below. One of: `unpaid`, `partially_paid`, `paid`, `refunded`. | | `fulfillment_status` | `string` | No | Filter to one of the values below. One of: `unfulfilled`, `partially_fulfilled`, `fulfilled`, `returned`. | | `channel` | `string` | No | Sales channel SLUG (the stable per-store key), not its id. | | `created_after` | `date-time` | No | ISO-8601 timestamp. Inclusive lower bound. | | `created_before` | `date-time` | No | ISO-8601 timestamp. Exclusive upper bound. | | `updated_after` | `date-time` | No | Only orders changed since this instant. THE sync filter: an order placed last month and confirmed this morning is exactly the row a `created_after` poll would miss. | | `search` | `string` | No | Match on order number, customer name or customer phone. | | `expand` | `string[]` | No | Include related objects. Allowed: `customer`, `items`. Comma-separated, one level. Omitted by default so a sync walking pages stays cheap. | ### Request **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS "https://api.salafems.com/ext/v1/orders?limit=25&updated_after=2026-08-01T00%3A00%3A00Z&expand=customer%2Citems" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/orders"); Object.entries({ limit: "25", updated_after: "2026-08-01T00:00:00Z", expand: "customer,items", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.salafems.com/ext/v1/orders", params={ "limit": "25", "updated_after": "2026-08-01T00:00:00Z", "expand": "customer,items", }, headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` ### Responses - `200` — A cursor page of orders. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "data": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "number": "ORD-20260811-1042", "source": "api", "channel": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Facebook", "slug": "facebook", "type": "facebook" }, "customer_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "location_id": null, "status": "pending", "payment_status": "unpaid", "fulfillment_status": "unfulfilled", "subtotal": "1250.00", "discount_amount": "1250.00", "shipping_charge": "1250.00", "tax_amount": "1250.00", "total_amount": "1250.00", "paid_amount": "1250.00", "due_amount": "1250.00", "refunded_amount": "1250.00", "shipping_address": null, "coupon": null, "custom_fields": null, "note": null, "delivered_at": "2026-08-11T10:00:00.000Z", "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ], "meta": { "has_more": true, "next_cursor": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001" } } ``` ## Create an order `POST /ext/v1/orders` · Requires scope `orders:write` Runs the SAME order-creation core as the dashboard and storefront: availability-checked stock reservation, coupon engine, totals math and plan-quota metering are all inherited, never re-implemented. Lines reference variants by id or SKU; the customer is an existing id or a find-or-create by phone. Price overrides are honored — this is a trusted-merchant surface. Company-scoped keys must send `store_id`. ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | Yes | REQUIRED. A unique value per logical request (a UUID is ideal); reuse it verbatim when retrying. A retry replays the original response with `Idempotent-Replayed: true` instead of running twice. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `channel` | `string` | No | A sales-channel SLUG for attribution. Defaults to the store's `manual` channel (Decision Q11 — tagging is optional, never forced). | | `location_id` | `uuid` | No | Location (warehouse) to allocate stock from. Defaults to the company's default location — the admin-desk rule. | | `customer_id` | `uuid` | No | An existing customer. Provide this OR `customer`. | | `customer` | `CreateOrderCustomerDto` | No | Find-or-create by phone. Provide this OR `customer_id`. | | `items` | `CreateOrderLineDto[]` | Yes | — | | `shipping_address` | `object` | No | Free-form shipping address object, stored verbatim. | | `shipping_charge` | `number` | No | Delivery charge as YOUR system computed it (trusted surface). A free-shipping coupon still zeroes it. Default 0. | | `tax_amount` | `number` | No | — | | `coupon_code` | `string` | No | Priced and redeemed by the same engine as every other surface; an unusable code fails the order rather than mispricing it. | | `note` | `string` | No | — | | `payment` | `CreateOrderPaymentDto` | No | An upfront settled payment (like the admin desk's "paid amount"). Omit for an unpaid order; record later payments via `POST /v1/orders/{id}/payments`. | ### `customer` fields | Name | Type | Required | Description | | --- | --- | --- | --- | | `phone` | `string` | Yes | Bangladeshi mobile, any accepted spelling — stored canonical. THE find-or-create match key. | | `full_name` | `string` | No | Required when the phone matches no existing customer (a new record needs a name); ignored when one exists — a sale never silently renames a customer. | | `email` | `string` | No | — | ### `items[]` fields | Name | Type | Required | Description | | --- | --- | --- | --- | | `variant_id` | `uuid` | No | The variant to sell. Provide this OR `sku`. | | `sku` | `string` | No | Resolve the variant by SKU instead of id. | | `quantity` | `number` | Yes | — | | `unit_price` | `number` | No | Override the regular unit price (trusted surface). Omitted = the variant's current catalog price — the admin-desk rule, verbatim. | | `discount_price` | `number` | No | Override the discounted unit price. Only honored when it is a REAL discount (above 0, below the regular price) — same rule as the desk. | ### `payment` fields | Name | Type | Required | Description | | --- | --- | --- | --- | | `payment_method_id` | `uuid` | No | One of the store's payment methods (must be active). | | `amount` | `number` | Yes | — | | `transaction_id` | `string` | No | — | ### Request **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS -X POST "https://api.salafems.com/ext/v1/orders" \ -H "Authorization: Bearer $SALAF_API_KEY" \ -H "Idempotency-Key: e7a1…-your-uuid" \ -H "Content-Type: application/json" \ -d '{ "channel": "facebook", "customer": { "phone": "01712345678", "full_name": "Rafiqul Islam" }, "items": [ { "sku": "TSHIRT-RED-M", "quantity": 2 } ], "shipping_charge": 120, "note": "Deliver after 6pm." }' ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/orders"); const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, "Idempotency-Key": "e7a1…-your-uuid", "Content-Type": "application/json", }, body: JSON.stringify({ "channel": "facebook", "customer": { "phone": "01712345678", "full_name": "Rafiqul Islam" }, "items": [ { "sku": "TSHIRT-RED-M", "quantity": 2 } ], "shipping_charge": 120, "note": "Deliver after 6pm." }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "channel": "facebook", "customer": { "phone": "01712345678", "full_name": "Rafiqul Islam", }, "items": [ { "sku": "TSHIRT-RED-M", "quantity": 2, }, ], "shipping_charge": 120, "note": "Deliver after 6pm.", } response = requests.post( "https://api.salafems.com/ext/v1/orders", json=payload, headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", "Idempotency-Key": "e7a1…-your-uuid", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 'facebook', 'customer' => [ 'phone' => '01712345678', 'full_name' => 'Rafiqul Islam', ], 'items' => [ [ 'sku' => 'TSHIRT-RED-M', 'quantity' => 2, ], ], 'shipping_charge' => 120, 'note' => 'Deliver after 6pm.', ]); $ch = curl_init('https://api.salafems.com/ext/v1/orders'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: e7a1…-your-uuid', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `201` — The created order, in the same shape as `GET /v1/orders/{id}` with customer and items expanded. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — Missing scope, or the plan order quota is exhausted (`plan_limit_reached`). - `409` — A request with this idempotency key is still in flight (`IDEMPOTENCY_IN_FLIGHT`). - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "number": "ORD-20260811-1042", "source": "api", "channel": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Facebook", "slug": "facebook", "type": "facebook" }, "customer_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "location_id": null, "status": "pending", "payment_status": "unpaid", "fulfillment_status": "unfulfilled", "subtotal": "1250.00", "discount_amount": "1250.00", "shipping_charge": "1250.00", "tax_amount": "1250.00", "total_amount": "1250.00", "paid_amount": "1250.00", "due_amount": "1250.00", "refunded_amount": "1250.00", "shipping_address": null, "coupon": null, "custom_fields": null, "note": null, "delivered_at": "2026-08-11T10:00:00.000Z", "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z", "items": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "variant_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_name": "Classic Cotton T-Shirt", "variant_name": "Red / M", "sku": "TSHIRT-RED-M", "quantity": 2, "unit_price": "1250.00", "discount_price": "1250.00", "subtotal": "1250.00" } ], "customer": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "full_name": "Rafiqul Islam", "email": null, "phone": "01712345678", "type": "manual", "status": "active" } } ``` ## Get an order by id `GET /ext/v1/orders/{id}` · Requires scope `orders:read` Add `?expand=customer,items` for the related objects. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The order id. | ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `expand` | `string[]` | No | — | | `store_id` | `string` | No | Restrict the lookup to one store (company-scoped keys). | ### Request **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS "https://api.salafems.com/ext/v1/orders/REPLACE_WITH_ID?expand=customer%2Citems" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/orders/REPLACE_WITH_ID"); Object.entries({ expand: "customer,items", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.salafems.com/ext/v1/orders/REPLACE_WITH_ID", params={ "expand": "customer,items", }, headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The order. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Order not found. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "number": "ORD-20260811-1042", "source": "api", "channel": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Facebook", "slug": "facebook", "type": "facebook" }, "customer_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "location_id": null, "status": "pending", "payment_status": "unpaid", "fulfillment_status": "unfulfilled", "subtotal": "1250.00", "discount_amount": "1250.00", "shipping_charge": "1250.00", "tax_amount": "1250.00", "total_amount": "1250.00", "paid_amount": "1250.00", "due_amount": "1250.00", "refunded_amount": "1250.00", "shipping_address": null, "coupon": null, "custom_fields": null, "note": null, "delivered_at": "2026-08-11T10:00:00.000Z", "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z", "items": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "variant_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_name": "Classic Cotton T-Shirt", "variant_name": "Red / M", "sku": "TSHIRT-RED-M", "quantity": 2, "unit_price": "1250.00", "discount_price": "1250.00", "subtotal": "1250.00" } ], "customer": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "full_name": "Rafiqul Islam", "email": null, "phone": "01712345678", "type": "manual", "status": "active" } } ``` ## Cancel an order `POST /ext/v1/orders/{id}/cancel` · Requires scope `orders:write` Sugar for the `cancelled` transition: same map, same stock unwind (release an open reservation, reverse a committed sale). ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The order id. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `note` | `string` | No | Recorded on the order's timeline. | ### Request **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS -X POST "https://api.salafems.com/ext/v1/orders/REPLACE_WITH_ID/cancel" \ -H "Authorization: Bearer $SALAF_API_KEY" \ -H "Idempotency-Key: e7a1…-your-uuid" \ -H "Content-Type: application/json" \ -d '{ "note": "Customer changed their mind." }' ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/orders/REPLACE_WITH_ID/cancel"); const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, "Idempotency-Key": "e7a1…-your-uuid", "Content-Type": "application/json", }, body: JSON.stringify({ "note": "Customer changed their mind." }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "note": "Customer changed their mind.", } response = requests.post( "https://api.salafems.com/ext/v1/orders/REPLACE_WITH_ID/cancel", json=payload, headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", "Idempotency-Key": "e7a1…-your-uuid", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 'Customer changed their mind.', ]); $ch = curl_init('https://api.salafems.com/ext/v1/orders/REPLACE_WITH_ID/cancel'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: e7a1…-your-uuid', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The cancelled order (customer and items expanded). - `400` — The order cannot be cancelled from its current status (`INVALID_STATUS_TRANSITION`). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Order not found. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "number": "ORD-20260811-1042", "source": "api", "channel": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Facebook", "slug": "facebook", "type": "facebook" }, "customer_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "location_id": null, "status": "pending", "payment_status": "unpaid", "fulfillment_status": "unfulfilled", "subtotal": "1250.00", "discount_amount": "1250.00", "shipping_charge": "1250.00", "tax_amount": "1250.00", "total_amount": "1250.00", "paid_amount": "1250.00", "due_amount": "1250.00", "refunded_amount": "1250.00", "shipping_address": null, "coupon": null, "custom_fields": null, "note": null, "delivered_at": "2026-08-11T10:00:00.000Z", "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z", "items": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "variant_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_name": "Classic Cotton T-Shirt", "variant_name": "Red / M", "sku": "TSHIRT-RED-M", "quantity": 2, "unit_price": "1250.00", "discount_price": "1250.00", "subtotal": "1250.00" } ], "customer": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "full_name": "Rafiqul Islam", "email": null, "phone": "01712345678", "type": "manual", "status": "active" } } ``` ## List an order's payments `GET /ext/v1/orders/{id}/payments` · Requires scope `payments:read` Every settled, pending and refunded row against the order, oldest first. Refunds are negative amounts with status `refunded`. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The order id. | ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Restrict the lookup to one store. Breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | ### Request **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS "https://api.salafems.com/ext/v1/orders/REPLACE_WITH_ID/payments" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/orders/REPLACE_WITH_ID/payments"); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.salafems.com/ext/v1/orders/REPLACE_WITH_ID/payments", headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows = page["data"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; ``` ### Responses - `200` — The order's payment rows. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Order not found. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "data": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "order_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "amount": "1250.00", "status": "paid", "transaction_id": "TRX9F3K2", "payment_method": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "bKash", "type": "mobile_banking" }, "paid_at": "2026-08-11T10:00:00.000Z", "created_at": "2026-08-11T10:00:00.000Z" } ] } ``` ## Record a payment against an order `POST /ext/v1/orders/{id}/payments` · Requires scope `payments:write` The staff path verbatim: the amount is capped at the order's NET due (refunds counted), the paid/due rollup recomputes under a row lock, and the paid-in-full events fire exactly once. A provider transaction id is unique per store — recording the same TrxID twice is a 409. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The order id. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | Yes | REQUIRED. A unique value per logical request (a UUID is ideal); reuse it verbatim when retrying. A retry replays the original response with `Idempotent-Replayed: true` instead of running twice. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `amount` | `number` | Yes | — | | `payment_method_id` | `uuid` | No | One of the store's payment methods (must be active). | | `transaction_id` | `string` | No | Provider transaction id. Unique per store — recording the same TrxID twice is a 409. | ### Request **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS -X POST "https://api.salafems.com/ext/v1/orders/REPLACE_WITH_ID/payments" \ -H "Authorization: Bearer $SALAF_API_KEY" \ -H "Idempotency-Key: e7a1…-your-uuid" \ -H "Content-Type: application/json" \ -d '{ "amount": 500, "transaction_id": "TRX9F3K2" }' ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/orders/REPLACE_WITH_ID/payments"); const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, "Idempotency-Key": "e7a1…-your-uuid", "Content-Type": "application/json", }, body: JSON.stringify({ "amount": 500, "transaction_id": "TRX9F3K2" }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "amount": 500, "transaction_id": "TRX9F3K2", } response = requests.post( "https://api.salafems.com/ext/v1/orders/REPLACE_WITH_ID/payments", json=payload, headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", "Idempotency-Key": "e7a1…-your-uuid", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 500, 'transaction_id' => 'TRX9F3K2', ]); $ch = curl_init('https://api.salafems.com/ext/v1/orders/REPLACE_WITH_ID/payments'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: e7a1…-your-uuid', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `201` — The updated order (customer and items expanded). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Order not found. - `409` — Duplicate transaction id, or an idempotency key still in flight. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "number": "ORD-20260811-1042", "source": "api", "channel": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Facebook", "slug": "facebook", "type": "facebook" }, "customer_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "location_id": null, "status": "pending", "payment_status": "unpaid", "fulfillment_status": "unfulfilled", "subtotal": "1250.00", "discount_amount": "1250.00", "shipping_charge": "1250.00", "tax_amount": "1250.00", "total_amount": "1250.00", "paid_amount": "1250.00", "due_amount": "1250.00", "refunded_amount": "1250.00", "shipping_address": null, "coupon": null, "custom_fields": null, "note": null, "delivered_at": "2026-08-11T10:00:00.000Z", "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z", "items": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "variant_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_name": "Classic Cotton T-Shirt", "variant_name": "Red / M", "sku": "TSHIRT-RED-M", "quantity": 2, "unit_price": "1250.00", "discount_price": "1250.00", "subtotal": "1250.00" } ], "customer": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "full_name": "Rafiqul Islam", "email": null, "phone": "01712345678", "type": "manual", "status": "active" } } ``` ## Change an order status `PATCH /ext/v1/orders/{id}/status` · Requires scope `orders:write` Validated against the SAME strict transition map staff use — an illegal move is refused with `INVALID_STATUS_TRANSITION`. Stock side effects (commit on delivery, release/reverse on cancel/return) are the staff path's, inherited. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The order id. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `status` | `string` | Yes | — One of: `pending`, `follow_up`, `confirmed`, `processing`, `ready_to_ship`, `in_transit`, `delivered`, `returned`, `failed_delivery`, `cancelled`, `hold`, `spam`. | | `note` | `string` | No | Recorded on the order's timeline. | ### Request **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS -X PATCH "https://api.salafems.com/ext/v1/orders/REPLACE_WITH_ID/status" \ -H "Authorization: Bearer $SALAF_API_KEY" \ -H "Idempotency-Key: e7a1…-your-uuid" \ -H "Content-Type: application/json" \ -d '{ "status": "confirmed", "note": "Confirmed over the phone." }' ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/orders/REPLACE_WITH_ID/status"); const response = await fetch(url, { method: "PATCH", headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, "Idempotency-Key": "e7a1…-your-uuid", "Content-Type": "application/json", }, body: JSON.stringify({ "status": "confirmed", "note": "Confirmed over the phone." }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "status": "confirmed", "note": "Confirmed over the phone.", } response = requests.patch( "https://api.salafems.com/ext/v1/orders/REPLACE_WITH_ID/status", json=payload, headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", "Idempotency-Key": "e7a1…-your-uuid", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 'confirmed', 'note' => 'Confirmed over the phone.', ]); $ch = curl_init('https://api.salafems.com/ext/v1/orders/REPLACE_WITH_ID/status'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: e7a1…-your-uuid', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The updated order (customer and items expanded). - `400` — The transition map forbids this move (`INVALID_STATUS_TRANSITION`). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Order not found. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "number": "ORD-20260811-1042", "source": "api", "channel": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Facebook", "slug": "facebook", "type": "facebook" }, "customer_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "location_id": null, "status": "pending", "payment_status": "unpaid", "fulfillment_status": "unfulfilled", "subtotal": "1250.00", "discount_amount": "1250.00", "shipping_charge": "1250.00", "tax_amount": "1250.00", "total_amount": "1250.00", "paid_amount": "1250.00", "due_amount": "1250.00", "refunded_amount": "1250.00", "shipping_address": null, "coupon": null, "custom_fields": null, "note": null, "delivered_at": "2026-08-11T10:00:00.000Z", "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z", "items": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "variant_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_name": "Classic Cotton T-Shirt", "variant_name": "Red / M", "sku": "TSHIRT-RED-M", "quantity": 2, "unit_price": "1250.00", "discount_price": "1250.00", "subtotal": "1250.00" } ], "customer": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "full_name": "Rafiqul Islam", "email": null, "phone": "01712345678", "type": "manual", "status": "active" } } ``` --- # Customers Part of the [Salaf Commerce API reference](https://www.salafems.com/developers/reference.md). Paths below are shown exactly as the server routes them — prefix them with `https://api.salafems.com`. Authenticate every request with `Authorization: Bearer salaf_sk_YOUR_API_KEY`; each operation names the scope its key must hold. Every failure uses the one error envelope — see [Errors](https://www.salafems.com/developers/errors.md). ## List customers `GET /ext/v1/customers` · Requires scope `customers:read` Addresses are on the detail read only — a page of 100 customers should not carry every address any of them ever saved. ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | `number` (default `25`, max 100) | No | Page size. | | `starting_after` | `string` | No | Return the page AFTER this object id (the previous page's `next_cursor`). | | `ending_before` | `string` | No | Return the page BEFORE this object id. | | `sort` | `string` (default `-created_at`) | No | Newest first by default. Only `created_at` is sortable — a cursor over a mutable key (like `updated_at`) cannot page reliably. Use `updated_after` to sync changes. One of: `created_at`, `-created_at`. | | `store_id` | `string` | No | Restrict to one store. Required breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | ### Request **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS "https://api.salafems.com/ext/v1/customers?limit=25" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/customers"); Object.entries({ limit: "25", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.salafems.com/ext/v1/customers", params={ "limit": "25", }, headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` ### Responses - `200` — A cursor page of customers. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "data": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "full_name": "Rafiqul Islam", "email": null, "phone": "01712345678", "status": "active", "type": "manual", "gender": null, "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ], "meta": { "has_more": true, "next_cursor": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001" } } ``` ## Create a customer `POST /ext/v1/customers` · Requires scope `customers:write` Phones are canonicalized (`+880…` spellings collapse to `01…`) and unique per store, as is email — a duplicate is a 409. API-created customers are always type `manual`. ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `full_name` | `string` | Yes | — | | `phone` | `string` | No | Bangladeshi mobile, any accepted spelling — stored canonical (`01712345678`). Unique per store. | | `email` | `string` | No | — | | `status` | `string` (default `active`) | No | — One of: `active`, `inactive`, `blocked`. | | `gender` | `string` | No | — One of: `male`, `female`. | ### Request **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS -X POST "https://api.salafems.com/ext/v1/customers" \ -H "Authorization: Bearer $SALAF_API_KEY" \ -H "Idempotency-Key: e7a1…-your-uuid" \ -H "Content-Type: application/json" \ -d '{ "full_name": "Rafiqul Islam", "phone": "01712345678" }' ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/customers"); const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, "Idempotency-Key": "e7a1…-your-uuid", "Content-Type": "application/json", }, body: JSON.stringify({ "full_name": "Rafiqul Islam", "phone": "01712345678" }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "full_name": "Rafiqul Islam", "phone": "01712345678", } response = requests.post( "https://api.salafems.com/ext/v1/customers", json=payload, headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", "Idempotency-Key": "e7a1…-your-uuid", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 'Rafiqul Islam', 'phone' => '01712345678', ]); $ch = curl_init('https://api.salafems.com/ext/v1/customers'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: e7a1…-your-uuid', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `201` — The created customer, addresses included. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `409` — A customer with this phone or email already exists. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "full_name": "Rafiqul Islam", "email": null, "phone": "01712345678", "status": "active", "type": "manual", "gender": null, "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z", "addresses": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "label": "Home", "recipient_name": "Rafiqul Islam", "recipient_phone": "01712345678", "address_line": "House 12, Road 5", "city": "Dhaka", "area": "Dhanmondi", "postal_code": "1209", "is_default": true, "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ] } ``` ## Get a customer by id, with their addresses `GET /ext/v1/customers/{id}` · Requires scope `customers:read` The customer plus every saved address, default address first. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The customer id. | ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Restrict the lookup to one store. Breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | ### Request **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS "https://api.salafems.com/ext/v1/customers/REPLACE_WITH_ID" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/customers/REPLACE_WITH_ID"); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.salafems.com/ext/v1/customers/REPLACE_WITH_ID", headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The customer. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Customer not found. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "full_name": "Rafiqul Islam", "email": null, "phone": "01712345678", "status": "active", "type": "manual", "gender": null, "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z", "addresses": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "label": "Home", "recipient_name": "Rafiqul Islam", "recipient_phone": "01712345678", "address_line": "House 12, Road 5", "city": "Dhaka", "area": "Dhanmondi", "postal_code": "1209", "is_default": true, "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ] } ``` ## Update a customer `PATCH /ext/v1/customers/{id}` · Requires scope `customers:write` Same rules as creation (canonical phone, per-store uniqueness). The seeded walk-in system record cannot be edited through any surface, this one included. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The customer id. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `full_name` | `string` | No | — | | `phone` | `string` | No | — | | `email` | `string` | No | — | | `status` | `string` | No | — One of: `active`, `inactive`, `blocked`. | | `gender` | `string` | No | — One of: `male`, `female`. | ### Request **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS -X PATCH "https://api.salafems.com/ext/v1/customers/REPLACE_WITH_ID" \ -H "Authorization: Bearer $SALAF_API_KEY" \ -H "Idempotency-Key: e7a1…-your-uuid" \ -H "Content-Type: application/json" \ -d '{ "email": "rafiq@example.com" }' ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/customers/REPLACE_WITH_ID"); const response = await fetch(url, { method: "PATCH", headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, "Idempotency-Key": "e7a1…-your-uuid", "Content-Type": "application/json", }, body: JSON.stringify({ "email": "rafiq@example.com" }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "email": "rafiq@example.com", } response = requests.patch( "https://api.salafems.com/ext/v1/customers/REPLACE_WITH_ID", json=payload, headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", "Idempotency-Key": "e7a1…-your-uuid", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 'rafiq@example.com', ]); $ch = curl_init('https://api.salafems.com/ext/v1/customers/REPLACE_WITH_ID'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: e7a1…-your-uuid', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The updated customer, addresses included. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Customer not found. - `409` — A customer with this phone or email already exists. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "full_name": "Rafiqul Islam", "email": null, "phone": "01712345678", "status": "active", "type": "manual", "gender": null, "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z", "addresses": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "label": "Home", "recipient_name": "Rafiqul Islam", "recipient_phone": "01712345678", "address_line": "House 12, Road 5", "city": "Dhaka", "area": "Dhanmondi", "postal_code": "1209", "is_default": true, "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ] } ``` --- # Inventory Part of the [Salaf Commerce API reference](https://www.salafems.com/developers/reference.md). Paths below are shown exactly as the server routes them — prefix them with `https://api.salafems.com`. Authenticate every request with `Authorization: Bearer salaf_sk_YOUR_API_KEY`; each operation names the scope its key must hold. Every failure uses the one error envelope — see [Errors](https://www.salafems.com/developers/errors.md). ## Adjust stock at a location `POST /ext/v1/inventory/adjustments` · Requires scope `inventory:write` The staff adjustment path verbatim: row-locked, ledgered, activity-logged. A `reason` is always required here; on-hand can never go below zero or below the reserved quantity; `damage` must remove stock; `purchase` requires a `unit_cost` and folds into the moving-average cost. ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | Yes | REQUIRED. A unique value per logical request (a UUID is ideal); reuse it verbatim when retrying. A retry replays the original response with `Idempotent-Replayed: true` instead of running twice. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `location_id` | `uuid` | Yes | Location (warehouse) to adjust. | | `variant_id` | `uuid` | Yes | The variant being adjusted. | | `quantity_change` | `number` | Yes | Signed change to on-hand. Positive adds stock, negative removes; never zero. Cannot take on-hand below zero or below reservations — the same rules staff adjustments obey. | | `type` | `string` (default `adjustment`) | No | Ledger category. `purchase` adds stock at a required `unit_cost`; `damage` must remove stock; default `adjustment`. One of: `purchase`, `return`, `adjustment`, `damage`. | | `reason` | `string` | Yes | Stored on the ledger entry. Required. | | `unit_cost` | `number` | No | Per-unit cost of the moved units. REQUIRED for `purchase` (stock cannot enter at no cost); otherwise the variant's current cost values the movement. Write-only — cost never appears in any response (Q6). | | `low_stock_threshold` | `number` | No | Optionally update the low-stock threshold for this level. | ### Request **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS -X POST "https://api.salafems.com/ext/v1/inventory/adjustments" \ -H "Authorization: Bearer $SALAF_API_KEY" \ -H "Idempotency-Key: e7a1…-your-uuid" \ -H "Content-Type: application/json" \ -d '{ "location_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "variant_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "quantity_change": -5, "reason": "Cycle count correction — shelf B4." }' ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/inventory/adjustments"); const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, "Idempotency-Key": "e7a1…-your-uuid", "Content-Type": "application/json", }, body: JSON.stringify({ "location_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "variant_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "quantity_change": -5, "reason": "Cycle count correction — shelf B4." }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "location_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "variant_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "quantity_change": -5, "reason": "Cycle count correction — shelf B4.", } response = requests.post( "https://api.salafems.com/ext/v1/inventory/adjustments", json=payload, headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", "Idempotency-Key": "e7a1…-your-uuid", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php '5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001', 'variant_id' => '5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001', 'quantity_change' => -5, 'reason' => 'Cycle count correction — shelf B4.', ]); $ch = curl_init('https://api.salafems.com/ext/v1/inventory/adjustments'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: e7a1…-your-uuid', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `201` — The adjusted stock level, in the same shape as `GET /v1/inventory/levels` rows. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Unknown location or variant. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "location_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "variant_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "sku": "TSHIRT-RED-M", "on_hand": 10, "reserved": 7, "available": 3, "low_stock_threshold": 5, "is_low_stock": false, "status": "active", "location": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Main Warehouse", "code": "WH-01" }, "variant": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "sku": "TSHIRT-RED-M", "barcode": null, "name": "Red / M", "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001" }, "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ``` ## List stock levels (one row per variant per location) `GET /ext/v1/inventory/levels` · Requires scope `inventory:read` Each row carries `on_hand`, `reserved` and the `available` difference — sell against `available`, not `on_hand`. ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | `number` (default `25`, max 100) | No | Page size. | | `starting_after` | `string` | No | Return the page AFTER this object id (the previous page's `next_cursor`). | | `ending_before` | `string` | No | Return the page BEFORE this object id. | | `sort` | `string` (default `-created_at`) | No | Newest first by default. Only `created_at` is sortable — a cursor over a mutable key (like `updated_at`) cannot page reliably. Use `updated_after` to sync changes. One of: `created_at`, `-created_at`. | | `store_id` | `string` | No | Restrict to one store. Required breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | | `warehouse_id` | `string` | No | Only levels at this location. | | `variant_id` | `string` | No | Only levels for this variant. | | `low_stock` | `boolean` | No | Only levels at or below the variant's low-stock threshold — the reorder query, answered server-side so an integrator does not page the whole catalog to find twelve rows. | ### Request **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS "https://api.salafems.com/ext/v1/inventory/levels?limit=25" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/inventory/levels"); Object.entries({ limit: "25", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.salafems.com/ext/v1/inventory/levels", params={ "limit": "25", }, headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` ### Responses - `200` — A cursor page of stock levels. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "data": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "location_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "variant_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "sku": "TSHIRT-RED-M", "on_hand": 10, "reserved": 7, "available": 3, "low_stock_threshold": 5, "is_low_stock": false, "status": "active", "location": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Main Warehouse", "code": "WH-01" }, "variant": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "sku": "TSHIRT-RED-M", "barcode": null, "name": "Red / M", "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001" }, "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ], "meta": { "has_more": true, "next_cursor": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001" } } ``` ## List stock movements (the append-only ledger behind the levels) `GET /ext/v1/inventory/movements` · Requires scope `inventory:read` Every quantity change with before/after readings — reservations, sales, adjustments, transfers. Costs never appear here. ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | `number` (default `25`, max 100) | No | Page size. | | `starting_after` | `string` | No | Return the page AFTER this object id (the previous page's `next_cursor`). | | `ending_before` | `string` | No | Return the page BEFORE this object id. | | `sort` | `string` (default `-created_at`) | No | Newest first by default. Only `created_at` is sortable — a cursor over a mutable key (like `updated_at`) cannot page reliably. Use `updated_after` to sync changes. One of: `created_at`, `-created_at`. | | `store_id` | `string` | No | Restrict to one store. Required breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | | `warehouse_id` | `string` | No | Only movements at this location. | | `variant_id` | `string` | No | Only movements for this variant. | ### Request **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS "https://api.salafems.com/ext/v1/inventory/movements?limit=25" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/inventory/movements"); Object.entries({ limit: "25", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.salafems.com/ext/v1/inventory/movements", params={ "limit": "25", }, headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` ### Responses - `200` — A cursor page of movements. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "data": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "location_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "variant_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "type": "adjustment", "quantity": -2, "quantity_before": 12, "quantity_after": 10, "reference_type": "manual", "reference_id": null, "note": "Damaged in transit", "created_at": "2026-08-11T10:00:00.000Z" } ], "meta": { "has_more": true, "next_cursor": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001" } } ``` --- # Locations Part of the [Salaf Commerce API reference](https://www.salafems.com/developers/reference.md). Paths below are shown exactly as the server routes them — prefix them with `https://api.salafems.com`. Authenticate every request with `Authorization: Bearer salaf_sk_YOUR_API_KEY`; each operation names the scope its key must hold. Every failure uses the one error envelope — see [Errors](https://www.salafems.com/developers/errors.md). ## List locations `GET /ext/v1/locations` · Requires scope `locations:read` Locations belong to the COMPANY and are shared across its stores, so they carry `company_id` rather than `store_id` and `store_id=` does not narrow them. ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | `number` (default `25`, max 100) | No | Page size. | | `starting_after` | `string` | No | Return the page AFTER this object id (the previous page's `next_cursor`). | | `ending_before` | `string` | No | Return the page BEFORE this object id. | | `sort` | `string` (default `-created_at`) | No | Newest first by default. Only `created_at` is sortable — a cursor over a mutable key (like `updated_at`) cannot page reliably. Use `updated_after` to sync changes. One of: `created_at`, `-created_at`. | | `store_id` | `string` | No | Restrict to one store. Required breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | ### Request **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS "https://api.salafems.com/ext/v1/locations?limit=25" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/locations"); Object.entries({ limit: "25", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.salafems.com/ext/v1/locations", params={ "limit": "25", }, headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` ### Responses - `200` — A cursor page of locations. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "data": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "company_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Main Warehouse", "code": "WH-01", "address": null, "status": "active", "is_default": true, "latitude": "23.8103", "longitude": "90.4125", "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ], "meta": { "has_more": true, "next_cursor": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001" } } ``` ## Get a location by id `GET /ext/v1/locations/{id}` · Requires scope `locations:read` One location (warehouse) record. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The location id. | ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Restrict the lookup to one store. Breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | ### Request **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS "https://api.salafems.com/ext/v1/locations/REPLACE_WITH_ID" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/locations/REPLACE_WITH_ID"); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.salafems.com/ext/v1/locations/REPLACE_WITH_ID", headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The location. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Location not found. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "company_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Main Warehouse", "code": "WH-01", "address": null, "status": "active", "is_default": true, "latitude": "23.8103", "longitude": "90.4125", "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ``` --- # Channels Part of the [Salaf Commerce API reference](https://www.salafems.com/developers/reference.md). Paths below are shown exactly as the server routes them — prefix them with `https://api.salafems.com`. Authenticate every request with `Authorization: Bearer salaf_sk_YOUR_API_KEY`; each operation names the scope its key must hold. Every failure uses the one error envelope — see [Errors](https://www.salafems.com/developers/errors.md). ## List sales channels `GET /ext/v1/channels` · Requires scope `channels:read` The `slug` values here are what `GET /v1/orders?channel=` filters on and what `POST /v1/orders` accepts as `channel`. ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | `number` (default `25`, max 100) | No | Page size. | | `starting_after` | `string` | No | Return the page AFTER this object id (the previous page's `next_cursor`). | | `ending_before` | `string` | No | Return the page BEFORE this object id. | | `sort` | `string` (default `-created_at`) | No | Newest first by default. Only `created_at` is sortable — a cursor over a mutable key (like `updated_at`) cannot page reliably. Use `updated_after` to sync changes. One of: `created_at`, `-created_at`. | | `store_id` | `string` | No | Restrict to one store. Required breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | ### Request **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS "https://api.salafems.com/ext/v1/channels?limit=25" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/channels"); Object.entries({ limit: "25", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.salafems.com/ext/v1/channels", params={ "limit": "25", }, headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` ### Responses - `200` — A cursor page of channels. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "data": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "type": "facebook", "name": "Facebook", "slug": "facebook", "status": "active", "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ], "meta": { "has_more": true, "next_cursor": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001" } } ``` --- # Overview Outbound events: the envelope, the guarantees, the rules. A webhook is a signed `POST` from us to a URL you own, sent when something happens in a store. It carries the same object shape the REST API returns, so an `order.created` webhook and `GET /orders/{id}` can never disagree about what an order looks like — they are produced by the same serializer. Webhooks replace polling for the things you need to know about *quickly*. They do not replace polling entirely: `updated_after` remains the reconciliation path for anything you missed while your receiver was down. ## How an event reaches you ```text your store does something (a sale, a stock move, a status change) │ ├─ in the SAME database transaction ──▶ the event is recorded │ (with a snapshot of the object) ├─ just after the commit ─────────────▶ fan-out to your subscribed endpoints │ (a 30s sweeper re-drives anything missed) └─ per endpoint ──────────────────────▶ signed POST → 2xx, or the retry ladder ``` The first step is the one that matters. The event row is written inside the same transaction as the fact it describes, so "the database committed but the event vanished" cannot happen: they commit together or not at all. Everything after that — the queue, the sweeper, the retries — is about speed, not correctness. ## The payload Every delivery has the same envelope. Only `data` changes between event types. ```json { "id": "evt_01J4X8G9ABCDEFGHJKMNPQRSTV", "type": "order.updated", "api_version": "2026-08", "created_at": "2026-08-11T10:00:00.000Z", "company_id": "e07b4a29-6c13-4d85-b920-3f8e1c65d704", "store_id": "b21c5f04-9d7e-4c11-8f3a-6e0d2b915a77", "data": { "object": { "id": "c9d8e7f6-1a2b-4c3d-8e9f-0a1b2c3d4e5f", "number": "ORD-20260811-1042", "status": "confirmed", "payment_status": "partially_paid", "total_amount": "2310.00" }, "previous": { "status": "pending" } } } ``` | Field | What it is | | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | | `id` | `evt_` + a ULID. Globally unique, sortable by creation time. **This is your de-duplication key.** | | `type` | The event type, from the [catalog](https://www.salafems.com/developers/webhook-events.md). Treat it as an open set. | | `api_version` | The payload version pinned to *your endpoint* when it was created. See [Versioning](https://www.salafems.com/developers/versioning.md). | | `created_at` | When the event was recorded — not when it was delivered. A retry two days later still carries the original. | | `company_id`, `store_id` | Which tenant this happened in. Load-bearing if one endpoint receives several stores. | | `data.object` | The full object, from the same serializer the REST API uses. | | `data.previous` | Only on `*.updated` events, and only the fields that changed. Today that is `status`. | > **Note — Orders always arrive expanded** > > The REST list endpoint makes `items` and `customer` opt-in, because a sync > walking a hundred orders a page does not want them. A webhook is the opposite > situation — one event, delivered once, to a consumer who would otherwise need > a second round trip — so order payloads always carry both. ## What we guarantee, and what we do not | | | | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | **At-least-once delivery** | You will receive every event at least once, and occasionally more than once. A network timeout is indistinguishable from a slow success, so we retry. | | **No ordering guarantee** | Deliveries run in parallel and retries reshuffle everything. `order.updated` can arrive before `order.created`. | | **A snapshot, not a pointer** | `data.object` is the object as it was inside the transaction that produced the event — not the state at delivery time, and not something you must re-fetch. | | **Byte-identical redelivery** | A retry sends exactly the same bytes as the first attempt, so a signature you stored still verifies. | Those three properties add up to one rule, which is the whole of writing a correct receiver: **de-duplicate on `id`, and treat every handler as an upsert.** ## The four rules of a receiver 1. **Verify the signature before you trust the body.** With a constant-time comparison, against the raw bytes. → [Verifying signatures](https://www.salafems.com/developers/webhook-signatures.md) 2. **De-duplicate on `X-Salaf-Event-Id`.** A unique index on that id, and a `DO NOTHING` on conflict, is the entire mechanism. 3. **Upsert from `data.object`.** Never patch field-by-field from an event, and never infer state from arrival order. When in doubt, `GET` the resource — the REST API is always authoritative. 4. **Answer 2xx fast, work asynchronously.** Anything is a success if it is `2xx`; we give you **10 seconds** before we call it a timeout and retry. Queue the work, then answer. > **Warning — A slow 200 costs you a duplicate** > > Ten seconds is generous for "I have it" and deliberately hostile to "let me > do the work first". If your handler talks to a payment provider, rebuilds a > cache, or sends an email inline, one slow dependency turns into a timeout on > our side and a second delivery on yours — of an event you already processed. ## Registering an endpoint Endpoints are created in the dashboard, under **Settings → Developer → Webhooks**. You give a name, an `https://` URL, and the events you want. - **The signing secret is shown once**, at creation, and again only when you rotate it. It starts with `whsec_`. - **Subscribe to specific types, or to `*`.** The wildcard means *every* event, including types added later. It cannot be mixed with an explicit list — `["*", "order.created"]` is refused, because it reads as if the second entry narrowed something, and it does not. - **Five endpoints per store** by default (`max_webhook_endpoints`, raised by plan). > **Note — Webhook endpoints cannot be managed over the API** > > There is no public endpoint for creating or editing them, and the > `webhooks:manage` scope in the catalogue is not grantable. That is > deliberate: a leaked API key must not be able to point a merchant's event > stream at somebody else's server. ### URL requirements Your URL is fetched by our servers, which makes it a [server-side request forgery](https://owasp.org/www-community/attacks/Server_Side_Request_Forgery) target. Four rules follow, and they are enforced both when you save an endpoint and again on **every** delivery, because DNS can change under a name that validated last week: - **`https://` only.** A payload carries customer data and a signature; `http` would send both in the clear. - **No credentials in the URL**, and no privileged port other than `443`. - **Public addresses only.** The hostname is resolved and the actual IP is checked — a name that points at `127.0.0.1` or `169.254.169.254` is refused. - **Redirects are never followed.** A `3xx` is recorded as a failed delivery with the message *"Redirects are not followed. Register the final URL directly."* Register the URL you actually want called. ## Next - [Event catalog](https://www.salafems.com/developers/webhook-events.md) — every type we send. - [Verifying signatures](https://www.salafems.com/developers/webhook-signatures.md) — with test vectors you can check your code against before going live. - [Retries & ordering](https://www.salafems.com/developers/webhook-retries.md) — the ladder, dead deliveries, and the three-day auto-disable. - [Testing](https://www.salafems.com/developers/webhook-testing.md) — `salaf.ping` and a local loop. - [Handle webhooks idempotently](https://www.salafems.com/developers/guide-handle-webhooks.md) — the whole receiver, end to end. --- # Event catalog Every event type we send, and what each one carries. Twenty-four event types, in eight groups. Every one of them maps to a real write in the product — nothing here fires from a place that does not exist, and nothing is emitted "for completeness". Subscribe to the ones you need, or to `*` for everything including types added later. > **Warning — Treat the catalog as an open set** > > Event names are **additive forever**: a new event is a new entry, but an > existing name is never renamed and never changes meaning. A rename would be a > silent outage on your side that no deprecation header could reach. > > The corollary is that your handler must ignore types it does not recognise > rather than throw on them — a `default:` branch that logs and returns. ## Orders One fact, one event. An order rung up on a till, typed in at the desk, placed on a storefront or posted through this API is the same fact — *an order was created* — and all four emit `order.created`. Tell them apart with `data.object.source` (`admin`, `storefront`, `pos`, `channel`, `api`) or `data.object.channel`. | Event | `data.object` | Fires when | | ----------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `order.created` | order | An order was placed on **any** surface. | | `order.updated` | order | The order status changed — whether a human moved it or a courier webhook did. Carries `data.previous.status`. | | `order.paid` | order | The order crossed into fully-paid. **Edge-triggered**: it fires on the transition, not on every subsequent payment. | | `order.payment.created` | order\_payment | A payment row was written against an order. Refunds appear here too, as negative amounts — read `data.object.amount`, do not assume a sign. | | `order.cancelled` | order | The order moved to cancelled and its stock was released. | | `order.fulfilled` | order | The order reached delivered — the point where the sale is finalised and stock is committed. | | `order.refund.created` | order\_payment | A refund was paid out against a return. | ## Returns | Event | `data.object` | Fires when | | ---------------- | ------------- | -------------------------------------------------------------------------------------------------- | | `return.created` | return | A customer return was opened. | | `return.updated` | return | A return changed status (approved, received, completed, rejected). Carries `data.previous.status`. | ## Shipping | Event | `data.object` | Fires when | | ------------------ | ------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `shipment.created` | shipment | A shipment was booked for an order, with or without a courier. | | `shipment.updated` | shipment | A shipment changed status — including the courier-webhook-driven transitions that also move the order. Carries `data.previous.status`. | ## Inventory | Event | `data.object` | Fires when | | ----------------------- | ----------------- | ---------------------------------------------------------------------------------------------- | | `inventory.adjusted` | stock\_movement | A manual stock adjustment was made (correction, damage, initial stock) — always with a reason. | | `inventory.transferred` | stock\_transfer | Stock moved between two locations. One event for the whole transfer, never one per line. | | `inventory.updated` | variant level set | The sellable level for a variant at a location changed, for any reason. | | `inventory.low_stock` | inventory\_level | A variant **crossed** its low-stock threshold at a location. | > **Note — Two inventory events that behave unusually — on purpose** > > **`inventory.updated` is coalesced**: at most one event per variant per ten > seconds, so a 500-line stock import does not become 500 events describing > levels that were superseded before the first one arrived. The payload carries > every location for that variant, so it is always internally consistent — read > the level from `data.object`, never a delta. > > **`inventory.low_stock` is edge-triggered**: it fires on the crossing, once. > Staying below the threshold does not re-fire it, and rising back above it > re-arms the trigger. A variant with a threshold of `0` never fires it at all — > that means "not configured", not "always low". ## Catalog | Event | `data.object` | Fires when | | ------------------ | ------------- | ------------------------------------------------------------------------------------- | | `product.created` | product | A product and its variants were created. | | `product.updated` | product | A product was edited. A bulk save emits one event per product, not one for the batch. | | `product.archived` | product | A product was archived and is no longer sellable. | ## Customers | Event | `data.object` | Fires when | | ------------------ | --------------- | ----------------------------------------------------------------------------------------------------------------------- | | `customer.created` | customer | A customer record was created — including the find-or-create that happens at storefront checkout and on `POST /orders`. | | `customer.updated` | customer | A customer record was edited. | | `customer.merged` | customer\_merge | Two customer records were merged. | > **Warning — customer.merged means an id you hold may be stale** > > `data.object` is the **survivor**, and `data.object.merged_from_id` is now a > pointer rather than a record. Repoint anything you hold against it. > > Nothing breaks if you do not: the merged-away id still resolves to the > survivor on every lookup path. But your own reports will double-count a > customer that Salaf now considers one person. ## POS | Event | `data.object` | Fires when | | -------------------- | ------------- | --------------------------------------------------------------------------------- | | `pos.session.opened` | pos\_session | A cashier opened a register with a starting float. | | `pos.session.closed` | pos\_session | A register was counted and closed. Carries the expected/counted/variance summary. | ## System | Event | `data.object` | Fires when | | ------------ | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `salaf.ping` | ping | Sent by the **Send test event** button. It rides the real pipeline — same signing, same retries, same delivery log — so a ping that arrives proves a real event would. See [Testing](https://www.salafems.com/developers/webhook-testing.md). | ## Object shapes Most `data.object` shapes are the ones the REST API already publishes, field for field: | Object | Documented at | | ---------------- | -------------------------------------------------------------------------------------------- | | order | [Orders](https://www.salafems.com/developers/reference/orders.md) | | order\_payment | [Orders → List an order's payments](https://www.salafems.com/developers/reference/orders.md) | | product | [Products](https://www.salafems.com/developers/reference/products.md) | | customer | [Customers](https://www.salafems.com/developers/reference/customers.md) | | inventory\_level | [Inventory](https://www.salafems.com/developers/reference/inventory.md) | | stock\_movement | [Inventory](https://www.salafems.com/developers/reference/inventory.md) | Six shapes are webhook-only, because the v1 REST surface has no endpoint for them yet. They follow the same conventions — `snake_case`, whitelisted fields, money as two-decimal strings, ISO-8601 UTC timestamps — and when a REST endpoint for them lands, it will publish exactly these: **`return`** — `id`, `store_id`, `order_id`, `order_number`, `number`, `status`, `reason`, `location_id`, `refund_amount`, `restock`, `note`, `received_at`, `refunded_at`, `created_at`, `updated_at`, and `items[]` of `{ id, order_item_id, variant_id, quantity, restock }`. **`shipment`** — `id`, `store_id`, `order_id`, `order_number`, `location_id`, `courier` (the slug, or `null` for a self-delivered shipment), `tracking_number`, `external_id` (the courier's own consignment id), `status`, `shipping_charge`, `shipped_at`, `delivered_at`, `returned_at`, `created_at`, `updated_at`. **The `inventory.updated` variant level set** — `variant_id`, `store_id`, `sku`, `total_on_hand`, `total_available` (summed across locations), and `levels[]`, one full inventory-level object per location. Note that this is a *set*, not a single level: a transfer changes two locations at once, and a consumer receiving them separately would briefly believe stock had vanished. The catalog endpoint reports this event's `objectType` as `inventory_level`, which describes the entries in `levels[]` rather than the wrapper. **`stock_transfer`** — `reference_id` (the id the two ledger entries share), `reference_type`, `store_id`, `from_location_id`, `to_location_id`, `note`, `occurred_at`, and `lines[]` of `{ variant_id, quantity }`. **`customer_merge`** — the full survivor customer object, plus `merged_from_id`, `moved` (a count of the rows of each kind that moved across) and `merged_at`. **`pos_session`** — `id`, `store_id`, `register` (`{ id, name, code }`), `number`, `status`, `opened_at`, `opening_float`, `closed_at`, `created_at`, `updated_at`, and `cash_summary` — `null` while the session is open, and otherwise `{ expected_cash, counted_cash, variance, variance_note, needs_approval }`. **`ping`** — `store_id`, `message`, `sent_at`. > **Note — Who closed the till is deliberately absent** > > `pos_session` carries no staff user ids. They are internal principals with no > meaning outside our system, and there is no users API to resolve them > against, so publishing them would be an id you could store and never use. ## Fetching the catalog programmatically The dashboard's event picker, the validation that runs when you save an endpoint, and this page are all fed by one typed constant in the backend. You can read it too, from the dashboard API: ```http GET /api/v1/developer/webhook-endpoints/events ``` It answers with `wildcard`, the current `apiVersion`, and an `events` array of `{ type, group, label, description, objectType, carriesPrevious }`. > **Warning — That is the dashboard API, not the public one** > > This endpoint lives on `/api/v1` and authenticates with a **staff session** > (`developer.read` permission, plus a plan carrying `api_access`) — not with a > `salaf_sk_` API key. It is there so a tool building a subscription UI can > read the same list the dashboard does. Everything it returns is on this page. --- # Verifying signatures The signing scheme, code in three languages, and test vectors. Anyone can `POST` JSON at your endpoint. The signature is what tells you a delivery is ours — verify it before you read the body, not after. ## The scheme HMAC-SHA256 over `"{timestamp}.{rawBody}"`, keyed with your endpoint's signing secret, hex-encoded, and sent in one header: ```http POST /webhooks/salaf HTTP/1.1 Content-Type: application/json X-Salaf-Signature: t=1754899200,v1=dd3c17ef08611a833fd18718fb90d48d278db0f7f1ced1c1812aad359a718de8 X-Salaf-Event: order.created X-Salaf-Event-Id: evt_01J4X8G9ABCDEFGHJKMNPQRSTV ``` | Part | Meaning | | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `t` | The Unix timestamp, in **seconds**, at which the request was signed. | | `v1` | A hex HMAC-SHA256 digest. `v1` names the *scheme*, not the payload version — a future `v2=` would mean a different algorithm, and receivers that only understand `v1` would keep working. | There may be **two `v1=` values**. That is a secret rotation in progress: the old secret keeps co-signing for 24 hours so you can deploy the new one at your own pace. Accept the delivery if *either* matches — see [Rotating a secret](https://www.salafems.com/developers/webhook-rotation.md). Three details carry the whole security argument, and skipping any one of them produces code that looks like it verifies and does not: - **The timestamp is inside the signed string.** Signing the body alone would let anyone who captured one delivery replay it forever with a fresh `t=`, because the header would still verify. Because `t` is signed, changing it invalidates the signature — which is what makes your freshness check meaningful rather than decorative. - **The raw bytes are signed, not a parsed object.** See [the raw-body trap](#the-raw-body-trap). - **The comparison must be constant-time.** `a === b` on a digest leaks, through timing, how many leading characters an attacker guessed right. That turns forging a 64-character hex string from impossible into a few thousand requests. ## Verifying **Node.js** ```javascript import crypto from "node:crypto"; const TOLERANCE_SECONDS = 300; // 5 minutes /** * @param rawBody the EXACT bytes we sent, as a string or Buffer — never a * re-serialised object. */ export function verifySalafSignature(rawBody, header, secret) { if (!header) return false; let timestamp = null; const signatures = []; for (const part of header.split(",")) { const [key, value] = part.split("="); if (key?.trim() === "t") timestamp = Number(value?.trim()); if (key?.trim() === "v1") signatures.push(value?.trim()); } if (!Number.isFinite(timestamp) || signatures.length === 0) return false; // Reject anything captured and replayed later. The timestamp is INSIDE the // signed string, so an attacker cannot simply put a fresh one in the header. const now = Math.floor(Date.now() / 1000); if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false; const expected = crypto .createHmac("sha256", secret) .update(`${timestamp}.${rawBody}`, "utf8") .digest("hex"); // During a secret rotation there are TWO v1= values; either may match. return signatures.some((candidate) => timingSafeEqual(expected, candidate)); } // Never use === on a signature: the early exit leaks, through timing, how // many leading characters an attacker guessed right. function timingSafeEqual(a, b) { if (a.length !== b.length) return false; return crypto.timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")); } ``` **Python** ```python import hashlib import hmac import time TOLERANCE_SECONDS = 300 # 5 minutes def verify_salaf_signature(raw_body: bytes, header: str, secret: str) -> bool: """raw_body is the exact request body — bytes, not a parsed dict.""" if not header: return False timestamp = None signatures = [] for part in header.split(","): key, _, value = part.partition("=") if key.strip() == "t": timestamp = value.strip() elif key.strip() == "v1": signatures.append(value.strip()) if timestamp is None or not signatures: return False # Replay window. The timestamp is signed, so it cannot be swapped. if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS: return False signed_payload = timestamp.encode() + b"." + raw_body expected = hmac.new( secret.encode(), signed_payload, hashlib.sha256 ).hexdigest() # compare_digest, never ==: a plain comparison leaks the match length. # Two v1= values appear during a rotation; either may match. return any(hmac.compare_digest(expected, s) for s in signatures) ``` **PHP** ```php SALAF_TOLERANCE_SECONDS) { return false; } $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); // hash_equals, never ===. Two signatures ride along during a rotation. foreach ($signatures as $candidate) { if (hash_equals($expected, $candidate)) { return true; } } return false; } ``` Then reject the delivery if verification fails. A `400` is the right answer: we will retry it, but no number of retries will make an unsigned request valid, and the failure is visible in the merchant's delivery log where they can act on it. ## The five-minute window Reject any delivery whose `t` is more than **300 seconds** from your own clock, in either direction. We do not enforce this — you do, and the code above shows where. That window is a compromise between two real problems: a captured request should not be replayable tomorrow, and two machines nobody synchronised will disagree by a few seconds. Five minutes is comfortably more than clock skew and comfortably less than useful to an attacker. > **Note — A retried delivery still carries a fresh timestamp** > > Signing happens per attempt, not per event. A delivery retried 24 hours later > is signed at the moment it is sent, so it passes your window check — while > `created_at` in the body still tells you when the event actually happened. ## The raw-body trap This is the single most common way a working integration breaks intermittently, so it is worth stating bluntly: **verify against the exact bytes you received.** Re-serialising JSON produces different bytes on different platforms — key order, unicode escaping, whitespace, number formatting. A receiver that verifies against `JSON.stringify(req.body)` will match on most payloads and fail on the ones containing a Bengali product name or a decimal that round-trips differently. It looks like an intermittent network problem and it is not. Every popular framework parses the body for you by default, so getting the raw bytes takes one deliberate line: **Express** ```javascript import express from "express"; const app = express(); // Option A — keep the parsed body AND capture the raw bytes beside it. app.use( express.json({ verify: (req, _res, buffer) => { req.rawBody = buffer; }, }), ); // Option B — for the webhook route only, do not parse at all. app.post( "/webhooks/salaf", express.raw({ type: "application/json" }), (req, res) => { // req.body is a Buffer here — exactly the bytes we signed. const ok = verifySalafSignature( req.body, req.get("X-Salaf-Signature"), process.env.SALAF_WEBHOOK_SECRET, ); if (!ok) return res.sendStatus(400); const event = JSON.parse(req.body.toString("utf8")); res.sendStatus(200); void handleLater(event); }, ); // What NOT to do: JSON.stringify(req.body). Re-serialising changes key // order, unicode escaping and whitespace, so the digest will differ from // ours on some payloads and match on others — the worst kind of bug. ``` **Django / Flask** ```python # Django — request.body is the raw bytes, before any parsing. from django.http import HttpResponse from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST @csrf_exempt @require_POST def salaf_webhook(request): ok = verify_salaf_signature( request.body, # bytes — do NOT use request.POST or json.dumps() request.headers.get("X-Salaf-Signature"), settings.SALAF_WEBHOOK_SECRET, ) if not ok: return HttpResponse(status=400) event = json.loads(request.body) enqueue_event(event) # queue it; answer immediately return HttpResponse(status=200) # Flask — request.get_data() with cache=True, so calling .json later still # works. request.get_json() alone would give you a dict you cannot verify. @app.post("/webhooks/salaf") def salaf_webhook_flask(): raw = request.get_data(cache=True) if not verify_salaf_signature( raw, request.headers.get("X-Salaf-Signature"), SECRET ): return "", 400 enqueue_event(json.loads(raw)) return "", 200 ``` **Laravel** ```php withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]); class SalafWebhookController extends Controller { public function handle(Request $request) { // getContent() is the raw body. $request->all() and $request->json() // are parsed structures — re-encoding them changes the bytes. $raw = $request->getContent(); $ok = verify_salaf_signature( $raw, $request->header('X-Salaf-Signature'), config('services.salaf.webhook_secret'), ); if (! $ok) { return response()->noContent(400); } $event = json_decode($raw, true); SalafEventJob::dispatch($event); // queue, then answer return response()->noContent(200); } } ``` ## Verify your implementation These vectors are pinned by a test in our own codebase, so they cannot drift away from what the server does. Run your verification code against them before you point it at a live endpoint — if it disagrees here, it will disagree in production, and you will be debugging it against real orders. | | | | ----------------- | ---------------------------------------------------------------------------------- | | **Secret** | `whsec_TestSecretForDocumentationVectorsOnly1` | | **Timestamp** | `1754899200` | | **Raw body** | `{"id":"evt_01J4X8G9ABCDEFGHJKMNPQRSTV","type":"salaf.ping"}` | | **Signed string** | `1754899200.{"id":"evt_01J4X8G9ABCDEFGHJKMNPQRSTV","type":"salaf.ping"}` | | **Expected `v1`** | `dd3c17ef08611a833fd18718fb90d48d278db0f7f1ced1c1812aad359a718de8` | | **Full header** | `t=1754899200,v1=dd3c17ef08611a833fd18718fb90d48d278db0f7f1ced1c1812aad359a718de8` | **Node.js** ```javascript import crypto from "node:crypto"; const secret = "whsec_TestSecretForDocumentationVectorsOnly1"; const timestamp = 1754899200; const body = '{"id":"evt_01J4X8G9ABCDEFGHJKMNPQRSTV","type":"salaf.ping"}'; const signature = crypto .createHmac("sha256", secret) .update(`${timestamp}.${body}`, "utf8") .digest("hex"); console.log(signature); // dd3c17ef08611a833fd18718fb90d48d278db0f7f1ced1c1812aad359a718de8 console.assert( signature === "dd3c17ef08611a833fd18718fb90d48d278db0f7f1ced1c1812aad359a718de8", "Your signing does not match Salaf's. Check that you sign " + "`timestamp + '.' + rawBody` and hex-encode the digest.", ); ``` **Python** ```python import hashlib import hmac secret = "whsec_TestSecretForDocumentationVectorsOnly1" timestamp = "1754899200" body = '{"id":"evt_01J4X8G9ABCDEFGHJKMNPQRSTV","type":"salaf.ping"}' signature = hmac.new( secret.encode(), timestamp.encode() + b"." + body.encode(), hashlib.sha256, ).hexdigest() print(signature) # dd3c17ef08611a833fd18718fb90d48d278db0f7f1ced1c1812aad359a718de8 assert signature == ( "dd3c17ef08611a833fd18718fb90d48d278db0f7f1ced1c1812aad359a718de8" ), "Your signing does not match Salaf's." ``` **PHP** ```php **Warning — Note the body has no spaces** > > The raw body above is exactly 59 bytes with no whitespace between tokens — > which is what we send, and what you must hash. If you retyped it from this > page with pretty-printing, your digest will not match, and that mismatch is > the raw-body trap in miniature. If your digest matches, your signing is correct. The remaining things to check in your own code are the ones the vector cannot exercise: that you compare in constant time, that you enforce the timestamp window, and that you accept either `v1` value when two are present. ## Where the secret lives - **Shown once**, when the endpoint is created, and again only when you rotate it. It starts with `whsec_`. - **Store it like a password** — an environment variable or a secret manager, never in source. The `whsec_` prefix exists so secret scanners can catch it if it does end up in a commit. - **One secret per endpoint.** If you receive events at two URLs, they have two different secrets, and verifying with the wrong one fails exactly as if the request were forged. - **We cannot show it to you again.** It is encrypted rather than hashed (signing needs the plaintext back), but there is no screen and no support process that prints it. If you lose it, rotate. --- # Retries & ordering The retry ladder, dead deliveries, and auto-disable. A delivery succeeds on any `2xx`. Anything else — a `4xx`, a `5xx`, a redirect, a connection failure, or no answer within **10 seconds** — is a failure, and failures are retried on a fixed ladder. ## What counts as success | Response | Outcome | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | | `200`–`299` | **Delivered.** Nothing else is read; the body is kept as a snippet for the log. | | `3xx` | Failed. Redirects are never followed — register the final URL directly. | | `4xx` | Failed, and retried. A `404` from a receiver that has not deployed yet is indistinguishable from a `404` from one that never will. | | `5xx` | Failed, and retried. | | No response in 10s | Failed, and retried. | The body of your response is never interpreted — only the status. The first kilobyte of it is stored in the delivery log so the merchant can see what your server said, with any header-shaped lines (`Set-Cookie:`, `Authorization:`) stripped before storage. ## The retry ladder An initial attempt plus eight retries, spanning roughly 45 hours: | Attempt | Sent | | ------- | -------------------------------------------- | | 1 | Immediately | | 2 | 30 seconds after attempt 1 | | 3 | 2 minutes later | | 4 | 10 minutes later | | 5 | 30 minutes later | | 6 | 2 hours later | | 7 | 6 hours later | | 8 | 12 hours later | | 9 | 24 hours later | | — | if attempt 9 fails, the delivery is **dead** | The shape is deliberate rather than exponential. The first rungs are minutes, because most failures are a deploy or a blip. The last are hours, because the rest are an outage somebody has to wake up for. Pure exponential backoff gives you neither: it is too impatient at the start and absurd at the end. Spanning \~45 hours means a Friday-evening outage is still being retried on Sunday morning. `next_retry_at` on the delivery record always says exactly when the next attempt is due, so you never have to infer your position on the ladder from this table. ## Delivery states | Status | Meaning | | ----------- | --------------------------------------------------------------------------------------- | | `pending` | Created, not yet attempted — or waiting for its next rung. | | `delivered` | A `2xx` was received. Terminal. | | `failed` | An attempt failed and **another is scheduled**. The dashboard shows this as *Retrying*. | | `dead` | The ladder is exhausted. Nothing further happens automatically. | A `dead` delivery is not lost. The event and its exact payload are kept for **30 days**, and the merchant can re-send it from **Settings → Developer → Webhooks → delivery history**. A manual retry restarts the ladder at rung one, so a receiver that has just been fixed does not wait out the 24-hour rung the automatic schedule had reached. > **Note — A disabled endpoint closes its deliveries rather than retrying them** > > If an endpoint is disabled while deliveries are queued for it, those > deliveries are marked `dead` with the reason recorded, instead of > accumulating attempts against a receiver nobody wants contacted. Turning the > endpoint back on does not resurrect them — retry the ones you want from the > log. ## Auto-disable after three days If **every** delivery to an endpoint fails continuously for three days, we turn it off. The endpoint's status becomes `auto_disabled`, and the merchant gets a dashboard notification: > **Webhook endpoint disabled** — *\* failed every delivery for 3 > days and has been turned off. Fix the receiver, then re-enable it under > Settings → Developer → Webhooks. Three things about this are worth planning around: - **It is measured from the first failure in the current failing streak**, not from a failure count. A count cannot tell "broken since Tuesday" apart from "fails its one event a month". A single success resets the clock. - **Re-enabling is manual.** An endpoint that resumed by itself would go straight back to failing, and the notification would become noise the merchant learns to ignore. - **Re-enabling clears the health verdict** — the failure streak resets, so one bad attempt after a fix does not immediately disable it again. While an endpoint is disabled, events are still recorded for the store; they are simply not delivered to that endpoint. Use `updated_after` polling to backfill whatever you missed — that is the reconciliation path webhooks never remove the need for. ## Ordering is not guaranteed It is not "usually in order" or "in order except under load". It is **not guaranteed**, and you should design as if events arrive shuffled. Deliveries run on parallel workers, each subscriber carries its own retry ladder, and a delivery that failed once is by definition behind one that did not. So `order.updated` can arrive before `order.created`; two updates to the same order can arrive in either order. Promising ordering would mean a single serialised queue per store — one slow receiver would then stall every other event for that merchant, and a permanent failure would stall them forever. That is a worse system, honestly described. **What to do instead**, and it is genuinely less work than handling order: - **Upsert, do not patch.** Every payload carries the whole object as of its own transaction, so writing it wholesale is always safe. - **Ignore arrival order; use the data.** `data.object.updated_at` and `created_at` on the envelope tell you when things actually happened. If you keep the newest snapshot per id, out-of-order delivery becomes a non-event. - **`GET` when in doubt.** The REST API is authoritative at the moment you call it. An event tells you *that* something changed; the API tells you what is true now. - **Handle the create you never saw.** If `order.updated` arrives for an id you do not know, create the record from `data.object` rather than dropping the event — the `order.created` you were waiting for may be two retries behind. > **Warning — Duplicates are not an edge case** > > A timeout on our side is indistinguishable from a slow success on yours: you > may have processed the event perfectly and lost the acknowledgement. We > retry, so you get it twice. > > De-duplicating on `X-Salaf-Event-Id` — a unique column and an > insert-or-ignore — is the entire defence, and it is three lines. See > [Handle webhooks idempotently](https://www.salafems.com/developers/guide-handle-webhooks.md). ## Retention | | | | -------------------------------- | --------------------------------------- | | Events and their payloads | **30 days** | | Delivery attempts and their logs | **30 days** (deleted with the endpoint) | After that a delivery cannot be replayed from the dashboard. If your receiver was down for longer than that, reconcile with [`updated_after`](https://www.salafems.com/developers/pagination.md#syncing-changes-with-updated_after) rather than waiting for events that no longer exist. --- # Testing The salaf.ping event and how to develop against it. There is no sandbox tenant and no test-mode key, so the way to prove a receiver works is to send it a real event that changes nothing: `salaf.ping`. ## Send a test event In the dashboard: **Settings → Developer → Webhooks**, open an endpoint, and choose **Send test event**. The ping is not a shortcut. It is recorded in the outbox, fanned out, signed with your endpoint's actual secret, delivered by the same worker, retried on the same ladder, and written to the same delivery log as any other event. A ping that arrives is therefore real evidence that an `order.created` would — and a ping that fails is debuggable with the same detail view, showing the exact payload sent and the response received. ```json { "id": "evt_01J4X8G9ABCDEFGHJKMNPQRSTV", "type": "salaf.ping", "api_version": "2026-08", "created_at": "2026-08-11T10:00:00.000Z", "company_id": "e07b4a29-6c13-4d85-b920-3f8e1c65d704", "store_id": "b21c5f04-9d7e-4c11-8f3a-6e0d2b915a77", "data": { "object": { "store_id": "b21c5f04-9d7e-4c11-8f3a-6e0d2b915a77", "message": "This is a test event from Salaf. If you can read this, your endpoint and signature verification are working.", "sent_at": "2026-08-11T10:00:00.000Z" } } } ``` > **Warning — Subscribe to salaf.ping, or the button refuses** > > The ping goes through the same subscription gate as every other event, so an > endpoint that is not subscribed to `salaf.ping` has nothing to send. You get: > > *"This endpoint is not subscribed to salaf.ping. Add it to the endpoint (or > subscribe to all events) and try again."* > > Add `salaf.ping` to the endpoint's event list — or use the `*` wildcard, > which includes it. It is worth keeping subscribed permanently: it is the only > event you can produce on demand when something looks wrong in production. The endpoint must also be **active**. Sending a test to a disabled or auto-disabled endpoint answers *"Enable the endpoint before sending a test event."* ## Reading the result The delivery history for an endpoint shows every attempt: status, HTTP code, attempt number, duration, next retry, and the response your server sent (first kilobyte). Open one and you also get the exact payload we signed — byte for byte, which is what makes a signature mismatch diagnosable rather than mysterious. The failures worth recognising: | What you see | What it usually is | | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `No response within 10s` | The handler is doing the work inline. Queue it and answer first. | | HTTP `400` with your own error text | Your signature check is rejecting us. Run the [test vectors](https://www.salafems.com/developers/webhook-signatures.md#verify-your-implementation). | | `Redirects are not followed…` | The URL redirects (often `http` → `https`, or a trailing-slash rule). Register the final URL. | | A DNS or address error | The hostname does not resolve publicly. Tunnels and private addresses are refused, deliberately. | | HTTP `404` | The route exists in your framework but not for `POST`, or a CSRF/auth middleware is in front of it. | ## Developing locally Your URL must be public `https` — we resolve the hostname and refuse private addresses, so `localhost`, `127.0.0.1` and anything on a private range cannot be registered. Two approaches that work: 1. **A public tunnel** (ngrok, Cloudflare Tunnel, Tailscale Funnel) pointed at your dev server. Register the tunnel's `https` URL as an endpoint, subscribe it to `*`, and develop against real events. Remember to delete the endpoint when the tunnel dies, or it will fail for three days and auto-disable. 2. **A replayed payload, offline.** Copy the exact payload and the `X-Salaf-Signature` header from a delivery in the log, and post them at your own handler from a test. This is the one that belongs in your test suite: it needs no network, and it catches the raw-body mistakes that only show up on payloads containing non-ASCII text. > **Note — Do not re-sign a captured payload with a new timestamp** > > If you replay a real delivery in a test, the timestamp is old, so your > five-minute window check will reject it — correctly. Test the window by > freezing your clock (or injecting `now`) rather than by loosening the > tolerance. A receiver that accepts a day-old timestamp is a receiver anyone > can replay. ## A checklist before you go live - The endpoint is subscribed to the events you actually handle, plus `salaf.ping`. - Signature verification runs against the **raw body** and rejects on failure. - The five-minute timestamp window is enforced. - Both `v1=` values are accepted, so a [rotation](https://www.salafems.com/developers/webhook-rotation.md) does not take you down. - Events are de-duplicated on `X-Salaf-Event-Id`. - Handlers upsert, and tolerate an update arriving before its create. - The endpoint answers `2xx` in well under 10 seconds and does its work asynchronously. - Unknown event types are ignored rather than throwing. --- # Headers Every header on a delivery, and what to do with it. Every delivery carries the same seven headers. Nothing else is sent, and no header is ever omitted. ```http POST /webhooks/salaf HTTP/1.1 Content-Type: application/json User-Agent: Salaf-Webhooks/1.0 X-Salaf-Signature: t=1754899200,v1=dd3c17ef08611a833fd18718fb90d48d278db0f7f1ced1c1812aad359a718de8 X-Salaf-Event: order.created X-Salaf-Event-Id: evt_01J4X8G9ABCDEFGHJKMNPQRSTV X-Salaf-Delivery: whd_01J4X8GB2CDEFGHJKMNPQRSTVW X-Salaf-Api-Version: 2026-08 ``` | Header | Value | What to do with it | | --------------------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `X-Salaf-Signature` | `t=,v1=[,v1=]` | **Verify it before reading the body.** Two `v1` values mean a rotation is in progress; accept either. See [Verifying signatures](https://www.salafems.com/developers/webhook-signatures.md). | | `X-Salaf-Event` | `order.created` | Route on it if it saves you parsing the body first. It is always identical to `type` in the payload. | | `X-Salaf-Event-Id` | `evt_` + ULID | **Your de-duplication key.** Identical to `id` in the payload. Stable across every retry of the same event. | | `X-Salaf-Delivery` | `whd_` + ULID | This *attempt's* record. Changes between retries, unlike the event id. Quote it to support; never de-duplicate on it. | | `X-Salaf-Api-Version` | `2026-08` | The payload version pinned to your endpoint. Identical to `api_version` in the body. | | `Content-Type` | `application/json` | The body is always JSON, always UTF-8. | | `User-Agent` | `Salaf-Webhooks/1.0` | Useful in access logs. **Not** an authentication signal — anyone can send it. | > **Warning — Event id and delivery id are not the same thing** > > One event fanned out to three endpoints produces three deliveries, and each > retry of any of them keeps its own delivery id. So: > > - **De-duplicate on `X-Salaf-Event-Id`.** It identifies the *fact*, and it is > the same on every retry — which is exactly what makes at-least-once > delivery safe. > - **Log `X-Salaf-Delivery`.** It identifies the *attempt*, and it is what > matches a row in the merchant's delivery history when you need someone to > look at their side. > > A receiver that de-duplicates on the delivery id de-duplicates nothing: every > retry has a fresh one, so every retry runs again. ## What is not sent - **No authentication header.** The signature is the authentication. Do not expect a bearer token, and do not put a secret in your webhook URL's query string — it would end up in logs and proxies. If your receiver needs an extra layer, put it behind mTLS or an allow-list, not a URL secret. - **No custom headers.** There is no way to configure additional headers on an endpoint. - **No compression or chunking.** Bodies are small and sent whole. ## Your response Only the status line is interpreted. `2xx` is success; everything else is a failure that gets [retried](https://www.salafems.com/developers/webhook-retries.md). The first kilobyte of your response body **is** stored, and shown to the merchant in the delivery log — so a plain-text error message from your handler is genuinely useful there. Header-shaped lines (`Set-Cookie:`, `Authorization:`, `X-Api-Key:` and friends) are stripped before storage, in case a framework echoes the request back in a debug response; but the simplest protection is not to echo secrets in an error body in the first place. Response headers you send are not read at all — including `Retry-After`. The retry schedule is ours and is not negotiable per delivery. --- # Rotating a secret The dual-signing window, and what a receiver does during it. Rotating a signing secret replaces it without dropping a single delivery. The old secret keeps co-signing for 24 hours, so you deploy the new one whenever your own release process allows. ## What happens when you rotate In the dashboard: **Settings → Developer → Webhooks**, open the endpoint, and choose **Rotate signing secret**. 1. A new `whsec_` secret is generated and shown to you **once**. 2. The old secret keeps signing alongside it for **24 hours**. 3. Every delivery in that window carries **two** `v1=` values — one from each secret: ```http X-Salaf-Signature: t=1754899200,v1=,v1= ``` 4. When the window closes, the old secret stops signing and deliveries carry one `v1=` again. A receiver that checks the incoming signature against *its* secret and accepts any match therefore keeps working throughout — before the deploy it matches the second value, after the deploy the first. Nothing has to be swapped atomically on two machines at once. > **Warning — This only works if you loop over the v1 values** > > A verifier that reads the *first* `v1=` and stops will break the moment a > rotation starts, because during the window the first value is signed with the > secret you have not deployed yet. > > Every sample on the > [signature page](https://www.salafems.com/developers/webhook-signatures.md#verifying) loops. If yours > does not, fix it before you rotate, not during. ## The rotation you should be doing 1. **Rotate** in the dashboard and copy the new secret. 2. **Deploy it** to your receiver — any time in the next 24 hours. No coordination, no maintenance window. 3. **Confirm** with [a test event](https://www.salafems.com/developers/webhook-testing.md): a `salaf.ping` that verifies with your new secret proves the deploy landed. There is nothing to do at the end of the window. The old secret expires on its own; you never have to come back and turn something off. ## Rotating immediately The dialog offers an **immediate** option, which drops the old secret at once instead of co-signing. Use it for exactly one situation: **the secret leaked.** Anything else — a routine rotation, a policy schedule, a departing contractor whose access you have already removed — should use the window, because immediate rotation means every delivery fails from the moment you click until the moment your new secret is live. Those failures are retried, so nothing is lost, but you have chosen an outage you did not need. > **Danger — A leaked signing secret is a forgery risk, not a data leak** > > The secret does not grant access to anything. What it does is let whoever > holds it produce requests your receiver will believe came from Salaf — > invented orders, fabricated payment events, stock levels that never existed. > > If a secret is exposed, rotate immediately and then check what your receiver > processed in the interim. ## Verifying during a rotation Nothing about verification changes — you still hash `"{timestamp}.{rawBody}"` with the one secret you hold and compare against each `v1=` in constant time. The only requirement is that you compare against **all** of them: **Node.js** ```javascript import crypto from "node:crypto"; const TOLERANCE_SECONDS = 300; // 5 minutes /** * @param rawBody the EXACT bytes we sent, as a string or Buffer — never a * re-serialised object. */ export function verifySalafSignature(rawBody, header, secret) { if (!header) return false; let timestamp = null; const signatures = []; for (const part of header.split(",")) { const [key, value] = part.split("="); if (key?.trim() === "t") timestamp = Number(value?.trim()); if (key?.trim() === "v1") signatures.push(value?.trim()); } if (!Number.isFinite(timestamp) || signatures.length === 0) return false; // Reject anything captured and replayed later. The timestamp is INSIDE the // signed string, so an attacker cannot simply put a fresh one in the header. const now = Math.floor(Date.now() / 1000); if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false; const expected = crypto .createHmac("sha256", secret) .update(`${timestamp}.${rawBody}`, "utf8") .digest("hex"); // During a secret rotation there are TWO v1= values; either may match. return signatures.some((candidate) => timingSafeEqual(expected, candidate)); } // Never use === on a signature: the early exit leaks, through timing, how // many leading characters an attacker guessed right. function timingSafeEqual(a, b) { if (a.length !== b.length) return false; return crypto.timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")); } ``` **Python** ```python import hashlib import hmac import time TOLERANCE_SECONDS = 300 # 5 minutes def verify_salaf_signature(raw_body: bytes, header: str, secret: str) -> bool: """raw_body is the exact request body — bytes, not a parsed dict.""" if not header: return False timestamp = None signatures = [] for part in header.split(","): key, _, value = part.partition("=") if key.strip() == "t": timestamp = value.strip() elif key.strip() == "v1": signatures.append(value.strip()) if timestamp is None or not signatures: return False # Replay window. The timestamp is signed, so it cannot be swapped. if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS: return False signed_payload = timestamp.encode() + b"." + raw_body expected = hmac.new( secret.encode(), signed_payload, hashlib.sha256 ).hexdigest() # compare_digest, never ==: a plain comparison leaks the match length. # Two v1= values appear during a rotation; either may match. return any(hmac.compare_digest(expected, s) for s in signatures) ``` **PHP** ```php SALAF_TOLERANCE_SECONDS) { return false; } $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); // hash_equals, never ===. Two signatures ride along during a rotation. foreach ($signatures as $candidate) { if (hash_equals($expected, $candidate)) { return true; } } return false; } ``` ## What rotation does not change - **`api_version` is pinned at creation and never changes**, not by rotation and not by editing the endpoint. Your payload shape is stable for the life of the endpoint. - **The endpoint's URL, subscriptions and delivery history are untouched.** - **In-flight retries are re-signed at each attempt**, so a delivery that was queued before the rotation and retried after it is signed with whatever secrets are active at the moment it is sent — never with a stale one. - **The endpoint id stays the same**, so anything you have keyed on it is fine. ## If you lose the secret There is no way to display it again — it is stored encrypted so the delivery worker can sign with it, but no screen and no support process prints it back. Rotate, and treat it as an ordinary rotation with the 24-hour window: the old secret you have lost is still the one that works, so nothing breaks while you deploy the new one. --- # Overview End-to-end recipes for the four common integrations. Four end-to-end recipes, each written against endpoints that exist today. Every field name is the real one, and every sample runs. ## The guides | Guide | What it covers | You need | | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | | [Create an order](https://www.salafems.com/developers/guide-create-an-order.md) | Resolving a customer and variants, placing the order idempotently, and reading what comes back. | `orders:write`, `products:read` | | [Sync inventory to an ERP](https://www.salafems.com/developers/guide-sync-inventory.md) | The `updated_after` + cursor pattern, checkpoints that survive a crash, and why offset paging is not offered. | `products:read`, `inventory:read` | | [Handle webhooks idempotently](https://www.salafems.com/developers/guide-handle-webhooks.md) | Verify, de-duplicate, upsert, answer fast — the whole receiver. | A webhook endpoint | | [Build a custom storefront](https://www.salafems.com/developers/guide-custom-storefront.md) | Reading the catalogue, checking availability, taking an order and recording payment. | Read scopes, `orders:write`, `payments:write` | ## Before any of them 1. **Create a key** with only the scopes that guide needs ([Authentication](https://www.salafems.com/developers/authentication.md)). Start with a store key. 2. **Export it** as `SALAF_API_KEY` — every sample in these docs reads it from the environment rather than inlining it. 3. **Point at `/ext/v1`** — `https://api.salafems.com/ext/v1`. The shorter `/v1` alias is not deployed yet. ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS "https://api.salafems.com/ext/v1/products?limit=1" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` If that returns a product, you are ready for any of the four. ## The patterns they share Every integration in this list ends up leaning on the same four things, so they are worth learning once: - **Cursor pagination** — [`limit` + `starting_after` + `meta.has_more`](https://www.salafems.com/developers/pagination.md#walking-every-page). - **Incremental sync** — [`updated_after` with a watermark](https://www.salafems.com/developers/pagination.md#syncing-changes-with-updated_after). - **Idempotent writes** — [one key per logical operation](https://www.salafems.com/developers/idempotency.md#idempotency-key). - **One error shape** — [branch on `code`, log the `request_id`](https://www.salafems.com/developers/errors.md#handling-errors-well). --- # Create an order Customer, variants, idempotency key, and what comes back. Your system took an order — on a Facebook page, in a call centre, through your own checkout — and it needs to exist in Salaf, with stock reserved and the merchant's dashboard showing it. This is that call, start to finish. **You need** a key with `orders:write` (and `products:read` if you resolve variants by id). `orders:write` implies `orders:read`, so you can read the order back with the same key. ## 1. Decide the idempotency key first Before you build the request, generate the key and **store it with your own order record**. That ordering is the whole point. If you generate the key at the moment of the HTTP call, a process restart between attempts produces a new key — and a new order. Generated first and persisted, the same key survives a crash, a queue redelivery, and a manual re-run. ```text your_order.salaf_idempotency_key = "3f1a5c8e-0b2d-4f77-9a13-5e6c7d8f9a10" ``` ## 2. Resolve the line items Lines reference a variant by **`variant_id` or `sku`** — one or the other, per line. If your own system already stores SKUs, use them and skip this step entirely; that is what they are for. If you need to look one up — to check it exists before placing the order, or to read its current price: **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS "https://api.salafems.com/ext/v1/variants/TSHIRT-RED-M" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/variants/TSHIRT-RED-M"); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.salafems.com/ext/v1/variants/TSHIRT-RED-M", headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` `GET /variants/{identifier}` accepts an id, a SKU **or a barcode**, so a barcode scanner's output can be looked up directly. > **Note — Variants are the commercial unit** > > Price, SKU and stock hang off variants, not products. A product is the thing > a shopper browses; a variant is the thing they buy. Every order line > references a variant. ## 3. Resolve the customer Two options, and you must pick exactly one: - **`customer_id`** — an existing customer in this store. - **`customer`** — `{ phone, full_name?, email? }`, which finds or creates. Find-or-create matches on the **canonical Bangladeshi phone number**, so `+8801712345678` and `01712345678` are the same person. `full_name` is required only when the phone matches nobody. > **Warning — A match never updates the existing record** > > If the phone is already known, that customer is used exactly as they are and > the `full_name`/`email` you sent are ignored. A sale is not a CRM edit. Use > `PATCH /customers/{id}` when you actually mean to change someone's details. ## 4. Place the order **cURL** ```bash # The key identifies the ORDER you are placing, not this HTTP attempt. # Generate it once, store it with your own record, and reuse it on retries. IDEMPOTENCY_KEY=$(uuidgen) curl -sS -X POST "https://api.salafems.com/ext/v1/orders" \ -H "Authorization: Bearer $SALAF_API_KEY" \ -H "Idempotency-Key: $IDEMPOTENCY_KEY" \ -H "Content-Type: application/json" \ -d '{ "channel": "facebook", "customer": { "phone": "01712345678", "full_name": "Rafiqul Islam" }, "items": [ { "sku": "TSHIRT-RED-M", "quantity": 2 } ], "shipping_charge": 120, "note": "Deliver after 6pm." }' ``` **JavaScript** ```javascript import { randomUUID } from "node:crypto"; // One key per logical order. Persist it next to your own order record BEFORE // the call, so a retry after a process restart still sends the same one. const idempotencyKey = randomUUID(); const response = await fetch("https://api.salafems.com/ext/v1/orders", { method: "POST", headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, "Idempotency-Key": idempotencyKey, "Content-Type": "application/json", }, body: JSON.stringify({ channel: "facebook", customer: { phone: "01712345678", full_name: "Rafiqul Islam" }, items: [{ sku: "TSHIRT-RED-M", quantity: 2 }], shipping_charge: 120, note: "Deliver after 6pm.", }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } // 201 with the full order: items and customer expanded, totals computed, // stock already reserved. const order = await response.json(); console.log(order.number, order.total_amount, order.payment_status); // true when this was a replay of an earlier identical call. console.log(response.headers.get("Idempotent-Replayed") === "true"); ``` **Python** ```python import os import uuid import requests # One key per logical order — generated where the order is DECIDED, not # inside the retry loop, and stored with your own record. idempotency_key = str(uuid.uuid4()) response = requests.post( "https://api.salafems.com/ext/v1/orders", json={ "channel": "facebook", "customer": {"phone": "01712345678", "full_name": "Rafiqul Islam"}, "items": [{"sku": "TSHIRT-RED-M", "quantity": 2}], "shipping_charge": 120, "note": "Deliver after 6pm.", }, headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", "Idempotency-Key": idempotency_key, }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") order = response.json() print(order["number"], order["total_amount"], order["payment_status"]) print(response.headers.get("Idempotent-Replayed") == "true") ``` **PHP** ```php 'facebook', 'customer' => ['phone' => '01712345678', 'full_name' => 'Rafiqul Islam'], 'items' => [ ['sku' => 'TSHIRT-RED-M', 'quantity' => 2], ], 'shipping_charge' => 120, 'note' => 'Deliver after 6pm.', ]); $ch = curl_init('https://api.salafems.com/ext/v1/orders'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('SALAF_API_KEY'), 'Idempotency-Key: ' . $idempotencyKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } echo $response['number'] . ' ' . $response['total_amount'] . PHP_EOL; ``` The fields that matter, and their defaults: | Field | Required | Notes | | ---------------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `items[]` | Yes | At least one line. `variant_id` **or** `sku`, plus `quantity`. | | `customer_id` / `customer` | Yes, one of | Never both. | | `store_id` | Only for company keys | A store key may repeat its own store, and may not name another. | | `channel` | No | A channel **slug**. Defaults to the store's `manual` channel. | | `location_id` | No | Which warehouse reserves the stock. Defaults to the company default. | | `shipping_charge` | No | Default `0`. **Nothing calculates this for you.** | | `tax_amount` | No | Default `0`. | | `coupon_code` | No | Priced and redeemed by the same engine as every other surface. An unusable code fails the order rather than mispricing it. | | `payment` | No | An upfront settled payment — `{ amount, payment_method_id?, transaction_id? }`. | | `unit_price` / `discount_price` per line | No | Your prices are trusted here. Omitted, the catalogue price is used. | ## 5. Read the response `201` with the **full order** — items and customer expanded, totals computed, stock already reserved: ```json { "id": "c9d8e7f6-1a2b-4c3d-8e9f-0a1b2c3d4e5f", "number": "ORD-20260811-1042", "source": "api", "channel": { "slug": "facebook", "name": "Facebook", "type": "facebook" }, "customer_id": "5b1e7c33-2a94-4f18-8d60-7e3b9c40a2f1", "status": "pending", "payment_status": "unpaid", "fulfillment_status": "unfulfilled", "subtotal": "2500.00", "shipping_charge": "120.00", "total_amount": "2620.00", "paid_amount": "0.00", "due_amount": "2620.00", "items": ["…one object per line…"], "customer": { "full_name": "Rafiqul Islam", "phone": "01712345678" } } ``` **Store `id` and `number` against your own record.** `id` is what every later call takes; `number` is what the merchant and the customer will say out loud. Two things to note in that body: `source` is `api` and cannot be set — it is how the merchant tells API orders from POS ones — and money is a **string** with two decimals, because JSON numbers cannot hold every `Decimal(12,2)` exactly. ## 6. Handle the failures that matter | Code | HTTP | What to do | | ----------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------------------- | | `IDEMPOTENCY_IN_FLIGHT` | 409 | Wait a couple of seconds and retry **with the same key**. Never re-key. | | `IDEMPOTENCY_CONFLICT` | 422 | Your code reused a key for a different body. A bug; do not retry. | | `INSUFFICIENT_STOCK` | 400 | `fields.items` names the line. Surface it to whoever is taking the order — this is a real business answer, not a transient failure. | | `VALIDATION_ERROR` | 422 | Unknown SKU, bad phone, unknown channel slug, missing `store_id` on a company key. `fields` says which. | | `FORBIDDEN_SCOPE` | 403 | The key lacks `orders:write`. Scopes are fixed at creation — make a new key. | | `RATE_LIMITED` | 429 | Writes are 30/min per key by default. Honour `Retry-After`. | ## What happens next Placing the order reserves stock but does not finish it. The rest of its life happens through: - **`POST /orders/{id}/payments`** — record money as it arrives. The amount is capped at the net due, and `transaction_id` is unique per store. - **`PATCH /orders/{id}/status`** — move it along, validated against the same strict transition map staff use. - **`POST /orders/{id}/cancel`** — release the reservation. - **[Webhooks](https://www.salafems.com/developers/webhooks.md)** — `order.updated`, `order.paid` and `order.fulfilled` tell you when the merchant's staff move it, so you do not have to poll for it. ## The whole thing, in order 1. Generate an idempotency key and persist it with your record. 2. Resolve variants (or use your SKUs directly). 3. Build the body — customer, items, shipping charge, channel. 4. `POST /orders` with the key. Retry the same key on `409` and on network failure. 5. Store `id` and `number`. 6. Subscribe to `order.*` webhooks, or poll `updated_after`, for the rest. --- # Sync inventory to an ERP Cursors, watermarks and checkpoints that survive a crash. An ERP, an accounting package or a warehouse system needs its own copy of the store's catalogue, stock and orders — and needs it to stay correct while the store keeps trading. This is the shape that gets built, every time. **You need** a key with `products:read`, `inventory:read`, `locations:read` and usually `orders:read`. A store key unless head office genuinely reads several stores at once. ## The shape ```text once full load → page everything, record a watermark then incremental poll → updated_after=, upsert on id alongside slow full re-read → the small lists that have no updated_after ``` ## 1. Full load Page through with a cursor until the end. Ask for `limit=100` — the rate limit counts *requests*, not rows, so a bigger page is free throughput. **cURL** ```bash # Page 1 — no cursor. curl -sS "https://api.salafems.com/ext/v1/orders?limit=100" \ -H "Authorization: Bearer $SALAF_API_KEY" # The response ends with: # "meta": { "has_more": true, "next_cursor": "0f0b…c31" } # Feed that cursor back as starting_after, and repeat until # has_more is false. curl -sS "https://api.salafems.com/ext/v1/orders?limit=100&starting_after=0f0b…c31" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` **JavaScript** ```javascript async function* everyOrder(params = {}) { let cursor = null; do { const url = new URL("https://api.salafems.com/ext/v1/orders"); url.searchParams.set("limit", "100"); for (const [key, value] of Object.entries(params)) { url.searchParams.set(key, value); } if (cursor) { url.searchParams.set("starting_after", cursor); } const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}` }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); yield* data; // next_cursor is null on the last page, which ends the loop. cursor = meta.has_more ? meta.next_cursor : null; } while (cursor); } for await (const order of everyOrder({ status: "confirmed" })) { console.log(order.number, order.total_amount); } ``` **Python** ```python import os import requests def every_order(**params): cursor = None headers = {"Authorization": f"Bearer {os.environ['SALAF_API_KEY']}"} while True: query = {"limit": 100, **params} if cursor: query["starting_after"] = cursor response = requests.get( "https://api.salafems.com/ext/v1/orders", params=query, headers=headers, timeout=30, ) response.raise_for_status() page = response.json() yield from page["data"] if not page["meta"]["has_more"]: return cursor = page["meta"]["next_cursor"] for order in every_order(status="confirmed"): print(order["number"], order["total_amount"]) ``` **PHP** ```php 100], $params); if ($cursor !== null) { $query['starting_after'] = $cursor; } $ch = curl_init('https://api.salafems.com/ext/v1/orders?' . http_build_query($query)); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $page = json_decode(curl_exec($ch), true); curl_close($ch); yield from $page['data']; $cursor = $page['meta']['has_more'] ? $page['meta']['next_cursor'] : null; } while ($cursor !== null); } foreach (every_order(['status' => 'confirmed']) as $order) { echo $order['number'] . ' ' . $order['total_amount'] . PHP_EOL; } ``` Do this for `/products` and `/orders`, then for `/inventory/levels`. **Record the timestamp you started at**, not the one you finished at. Anything that changed while the load was running is then picked up by the first incremental poll rather than falling into the gap between them. ## 2. Incremental polling `updated_after` is the workhorse. It returns everything modified since an instant — including rows created long before it, which is precisely what a `created_after` poll misses. **cURL** ```bash # Everything that changed since the last successful sync. # Store the timestamp you STARTED the run at, not the one you finished at. SINCE="2026-08-10T00:00:00Z" curl -sS "https://api.salafems.com/ext/v1/orders?updated_after=$SINCE&limit=100&expand=items,customer" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` **JavaScript** ```javascript // Read the watermark BEFORE the run: anything modified while the run is // in flight is then picked up next time instead of being skipped. const runStartedAt = new Date().toISOString(); const since = await loadWatermark(); // your storage let cursor = null; do { const url = new URL("https://api.salafems.com/ext/v1/orders"); url.searchParams.set("updated_after", since); url.searchParams.set("limit", "100"); url.searchParams.set("expand", "items,customer"); if (cursor) { url.searchParams.set("starting_after", cursor); } const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}` }, }); const { data, meta } = await response.json(); for (const order of data) { // Upsert on id — an order can appear in several runs as it moves // through its statuses, and the newest snapshot always wins. await upsertOrder(order); } cursor = meta.has_more ? meta.next_cursor : null; } while (cursor); await saveWatermark(runStartedAt); ``` **Python** ```python import os from datetime import datetime, timezone import requests # Read the watermark BEFORE the run, so rows changed mid-run are caught # by the next one rather than skipped by this one. run_started_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") since = load_watermark() # your storage headers = {"Authorization": f"Bearer {os.environ['SALAF_API_KEY']}"} cursor = None while True: query = { "updated_after": since, "limit": 100, "expand": "items,customer", } if cursor: query["starting_after"] = cursor page = requests.get( "https://api.salafems.com/ext/v1/orders", params=query, headers=headers, timeout=30, ).json() for order in page["data"]: # Upsert on id: the same order reappears as its status changes. upsert_order(order) if not page["meta"]["has_more"]: break cursor = page["meta"]["next_cursor"] save_watermark(run_started_at) ``` **PHP** ```php $since, 'limit' => 100, 'expand' => 'items,customer', ]; if ($cursor !== null) { $query['starting_after'] = $cursor; } $ch = curl_init('https://api.salafems.com/ext/v1/orders?' . http_build_query($query)); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $page = json_decode(curl_exec($ch), true); curl_close($ch); foreach ($page['data'] as $order) { upsert_order($order); // upsert on id } $cursor = $page['meta']['has_more'] ? $page['meta']['next_cursor'] : null; } while ($cursor !== null); save_watermark($runStartedAt); ``` Three rules make it correct: 1. **Watermark before the run**, as above. 2. **Upsert on `id`.** The same order legitimately appears in many runs as it moves through its statuses; the newest snapshot wins. 3. **Overlap slightly.** Subtracting a minute from your stored watermark costs a few duplicate rows — which rule 2 already absorbs — and protects you from clock skew between your machine and ours. > **Warning — updated\_after exists on orders and products only** > > Customers, categories, brands, inventory levels, movements, locations and > channels do not have it yet. > > For those, re-read the whole list on a slower schedule. They are small and > change rarely — a nightly full pass is entirely reasonable, and cheaper than > the machinery you would build to avoid it. ## 3. Stock levels Levels are one row per variant per location, so this list is the one that grows fastest. It has no `updated_after`, which makes a full paged pass the sync. **cURL** ```bash # Levels are one row per variant per location. Page with the cursor; # there is no updated_after on this resource, so a full pass is the sync. curl -sS "https://api.salafems.com/ext/v1/inventory/levels?limit=100" \ -H "Authorization: Bearer $SALAF_API_KEY" # Then follow meta.next_cursor until has_more is false: curl -sS "https://api.salafems.com/ext/v1/inventory/levels?limit=100&starting_after=0f0b…c31" \ -H "Authorization: Bearer $SALAF_API_KEY" # Only what is running out, for a reorder report: curl -sS "https://api.salafems.com/ext/v1/inventory/levels?low_stock=true&limit=100" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` **JavaScript** ```javascript // Checkpoint the CURSOR, not a row count: a crash at page 40 resumes at // page 40 rather than re-reading everything from the top. async function syncLevels(checkpoint) { let cursor = await checkpoint.load(); // null on a fresh run do { const url = new URL("https://api.salafems.com/ext/v1/inventory/levels"); url.searchParams.set("limit", "100"); if (cursor) { url.searchParams.set("starting_after", cursor); } const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}` }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); // available = on_hand − reserved. Sell against available; report on // on_hand. Writing the wrong one into an ERP oversells the store. await erp.upsertStock( data.map((level) => ({ sku: level.sku, warehouse: level.location.code, onHand: level.on_hand, available: level.available, })), ); cursor = meta.has_more ? meta.next_cursor : null; await checkpoint.save(cursor); } while (cursor); } ``` **Python** ```python import os import requests HEADERS = {"Authorization": f"Bearer {os.environ['SALAF_API_KEY']}"} def sync_levels(checkpoint): """Checkpoint the cursor so a crash resumes mid-walk, not from zero.""" cursor = checkpoint.load() while True: query = {"limit": 100} if cursor: query["starting_after"] = cursor response = requests.get( "https://api.salafems.com/ext/v1/inventory/levels", params=query, headers=HEADERS, timeout=30, ) response.raise_for_status() page = response.json() # available = on_hand - reserved. Sell against available. erp.upsert_stock( [ { "sku": level["sku"], "warehouse": level["location"]["code"], "on_hand": level["on_hand"], "available": level["available"], } for level in page["data"] ] ) if not page["meta"]["has_more"]: checkpoint.save(None) return cursor = page["meta"]["next_cursor"] checkpoint.save(cursor) ``` **PHP** ```php load(); do { $query = ['limit' => 100]; if ($cursor !== null) { $query['starting_after'] = $cursor; } $ch = curl_init('https://api.salafems.com/ext/v1/inventory/levels?' . http_build_query($query)); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $page = json_decode(curl_exec($ch), true); curl_close($ch); foreach ($page['data'] as $level) { // available = on_hand - reserved. erp_upsert_stock( $level['sku'], $level['location']['code'], $level['on_hand'], $level['available'], ); } $cursor = $page['meta']['has_more'] ? $page['meta']['next_cursor'] : null; $checkpoint->save($cursor); } while ($cursor !== null); } ``` The three numbers, and which one to write into your ERP: | Field | Meaning | | ----------- | -------------------------------------------------- | | `on_hand` | Physically present. What a stock count would find. | | `reserved` | Held for orders that have not shipped. | | `available` | `on_hand − reserved`. **Sell against this one.** | > **Danger — Writing on\_hand where you meant available oversells** > > A variant with 10 on hand and 8 reserved has 2 available. An ERP that > publishes 10 as sellable will take eight orders it cannot fill, and the > merchant finds out from customers. `low_stock=true` narrows the list to variants at or below their threshold — that is the reorder report, and it is far cheaper than fetching everything and filtering client-side. ## 4. Checkpoint properly The difference between a sync that survives an outage and one that starts over: - **Checkpoint the cursor**, not a page number. `meta.next_cursor` is a position anchored to a row, so resuming from it is exact — even if the store took 200 orders while you were down. - **Checkpoint after the page is committed** to your side, not after it is fetched. Crashing between fetch and commit should re-fetch, and it does. - **Clear the cursor when `has_more` is false**, so the next run starts a fresh pass. ## Why there are no page numbers Because the store keeps trading while your sync runs. With offset pages, one order placed between page 1 and page 2 shifts every row down by one. Page 2 then re-serves a row you already had and **skips one entirely** — silently. The merchant discovers it weeks later as a missing order in their accounts. A cursor is anchored to a row rather than to a count, so new rows arriving at the head do not move the rows behind them. Deep pages are cheap for the same reason: there is no `OFFSET 40000` for the database to count through. Full reasoning on the [Pagination](https://www.salafems.com/developers/pagination.md#why-cursors-instead-of-page-numbers) page. ## Adding webhooks later Polling is the reconciliation path and should stay, but you do not have to poll *often* once [webhooks](https://www.salafems.com/developers/webhooks.md) are wired up: | Instead of polling for | Subscribe to | | ---------------------- | -------------------------------------------------------------------------------------------------- | | New and changed orders | `order.created`, `order.updated`, `order.paid`, `order.cancelled` | | Stock movement | `inventory.updated` (coalesced per variant per 10s), `inventory.adjusted`, `inventory.transferred` | | Reorder alerts | `inventory.low_stock` (edge-triggered, once per crossing) | | Catalogue changes | `product.created`, `product.updated`, `product.archived` | Keep the poll as a daily or hourly safety net. Webhooks are at-least-once and your receiver can be down; a periodic `updated_after` pass closes any gap without anybody having to notice there was one. ## Scheduling and limits - **120 reads per minute per key** by default. A full pass of 10,000 products at `limit=100` is 100 requests — under a minute of quota. - **Do not poll tightly.** Once a minute is plenty for almost every integration; once every few minutes is plenty for most. - **Split heavy work onto its own key.** Buckets are per key, so a nightly bulk load on one key cannot starve a real-time listener on another — and you can revoke one without touching the other. - **Watch `X-RateLimit-Remaining`** and slow down before it reaches zero. Backing off at 10 remaining is cheaper than a `429` and a retry. ## Alerting Log the `request_id` of every failure — it is the only handle that finds your exact request in our logs. Alert on `KEY_REVOKED` **specifically and loudly**: it means a human at the merchant deliberately turned your integration off. Retrying forever, quietly, is the wrong response to that. --- # Handle webhooks idempotently Verify, dedupe, upsert, and answer fast. A correct webhook receiver is four steps in a fixed order. Get the order wrong and you have a receiver that works in testing and corrupts data in production. ```text 1. verify the signature ─ before parsing, against the raw bytes 2. de-duplicate on event id ─ delivery is at-least-once 3. answer 2xx ─ within 10 seconds, before doing the work 4. upsert from data.object ─ off the request, in any order ``` ## The whole receiver **Node.js** ```javascript app.post( "/webhooks/salaf", express.raw({ type: "application/json" }), async (req, res) => { // 1. Verify BEFORE parsing or trusting anything. const ok = verifySalafSignature( req.body, req.get("X-Salaf-Signature"), process.env.SALAF_WEBHOOK_SECRET, ); if (!ok) { return res.sendStatus(400); // we will not retry a 4xx into working } const event = JSON.parse(req.body.toString("utf8")); const eventId = req.get("X-Salaf-Event-Id"); // === event.id // 2. Dedupe. Delivery is at-least-once: the same event WILL arrive // twice eventually. A unique index on the event id is the whole trick. const inserted = await db.query( `INSERT INTO salaf_events (id, type, payload) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING`, [eventId, event.type, event], ); // 3. Answer FAST. Anything slower than a few hundred ms risks our 10s // timeout, and a timeout means a retry you did not need. res.sendStatus(200); if (inserted.rowCount === 0) { return; // already handled — nothing more to do } // 4. Do the work off the request. Order is NOT guaranteed, so write // upserts: order.updated may land before order.created. void queue.add("salaf-event", { eventId }); }, ); async function handleEvent(event) { switch (event.type) { case "order.created": case "order.updated": case "order.paid": // data.object is the full snapshot as it was inside the transaction // that produced it — upsert it, do not patch field by field. return upsertOrder(event.data.object); case "inventory.updated": return setLevels(event.data.object.variant_id, event.data.object.levels); default: return; // unknown types are normal: the catalog only ever grows } } ``` **Python** ```python import json from django.db import IntegrityError from django.http import HttpResponse @csrf_exempt @require_POST def salaf_webhook(request): # 1. Verify before trusting the body. if not verify_salaf_signature( request.body, request.headers.get("X-Salaf-Signature"), settings.SALAF_WEBHOOK_SECRET, ): return HttpResponse(status=400) event = json.loads(request.body) event_id = request.headers["X-Salaf-Event-Id"] # same as event["id"] # 2. Dedupe on the event id — delivery is at-least-once. try: SalafEvent.objects.create( id=event_id, type=event["type"], payload=event ) except IntegrityError: return HttpResponse(status=200) # already seen; still a success # 3. Queue the work and answer immediately: our timeout is 10 seconds, # and a slow 200 costs you a duplicate you did not need. process_salaf_event.delay(event_id) return HttpResponse(status=200) def handle_event(event): kind = event["type"] obj = event["data"]["object"] # 4. Upsert, never patch: events can arrive out of order, and every # payload carries the complete object as of its own transaction. if kind in ("order.created", "order.updated", "order.paid"): upsert_order(obj) elif kind == "inventory.updated": set_levels(obj["variant_id"], obj["levels"]) # Unknown types are expected — the catalog only ever grows. ``` **PHP** ```php getContent(), $request->header('X-Salaf-Signature'), config('services.salaf.webhook_secret'), )) { return response()->noContent(400); } $event = json_decode($request->getContent(), true); $eventId = $request->header('X-Salaf-Event-Id'); // same as $event['id'] // 2. Dedupe on the event id (unique column). At-least-once delivery // means duplicates are routine, not exceptional. $isNew = SalafEvent::query()->insertOrIgnore([ 'id' => $eventId, 'type' => $event['type'], 'payload' => json_encode($event), ]) === 1; // 3. Queue and answer — the timeout is 10 seconds. if ($isNew) { SalafEventJob::dispatch($eventId); } return response()->noContent(200); } public function process(array $event): void { $object = $event['data']['object']; // 4. Upsert: order.updated can arrive before order.created. match ($event['type']) { 'order.created', 'order.updated', 'order.paid' => $this->upsertOrder($object), 'inventory.updated' => $this->setLevels($object['variant_id'], $object['levels']), default => null, // unknown types are normal }; } ``` The rest of this page is why each of those four lines is where it is. ## 1. Verify first Anyone can `POST` JSON at a public URL. Until the signature checks out, the body is a stranger's input — do not parse it, log it, or let it near a database. Three rules, all of which the samples on [Verifying signatures](https://www.salafems.com/developers/webhook-signatures.md) implement: - Hash the **raw bytes**, never a re-serialised object. - Compare in **constant time** (`timingSafeEqual`, `hmac.compare_digest`, `hash_equals`). - Reject a timestamp more than **five minutes** from your clock. Answer `400` when verification fails. We will retry, and every one of those retries lands in the merchant's delivery log where a human can see something is wrong — which is better than silently discarding requests that might have been ours. ## 2. De-duplicate on the event id **Duplicates are routine, not exceptional.** A timeout on our side is indistinguishable from a slow success on yours: you may have processed the event perfectly and lost the acknowledgement. So we retry, and you receive it again. The defence is one unique column: ```sql CREATE TABLE salaf_events ( id TEXT PRIMARY KEY, -- the evt_… id, verbatim type TEXT NOT NULL, payload JSONB NOT NULL, received_at TIMESTAMPTZ NOT NULL DEFAULT now(), handled_at TIMESTAMPTZ ); ``` Insert with `ON CONFLICT DO NOTHING`. If the insert did nothing, you have seen this event: answer `200` and stop. If it inserted, you own it. > **Warning — De-duplicate on the EVENT id, not the delivery id** > > `X-Salaf-Event-Id` (`evt_…`) identifies the fact and is **identical on every > retry**. `X-Salaf-Delivery` (`whd_…`) identifies the attempt and is > **different every time**. > > A receiver keyed on the delivery id de-duplicates nothing — every retry looks > new, and every retry runs again. Log the delivery id; key on the event id. > **Note — Storing the raw event pays for itself** > > Keeping the payload as you received it turns "why is this order wrong?" into > a query rather than an investigation, and lets you re-run a handler after > fixing a bug without asking the merchant to re-send anything. Our own copy is > kept for 30 days; yours can be kept for as long as you like. ## 3. Answer fast, work later The delivery times out after **10 seconds**. That is generous for "I have it" and deliberately hostile to "let me do the work first". Answer as soon as the event is durably recorded, then process it from a queue. A handler that talks to a payment provider, rebuilds a cache or sends an email inline will eventually exceed the timeout — and a timeout is not just slow, it produces a retry, so the slow work runs twice. If you have no queue, the minimum viable version is: insert the event row, answer `200`, and let a worker poll the table for `handled_at IS NULL`. That is a queue, and it is ten lines. ## 4. Upsert, in any order **Ordering is not guaranteed.** Deliveries run in parallel and retries reshuffle everything, so `order.updated` can arrive before `order.created`. This is much less painful than it sounds, because every payload carries the **whole object** as of the transaction that produced it. So: - **Write the object wholesale.** `INSERT … ON CONFLICT … DO UPDATE`, or your ORM's upsert. Never patch field-by-field from an event. - **Handle the create you never saw.** An update for an unknown id should create the record from `data.object`, not drop the event. - **Guard with a timestamp if you keep your own state machine.** Ignore a payload whose `data.object.updated_at` is older than what you already stored, and out-of-order delivery stops mattering entirely. - **`GET` the resource when in doubt.** The REST API is authoritative at the moment you call it. ```js // Last-write-wins, safe against out-of-order delivery. await db.query( `INSERT INTO orders (id, number, status, payload, updated_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, payload = EXCLUDED.payload, updated_at = EXCLUDED.updated_at WHERE orders.updated_at < EXCLUDED.updated_at`, [order.id, order.number, order.status, order, order.updated_at], ); ``` ## Ignore what you do not know The catalog only ever grows, and a merchant subscribed to `*` receives new event types the day they ship. A handler that throws on an unrecognised `type` turns our additive change into your outage. `default: return`. Log it at debug level if you like. The same applies to fields: never validate a payload against a closed schema that rejects unknown keys. ## Failure modes worth handling | Situation | What to do | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Signature fails | `400`, log, alert if it is more than a stray. Check the [test vectors](https://www.salafems.com/developers/webhook-signatures.md#verify-your-implementation) before blaming the network. | | Your database is down | Return `5xx` **without** answering `2xx`. We retry on the ladder — this is exactly what it is for. | | A handler bug corrupts a record | Fix, then replay from your own stored events. No need to involve the merchant. | | You were down for days | Deliveries go `dead` after \~45 hours, and events are purged after 30 days. Reconcile with [`updated_after`](https://www.salafems.com/developers/pagination.md#syncing-changes-with-updated_after) rather than asking for a re-send. | | A merchant reports missing data | Ask for the `X-Salaf-Delivery` id from their delivery log; it pins the exact attempt and the exact payload we sent. | > **Danger — Never answer 2xx for an event you failed to record** > > A `200` means "I have this, do not send it again". If your insert failed and > you answer `200` anyway, the event is gone — we will not retry it, and no > amount of looking at our delivery log will show anything wrong. > > Answer `5xx` when you could not record it. That is what the retry ladder is > for, and it costs you nothing. ## Ready to go live Work through the [pre-launch checklist](https://www.salafems.com/developers/webhook-testing.md#a-checklist-before-you-go-live), then send a [`salaf.ping`](https://www.salafems.com/developers/webhook-testing.md) and watch it arrive. --- # Build a custom storefront The read path, checkout, and what the API does not give you. You want to build your own shopping experience — a mobile app, a headless frontend, a Facebook shop bot — on top of a Salaf store's catalogue and checkout. The read path and the order path both exist. This page is honest about where they stop. **You need** `products:read`, `inventory:read`, `channels:read`, `locations:read`, `orders:write`, `customers:write` and `payments:write`. > **Danger — The API key never goes near a browser** > > A `salaf_sk_` key grants everything its scopes allow across a whole store, > and there is no such thing as a public or restricted key. It belongs on your > server, and only on your server. > > Your storefront calls **your** backend; your backend calls Salaf. Any design > where a shopper's device holds the key is a design where any shopper can read > every customer record and place orders at any price. ## What the API gives you | You need | Endpoint | Notes | | -------------------- | -------------------------------- | ---------------------------------------------------- | | Catalogue listing | `GET /products` | Variants are nested. Filter `status=active`. | | One product page | `GET /products/{id}` | | | Navigation | `GET /categories`, `GET /brands` | Flat lists; `parent_id` gives you the category tree. | | SKU / barcode lookup | `GET /variants/{identifier}` | Accepts id, SKU or barcode. | | Availability | `GET /inventory/levels` | Filter by `variant_id`. | | Attribution | `GET /channels` | Slugs for tagging the order's origin. | | Checkout | `POST /orders` | Find-or-create customer, stock reserved. | | Payment | `POST /orders/{id}/payments` | Record what your gateway settled. | | Order status | `GET /orders/{id}`, webhooks | For an order-tracking page. | ## 1. Render the catalogue **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS "https://api.salafems.com/ext/v1/products?status=active&limit=50" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/products"); Object.entries({ status: "active", limit: "50", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.salafems.com/ext/v1/products", params={ "status": "active", "limit": "50", }, headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` A product carries its `variants[]` inline, so one list request gives you cards *and* prices. Two conventions to build around: - **Money is a string** with two decimals — `"1250.00"`. Parse it as a decimal, never as a float, and never do arithmetic on it in JavaScript numbers if you can avoid it. - **`discount_price` is the sale price** when it is set and genuinely below `price`. Show both; sell at the lower one. Cache aggressively. A catalogue changes far less often than it is browsed, and your rate limit is 120 reads a minute per key — which is plenty for a server serving from cache and nowhere near enough to proxy every page view. ## 2. Show availability honestly **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS "https://api.salafems.com/ext/v1/inventory/levels?variant_id=REPLACE_WITH_VARIANT_ID" \ -H "Authorization: Bearer $SALAF_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/inventory/levels"); Object.entries({ variant_id: "REPLACE_WITH_VARIANT_ID", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.salafems.com/ext/v1/inventory/levels", params={ "variant_id": "REPLACE_WITH_VARIANT_ID", }, headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` You get one row per location. For a single-warehouse store that is one row; for a multi-location store, sum `available` across the locations you actually ship from. | Field | Use it for | | -------------- | ---------------------------------------------------- | | `available` | **Whether a shopper may buy.** `on_hand − reserved`. | | `on_hand` | Internal reporting. Not a sellable number. | | `is_low_stock` | An "only a few left" badge. | > **Warning — Availability is a hint, not a lock** > > Nothing reserves stock until the order is created. Between your page render > and the shopper's checkout, a POS sale can take the last unit — so > `POST /orders` may still answer `INSUFFICIENT_STOCK`, and your checkout has > to handle that gracefully rather than treating it as a server error. > > That is the correct design: the reservation happens once, atomically, at > order creation, in the same code the till uses. ## 3. Take the order Checkout is one call. It finds or creates the customer by phone, reserves stock, prices any coupon, and returns the whole order. **cURL** ```bash # The key identifies the ORDER you are placing, not this HTTP attempt. # Generate it once, store it with your own record, and reuse it on retries. IDEMPOTENCY_KEY=$(uuidgen) curl -sS -X POST "https://api.salafems.com/ext/v1/orders" \ -H "Authorization: Bearer $SALAF_API_KEY" \ -H "Idempotency-Key: $IDEMPOTENCY_KEY" \ -H "Content-Type: application/json" \ -d '{ "channel": "facebook", "customer": { "phone": "01712345678", "full_name": "Rafiqul Islam" }, "items": [ { "sku": "TSHIRT-RED-M", "quantity": 2 } ], "shipping_charge": 120, "note": "Deliver after 6pm." }' ``` **JavaScript** ```javascript import { randomUUID } from "node:crypto"; // One key per logical order. Persist it next to your own order record BEFORE // the call, so a retry after a process restart still sends the same one. const idempotencyKey = randomUUID(); const response = await fetch("https://api.salafems.com/ext/v1/orders", { method: "POST", headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, "Idempotency-Key": idempotencyKey, "Content-Type": "application/json", }, body: JSON.stringify({ channel: "facebook", customer: { phone: "01712345678", full_name: "Rafiqul Islam" }, items: [{ sku: "TSHIRT-RED-M", quantity: 2 }], shipping_charge: 120, note: "Deliver after 6pm.", }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } // 201 with the full order: items and customer expanded, totals computed, // stock already reserved. const order = await response.json(); console.log(order.number, order.total_amount, order.payment_status); // true when this was a replay of an earlier identical call. console.log(response.headers.get("Idempotent-Replayed") === "true"); ``` **Python** ```python import os import uuid import requests # One key per logical order — generated where the order is DECIDED, not # inside the retry loop, and stored with your own record. idempotency_key = str(uuid.uuid4()) response = requests.post( "https://api.salafems.com/ext/v1/orders", json={ "channel": "facebook", "customer": {"phone": "01712345678", "full_name": "Rafiqul Islam"}, "items": [{"sku": "TSHIRT-RED-M", "quantity": 2}], "shipping_charge": 120, "note": "Deliver after 6pm.", }, headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", "Idempotency-Key": idempotency_key, }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") order = response.json() print(order["number"], order["total_amount"], order["payment_status"]) print(response.headers.get("Idempotent-Replayed") == "true") ``` **PHP** ```php 'facebook', 'customer' => ['phone' => '01712345678', 'full_name' => 'Rafiqul Islam'], 'items' => [ ['sku' => 'TSHIRT-RED-M', 'quantity' => 2], ], 'shipping_charge' => 120, 'note' => 'Deliver after 6pm.', ]); $ch = curl_init('https://api.salafems.com/ext/v1/orders'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('SALAF_API_KEY'), 'Idempotency-Key: ' . $idempotencyKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } echo $response['number'] . ' ' . $response['total_amount'] . PHP_EOL; ``` For a storefront specifically: - **Send a `channel` slug** so the merchant can see where their sales come from. `GET /channels` lists what the store has; omitted, orders land on `manual`. - **Compute `shipping_charge` yourself.** Nothing calculates it on this path, and omitting it means free delivery. - **Use an idempotency key per checkout attempt**, stored with your own cart or session record, so a double-tapped Pay button cannot produce two orders. See [Writes & idempotency](https://www.salafems.com/developers/idempotency.md). - **Do not send `unit_price`** unless you have a real reason. The endpoint trusts you, which means a bug in your pricing becomes the merchant's revenue problem. Omit it and the catalogue price is used. ## 4. Record payment If you take money through your own gateway, tell Salaf what settled: **cURL** ```bash export SALAF_API_KEY="salaf_sk_YOUR_API_KEY" curl -sS -X POST "https://api.salafems.com/ext/v1/orders/REPLACE_WITH_ORDER_ID/payments" \ -H "Authorization: Bearer $SALAF_API_KEY" \ -H "Idempotency-Key: REPLACE_WITH_A_UUID" \ -H "Content-Type: application/json" \ -d '{ "amount": 500, "transaction_id": "TRX9F3K2" }' ``` **JavaScript** ```javascript const url = new URL("https://api.salafems.com/ext/v1/orders/REPLACE_WITH_ORDER_ID/payments"); const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}`, "Idempotency-Key": "REPLACE_WITH_A_UUID", "Content-Type": "application/json", }, body: JSON.stringify({ "amount": 500, "transaction_id": "TRX9F3K2" }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "amount": 500, "transaction_id": "TRX9F3K2", } response = requests.post( "https://api.salafems.com/ext/v1/orders/REPLACE_WITH_ORDER_ID/payments", json=payload, headers={ "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}", "Idempotency-Key": "REPLACE_WITH_A_UUID", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 500, 'transaction_id' => 'TRX9F3K2', ]); $ch = curl_init('https://api.salafems.com/ext/v1/orders/REPLACE_WITH_ORDER_ID/payments'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: REPLACE_WITH_A_UUID', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` `transaction_id` is unique per store, so replaying the same provider reference is a `409` rather than a double credit. The amount is capped at the order's net due. Recording payment in full moves the order to `payment_status: "paid"` and fires `order.paid`. For cash on delivery — the common case in Bangladesh — record nothing at checkout. The merchant's staff record it when the courier settles. ## 5. Keep the shopper informed An order-tracking page needs the order's current state. Either read it — `GET /orders/{id}` — or, better, subscribe to [webhooks](https://www.salafems.com/developers/webhooks.md) and push a notification when it changes: | Event | Tell the shopper | | ----------------- | ------------------------------------------------------ | | `order.updated` | "Confirmed", "Out for delivery" — `data.object.status` | | `order.paid` | Payment received | | `order.fulfilled` | Delivered | | `order.cancelled` | Cancelled, with the reason if you captured one | ## What the API does not give you Being blunt, so you can design around it rather than discover it halfway: | Not available | What to do | | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | **No shopper authentication.** There are no customer logins, sessions or password endpoints. | Own the identity in your app. Map your user to a Salaf `customer_id` and store it yourself. | | **No cart.** Carts are not a Salaf concept on this surface. | Keep the cart in your own system; the order is created once, at checkout. | | **No shipping-rate calculation.** | Compute it and send `shipping_charge`. | | **No coupon validation endpoint.** | You can only find out by placing the order — an unusable code fails it. Validate the shape client-side and handle the failure at checkout. | | **No payment-gateway integration for you.** Salaf records payments; it does not take them on this surface. | Use your own gateway, then `POST /orders/{id}/payments`. | | **No customer address book over the API.** Addresses are returned on a customer read, but there is no endpoint to add one. | Send `shipping_address` on the order; it is stored verbatim with it. | | **No product search beyond `search=`.** Name and SKU only. | Index the catalogue yourself if you need facets or fuzzy matching. | | **No images upload.** Product writes exclude media. | Manage images in the dashboard; read `thumbnail` and `images[]`. | | **No sandbox.** Every key acts on live data. | Test against a store the merchant treats as a test store, and use small amounts. | > **Note — There is already a storefront, if you want one** > > Salaf ships a hosted, themeable storefront — this guide is for teams who > specifically want their own. If you are weighing it up: the hosted one > already has shopper sessions, carts, coupon validation and payment gateways > wired in, and none of those are things this API hands you. ## Rate limits and caching 120 reads and 30 writes per minute, per key. A storefront is read-heavy and bursty, which means: - **Cache the catalogue** at your edge or in your backend. Re-read it on a schedule, or on the `product.*` webhooks. - **Do not cache availability for long.** Seconds, not minutes. - **Use a separate key for checkout writes**, so a traffic spike on the read path cannot exhaust the bucket your orders depend on. --- # Changelog Every change to the public API, newest first. Every change to the public API, newest first. Additive changes ship inside v1; anything breaking follows the [deprecation policy](https://www.salafems.com/developers/versioning.md#deprecation). ## 2026-08-15 — The API speaks MCP The API is now also a [Model Context Protocol server](https://www.salafems.com/developers/ai.md#the-store-mcp-server) at `https://api.salafems.com/ext/mcp/v1` — Streamable HTTP, the same `Bearer` keys, the same tenant boundary, the same scopes. Nothing about REST changes; this is a second, agent-shaped door into the same implementation. - **22 curated tools**: 12 reads over products, orders, payments, customers, inventory, locations and channels, and 10 writes — one per REST write endpoint. Results are byte-equivalent to the corresponding REST responses. - **Each tool requires its REST twin's scope.** A refusal is an in-band, readable error naming the missing scope; a read-scoped key cannot write. - **Write tools take `idempotency_key` as an argument** (MCP has no headers) — required on `create_order`, `record_order_payment` and `adjust_inventory`, honoured everywhere else, with the [same replay/conflict rules](https://www.salafems.com/developers/idempotency.md) as the header. - A companion **docs MCP server** — no auth, answers from these pages — runs at `https://www.salafems.com/developers/mcp`. See [Build with AI](https://www.salafems.com/developers/ai.md). ## 2026-08-11 — Write endpoints and idempotency Ten write endpoints, all running the same code the dashboard runs — the same stock reservation, coupon engine, totals math and status-transition rules. See [Writes & idempotency](https://www.salafems.com/developers/idempotency.md). | Resource | Endpoints | | --------- | --------------------------------------------------------------------------------------------------- | | Orders | `POST /orders`, `PATCH /orders/{id}/status`, `POST /orders/{id}/cancel` | | Payments | `POST /orders/{id}/payments`, and the read that completes the resource, `GET /orders/{id}/payments` | | Customers | `POST /customers`, `PATCH /customers/{id}` | | Inventory | `POST /inventory/adjustments` | | Products | `POST /products`, `PATCH /products/{id}`, `POST /products/{id}/archive` | - **`Idempotency-Key`** is now honoured on every write and **required** on `POST /orders`, `POST /orders/{id}/payments` and `POST /inventory/adjustments` (`400` naming the header when it is missing). A repeat of a completed request replays the original response verbatim with `Idempotent-Replayed: true`; a repeat with a different body is `422 IDEMPOTENCY_CONFLICT`; a repeat while the original is running is `409 IDEMPOTENCY_IN_FLIGHT`. Keys are isolated per API key and expire after 24 hours. - **Company keys must send `store_id` in the body** on every write (`422` without it). A store key may repeat its own store id and may not name another (`422`). - **Find-or-create customers on order creation**, matched on the canonical Bangladesh phone number. An existing customer is never modified by an order. - **Two new error codes are now reachable**: `INSUFFICIENT_STOCK` and `INVALID_STATUS_TRANSITION`, both `400`, both carrying `fields`. - **The write rate-limit bucket is now in use** — 30 requests per minute per key by default, counted separately from reads. - **The OpenAPI description now carries 50 response schemas**, so every example in the reference is generated from the same artifact that drives the server's validation instead of being written by hand. ### Write scopes are grantable `orders:write`, `payments:write`, `customers:write`, `inventory:write` and `products:write` now have endpoints behind them. `payments:read` does too. `webhooks:manage` remains declared but not grantable. ## 2026-08-11 — Webhooks Outbound events, so you can stop polling for the things you need to know about quickly. See [Webhooks](https://www.salafems.com/developers/webhooks.md). - **24 event types** across orders, returns, shipping, inventory, catalog, customers and POS, plus `salaf.ping` for testing. Subscribe to specific types or to `*`. The [catalog](https://www.salafems.com/developers/webhook-events.md) is also readable from the dashboard API. - **Endpoints are created in the dashboard** under Settings → Developer → Webhooks. `https` only, public addresses only, no redirects followed, five per store by default. - **HMAC-SHA256 signatures** over `"{timestamp}.{rawBody}"` in `X-Salaf-Signature`, with published [test vectors](https://www.salafems.com/developers/webhook-signatures.md#verify-your-implementation) so you can check your verification code before going live. - **Rotation dual-signs for 24 hours** — the previous secret keeps co-signing as a second `v1=`, so rotating costs no downtime. - **At-least-once delivery, no ordering guarantee.** De-duplicate on `X-Salaf-Event-Id` and write handlers as upserts. - **An initial attempt plus eight retries over \~45 hours** (30s, 2m, 10m, 30m, 2h, 6h, 12h, 24h), then `dead`, with manual retry from the delivery log. An endpoint failing continuously for three days is auto-disabled and the merchant is notified. - **Payloads are snapshots taken inside the transaction** that produced them, built by the same serializers as the REST responses, and kept — with their delivery logs — for 30 days. ## 2026-08-11 — API usage in the dashboard Every API key now reports its own traffic under **Settings → Developer**: requests, errors, rate-limited responses and average latency, per key per UTC day, kept for 13 months. Nothing changes on the API surface — no new endpoint, no new header, no cost to your requests. It is there so a merchant can see which integration is making which calls, and so "the API is slow" can be answered with numbers. ## 2026-08-11 — Public read API launch The first release of the Salaf Commerce API. Everything below is new. ### Authentication - **API keys.** `salaf_sk_` followed by 40 characters, presented as `Authorization: Bearer`. Created under **Settings → Developer**; the secret is shown exactly once and stored only as a SHA-256 hash. - **Two key types.** Store keys read one store; company keys read every live store in the company and are restricted to company owners. - **Scopes**, fixed at creation, with `write` implying `read` of the same resource. A key can only be granted scopes the person creating it can back with their own permissions. - **Rotation with a 24-hour grace window**, so a key can be replaced without an outage, plus an immediate option for a leaked key. - **Immediate revocation** — the authentication cache is evicted on revoke rather than left to expire. ### Endpoints All read-only. 16 endpoints across 8 resources: | Resource | Endpoints | | ---------- | ------------------------------------------------------------------- | | Products | `GET /products`, `GET /products/{id}`, `GET /variants/{identifier}` | | Categories | `GET /categories`, `GET /categories/{id}` | | Brands | `GET /brands`, `GET /brands/{id}` | | Orders | `GET /orders`, `GET /orders/{id}` | | Customers | `GET /customers`, `GET /customers/{id}` | | Inventory | `GET /inventory/levels`, `GET /inventory/movements` | | Locations | `GET /locations`, `GET /locations/{id}` | | Channels | `GET /channels` | `GET /variants/{identifier}` accepts an id, a SKU or a barcode — a barcode scanner's output can be looked up directly. ### Conventions - **Cursor pagination** on every list: `limit` (max 100, default 25), `starting_after` / `ending_before`, and a `meta` block carrying `has_more` and `next_cursor`. - **`updated_after`** on orders and products, for incremental syncs. - **`expand=customer,items`** on orders. - **Money as strings** with two decimals (`"1250.00"`), so decimal precision survives JSON. - **ISO-8601 UTC timestamps**, always with the `Z`. - **`snake_case` field names** throughout. - **A single error shape** — one `error` object with `code`, `message`, optional `fields`, and `request_id`. - **Request ids** on every request, returned as `X-Request-Id`. A caller's own `X-Request-Id` is honoured. ### Limits - **120 reads and 30 writes per minute per key** by default, raised by plan. - `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` on every response; `429` carries `Retry-After`. ### Known limitations at launch Stated plainly so you can plan around them rather than discover them. Several were lifted the same day — see the write and webhook entries above; the list is kept as it was written. - **The base path is `/ext/v1`, not `/v1`.** The shorter alias is written but not yet deployed. When it goes live it will be announced here and both paths will serve the same routes. - **No write endpoints.** Creating orders, adjusting stock and recording payments are built but unreleased. - **No webhooks.** Poll with `updated_after` in the meantime. - **`updated_after` is missing on customers, categories, brands, inventory, locations and channels.** Re-read those lists on a schedule. - **`payments:read` is grantable but has no endpoint yet.** A key can hold the scope; nothing currently accepts it. - **`webhooks:manage` is declared but not grantable.** - **No sandbox or test mode.** There is no `salaf_sk_test_` prefix; every key acts on live data. - **No SDKs.** The four sample languages in these docs are the support level for v1. - **Locations ignore `store_id`.** They belong to the company and are shared across its stores. - **Only `created_at` is sortable.** A cursor over a mutable column cannot page reliably — see [Pagination](https://www.salafems.com/developers/pagination.md#why-you-can-only-sort-by-created_at).