SHOP DESKDevelopers
shopdeskbd.com

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

EndpointScopeIdempotency-KeyAnswers
POST /ordersorders:writeRequired201 + the created order
PATCH /orders/{id}orders:writeHonoured200 + the updated order (note and follow-up only)
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 }
POST /products/{id}/imagesproducts:writeHonoured201 + the image (one multipart file), or { data } for a batch (links in JSON, or several parts)
POST /products/{id}/images/reorderproducts:writeHonoured200 + { data }, the gallery in its new order
PATCH /products/{id}/images/{imageId}products:writeHonoured200 + the image
DELETE /products/{id}/images/{imageId}products:writeHonoured200 + { id, deleted }
PATCH /variants/{identifier}products:writeHonoured200 + the variant
POST /categoriescatalog:writeHonoured201 + the category (JSON or multipart)
PATCH /categories/{id}catalog:writeHonoured200 + the category (JSON or multipart)
POST /brandscatalog:writeHonoured201 + the brand (JSON or multipart)
PATCH /brands/{id}catalog:writeHonoured200 + the brand (JSON or multipart)
POST /attributescatalog:writeHonoured201 + the attribute with its values
PATCH /attributes/{id}catalog:writeHonoured200 + the attribute with its values
POST /attributes/{id}/valuescatalog:writeHonoured201 + the value
PATCH /attributes/{id}/values/{valueId}catalog:writeHonoured200 + the value
POST /videosvideos:writeRequired201 + the upload session and its part plan
POST /videos/importvideos:writeRequired202 + the video, copied from the link in the background
POST /videos/{id}/part-urlsvideos:writeHonoured200 + { data }, a signed URL per part
POST /videos/{id}/completevideos:writeHonoured200 + the ready video, or 202 while it is being checked
POST /videos/{id}/postervideos:writeHonoured200 + the video with its cover (JSON or multipart)
DELETE /videos/{id}videos:writeHonoured200 + { id, deleted }
POST /products/{id}/videosvideos:writeHonoured200 + the placement
DELETE /products/{id}/videos/{videoId}videos:writeHonoured200 + { 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/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. 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."
  }'

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

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.
  • 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/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

HTTPCodeMeans
400VALIDATION_ERRORA required Idempotency-Key is missing, or one sent to any write is empty or longer than 255 characters. 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, 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).
413VALIDATION_ERRORAn image upload (a multipart part) larger than 10 MB. Resize it and retry.
429RATE_LIMITEDBesides 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).
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.