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.
Authorization: Bearer salaf_sk_7f3kq9p2xR4mVnB8tLcYwZ1sD6hJ0gKeQaUiOpNmKey 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.
In the dashboard and in our logs a key appears masked, as its prefix sample and last four characters:
salaf_sk_7f3kq9p2…OpNmStore 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.
export SALAF_API_KEY="salaf_sk_YOUR_API_KEY"
curl -sS "https://api.salafems.com/ext/v1/locations" \
-H "Authorization: Bearer $SALAF_API_KEY"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();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
$key = getenv('SALAF_API_KEY');
$ch = curl_init('https://api.salafems.com/ext/v1/locations');
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'];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.
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"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();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
$key = getenv('SALAF_API_KEY');
$ch = curl_init('https://api.salafems.com/ext/v1/orders?store_id=REPLACE_WITH_STORE_ID&limit=50');
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'];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 |
Three rules govern them:
- Write implies read of the same resource. A key with
orders:writealso satisfiesorders: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 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:
- Rotate in the dashboard and copy the new secret.
- Both keys are valid for the grace window — 24 hours by default.
- Deploy the new key wherever the old one lives.
- 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. |
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. Never403; a403would confirm the record exists.
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_KEYfrom 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_atin the dashboard tells you which is which. - Only the scopes it needs. A reporting tool that reads orders should not
hold
customers:readas well.