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.
Before you start
You need three things, and all three are things a store owner already has or can grant in a minute:
- A Salaf EMS account with access to the store you want to integrate.
- A plan that includes API access. The
api_accessfeature 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 answers401 UNAUTHORIZEDwith the message "API access is not included in your current plan." - The
developerpermission on your user (developer.readto see keys,developer.manageto create them). Company owners have it already.
Create an API key
Keys are created in the dashboard, never through the API.
- Open Settings → Developer and choose Create API key.
- 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.
- Give it a name you will recognise in six months ("NetSuite nightly sync" beats "test").
- 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.
- Optionally set an expiry date.
A key looks like this — the literal prefix salaf_sk_ followed by 40
characters:
salaf_sk_7f3kq9p2xR4mVnB8tLcYwZ1sD6hJ0gKeQaUiOpNmMake 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.
Paste a key and every snippet on this page switches from the placeholder to your key — and the Try it panel under each endpoint is ready to send. It is stored in this browser only and goes nowhere except, if you press Send, straight to the API host you pick there.
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"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();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
$key = getenv('SALAF_API_KEY');
$ch = curl_init('https://api.salafems.com/ext/v1/products?limit=1');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => 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:
{
"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. |
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
Running the backend locally, the same routes are on your own port with no proxy in the way:
# 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.
{
"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 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 |
| Webhooks (outbound events) | Available — see Webhooks |
| 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 — store vs company keys, rotation without downtime, and revocation.
- Pagination — the
updated_afterpattern every sync integration ends up needing. - Writes & idempotency — every write endpoint and
the
Idempotency-Keycontract. - Webhooks — stop polling for the things you need to know about quickly.
- Guides — four end-to-end recipes.
- API reference — every endpoint, generated from the published OpenAPI description.