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_receivedis stable. The sales summary (GET /reports/sales-summary) now counts money that arrived on orders later cancelled or returned, so a past window'smoney_receivedno 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_shipuntil the courier reports the pickup, then moves toin_transit(with itsorder.updatedwebhook). Steadfast and hand-recorded shipments still move toin_transitwhen they are booked. - Orders too large to hold.
POST /ordersrefuses an order whose line or total comes to more than 9,999,999,999.99 with422 VALIDATION_ERROR(it used to fail with500 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
pendingwith nothing paid after the store's window (default 24 hours, set in the dashboard) is cancelled. You receive the usualorder.cancelledwebhook; 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 MCPrecord_order_paymenttool) now refuses a payment through a payment method set to require a transaction id when none is sent:400,error.fields.transactionIdis["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 nowduplicate_transaction_id, and the message names the order that holds it. - Parcels out for delivery. A payment is refused with
cod_parcel_activeonly 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
returnedwithout a pickup. On an order that was never delivered,PATCH /orders/{id}/statustoreturned(and the MCPupdate_order_statustool) 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
returnedand the advance. On an order that was never delivered,PATCH /orders/{id}/statustoreturned(and the MCPupdate_order_statustool) 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_parcelnow says which way out. The409CONFLICTwitherror.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}/statustoin_transit, and the MCPupdate_order_statustool, are refused while the order's parcel is cancelled:409CONFLICT,error.fields.statusis["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}/statustoreturned, and the MCPupdate_order_statustool, are refused while a courier that reports pickups (Pathao, RedX, CarryBee, Bahok) has not reported picking the parcel up:409CONFLICT,error.fields.statusis["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_amountby the whole collected amount and changedpayment_status, as if the money had gone back, although no refund was made. - Now.
paid_amountandrefunded_amountstay as they are until the store records the refund. Thenrefunded_amountandpayment_statusmove 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_amountshows 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, withprevious.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_transitandfailed_deliverytoreturned(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. Withvideo_url, a link to an H.264 MP4 file, it imports the file and places it in one call. It answers at once, andvideo.statusandplacement.statussay where the import is. Withvideo_id, a video this key uploaded or imported, it only places it. With neither, it changes nothing and returnsupload_steps, the REST calls a script makes to upload a file. It takesgallery_slot,sort_orderandtitle, and the option value either by name, asoption: { "attribute": "Colour", "value": "Red" }, or asoption_value_id.- An option by name reads the product, so it needs
products:readbesidevideos:write. A name that the product's active variants do not use fails with the product's options ascandidates, and nothing is imported or placed. Withproducts:read, anoption_value_idsent withvideo_urlis 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 withvideo_id. idempotency_keyis required withvideo_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 ofget_product,list_products,create_productandupdate_product: the product's ready videos, as on REST.create_productdoes not take videos; place them withattach_product_videoonce 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.
2026-09-29 — Product videos: uploads, imports by link, covers and videos on products
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 ofproducts: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 /videosstarts 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-urlssigns up to 10 parts at a time, each with its base64 SHA-256; your codePUTs each part to its URL.POST /videos/{id}/completeanswers200when the video is ready,202while it is still being checked (pollGET /videos/{id}), or422with a reject code.Idempotency-Keyis 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/importtakes an https link to an MP4 file (not a YouTube or other player page) and answers202. 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 endsabortedwith animport_*code infailure.codeand 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 endsexpired, and a download slower than 64 KB a second on average after its first minute endsimport_download_failed. Withoutfile_name, the video is named after the link's last path segment only when that reads as a file name, elsevideo.mp4; nothing else of the link is kept. - Covers.
POST /videos/{id}/postersets 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}/videosplaces one of the key's videos on a product, for every variant or for one option value (option_value_id, such as Colour: Red), withgallery_slot,sort_orderandtitle. Placing it again moves it, and nothing else on the product changes.DELETE /products/{id}/videos/{videoId}takes a video off.POST /productstakesvideos[]with the same fields, which needsvideos:writebesideproducts: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[]onGET /products,GET /products/{id}, the product write responses andproduct.*webhook payloads:id,url,poster_url,width,height,duration_ms,has_audio,title,gallery_slot,sort_orderandoption_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_idis null for a value no active variant uses (the product page then shows the video for every variant). product.updatedwhen a placed video changes. Besides product edits,product.updatedis 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 /videoslists the videos this key started, and another key's video is404on every/videosendpoint. 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_LIMITEDwithRetry-Afterto 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 14VIDEO_*reject codes (VIDEO_HEVC,VIDEO_FRAGMENTED,VIDEO_TOO_LONG…).PLAN_FEATURE_REQUIREDnow also covers product videos. The plan's limit on the number of products still answers403 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.
2026-09-29 — Store MCP: catalog tools and photos by link
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_productnow declaresopenWorldHint: 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_brandandadd_product_images_from_urlsdeclare it too; every other tool still statesopenWorldHint: false. create_productbuilds a whole product. It takesimages(up to 10 photo links, stored before the call answers), each variant'soptions—{ "attribute": "Colour", "value": "Red" }, by name (any case), slug or id — andimage_index, the variant's photo by its position inimages. A new variant onupdate_producttakesoptionstoo. 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 itsparentby name, slug or id, withimage_urlandbanner_urlby link.create_brand(catalog:write) takeslogo_urlandbanner_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_productorupdate_product, the attribute oncreate_attribute, the parent oncreate_category— the failed result's error object carriescandidatesbesidefields: 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_keyanswers 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.
2026-09-29 — Products: photos by link, variant options, photo batches and variant edits
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:writealso downloads photo links. A key withproducts:writecan 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}withvariantsused to drop the options (Colour, Size…) of the existing variants it listed, because the body had no way to carry them — or refuse with409when the variant had sold. Existing variants now keep their options, from the REST API and the store MCP server'supdate_productalike. - Behaviour change: stricter image checks on uploads.
POST /products/{id}/imagesnow 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 (422onimage), 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.updatedon a single-variant edit. Editing one variant — in the dashboard, or through the newPATCH /variants/{identifier}— now emitsproduct.updated. It did not before. - Build a whole product in one call.
POST /productstakesimages[]: up to 10 photos bysource_url, stored before the call answers. Each variant takesoptions[]—{ "attribute": "Colour", "value": "Red" }, by name (any case), slug or id; an unknown name is a422on that field pointing atPOST /attributes, and nothing is created — andimage_index, its photo's position inimages[]. See Build a catalog through the API. - Restate the gallery on update. On
PATCH /products/{id},images[]is the full gallery:image_idkeeps a photo,source_urladds one, and photos left out are deleted. A new variant takesoptions[]; a variant takesimage_indexorimage_idfor its photo.optionson an existing variant is refused. - All or nothing. One photo link that cannot be used fails the whole call
with
422namingimages[i].source_url; nothing is written, and photos already stored for the call are deleted. A replayedIdempotency-Keyanswers without downloading again. See Photos by link. - Photo batches.
POST /products/{id}/imagesadds up to 10 photos in one gallery change: links in a JSON body ({ "images": [{ "source_url": … }] }), or up to 10imageparts with per-file options as a JSON string in animagesfield. A batch answers{ data }, the new images in the order sent. A singleimagepart withoutimagesanswers exactly as before. POST /products/{id}/images/reordertakes 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'sprice,discount_price,sku,barcode,weightand 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, inproduct.*webhook payloads, and in the store MCP server's product tools.
2026-09-29 — Catalog structure: attributes, category and brand writes, images by link
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:readalso reads attributes. Attributes join categories and brands as catalog structure, soGET /attributesandGET /attributes/{id}answer any key withproducts:read(orproducts:write). - A new scope,
catalog:write, creates and updates categories, brands and attributes. It is granted only together withproducts:read(orproducts:write): creating a key with it alone is refused, and the dashboard ticksproducts:readfor you. Existing keys do not get it. See Authentication. - Attributes.
GET /attributes(withsearchandtype) andGET /attributes/{id}return each attribute with itsvalues[](id,value,position,swatch_hex,swatch_image_url).POST /attributescreates one with its first values;PATCH /attributes/{id}renames it or changes its slug,input_typeor flags —typeonly while no product uses it (409,fields.type).POST /attributes/{id}/valuesandPATCH …/values/{valueId}add and edit values; a repeated value is409withfields.value. - Category and brand writes.
POST /categories,PATCH /categories/{id},POST /brandsandPATCH /brands/{id}take the name, slug, description, status, SEO and (categories)parent_idandposition. Images come as links in a JSON body (image_url,logo_url,banner_url) or as parts of amultipart/form-databody (image,logo,banner);remove_image,remove_logoandremove_bannerdelete 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
422naming the field, and nothing is written. The same checks apply to uploaded parts (50 MB in all per request), and to an attribute value'sswatch_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_LIMITEDwithRetry-Afterbeyond any of these). See Rate limits. - Both body encodings in the spec.
POST /products/{id}/imagesand the category and brand writes declare their JSON body and theirmultipart/form-databody in the OpenAPI document, so generated clients see both; the reference shows the JSON one. - Every write documents its
409. OnceIdempotency-Keyis sent, any write answers409 IDEMPOTENCY_IN_FLIGHTwhile 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_imageandseo({ title, description }ornull) in every response, including the store MCP server'slist_categoriesandlist_brands. - Correction: the multipart image upload's
Idempotency-Keycovers 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/listnow 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/listand read-only tools count against the read limit,get_sales_summaryandget_customer_summarycount as 5 reads each, and write tools, unknown tools (including a tool outside the key's scopes, which itstools/listdoes not show) and unknown methods count against the write limit. The bucket is chosen from the JSON-RPC message, never from theMcp-MethodorMcp-Nameheaders. JSON-RPC batches are refused with400and error-32600. See Rate limits. - Behaviour change: compact output. Every tool declares an
outputSchemaand answers withstructuredContent; 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) andrecord_call_outcome(confirmed, cancelled, call back or no answer, in one call). The new read tools answerconciseby default; passresponse_format: "detailed"for the full objects. See Build a COD confirmation tool. - The existing tools take the new REST filters.
list_ordersgainscustomer_id,assignee_id,follow_up_beforeandfollow_up_after;list_customers,list_categoriesandlist_brandsgainsearch;get_customertakesexpand: ["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 gainslist_mcp_toolsandget_mcp_tool, and its tools now declarereadOnlyHintand refuse unknown arguments.
2026-09-29 — Store profile, sales reports, call-backs and phone search
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:readcovers the store profile.reports:readcovers 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 passstore_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_periodadds 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 takefrom/toor a namedperiod(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 forcompare=previous_periodand top products. It is told apart fromFORBIDDEN_SCOPEbecause 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 ordernote,follow_up_atandfollow_up_note, in any status, with an optionalIdempotency-Key.nullclears a field. It cannot change prices, lines or stock. See Build a COD confirmation tool.- Orders gain
follow_up_at,follow_up_noteandassignee({ id, name }ornull), in REST responses and order webhook payloads. New list filters:follow_up_before,follow_up_after,assignee_idandcustomer_id. GET /customers/{id}?expand=statsaddsorders_count,total_spent,last_order_atandreturned_count. It needsorders:readas well ascustomers:read; without it,statsis left out and the call still succeeds.searchonGET /customers(name, phone or email),GET /categoriesandGET /brands(name or slug).GET /customersalso takesphone: an exact match on the phone alone, in any Bangladeshi form.- Inventory levels gain
variant.product_name, besidevariant.name, inGET /inventory/levels,POST /inventory/adjustmentsand theinventory.updatedwebhook payload. - Behaviour change: phone search. On orders and customers,
searchnow matches a phone in any Bangladeshi form:+8801712345678,8801712345678and01712345678find the same records. Before, the+880forms found nothing. The term is still matched as typed on every field, and the phone field also tries it rewritten as01XXXXXXXXX, so order numbers such as20260929-0001match exactly as before. Searches can only return more results than before. - Behaviour change: more
order.updatedevents.order.updatednow also fires when an order's note, follow-up or delivery address changes, whether in the dashboard or through the API.data.previousholds the oldnote,follow_up_at,follow_up_noteorshipping_address, named as indata.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 ondata.previous.statusshould 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:writereads Stock adjustments, each with a reason (transfers come later);payments:readreads Payments recorded against orders (payment methods will sit under a store scope);payments:writereads Record a manual payment against an order (refunds come later). What each scope allows is unchanged, and no key gains or loses access. - A
401no longer carriesX-RateLimit-*headers. Those came from the per-IP backstop and put a relative number of seconds inX-RateLimit-Reset, where the documented value is a Unix timestamp. EveryX-RateLimit-*header you receive is now your key's own — see Rate limits. - Browser access (CORS).
DELETEis now an allowed method, soDELETE /products/{id}/images/{imageId}works from a browser. The store MCP server'sMCP-Protocol-Version,Mcp-MethodandMcp-Namerequest headers are allowed, andDeprecation,SunsetandLinkare readable. - The OpenAPI description is more accurate. Every write now declares
its
400: any write refuses anIdempotency-Keysent 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 own400in the same response. The status and cancel routes also declare their409, returned while the order's courier parcel is live (fields.statusis["live_parcel"]).expandlists its values (customer,items). TheIdempotency-Keyexample is a complete UUID, andinfo.contact.emailis 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 theX-Request-Idheader), not in the text the model reads. Failed tool calls now also count as errors on the dashboard's API usage page — they are HTTP200s, 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
DeprecationandSunsetheaders. The never-deployed/v1short alias is dropped:/ext/v1is 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
typeas the entry, negativequantity. Apurchaseoradjustmentrow with a negative quantity andreference_typemanual_undo;reference_idis the id of the entry it takes back. - It moves stock like any other movement, so
inventory.adjustedandinventory.updatedwebhooks 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_coston any type exceptpurchase. Apurchasestill requires it. Onadjustment,damageandreturnthe 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
returnthat removes stock — a negativequantity_change, or aset_on_handbelow 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 asadjustmentordamage; 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.comandapi.shopdeskbd.com. The spec title is "Shopdesk Commerce API"; the store MCP server now announces itself asshopdesk-commerce. - API keys created or rotated from now on start with
shopdesk_sk_. Every existingsalaf_sk_key keeps working unchanged — see Authentication. - Webhook headers are
X-Shopdesk-Signature,X-Shopdesk-Event,X-Shopdesk-Event-Id,X-Shopdesk-DeliveryandX-Shopdesk-Api-Version. Each is also sent under its oldX-Salaf-*name, same value, until 2026-12-31. TheUser-AgentisShopdesk-Webhooks/1.0. - The test event is
shopdesk.ping. Endpoints subscribed tosalaf.pingwere 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:
| Endpoint | Does |
|---|---|
POST /products/{id}/images | Uploads 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 gainedid,alt_textandis_thumbnail(additive —urlandpositionare unchanged). The same fields appear inproduct.*webhook payloads.- Each write answers with the image exactly as the next
GET /products/{id}will list it. Idempotency-Keyis 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_keyas an argument (MCP has no headers) — required oncreate_order,record_order_paymentandadjust_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.
| Resource | Endpoints |
|---|---|
| Orders | POST /orders, PATCH /orders/{id}/status, POST /orders/{id}/cancel |
| Payments | POST /orders/{id}/payments, and the read that completes the resource, GET /orders/{id}/payments |
| Customers | POST /customers, PATCH /customers/{id} |
| Inventory | POST /inventory/adjustments |
| Products | POST /products, PATCH /products/{id}, POST /products/{id}/archive |
Idempotency-Keyis now honoured on every write and required onPOST /orders,POST /orders/{id}/paymentsandPOST /inventory/adjustments(400naming the header when it is missing). A repeat of a completed request replays the original response verbatim withIdempotent-Replayed: true; a repeat with a different body is422 IDEMPOTENCY_CONFLICT; a repeat while the original is running is409 IDEMPOTENCY_IN_FLIGHT. Keys are isolated per API key and expire after 24 hours.- Company keys must send
store_idin the body on every write (422without 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_STOCKandINVALID_STATUS_TRANSITION, both400, both carryingfields. - 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.pingfor 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.
httpsonly, public addresses only, no redirects followed, five per store by default. - HMAC-SHA256 signatures over
"{timestamp}.{rawBody}"inX-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-Idand 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 asAuthorization: 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
writeimplyingreadof 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:
| Resource | Endpoints |
|---|---|
| Products | GET /products, GET /products/{id}, GET /variants/{identifier} |
| Categories | GET /categories, GET /categories/{id} |
| Brands | GET /brands, GET /brands/{id} |
| Orders | GET /orders, GET /orders/{id} |
| Customers | GET /customers, GET /customers/{id} |
| Inventory | GET /inventory/levels, GET /inventory/movements |
| Locations | GET /locations, GET /locations/{id} |
| Channels | GET /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 ametablock carryinghas_moreandnext_cursor. updated_afteron orders and products, for incremental syncs.expand=customer,itemson orders.- Money as strings with two decimals (
"1250.00"), so decimal precision survives JSON. - ISO-8601 UTC timestamps, always with the
Z. snake_casefield names throughout.- A single error shape — one
errorobject withcode,message, optionalfields, andrequest_id. - Request ids on every request, returned as
X-Request-Id. A caller's ownX-Request-Idis honoured.
Limits
- 120 reads and 30 writes per minute per key by default, raised by plan.
X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reseton every response;429carriesRetry-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_afterin the meantime. updated_afteris missing on customers, categories, brands, inventory, locations and channels. Re-read those lists on a schedule.payments:readis grantable but has no endpoint yet. A key can hold the scope; nothing currently accepts it.webhooks:manageis 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_atis sortable. A cursor over a mutable column cannot page reliably — see Pagination.