Writes & idempotency
Every write endpoint, and the Idempotency-Key contract behind them.
Thirty-two 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, the same image pipeline — 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 five of them require it.
The write endpoints
| Endpoint | Scope | Idempotency-Key | Answers |
|---|---|---|---|
POST /orders | orders:write | Required | 201 + the created order |
PATCH /orders/{id} | orders:write | Honoured | 200 + the updated order (note and follow-up only) |
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 } |
POST /products/{id}/images | products:write | Honoured | 201 + the image (one multipart file), or { data } for a batch (links in JSON, or several parts) |
POST /products/{id}/images/reorder | products:write | Honoured | 200 + { data }, the gallery in its new order |
PATCH /products/{id}/images/{imageId} | products:write | Honoured | 200 + the image |
DELETE /products/{id}/images/{imageId} | products:write | Honoured | 200 + { id, deleted } |
PATCH /variants/{identifier} | products:write | Honoured | 200 + the variant |
POST /categories | catalog:write | Honoured | 201 + the category (JSON or multipart) |
PATCH /categories/{id} | catalog:write | Honoured | 200 + the category (JSON or multipart) |
POST /brands | catalog:write | Honoured | 201 + the brand (JSON or multipart) |
PATCH /brands/{id} | catalog:write | Honoured | 200 + the brand (JSON or multipart) |
POST /attributes | catalog:write | Honoured | 201 + the attribute with its values |
PATCH /attributes/{id} | catalog:write | Honoured | 200 + the attribute with its values |
POST /attributes/{id}/values | catalog:write | Honoured | 201 + the value |
PATCH /attributes/{id}/values/{valueId} | catalog:write | Honoured | 200 + the value |
POST /videos | videos:write | Required | 201 + the upload session and its part plan |
POST /videos/import | videos:write | Required | 202 + the video, copied from the link in the background |
POST /videos/{id}/part-urls | videos:write | Honoured | 200 + { data }, a signed URL per part |
POST /videos/{id}/complete | videos:write | Honoured | 200 + the ready video, or 202 while it is being checked |
POST /videos/{id}/poster | videos:write | Honoured | 200 + the video with its cover (JSON or multipart) |
DELETE /videos/{id} | videos:write | Honoured | 200 + { id, deleted } |
POST /products/{id}/videos | videos:write | Honoured | 200 + the placement |
DELETE /products/{id}/videos/{videoId} | videos:write | Honoured | 200 + { product_id, video_id, removed } |
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 shopdesk_sk_…
Idempotency-Key: 3f1a5c8e-0b2d-4f77-9a13-5e6c7d8f9a10
Content-Type: application/jsonA 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. An
empty or longer value is refused with 400 VALIDATION_ERROR on every write,
including the ones where the header is optional.
# 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.shopdeskbd.com/ext/v1/orders" \
-H "Authorization: Bearer $SHOPDESK_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.shopdeskbd.com/ext/v1/orders", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SHOPDESK_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.shopdeskbd.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['SHOPDESK_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.shopdeskbd.com/ext/v1/orders');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('SHOPDESK_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. |
# 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.shopdeskbd.com/ext/v1/orders" \
-H "Authorization: Bearer $SHOPDESK_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.txtasync function createOrderOnce(order, idempotencyKey, attempt = 0) {
const response = await fetch("https://api.shopdeskbd.com/ext/v1/orders", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SHOPDESK_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}]`);
}import os
import time
import requests
HEADERS = {"Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}"}
def create_order_once(order, idempotency_key, attempts=5):
for _ in range(attempts):
response = requests.post(
"https://api.shopdeskbd.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
function create_order_once(array $order, string $idempotencyKey, int $attempts = 5): array
{
for ($attempt = 0; $attempt < $attempts; $attempt++) {
$ch = curl_init('https://api.shopdeskbd.com/ext/v1/orders');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('SHOPDESK_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 Shopdesk 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. GETrequests 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_idin the body is required. There is no such thing as a company order. Omitting it is422naming 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— never403, 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 is422: 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
channeltakes a channel slug (facebook,online-store), never an id. Omitted, it defaults to the store'smanualchannel. An unknown or inactive slug fails the order rather than mis-attributing it.sourceis alwaysapion 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 — type: "damage" must remove stock, and type: "return" can only
add stock. unit_cost is refused on every type except purchase: the other
types move units at the variant's current average cost, and a decrease filed as
adjustment or damage is booked as a loss on the merchant's P&L at that cost.
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 take no 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. A whole product can
be built in one POST /products: photos by link in images[] (up to 10),
each variant's options[] by attribute and value name (created first with
POST /attributes — an unknown name is a 422 on that exact field and
nothing is created), and each variant's photo as image_index, a position in
the same request's images[]. On PATCH /products/{id}, images[] is the
full gallery: image_id entries keep those photos, source_url entries
add new ones, and photos left out are deleted. Options are set on new
variants only; an existing variant keeps its options. See
Photos by link.
Product images have their own endpoints. POST /products/{id}/images
adds photos: one file as a multipart/form-data image part (PNG, JPEG,
WebP or GIF, at most 10 MB and 8000 px on the longest side — the type is read
from the bytes, not the file name) with alt_text, position,
is_thumbnail and, for a company key, store_id as text fields; up to ten
image parts with per-file options as a JSON string in one images field;
or up to ten links as JSON, { "images": [{ "source_url": "https://…" }] }.
A batch is one gallery change, all or nothing, and answers { data } — the
new images in the order sent. Stored copies are re-encoded to WebP with
metadata stripped, exactly as a dashboard upload is. A gallery holds at most
30 images; the first image a product receives becomes its thumbnail.
POST …/images/reorder takes every image id once, in the new order.
PATCH …/images/{imageId} changes alt text, position or the thumbnail flag
(the bytes are immutable — upload a new image and delete the old one), and
DELETE …/images/{imageId} is permanent: the object leaves storage, variants
that featured it lose the link, and a deleted thumbnail falls back to the
first remaining image. A company key names the store on a DELETE with the
store_id query parameter, since a DELETE carries no body. Each image in
an answer is exactly what the next GET /products/{id} lists in images[] —
id, url, position, alt_text, is_thumbnail. Idempotency-Key is
honoured on all of them. On a JSON body the fingerprint covers the links, and
a replay answers without downloading again; on a multipart upload it covers
the route only — neither the files nor the text fields — so a second upload
under a used key replays the first answer. Reuse a key only when retrying the
same upload.
PATCH /variants/{identifier} edits one variant — price,
discount_price, sku, barcode, weight and its photo (image_id) — by
id, SKU or barcode, through the dashboard's own single-variant edit: the sale
price must stay below the price, and the SKU locks once the variant has stock
or order history (409). null clears the sale price, barcode, weight or
photo. It emits product.updated.
Categories, brands and attributes (catalog:write) are written by the
dashboard's own services, so slugs, the category tree check (a category cannot
move under itself or its descendants), duplicate values and the rule that an
attribute's type cannot change while products use it all behave exactly as
in the dashboard. Category and brand images come either as links in a JSON
body (image_url, logo_url, banner_url) or as parts of a
multipart/form-data body (image, logo, banner, with the other fields as
text and seo as a JSON string). A link is downloaded by Shopdesk, checked
(PNG, JPEG, WebP or GIF read from the bytes, at most 10 MB and 8000 px on the
longest side, https only, public addresses only) and stored as our own copy;
the link itself is never kept. A link that fails fails the whole call with
422 naming the field, and nothing is written. A replayed Idempotency-Key
answers from the stored response without downloading again. An attribute
value's swatch_image_url is handled the same way. At most two calls that
download images run at once per key, and a company may have 2,000 links
downloaded per UTC day — beyond either, 429 RATE_LIMITED. Nothing on this
surface deletes a category, brand, attribute or value.
Product videos (videos:write) upload straight to storage: the API
signs a URL per part, your code PUTs the bytes there, and Shopdesk checks
the finished file itself. On POST /videos and POST /videos/import the
Idempotency-Key is required and is also the upload's name. Within the 24
hours a stored response is kept, a retry replays the first answer as it was
(resumed: false, the parts as they were then); after those 24 hours the
same key still returns the same upload, now with resumed: true. Either way
read GET /videos/{id} for the upload's live state. So reuse a key only for
the same file, and use a new one once that upload has ended. Send no key on
POST /videos/{id}/part-urls: asking again is always safe, and a replay
would hand back URLs that may have expired. A replayed
POST /videos/{id}/complete answers what the first call answered, 202
included: to check a video that stayed verifying, call complete again
without the header, or poll GET /videos/{id}. On the multipart cover
(POST /videos/{id}/poster with a file part) the key covers the form
fields, not the file, so send each new cover with a new key or none.
POST /products/{id}/videos places one of the key's videos and changes
nothing else on the product; videos[] on POST /products does the same
for a new product and needs videos:write beside products:write. A key
sees and changes only the videos it (or a key it replaced by rotation)
started. See
Upload a product video.
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, or one sent to any write is empty or longer than 255 characters. 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, an attribute value — two variants of a product with the same options, a SKU change on a variant with stock or order history, a status change or cancel on an order whose courier parcel is still live (fields.status is ["live_parcel"]), a move to returned before the courier has reported picking the parcel up (fields.status is ["parcel_not_picked_up"]), a move to in_transit while the order's parcel is cancelled (fields.status is ["shipment_cancelled"]), or an attribute type change while products use it (fields.type). |
| 413 | VALIDATION_ERROR | An image upload (a multipart part) larger than 10 MB. Resize it and retry. |
| 429 | RATE_LIMITED | Besides the per-key limit: a third call that downloads images while two are running, the company's 2,000 image links for the day (category, brand and swatch links, photo links on products, and video cover links), or a daily video cap (50 uploads started per store, 20 imports per key). |
| 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 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.