# 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`.

> **Danger — The API key never goes near a browser**
>
> A `salaf_sk_` key grants everything its scopes allow across a whole store,
> and there is no such thing as a public or restricted key. It belongs on your
> server, and only on your server.
>
> Your storefront calls **your** backend; your backend calls Salaf. Any design
> where a shopper's device holds the key is a design where any shopper can read
> every customer record and place orders at any price.

## 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

**cURL**

```bash
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"
```

**JavaScript**

```javascript
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();
```

**Python**

```python
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**

```php
<?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_price` is the sale price** when it is set and genuinely below
  `price`. 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

**cURL**

```bash
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"
```

**JavaScript**

```javascript
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();
```

**Python**

```python
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**

```php
<?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.                          |

> **Warning — Availability is a hint, not a lock**
>
> Nothing reserves stock until the order is created. Between your page render
> and the shopper's checkout, a POS sale can take the last unit — so
> `POST /orders` may still answer `INSUFFICIENT_STOCK`, and your checkout has
> to handle that gracefully rather than treating it as a server error.
>
> That is the correct design: the reservation happens once, atomically, at
> order creation, in the same code the till uses.

## 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.

**cURL**

```bash
# 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."
  }'
```

**JavaScript**

```javascript
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");
```

**Python**

```python
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**

```php
<?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 `channel` slug** so the merchant can see where their sales come
  from. `GET /channels` lists what the store has; omitted, orders land on
  `manual`.
- **Compute `shipping_charge` yourself.** 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](https://www.salafems.com/developers/idempotency.md).
- **Do not send `unit_price`** unless 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:

**cURL**

```bash
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"
}'
```

**JavaScript**

```javascript
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();
```

**Python**

```python
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**

```php
<?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](https://www.salafems.com/developers/webhooks.md) 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.                                                           |

> **Note — There is already a storefront, if you want one**
>
> Salaf ships a hosted, themeable storefront — this guide is for teams who
> specifically want their own. If you are weighing it up: the hosted one
> already has shopper sessions, carts, coupon validation and payment gateways
> wired in, and none of those are things this API hands you.

## 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.
