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.
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:
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"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();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
$key = getenv('SALAF_API_KEY');
$ch = curl_init('https://api.salafems.com/ext/v1/variants/TSHIRT-RED-M');
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']}");
}GET /variants/{identifier} accepts an id, a SKU or a barcode, so a
barcode scanner's output can be looked up directly.
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.
4. Place the order
# 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;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:
{
"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, andtransaction_idis 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 —
order.updated,order.paidandorder.fulfilledtell you when the merchant's staff move it, so you do not have to poll for it.
The whole thing, in order
- Generate an idempotency key and persist it with your record.
- Resolve variants (or use your SKUs directly).
- Build the body — customer, items, shipping charge, channel.
POST /orderswith the key. Retry the same key on409and on network failure.- Store
idandnumber. - Subscribe to
order.*webhooks, or pollupdated_after, for the rest.