SHOP DESKDevelopers
shopdeskbd.com

Changelog

Every change to the public API, newest first.

Every change to the public API, newest first. Additive changes ship inside v1; anything breaking follows the deprecation policy.

2026-10-11 — Money received keeps cancelled orders

  • money_received is stable. The sales summary (GET /reports/sales-summary) now counts money that arrived on orders later cancelled or returned, so a past window's money_received no longer drops when an order is cancelled. It still counts payments settled in the window, after refunds and voids.
  • Unchanged. Status values, error codes and the other money fields are the same.

2026-10-11 — In transit at pickup, and orders too large to hold

  • In transit at pickup. An order whose parcel is booked with Pathao, RedX, CarryBee or Bahok now stays ready_to_ship until the courier reports the pickup, then moves to in_transit (with its order.updated webhook). Steadfast and hand-recorded shipments still move to in_transit when they are booked.
  • Orders too large to hold. POST /orders refuses an order whose line or total comes to more than 9,999,999,999.99 with 422 VALIDATION_ERROR (it used to fail with 500 INTERNAL).
  • Unchanged. Status values, other error codes and money fields are the same.

2026-10-11 — Unpaid online-payment orders are cancelled automatically

  • Automatic cancels. A storefront order placed with an online payment method (bKash, SSLCommerz) that is still pending with nothing paid after the store's window (default 24 hours, set in the dashboard) is cancelled. You receive the usual order.cancelled webhook; the order's timeline names the System user.
  • Unchanged. Status values, error codes and money fields are the same.

2026-10-11 — Payments: transaction IDs and parcels out for delivery

  • Transaction ID required by the method. POST /orders/{id}/payments (and the MCP record_order_payment tool) now refuses a payment through a payment method set to require a transaction id when none is sent: 400, error.fields.transactionId is ["transaction_id_required"].
  • Duplicate transaction id names the order. Recording a transaction id already on a settled payment in the store is still a 409 CONFLICT; its code is now duplicate_transaction_id, and the message names the order that holds it.
  • Parcels out for delivery. A payment is refused with cod_parcel_active only while a courier parcel with a cash-on-delivery amount is out. A shipment delivered by the store's own rider, or a courier parcel booked with nothing to collect, no longer blocks it.
  • Unchanged. Status values, money fields and webhooks are the same.

2026-10-11 — No courier charges on a parcel the courier never took

  • returned without a pickup. On an order that was never delivered, PATCH /orders/{id}/status to returned (and the MCP update_order_status tool) no longer books the courier's expected delivery and return charges when the parcel's pickup is not recorded. If the courier bills them anyway, they arrive with its statement. With a recorded pickup nothing changes.
  • Unchanged. Status values, error codes, money fields and webhooks are the same.

2026-10-06 — A returned order keeps the delivery charge only when the courier had the parcel

  • returned and the advance. On an order that was never delivered, PATCH /orders/{id}/status to returned (and the MCP update_order_status tool) keeps up to the delivery charge from an advance only when the parcel's pickup is recorded. Without a recorded pickup, everything the customer paid is owed back to them. Nothing changes for orders already returned.
  • Unchanged. Status values, error codes, money fields and webhooks are the same.

2026-10-06 — Parcels the courier picked up can no longer be cancelled

A courier parcel can no longer be cancelled in the dashboard once the courier has picked it up; the merchant asks the courier to bring it back instead. Two order status moves through the API change with it.

  • live_parcel now says which way out. The 409 CONFLICT with error.fields.status ["live_parcel"] keeps its code. Its message now depends on the parcel: before pickup, cancel it in the dashboard first; after pickup, ask for it back with a Return request in the dashboard. For a courier that never reports a pickup (Steadfast) it names both ways out.
  • New refusal. PATCH /orders/{id}/status to in_transit, and the MCP update_order_status tool, are refused while the order's parcel is cancelled: 409 CONFLICT, error.fields.status is ["shipment_cancelled"]. Book the parcel again in the dashboard.
  • Unchanged. Status values, money fields and webhooks are the same.

2026-10-06 — Returned orders keep their stock held until the parcel is received

When an order moves to returned because the courier is bringing the parcel back, its stock is no longer put back on sale at once. It stays held for the order (not available to sell) until the merchant receives the parcel in the dashboard ("RTO received"); units that came back damaged are then written off. Stock levels you read through the API (available) reflect this.

  • New refusal. PATCH /orders/{id}/status to returned, and the MCP update_order_status tool, are refused while a courier that reports pickups (Pathao, RedX, CarryBee, Bahok) has not reported picking the parcel up: 409 CONFLICT, error.fields.status is ["parcel_not_picked_up"]. Cancel the parcel if the courier never took it.
  • Unchanged. The order status, money fields and order webhooks are the same as before. Stock-level updates (inventory.updated) now come when the parcel is received, not when the order turns returned. A sales channel's own "returned" still puts stock back at once.

2026-10-06 — A customer return no longer takes back the courier's collection

