# 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](https://www.salafems.com/developers/reference.md) — generated from the same decorators that
validate the request, so they cannot drift. This page covers the rules that
span all of them.

> **Note — Write responses are read responses**
>
> A write answers with the object re-read through the **public serializer** —
> the same shape the corresponding `GET` publishes, with orders and customers
> fully expanded. You never have to follow a write with a read to learn what
> you just created, and you can feed a write response into the same parser as a
> `GET`.

## `Idempotency-Key`

Send a unique value per **logical operation** — not per HTTP attempt. Every
retry of the same operation sends the same key.

```http
POST /ext/v1/orders HTTP/1.1
Authorization: Bearer salaf_sk_…
Idempotency-Key: 3f1a5c8e-0b2d-4f77-9a13-5e6c7d8f9a10
Content-Type: application/json
```

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

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

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

**cURL**

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

**JavaScript**

```javascript
async 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}]`);
}
```

**Python**

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

```php
<?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`.
- **`GET` requests ignore the header entirely.** Reads are already idempotent.

> **Warning — Generate the key where the operation is decided**
>
> The most common way to defeat idempotency is to generate the key inside the
> retry loop — that is a new key every attempt, which is exactly the duplicate
> orders the mechanism exists to prevent.
>
> Generate it when you decide to place the order, **persist it with your own
> record**, and reuse it on every attempt including after a process restart.

> **Note — Order creation is protected twice**
>
> `POST /orders` additionally derives a deterministic request id from your API
> key and idempotency key and hands it to the order core. So even in the narrow
> window where the idempotency row was written but the response never stored —
> a crash between the two — a retry converges on the *same order* rather than
> creating a second one. Nothing is required of you to get this; it is why
> order creation is the one write with belt and braces.

## Writing with a company key

A [company key](https://www.salafems.com/developers/authentication.md#store-keys-and-company-keys) can
read every store in the company, but a write has to land in exactly one.

- **`store_id` in the body is required.** There is no such thing as a company
  order. Omitting it is `422` naming 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` — never `403`, 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 is `422`: 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.

> **Warning — An existing customer is never updated by an order**
>
> If the phone matches, that customer is used **as-is**. The `full_name` and
> `email` you sent are ignored — a sale is not a CRM edit, and silently
> renaming a customer because a checkout form was filled in carelessly is worse
> than doing nothing.
>
> `full_name` is required only when the phone matches nobody, because a new
> record needs a name. To change an existing customer, call
> `PATCH /customers/{id}` deliberately.

### Attribution

- **`channel`** takes a channel *slug* (`facebook`, `online-store`), never an
  id. Omitted, it defaults to the store's `manual` channel. An unknown or
  inactive slug fails the order rather than mis-attributing it.
- **`source` is always `api`** on 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](https://www.salafems.com/developers/errors.md) 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](https://www.salafems.com/developers/rate-limits.md).
