# 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](https://www.shopdeskbd.com/developers/versioning.md#deprecation).

## 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](https://www.shopdeskbd.com/developers/mcp-tools.md#rest-operations-with-no-tool). See
  [Upload a product video](https://www.shopdeskbd.com/developers/guide-upload-a-product-video.md#with-the-store-mcp-server).

## 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 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](https://www.shopdeskbd.com/developers/authentication.md#scopes).
- **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 `PUT`s 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](https://www.shopdeskbd.com/developers/rate-limits.md#video-uploads).
- **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](https://www.shopdeskbd.com/developers/errors.md#on-the-video-endpoints).
- **A new reference page,** [Videos](https://www.shopdeskbd.com/developers/reference/videos.md), and a new
  guide, [Upload a product video](https://www.shopdeskbd.com/developers/guide-upload-a-product-video.md),
  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_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](https://www.shopdeskbd.com/developers/guide-photos-by-link.md#from-the-store-mcp-server).
- **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](https://www.shopdeskbd.com/developers/mcp-tools.md#rest-operations-with-no-tool). See
  [Build a catalog through the API](https://www.shopdeskbd.com/developers/guide-build-a-catalog.md#with-the-store-mcp-server).

## 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: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](https://www.shopdeskbd.com/developers/rate-limits.md#image-downloads).
- **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](https://www.shopdeskbd.com/developers/guide-build-a-catalog.md).
- **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](https://www.shopdeskbd.com/developers/guide-photos-by-link.md).
- **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.

## 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: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](https://www.shopdeskbd.com/developers/authentication.md#scopes).
- **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](https://www.shopdeskbd.com/developers/rate-limits.md#image-downloads).
- **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](https://www.shopdeskbd.com/developers/rate-limits.md#the-store-mcp-server).
- **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](https://www.shopdeskbd.com/developers/guide-cod-confirmation.md#with-the-store-mcp-server).
- **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](https://www.shopdeskbd.com/developers/mcp-tools.md) 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.

## 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: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](https://www.shopdeskbd.com/developers/guide-sales-reports.md).
- **`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](https://www.shopdeskbd.com/developers/rate-limits.md).
- **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](https://www.shopdeskbd.com/developers/errors.md).
- **`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](https://www.shopdeskbd.com/developers/guide-cod-confirmation.md).
- **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](https://www.shopdeskbd.com/developers/webhooks.md#dataprevious-on-orderupdated)
  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](https://www.shopdeskbd.com/developers/rate-limits.md).
- **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 `200`s, 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](https://www.shopdeskbd.com/developers/versioning.md).
- **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](https://www.shopdeskbd.com/developers/authentication.md#key-format).
- **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](https://www.shopdeskbd.com/developers/webhook-signatures.md#verify-your-implementation)
  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 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](https://www.shopdeskbd.com/developers/ai.md) gains no
  tools for them.

## 2026-08-15 — The API speaks MCP

The API is now also a [Model Context Protocol
server](https://www.shopdeskbd.com/developers/ai.md#the-store-mcp-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](https://www.shopdeskbd.com/developers/idempotency.md) 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](https://www.shopdeskbd.com/developers/ai.md).

## 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](https://www.shopdeskbd.com/developers/idempotency.md).

| 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-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](https://www.shopdeskbd.com/developers/webhooks.md).

- **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](https://www.shopdeskbd.com/developers/webhook-events.md) 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](https://www.shopdeskbd.com/developers/webhook-signatures.md#verify-your-implementation) 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:

| 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 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)

> **Note — The list below is kept as written on 2026-08-11**
>
> This list describes the API on launch day, not today. Most of it has been
> lifted since: writes, webhooks and `payments:read` endpoints shipped the same
> day (see the entries above), the `/v1` alias was dropped rather than deployed
> (2026-09-29), and free developer test stores are available on request. For
> the current state, see [Getting started](https://www.shopdeskbd.com/developers/getting-started.md).

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](https://www.shopdeskbd.com/developers/pagination.md#why-you-can-only-sort-by-created_at).