This is about a delivered cash-on-delivery order that the courier already collected. When the customer returns it and the store receives the goods as a return in the dashboard, the order now keeps its money figures until the store records the refund. The money is part of the courier's settlement, and the store refunds the customer from its own account.

  • Before. Receiving the last unit raised refunded_amount by the whole collected amount and changed payment_status, as if the money had gone back, although no refund was made.
  • Now. paid_amount and refunded_amount stay as they are until the store records the refund. Then refunded_amount and payment_status move as for any refund.
  • Status. The order still moves to returned, as before.
  • Nothing else changes shape. No new fields, no new status values.

2026-10-05 — Orders the customer kept only part of

fulfillment_status can now read partially_fulfilled. The value was always in the schema; nothing wrote it until now. It marks a delivered order whose customer kept only some of the items, while the courier brings the rest back. The order status stays delivered, and GET /orders?fulfillment_status=partially_fulfilled (and the MCP list_orders filter) now finds these orders.

  • Money. When the courier says how much it collected, the order's paid amount goes down by what the courier did not collect, at once. So due_amount shows the value of the items coming back until they are received as a return in the dashboard. Then it goes back to 0.
  • Webhooks. Marking an order this way sends order.updated, with previous.fulfillment_status.
  • Nothing else changes shape. No new fields, no new status values.

2026-10-05 — Delivered orders can no longer be moved to returned

PATCH /orders/{id}/status and the MCP update_order_status tool now refuse one move they used to accept: a delivered order to returned. The answer is 400 INVALID_STATUS_TRANSITION, with error.fields.status as before, and nothing is written.

  • Why. That move put every unit back on the shelf but kept the sale as the store's income, and the customer's money did not show as owed back. A delivered order now comes back through a return in the Shopdesk dashboard, which records each item and the money owed to the customer.
  • What still works. in_transit and failed_delivery to returned (the courier brought the parcel back without delivering it) are unchanged, and so are courier and sales-channel updates.

This goes out without the usual deprecation window because the old behaviour recorded wrong figures in the merchant's books, not merely a different shape.

2026-09-29 — Store MCP: videos on products

The store MCP server (/ext/mcp/v1) gains one tool, attach_product_video, and the product tools' results gain videos[]. Nothing existing changes behaviour. The REST side of this release is in the entry below.

  • attach_product_video (videos:write) puts a video on a product. With video_url, a link to an H.264 MP4 file, it imports the file and places it in one call. It answers at once, and video.status and placement.status say where the import is. With video_id, a video this key uploaded or imported, it only places it. With neither, it changes nothing and returns upload_steps, the REST calls a script makes to upload a file. It takes gallery_slot, sort_order and title, and the option value either by name, as option: { "attribute": "Colour", "value": "Red" }, or as option_value_id.
  • An option by name reads the product, so it needs products:read beside videos:write. A name that the product's active variants do not use fails with the product's options as candidates, and nothing is imported or placed. With products:read, an option_value_id sent with video_url is checked the same way before anything is imported. If the import started but the video could not be placed, the error names the imported video's id, to place it with video_id.
  • idempotency_key is required with video_url, so a retried call never imports the file twice. The server's instructions say so too.
  • It declares openWorldHint: true, because it downloads from outside the store. The import follows the REST rules: https only, public addresses only, a stated size of at most 200 MB within the plan's video storage, and 20 imports per key per UTC day.
  • Products gain videos[] in the results of get_product, list_products, create_product and update_product: the product's ready videos, as on REST. create_product does not take videos; place them with attach_product_video once the product exists.
  • Still REST-only: the upload session (POST /videos, part URLs, complete, GET /videos), covers, deleting a video and taking a video off a product. The reasons are listed on MCP tools. See Upload a product video.

