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.
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
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"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();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
$key = getenv('SALAF_API_KEY');
$ch = curl_init('https://api.salafems.com/ext/v1/products?status=active&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'];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_priceis the sale price when it is set and genuinely belowprice. 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
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"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();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
$key = getenv('SALAF_API_KEY');
$ch = curl_init('https://api.salafems.com/ext/v1/inventory/levels?variant_id=REPLACE_WITH_VARIANT_ID');
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'];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. |
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.
# 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;For a storefront specifically:
- Send a
channelslug so the merchant can see where their sales come from.GET /channelslists what the store has; omitted, orders land onmanual. - Compute
shipping_chargeyourself. 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.
- Do not send
unit_priceunless 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:
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"
}'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();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
$key = getenv('SALAF_API_KEY');
$payload = json_encode([
'amount' => 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 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. |
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.