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 — generated from the same decorators that validate the request, so they cannot drift. This page covers the rules that span all of them.
Idempotency-Key
Send a unique value per logical operation — not per HTTP attempt. Every retry of the same operation sends the same key.
POST /ext/v1/orders HTTP/1.1
Authorization: Bearer salaf_sk_…
Idempotency-Key: 3f1a5c8e-0b2d-4f77-9a13-5e6c7d8f9a10
Content-Type: application/jsonA 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.
# 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."
}'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");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
// One key per logical order. Store it with your own record before calling.
$idempotencyKey = bin2hex(random_bytes(16));
$payload = json_encode([
'channel' => '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. |
# 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.txtasync 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}]`);
}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
function create_order_once(array $order, string $idempotencyKey, int $attempts = 5): array
{
for ($attempt = 0; $attempt < $attempts; $attempt++) {
$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 => 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. GETrequests ignore the header entirely. Reads are already idempotent.
Writing with a company key
A company key can read every store in the company, but a write has to land in exactly one.
store_idin the body is required. There is no such thing as a company order. Omitting it is422naming 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— never403, 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 is422: 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.
Attribution
channeltakes a channel slug (facebook,online-store), never an id. Omitted, it defaults to the store'smanualchannel. An unknown or inactive slug fails the order rather than mis-attributing it.sourceis alwaysapion 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 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.