Additive: new endpoints, one new scope, new error codes and one new field on products. No existing key gains access, and nothing was removed. Every video endpoint needs a plan that includes product videos.

  • A new scope, videos:write. It uploads and imports product videos, sets their covers, places them on products and lists or deletes the key's own uploads. It is its own opt-in scope rather than part of products:write, because a video uses the plan's video storage and can be up to 200 MB. Existing keys do not get it. See Authentication.
  • Uploads straight to storage. POST /videos starts an upload of one H.264 MP4 of at most 200 MB (3 seconds to 5 minutes) and reserves its size against the plan's video storage. POST /videos/{id}/part-urls signs up to 10 parts at a time, each with its base64 SHA-256; your code PUTs each part to its URL. POST /videos/{id}/complete answers 200 when the video is ready, 202 while it is still being checked (poll GET /videos/{id}), or 422 with a reject code. Idempotency-Key is required on the start and also names the upload: within 24 hours a retry replays the first answer; after that the same key returns the same upload (resumed: true). Uploads must come from a server: storage accepts browser uploads from the Shopdesk dashboard only. A finished video that no product shows 48 hours after it is ready is released and removed 7 days later.
  • Imports from a link. POST /videos/import takes an https link to an MP4 file (not a YouTube or other player page) and answers 202. Shopdesk downloads the file in the background and checks it like an upload. The link must state the file's size, which must fit 200 MB and the plan's remaining video storage. A failed import ends aborted with an import_* code in failure.code and leaves nothing in storage. At most 20 imports start per key per UTC day. Each company's imports are copied one at a time; one whose copy has not started within an hour ends expired, and a download slower than 64 KB a second on average after its first minute ends import_download_failed. Without file_name, the video is named after the link's last path segment only when that reads as a file name, else video.mp4; nothing else of the link is kept.
  • Covers. POST /videos/{id}/poster sets the picture shown before the video plays: an uploaded file (multipart), one of the product's photos (image_id), or a link. A JPEG, PNG or WebP of at most 2 MB. Without a cover, product pages show the product's main photo.
  • Videos on products. POST /products/{id}/videos places one of the key's videos on a product, for every variant or for one option value (option_value_id, such as Colour: Red), with gallery_slot, sort_order and title. Placing it again moves it, and nothing else on the product changes. DELETE /products/{id}/videos/{videoId} takes a video off. POST /products takes videos[] with the same fields, which needs videos:write beside products:write. A video may be placed while it is still being checked; the product shows it once it is ready. While video uploads are switched off (403 VIDEO_UPLOADS_DISABLED) no video is placed, but one can still be taken off.
  • Products gain videos[] on GET /products, GET /products/{id}, the product write responses and product.* webhook payloads: id, url, poster_url, width, height, duration_ms, has_audio, title, gallery_slot, sort_order and option_value_id. Only ready videos are listed, whoever uploaded them. The list is empty when the store's plan does not include product videos, as on the store's own product pages. option_value_id is null for a value no active variant uses (the product page then shows the video for every variant).
  • product.updated when a placed video changes. Besides product edits, product.updated is sent for each product that shows a video when the video turns ready, when a ready video's cover changes, and when a released video is taken off archived products — so a webhook consumer sees a video arrive without polling.
  • Each key sees only its own uploads. GET /videos lists the videos this key started, and another key's video is 404 on every /videos endpoint. A key made by rotating another keeps the uploads of the key it replaced. DELETE /videos/{id} cancels an unfinished upload or releases a video that no live product shows.
  • Limits. 4 unfinished uploads per key and 6 per store (409 TOO_MANY_OPEN_UPLOADS); 50 uploads started per store per UTC day (429 RATE_LIMITED with Retry-After to midnight UTC); the plan's video storage and videos per product (403 PLAN_LIMIT_REACHED). A cover by link or upload counts as one image under the image limits. See Rate limits.
  • New error codes, answered only by the video endpoints and by placing videos on a product: VIDEO_UPLOADS_DISABLED, PLAN_LIMIT_REACHED, TOO_MANY_OPEN_UPLOADS, FILE_CHANGED, PARTS_INCOMPLETE, UPLOAD_IS_IMPORT, UPLOAD_VERIFYING, VIDEO_IN_USE, UPLOAD_NOT_ACTIVE, STORAGE_UNAVAILABLE, and the 14 VIDEO_* reject codes (VIDEO_HEVC, VIDEO_FRAGMENTED, VIDEO_TOO_LONG…). PLAN_FEATURE_REQUIRED now also covers product videos. The plan's limit on the number of products still answers 403 FORBIDDEN_SCOPE, as before. See Errors.
  • A new reference page, Videos, and a new guide, Upload a product video, which uploads, covers and places a video with plain HTTP calls.

The store MCP server (/ext/mcp/v1) gains five catalog tools, and create_product takes photo links and each variant's options by name. One behaviour change is listed first; clients that follow the MCP specification need no change. The REST side of this release is in the two entries below.

  • Behaviour change: create_product now declares openWorldHint: true. It downloads the photo links it is given, so it no longer claims to stay inside the store; a client that asks before running an open-world tool will now ask for it. Its new arguments are optional: a call without them behaves exactly as before. create_category, create_brand and add_product_images_from_urls declare it too; every other tool still states openWorldHint: false.
  • create_product builds a whole product. It takes images (up to 10 photo links, stored before the call answers), each variant's options — { "attribute": "Colour", "value": "Red" }, by name (any case), slug or id — and image_index, the variant's photo by its position in images. A new variant on update_product takes options too. An option name that matches nothing fails the call with nothing created and nothing downloaded.
  • Five new tools. create_attribute (catalog:write + products:read) makes sure an attribute exists with the values listed: if it already exists, it adds only the missing values and never renames or removes anything, so calling it twice adds nothing. create_category (catalog:write + products:read) takes its parent by name, slug or id, with image_url and banner_url by link. create_brand (catalog:write) takes logo_url and banner_url. add_product_images_from_urls (products:write) adds up to 10 photos from links in one change. update_variant (products:write) edits one variant's price, sale price, SKU, barcode, weight or photo, found by id, SKU or barcode. A key lists the new tools its scopes allow, as before.
  • Candidates instead of guesses. When a name matches nothing, or more than one thing — an option on create_product or update_product, the attribute on create_attribute, the parent on create_category — the failed result's error object carries candidates beside fields: the store's variant attributes, an attribute's values, or the matching categories, names beside ids. The rest of the error object is unchanged.
  • Links work as on REST. The same checks (https, public addresses only, PNG, JPEG, WebP or GIF read from the bytes, at most 10 MB, 8000 px and 40 megapixels), the same all-or-nothing rule, and the same limits: each link counts as one write, two image calls at once per key, and 2,000 links per company per UTC day. A retried call with the same idempotency_key answers without downloading again. See Photos by link.
  • Still REST-only: reordering photos, editing a photo's alt text or main flag, deleting photos, multipart uploads, swatch images by link, and updating categories, brands, attributes or values — the reasons are listed on MCP tools. See Build a catalog through the API.

