SALAF EMSDevelopers
salafems.com

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

EndpointScopeIdempotency-KeyAnswers
POST /ordersorders:writeRequired201 + the created order
PATCH /orders/{id}/statusorders:writeHonoured200 + the updated order
POST /orders/{id}/cancelorders:writeHonoured200 + the cancelled order
POST /orders/{id}/paymentspayments:writeRequired201 + the updated order
POST /customerscustomers:writeHonoured201 + the customer
PATCH /customers/{id}customers:writeHonoured200 + the customer
POST /inventory/adjustmentsinventory:writeRequired201 + the resulting stock level
POST /productsproducts:writeHonoured201 + the product
PATCH /products/{id}products:writeHonoured200 + the product
POST /products/{id}/archiveproducts:writeHonoured201 + { id, archived }

Request fields, validation rules and every declared status are in the reference — generated from the same decorators that validate the request, so they cannot drift. This page covers the rules that span all of them.

Idempotency-Key

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

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.

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

What happens on a repeat

SituationResult
First time this key is seenThe operation runs normally.
Same key, same body, already finishedThe stored response is replayed — original status code, byte-identical body, plus Idempotent-Replayed: true. The operation does not run again.
Same key, different body422 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 running409 IDEMPOTENCY_IN_FLIGHT. Retry in a few seconds. The operation never runs twice concurrently.
Same key, original failedRuns fresh. Failures are never replayable — you saw the error, and a retry deserves a real attempt.
Same key, more than 24 hours laterTreated as new.
# 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

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.

Writing with a company key

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

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/adjustmentsreason 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

HTTPCodeMeans
400VALIDATION_ERRORA required Idempotency-Key is missing. fields names the header.
400INSUFFICIENT_STOCKNot enough stock for a line. fields.items says which.
400INVALID_STATUS_TRANSITIONThe order cannot move from its current status to the requested one.
409IDEMPOTENCY_IN_FLIGHTThe same key is still being processed. Wait and retry with the same key.
409CONFLICTA duplicate unique value — a SKU, a phone, a provider transaction id.
422IDEMPOTENCY_CONFLICTThe same key was used for a different body.
422VALIDATION_ERRORAnything else about the request that is wrong, with fields.

Full list on the Errors 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.