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

```text
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:

**cURL**

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

**JavaScript**

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

**Python**

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

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

> **Note — Variants are the commercial unit**
>
> Price, SKU and stock hang off variants, not products. A product is the thing
> a shopper browses; a variant is the thing they buy. Every order line
> references a variant.

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

> **Warning — A match never updates the existing record**
>
> If the phone is already known, that customer is used exactly as they are and
> the `full_name`/`email` you sent are ignored. A sale is not a CRM edit. Use
> `PATCH /customers/{id}` when you actually mean to change someone's details.

## 4. Place the 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;
```

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:

```json
{
  "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, and `transaction_id` is 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](https://www.salafems.com/developers/webhooks.md)** — `order.updated`, `order.paid` and
  `order.fulfilled` tell you when the merchant's staff move it, so you do not
  have to poll for it.

## The whole thing, in order

1. Generate an idempotency key and persist it with your record.
2. Resolve variants (or use your SKUs directly).
3. Build the body — customer, items, shipping charge, channel.
4. `POST /orders` with the key. Retry the same key on `409` and on network
   failure.
5. Store `id` and `number`.
6. Subscribe to `order.*` webhooks, or poll `updated_after`, for the rest.