Additive: new endpoints, new optional fields and one new field on variants. One scope change and three behaviour changes are listed first.

  • Scope change: products:write also downloads photo links. A key with products:write can now send photo URLs, which Shopdesk downloads and stores — the same kind of change as its image uploads, fenced by the same checks and limits as category and brand links. No other key gains access.
  • Behaviour change: an update keeps a variant's options. PATCH /products/{id} with variants used to drop the options (Colour, Size…) of the existing variants it listed, because the body had no way to carry them — or refuse with 409 when the variant had sold. Existing variants now keep their options, from the REST API and the store MCP server's update_product alike.
  • Behaviour change: stricter image checks on uploads. POST /products/{id}/images now also refuses an image over 8000 px on its longest side or over 40 megapixels in all (16 for a GIF), or a file that is damaged or cut short (422 on image), as links already were. The file's signature is checked before anything decodes it. Normal photos are unaffected.
  • Behaviour change: images count as writes, and uploads share the image limits. Each image a call carries — a link or an uploaded file — counts as one write against the key's per-minute write limit, and a multipart upload now counts toward the two image calls a key may run at once. One request's files may total at most 50 MB (413). Single-photo uploads are unaffected. See Rate limits.
  • Behaviour change: product.updated on a single-variant edit. Editing one variant — in the dashboard, or through the new PATCH /variants/{identifier} — now emits product.updated. It did not before.
  • Build a whole product in one call. POST /products takes images[]: up to 10 photos by source_url, stored before the call answers. Each variant takes options[] — { "attribute": "Colour", "value": "Red" }, by name (any case), slug or id; an unknown name is a 422 on that field pointing at POST /attributes, and nothing is created — and image_index, its photo's position in images[]. See Build a catalog through the API.
  • Restate the gallery on update. On PATCH /products/{id}, images[] is the full gallery: image_id keeps a photo, source_url adds one, and photos left out are deleted. A new variant takes options[]; a variant takes image_index or image_id for its photo. options on an existing variant is refused.
  • All or nothing. One photo link that cannot be used fails the whole call with 422 naming images[i].source_url; nothing is written, and photos already stored for the call are deleted. A replayed Idempotency-Key answers without downloading again. See Photos by link.
  • Photo batches. POST /products/{id}/images adds up to 10 photos in one gallery change: links in a JSON body ({ "images": [{ "source_url": … }] }), or up to 10 image parts with per-file options as a JSON string in an images field. A batch answers { data }, the new images in the order sent. A single image part without images answers exactly as before.
  • POST /products/{id}/images/reorder takes every image id once, in the new order; a list that leaves one out or repeats one is refused.
  • PATCH /variants/{identifier} edits one variant's price, discount_price, sku, barcode, weight and photo (image_id), by id, SKU or barcode, with the dashboard's rules (a sale price below the price; a SKU locked once the variant has stock or order history).
  • Variants gain options[] — { attribute_id, attribute, value_id, value }, ordered by attribute name — on every product and variant read, in product.* webhook payloads, and in the store MCP server's product tools.

Additive: new endpoints, one new scope, and new fields on categories and brands. No existing key gains write access, and nothing was removed. The one scope change is listed first.

  • Scope change: products:read also reads attributes. Attributes join categories and brands as catalog structure, so GET /attributes and GET /attributes/{id} answer any key with products:read (or products:write).
  • A new scope, catalog:write, creates and updates categories, brands and attributes. It is granted only together with products:read (or products:write): creating a key with it alone is refused, and the dashboard ticks products:read for you. Existing keys do not get it. See Authentication.
  • Attributes. GET /attributes (with search and type) and GET /attributes/{id} return each attribute with its values[] (id, value, position, swatch_hex, swatch_image_url). POST /attributes creates one with its first values; PATCH /attributes/{id} renames it or changes its slug, input_type or flags — type only while no product uses it (409, fields.type). POST /attributes/{id}/values and PATCH …/values/{valueId} add and edit values; a repeated value is 409 with fields.value.
  • Category and brand writes. POST /categories, PATCH /categories/{id}, POST /brands and PATCH /brands/{id} take the name, slug, description, status, SEO and (categories) parent_id and position. Images come as links in a JSON body (image_url, logo_url, banner_url) or as parts of a multipart/form-data body (image, logo, banner); remove_image, remove_logo and remove_banner delete them. A replaced image is deleted only after the new one is saved. An explicit slug must use lowercase letters, digits and single hyphens, and a rename keeps the slug, as in the dashboard. Nothing on the API deletes a category, brand, attribute or value.
  • Images by link are downloaded and stored by Shopdesk, never kept as links: https only, public addresses only, at most 3 redirects, PNG, JPEG, WebP or GIF read from the bytes (never SVG), at most 10 MB, 8000 px on the longest side and 40 megapixels (16 for a GIF), 10 s per link, 30 s and 50 MB per call. One failing link fails the whole call with 422 naming the field, and nothing is written. The same checks apply to uploaded parts (50 MB in all per request), and to an attribute value's swatch_image_url; a swatch copy that no value shows any more is deleted. Each image counts as one write; at most two calls that download or upload images run at once per key, and a company may have 2,000 links downloaded per UTC day (429 RATE_LIMITED with Retry-After beyond any of these). See Rate limits.
  • Both body encodings in the spec. POST /products/{id}/images and the category and brand writes declare their JSON body and their multipart/form-data body in the OpenAPI document, so generated clients see both; the reference shows the JSON one.
  • Every write documents its 409. Once Idempotency-Key is sent, any write answers 409 IDEMPOTENCY_IN_FLIGHT while an earlier request with the same key is still running; every write's reference entry now says so, beside its own conflicts. Behaviour is unchanged.
  • Categories and brands gain banner_image and seo ({ title, description } or null) in every response, including the store MCP server's list_categories and list_brands.
  • Correction: the multipart image upload's Idempotency-Key covers the route only, not the text fields as this changelog and the idempotency page said. Behaviour is unchanged; the docs now say what it does.

2026-09-29 — Store MCP: order-desk tools, structured results, per-key tool lists

The store MCP server (/ext/mcp/v1) gains eight tools for the order desk, and every tool now returns structured results. Three behaviour changes are listed first; clients that follow the MCP specification need no change. The REST changes of this release are in the next entry.

  • Behaviour change: each key lists only its own tools. tools/list now shows only the tools whose scopes the key holds — a read-only key no longer sees the write tools. Calling a tool that is not in the key's list is refused as an unknown tool. Before, every key listed every tool and a call without the scope came back as an in-band refusal.
  • Behaviour change: reads use the read limit. Tool calls were all counted against the key's write limit. Now initialize, tools/list and read-only tools count against the read limit, get_sales_summary and get_customer_summary count as 5 reads each, and write tools, unknown tools (including a tool outside the key's scopes, which its tools/list does not show) and unknown methods count against the write limit. The bucket is chosen from the JSON-RPC message, never from the Mcp-Method or Mcp-Name headers. JSON-RPC batches are refused with 400 and error -32600. See Rate limits.
  • Behaviour change: compact output. Every tool declares an outputSchema and answers with structuredContent; the text copy is the same value as minified JSON, where it used to be indented.
  • Eight new tools. get_store_profile (the store and the key's access), get_sales_summary, list_low_stock, find_orders (by order number, name or any form of phone number), list_orders_needing_attention (unconfirmed orders, due call-backs), get_customer_summary (a customer with their order history, by id or phone), update_order_details (note and call-back) and record_call_outcome (confirmed, cancelled, call back or no answer, in one call). The new read tools answer concise by default; pass response_format: "detailed" for the full objects. See Build a COD confirmation tool.
  • The existing tools take the new REST filters. list_orders gains customer_id, assignee_id, follow_up_before and follow_up_after; list_customers, list_categories and list_brands gain search; get_customer takes expand: ["stats"].
  • Shopper-written text is returned as data. Order and follow-up notes, delivery addresses, customer names and emails, and checkout answers come back in labelled fields, at most 500 characters, with links, markup and hidden characters removed. The REST API still returns them unchanged.
  • Every tool states openWorldHint: false: none of them reaches a system outside the store.
  • A generated tool reference. MCP tools documents every tool from the published manifest at /openapi/mcp-tools-v1.json. The docs MCP server gains list_mcp_tools and get_mcp_tool, and its tools now declare readOnlyHint and refuse unknown arguments.

Additive: new endpoints, fields, filters, two new scopes and one new error code. No existing key gains access, and no field or endpoint was removed. Two behaviour changes are listed last. The store MCP changes of this release are in the entry above.

  • Two new scopes. store:read covers the store profile. reports:read covers the sales reports and is flagged sensitive. Existing keys do not get them; create a new key to use the endpoints below.
  • GET /store (store:read): name, primary domain, currency, business timezone, public contact details, storefront localization and the advance-payment plans offered at checkout. Company-scoped keys pass store_id.
  • GET /me (any valid key): the calling key's id, label, type, prefix, scopes and expiry, its company, the live stores it reaches and its per-minute rate limits. Nothing secret.
  • GET /reports/sales-summary (reports:read): orders, gross and net sales, net revenue and average order value for orders delivered in a window, the money received, and the cash on delivery still to collect. Each figure states its basis. Available on every plan with API access. compare=previous_period adds the equal-length window before and needs Advanced Reports. See Sales and reports.
  • GET /reports/top-products (reports:read, Advanced Reports): variants ranked by the revenue of units delivered and kept. It returns revenue and units only, never cost. Windows for both reports span at most 366 days, and answers are cached for 5 minutes. Both reports take from/to or a named period (today, yesterday, last_7_days, last_30_days, this_month, last_month), read on the store's calendar. Each report call counts as 5 reads against the key's read limit. See Rate limits.
  • A new error code, PLAN_FEATURE_REQUIRED (403). Returned when the key and its scopes are fine but the store's plan lacks the feature — today, Advanced Reports for compare=previous_period and top products. It is told apart from FORBIDDEN_SCOPE because the fix is a plan upgrade, not a new key. Only the new report features return it. See Errors.
  • PATCH /orders/{id} (orders:write): edits the order note, follow_up_at and follow_up_note, in any status, with an optional Idempotency-Key. null clears a field. It cannot change prices, lines or stock. See Build a COD confirmation tool.
  • Orders gain follow_up_at, follow_up_note and assignee ({ id, name } or null), in REST responses and order webhook payloads. New list filters: follow_up_before, follow_up_after, assignee_id and customer_id.
  • GET /customers/{id}?expand=stats adds orders_count, total_spent, last_order_at and returned_count. It needs orders:read as well as customers:read; without it, stats is left out and the call still succeeds.
  • search on GET /customers (name, phone or email), GET /categories and GET /brands (name or slug). GET /customers also takes phone: an exact match on the phone alone, in any Bangladeshi form.
  • Inventory levels gain variant.product_name, beside variant.name, in GET /inventory/levels, POST /inventory/adjustments and the inventory.updated webhook payload.
  • Behaviour change: phone search. On orders and customers, search now matches a phone in any Bangladeshi form: +8801712345678, 8801712345678 and 01712345678 find the same records. Before, the +880 forms found nothing. The term is still matched as typed on every field, and the phone field also tries it rewritten as 01XXXXXXXXX, so order numbers such as 20260929-0001 match exactly as before. Searches can only return more results than before.
  • Behaviour change: more order.updated events. order.updated now also fires when an order's note, follow-up or delivery address changes, whether in the dashboard or through the API. data.previous holds the old note, follow_up_at, follow_up_note or shipping_address, named as in data.object. The keys of a lines edit are unchanged and stay camelCase (subtotal, discountAmount, shippingCharge, taxAmount, totalAmount, itemCount); Webhooks now lists every key. Receivers that act only on data.previous.status should ignore events without it.

2026-09-29 — Contract fixes, a Courier Check cap on API orders, and the versioning policy

No REST endpoint, field or response body changes; the MCP change is below. Two behaviour changes, then a set of fixes that make the published contract match the server:

  • Courier Check runs at most 100 times per company per UTC day for orders created through the API (source: "api"). Past that, an API-created order is placed exactly as before but is not checked, then or later. A staff member can still re-check it from the order page in the dashboard, and that manual re-check is never capped. Storefront, dashboard, POS and sales-channel orders are not capped. The reason: an API key can create an order for any phone number, and a check looks that number up with the couriers.
  • Three scope descriptions now say only what ships. The key-creation screen promised endpoints that do not exist yet. inventory:write reads Stock adjustments, each with a reason (transfers come later); payments:read reads Payments recorded against orders (payment methods will sit under a store scope); payments:write reads Record a manual payment against an order (refunds come later). What each scope allows is unchanged, and no key gains or loses access.
  • A 401 no longer carries X-RateLimit-* headers. Those came from the per-IP backstop and put a relative number of seconds in X-RateLimit-Reset, where the documented value is a Unix timestamp. Every X-RateLimit-* header you receive is now your key's own — see Rate limits.
  • Browser access (CORS). DELETE is now an allowed method, so DELETE /products/{id}/images/{imageId} works from a browser. The store MCP server's MCP-Protocol-Version, Mcp-Method and Mcp-Name request headers are allowed, and Deprecation, Sunset and Link are readable.
  • The OpenAPI description is more accurate. Every write now declares its 400: any write refuses an Idempotency-Key sent empty or longer than 255 characters, the three that require the header also refuse a missing one, and order creation (INSUFFICIENT_STOCK) and the status and cancel routes (INVALID_STATUS_TRANSITION) name their own 400 in the same response. The status and cancel routes also declare their 409, returned while the order's courier parcel is live (fields.status is ["live_parcel"]). expand lists its values (customer, items). The Idempotency-Key example is a complete UUID, and info.contact.email is filled in. Two field descriptions that had fallen behind the server were regenerated, and CI now fails when the committed description is stale.
  • Store MCP errors. A failed tool call carries its request id in the result's _meta.request_id (the same value as the X-Request-Id header), not in the text the model reads. Failed tool calls now also count as errors on the dashboard's API usage page — they are HTTP 200s, so they used to count as successes.
  • Versioning policy. A breaking change only ever ships as a new major version, with at least 12 months between the deprecation notice and the shutdown (it was six), announced with Deprecation and Sunset headers. The never-deployed /v1 short alias is dropped: /ext/v1 is the base path. See Versioning.
  • Free developer test stores, on request. Building an integration and need a store that is not a merchant's live one? Email support@shopdeskbd.com.

2026-09-29 — Undo of a hand-made stock entry shows up in the movements list

Merchants can now undo (part of) a stock entry they made by hand in the dashboard — "I added 15, it should have been 10". The undo is its own row in GET /inventory/movements (additive; nothing existing changes):

  • Same type as the entry, negative quantity. A purchase or adjustment row with a negative quantity and reference_type manual_undo; reference_id is the id of the entry it takes back.
  • It moves stock like any other movement, so inventory.adjusted and inventory.updated webhooks fire for it as usual.
  • Units sold since the entry are never undone, and entries made through the API cannot be undone from the dashboard — correct those through the API.

2026-09-28 — Stock adjustments refuse two requests that booked wrong numbers

POST /inventory/adjustments and the MCP adjust_inventory tool now refuse two requests they used to accept. Both answer 422 VALIDATION_ERROR, and nothing is written:

  • unit_cost on any type except purchase. A purchase still requires it. On adjustment, damage and return the units move at the variant's current average cost — the value the stock valuation already carries them at. A stated cost there booked the write-off (or the found stock) at a number the valuation never had, and the two drifted apart. Drop the field from those requests; nothing else changes.
  • A return that removes stock — a negative quantity_change, or a set_on_hand below the current on-hand. A removal filed as a return booked no write-off at all, so the loss never reached the merchant's Profit & Loss. Record units that are gone as adjustment or damage; a return can still add stock.

This goes out without the usual deprecation window because the old behaviour recorded wrong figures in the merchant's books, not merely a different shape.

2026-09-14 — Salaf is now Shopdesk

The product was renamed; the API kept its shape. What changed on the wire, each with a compatibility path so nothing breaks on the day:

  • Documentation and hosts live at www.shopdeskbd.com and api.shopdeskbd.com. The spec title is "Shopdesk Commerce API"; the store MCP server now announces itself as shopdesk-commerce.
  • API keys created or rotated from now on start with shopdesk_sk_. Every existing salaf_sk_ key keeps working unchanged — see Authentication.
  • Webhook headers are X-Shopdesk-Signature, X-Shopdesk-Event, X-Shopdesk-Event-Id, X-Shopdesk-Delivery and X-Shopdesk-Api-Version. Each is also sent under its old X-Salaf-* name, same value, until 2026-12-31. The User-Agent is Shopdesk-Webhooks/1.0.
  • The test event is shopdesk.ping. Endpoints subscribed to salaf.ping were migrated automatically; the old name is still accepted when you save an endpoint.
  • The documented signature test vector was recomputed for the renamed event body.

2026-09-14 — Product images over the API

Galleries can now be managed without the dashboard. Three endpoints under products:write, all running the same gallery code the dashboard runs — the same WebP re-encode, the same thumbnail rules, the same 30-image cap:

EndpointDoes
POST /products/{id}/imagesUploads one image (multipart/form-data, image part, PNG/JPEG/WebP/GIF ≤ 10 MB) with optional alt_text, position and is_thumbnail
PATCH /products/{id}/images/{imageId}Changes alt text, position or which image is the thumbnail
DELETE /products/{id}/images/{imageId}Removes the image permanently (company keys pass store_id as a query parameter)
  • images[] on every product read gained id, alt_text and is_thumbnail (additive — url and position are unchanged). The same fields appear in product.* webhook payloads.
  • Each write answers with the image exactly as the next GET /products/{id} will list it.
  • Idempotency-Key is honoured on all three; the fingerprint covers the route and text fields, not the file bytes.
  • These are REST-only — the store MCP server gains no tools for them.

2026-08-15 — The API speaks MCP

The API is now also a Model Context Protocol server at https://api.shopdeskbd.com/ext/mcp/v1 — Streamable HTTP, the same Bearer keys, the same tenant boundary, the same scopes. Nothing about REST changes; this is a second, agent-shaped door into the same implementation.

  • 22 curated tools: 12 reads over products, orders, payments, customers, inventory, locations and channels, and 10 writes — one per REST write endpoint. Results are byte-equivalent to the corresponding REST responses.
  • Each tool requires its REST twin's scope. A refusal is an in-band, readable error naming the missing scope; a read-scoped key cannot write.
  • Write tools take idempotency_key as an argument (MCP has no headers) — required on create_order, record_order_payment and adjust_inventory, honoured everywhere else, with the same replay/conflict rules as the header.
  • A companion docs MCP server — no auth, answers from these pages — runs at https://www.shopdeskbd.com/developers/mcp. See Build with AI.

2026-08-11 — Write endpoints and idempotency

Ten write endpoints, all running the same code the dashboard runs — the same stock reservation, coupon engine, totals math and status-transition rules. See Writes & idempotency.

ResourceEndpoints
OrdersPOST /orders, PATCH /orders/{id}/status, POST /orders/{id}/cancel
PaymentsPOST /orders/{id}/payments, and the read that completes the resource, GET /orders/{id}/payments
CustomersPOST /customers, PATCH /customers/{id}
InventoryPOST /inventory/adjustments
ProductsPOST /products, PATCH /products/{id}, POST /products/{id}/archive
  • Idempotency-Key is now honoured on every write and required on POST /orders, POST /orders/{id}/payments and POST /inventory/adjustments (400 naming the header when it is missing). A repeat of a completed request replays the original response verbatim with Idempotent-Replayed: true; a repeat with a different body is 422 IDEMPOTENCY_CONFLICT; a repeat while the original is running is 409 IDEMPOTENCY_IN_FLIGHT. Keys are isolated per API key and expire after 24 hours.
  • Company keys must send store_id in the body on every write (422 without it). A store key may repeat its own store id and may not name another (422).
  • Find-or-create customers on order creation, matched on the canonical Bangladesh phone number. An existing customer is never modified by an order.
  • Two new error codes are now reachable: INSUFFICIENT_STOCK and INVALID_STATUS_TRANSITION, both 400, both carrying fields.
  • The write rate-limit bucket is now in use — 30 requests per minute per key by default, counted separately from reads.
  • The OpenAPI description now carries 50 response schemas, so every example in the reference is generated from the same artifact that drives the server's validation instead of being written by hand.

Write scopes are grantable

orders:write, payments:write, customers:write, inventory:write and products:write now have endpoints behind them. payments:read does too. webhooks:manage remains declared but not grantable.

2026-08-11 — Webhooks

Outbound events, so you can stop polling for the things you need to know about quickly. See Webhooks.

  • 24 event types across orders, returns, shipping, inventory, catalog, customers and POS, plus shopdesk.ping for testing. Subscribe to specific types or to *. The catalog is also readable from the dashboard API.
  • Endpoints are created in the dashboard under Settings → Developer → Webhooks. https only, public addresses only, no redirects followed, five per store by default.
  • HMAC-SHA256 signatures over "{timestamp}.{rawBody}" in X-Shopdesk-Signature, with published test vectors so you can check your verification code before going live.
  • Rotation dual-signs for 24 hours — the previous secret keeps co-signing as a second v1=, so rotating costs no downtime.
  • At-least-once delivery, no ordering guarantee. De-duplicate on X-Shopdesk-Event-Id and write handlers as upserts.
  • An initial attempt plus eight retries over ~45 hours (30s, 2m, 10m, 30m, 2h, 6h, 12h, 24h), then dead, with manual retry from the delivery log. An endpoint failing continuously for three days is auto-disabled and the merchant is notified.
  • Payloads are snapshots taken inside the transaction that produced them, built by the same serializers as the REST responses, and kept — with their delivery logs — for 30 days.

2026-08-11 — API usage in the dashboard

Every API key now reports its own traffic under Settings → Developer: requests, errors, rate-limited responses and average latency, per key per UTC day, kept for 13 months.

Nothing changes on the API surface — no new endpoint, no new header, no cost to your requests. It is there so a merchant can see which integration is making which calls, and so "the API is slow" can be answered with numbers.

2026-08-11 — Public read API launch

The first release of the Shopdesk Commerce API. Everything below is new.

Authentication

  • API keys. shopdesk_sk_ followed by 40 characters, presented as Authorization: Bearer. Created under Settings → Developer; the secret is shown exactly once and stored only as a SHA-256 hash.
  • Two key types. Store keys read one store; company keys read every live store in the company and are restricted to company owners.
  • Scopes, fixed at creation, with write implying read of the same resource. A key can only be granted scopes the person creating it can back with their own permissions.
  • Rotation with a 24-hour grace window, so a key can be replaced without an outage, plus an immediate option for a leaked key.
  • Immediate revocation — the authentication cache is evicted on revoke rather than left to expire.

Endpoints

All read-only. 16 endpoints across 8 resources:

ResourceEndpoints
ProductsGET /products, GET /products/{id}, GET /variants/{identifier}
CategoriesGET /categories, GET /categories/{id}
BrandsGET /brands, GET /brands/{id}
OrdersGET /orders, GET /orders/{id}
CustomersGET /customers, GET /customers/{id}
InventoryGET /inventory/levels, GET /inventory/movements
LocationsGET /locations, GET /locations/{id}
ChannelsGET /channels

GET /variants/{identifier} accepts an id, a SKU or a barcode — a barcode scanner's output can be looked up directly.

Conventions

  • Cursor pagination on every list: limit (max 100, default 25), starting_after / ending_before, and a meta block carrying has_more and next_cursor.
  • updated_after on orders and products, for incremental syncs.
  • expand=customer,items on orders.
  • Money as strings with two decimals ("1250.00"), so decimal precision survives JSON.
  • ISO-8601 UTC timestamps, always with the Z.
  • snake_case field names throughout.
  • A single error shape — one error object with code, message, optional fields, and request_id.
  • Request ids on every request, returned as X-Request-Id. A caller's own X-Request-Id is honoured.

Limits

  • 120 reads and 30 writes per minute per key by default, raised by plan.
  • X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset on every response; 429 carries Retry-After.

Known limitations at launch (historical)

Stated plainly so you can plan around them rather than discover them.

  • The base path is /ext/v1, not /v1. The shorter alias is written but not yet deployed. When it goes live it will be announced here and both paths will serve the same routes.
  • No write endpoints. Creating orders, adjusting stock and recording payments are built but unreleased.
  • No webhooks. Poll with updated_after in the meantime.
  • updated_after is missing on customers, categories, brands, inventory, locations and channels. Re-read those lists on a schedule.
  • payments:read is grantable but has no endpoint yet. A key can hold the scope; nothing currently accepts it.
  • webhooks:manage is declared but not grantable.
  • No sandbox or test mode. There is no shopdesk_sk_test_ prefix; every key acts on live data.
  • No SDKs. The four sample languages in these docs are the support level for v1.
  • Locations ignore store_id. They belong to the company and are shared across its stores.
  • Only created_at is sortable. A cursor over a mutable column cannot page reliably — see Pagination.