# Shopdesk Commerce API The public API for merchants and their integrations. Authenticate with `Authorization: Bearer shopdesk_sk_…`. Keys are issued from the dashboard under Settings → Developer, and their secret is shown exactly once. Conventions: fields are snake_case; timestamps are ISO-8601 in UTC; monetary amounts are strings with two decimals ("1250.00") so decimal precision survives JSON. Lists are cursor-paginated — pass the previous page's `meta.next_cursor` as `starting_after`. Every operation declares the scope it needs as `x-required-scope`. A key holding `:write` also satisfies `:read`. The one operation any valid key may call, `GET /me`, is marked `x-any-api-key` instead. ## Introduction - [Getting started](https://www.shopdeskbd.com/developers/getting-started.md): From no key to your first response, in five minutes. - [Authentication](https://www.shopdeskbd.com/developers/authentication.md): Key format, store vs company keys, rotation, revocation. - [Build with AI](https://www.shopdeskbd.com/developers/ai.md): Markdown twins, llms.txt, the spec URL, and the two MCP servers. ## Core concepts - [Errors](https://www.shopdeskbd.com/developers/errors.md): The error envelope, every code, and what to do with each. - [Pagination](https://www.shopdeskbd.com/developers/pagination.md): Cursor paging, the sync pattern, and why sorting is limited. - [Writes & idempotency](https://www.shopdeskbd.com/developers/idempotency.md): Every write endpoint, and the Idempotency-Key contract behind them. - [Rate limits](https://www.shopdeskbd.com/developers/rate-limits.md): Per-key buckets, headers, and handling 429. - [Versioning](https://www.shopdeskbd.com/developers/versioning.md): What we may change without warning, and what we may not. ## API reference - [Overview](https://www.shopdeskbd.com/developers/reference.md): Generated from the published OpenAPI description. - [Products](https://www.shopdeskbd.com/developers/reference/products.md): 11 Products endpoints (GET, POST, PATCH, DELETE), generated from the OpenAPI description. - [Categories](https://www.shopdeskbd.com/developers/reference/categories.md): 4 Categories endpoints (GET, POST, PATCH), generated from the OpenAPI description. - [Brands](https://www.shopdeskbd.com/developers/reference/brands.md): 4 Brands endpoints (GET, POST, PATCH), generated from the OpenAPI description. - [Attributes](https://www.shopdeskbd.com/developers/reference/attributes.md): 6 Attributes endpoints (GET, POST, PATCH), generated from the OpenAPI description. - [Orders](https://www.shopdeskbd.com/developers/reference/orders.md): 8 Orders endpoints (GET, POST, PATCH), generated from the OpenAPI description. - [Customers](https://www.shopdeskbd.com/developers/reference/customers.md): 4 Customers endpoints (GET, POST, PATCH), generated from the OpenAPI description. - [Inventory](https://www.shopdeskbd.com/developers/reference/inventory.md): 3 Inventory endpoints (POST, GET), generated from the OpenAPI description. - [Locations](https://www.shopdeskbd.com/developers/reference/locations.md): 2 Locations endpoints (GET), generated from the OpenAPI description. - [Channels](https://www.shopdeskbd.com/developers/reference/channels.md): 1 Channels endpoint (GET), generated from the OpenAPI description. - [Store](https://www.shopdeskbd.com/developers/reference/store.md): 2 Store endpoints (GET), generated from the OpenAPI description. - [Reports](https://www.shopdeskbd.com/developers/reference/reports.md): 2 Reports endpoints (GET), generated from the OpenAPI description. - [Videos](https://www.shopdeskbd.com/developers/reference/videos.md): 10 Videos endpoints (POST, DELETE, GET), generated from the OpenAPI description. - [MCP tools](https://www.shopdeskbd.com/developers/mcp-tools.md): Every store MCP tool — scopes, arguments, metering, output — generated from the tool manifest. ## Webhooks - [Overview](https://www.shopdeskbd.com/developers/webhooks.md): Outbound events: the envelope, the guarantees, the rules. - [Event catalog](https://www.shopdeskbd.com/developers/webhook-events.md): Every event type we send, and what each one carries. - [Verifying signatures](https://www.shopdeskbd.com/developers/webhook-signatures.md): The signing scheme, code in three languages, and test vectors. - [Retries & ordering](https://www.shopdeskbd.com/developers/webhook-retries.md): The retry ladder, dead deliveries, and auto-disable. - [Testing](https://www.shopdeskbd.com/developers/webhook-testing.md): The shopdesk.ping event and how to develop against it. - [Headers](https://www.shopdeskbd.com/developers/webhook-headers.md): Every header on a delivery, and what to do with it. - [Rotating a secret](https://www.shopdeskbd.com/developers/webhook-rotation.md): The dual-signing window, and what a receiver does during it. ## Guides - [Overview](https://www.shopdeskbd.com/developers/guides.md): End-to-end recipes for the common integrations. - [Create an order](https://www.shopdeskbd.com/developers/guide-create-an-order.md): Customer, variants, idempotency key, and what comes back. - [Sync inventory to an ERP](https://www.shopdeskbd.com/developers/guide-sync-inventory.md): Cursors, watermarks and checkpoints that survive a crash. - [Handle webhooks idempotently](https://www.shopdeskbd.com/developers/guide-handle-webhooks.md): Verify, dedupe, upsert, and answer fast. - [Build a custom storefront](https://www.shopdeskbd.com/developers/guide-custom-storefront.md): The read path, checkout, and what the API does not give you. - [Build a COD confirmation tool](https://www.shopdeskbd.com/developers/guide-cod-confirmation.md): Find the order by phone, confirm or cancel it, schedule the call-back. - [Sales and reports](https://www.shopdeskbd.com/developers/guide-sales-reports.md): The sales summary, comparisons and top products, and what each figure counts. - [Build a catalog through the API](https://www.shopdeskbd.com/developers/guide-build-a-catalog.md): Attributes, a category and a brand, then a product with variants and photos — in four calls. - [Photos by link](https://www.shopdeskbd.com/developers/guide-photos-by-link.md): Where links are taken, what is checked, all-or-nothing calls, limits and safe retries. - [Upload a product video](https://www.shopdeskbd.com/developers/guide-upload-a-product-video.md): Raw calls from start to finish, covers, a video for one option, imports by link, reject codes and limits. ## Reference material - [Changelog](https://www.shopdeskbd.com/developers/changelog.md): Every change to the public API, newest first. ## Machine-readable - [OpenAPI 3.0 description](https://www.shopdeskbd.com/openapi/public-v1.json): every endpoint, parameter, schema and scope — the artifact this documentation is generated from. - [llms.txt](https://www.shopdeskbd.com/llms.txt): this documentation as an index for AI tools. - [llms-full.txt](https://www.shopdeskbd.com/llms-full.txt): every page in one markdown document. --- # Getting started From no key to your first response, in five minutes. The Shopdesk Commerce API is a REST API over your store's real data — the same products, orders, customers and stock your staff see in the dashboard. It speaks JSON, authenticates with a single header, and needs nothing installed. This page takes you from no key to a real response. It should take about five minutes. > **Note — What ships today** > > Reading, writing and webhooks are all live: 17 read endpoints, 13 write > endpoints, and 24 outbound event types. What is *not* here is listed plainly > under [what is not here yet](#what-is-not-here-yet). ## Before you start You need three things, and all three are things a store owner already has or can grant in a minute: 1. **A Shop Desk account** with access to the store you want to integrate. 2. **A plan that includes API access.** The `api_access` feature is checked on every single request, not just when the key is made — so a plan change takes effect on the next call in either direction. Without it every request answers `401 UNAUTHORIZED` with the message *"API access is not included in your current plan."* 3. **The `developer` permission** on your user (`developer.read` to see keys, `developer.manage` to create them). Company owners have it already. ## Create an API key Keys are created in the dashboard, never through the API. 1. Open **Settings → Developer** and choose **Create API key**. 2. Pick the key type: - **Store key** — reads one store. This is the right default. - **Company key** — reads every live store in the company. Only a company owner can create one. 3. Give it a name you will recognise in six months (*"NetSuite nightly sync"* beats *"test"*). 4. Tick the scopes it needs. You can only grant scopes your own role can back — a user who cannot read orders in the dashboard cannot mint a key that reads them over the API. 5. Optionally set an expiry date. > **Warning — The secret is shown exactly once** > > Shopdesk stores only a SHA-256 hash of your key, so there is no "show it again" > button — not for you, and not for our support team. Copy it into your secret > manager on the spot. If you lose it, rotate the key. A key looks like this — the literal prefix `shopdesk_sk_` followed by 40 characters: ```text shopdesk_sk_7f3kq9p2xR4mVnB8tLcYwZ1sD6hJ0gKeQaUiOpNm ``` ## Make your first request Authenticate by putting the key in an `Authorization: Bearer` header. That is the whole authentication story — no signing, no token exchange, no expiry to refresh. Ask for one product. If the key works, this returns data; if it does not, the error tells you precisely why. **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/products?limit=1" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/products"); Object.entries({ limit: "1", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/products", params={ "limit": "1", }, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` ## Read the response A list endpoint answers with the rows under `data` and a cursor under `meta`: ```json { "data": [ { "id": "7c4e1d90-3b2a-4f58-9e6d-1a5c8b30f2d4", "store_id": "b21c5f04-9d7e-4c11-8f3a-6e0d2b915a77", "name": "Premium Cotton T-Shirt", "slug": "premium-cotton-t-shirt", "status": "active", "delivery_charge": "60.00", "variants": [ { "id": "0f9c1b8e-5f21-4a3d-9b6c-2d7e8a41c503", "sku": "TSHIRT-BLK-M", "name": "Black / M", "price": "1250.00", "discount_price": "1100.00", "status": "active" } ], "created_at": "2026-07-14T09:12:44.000Z", "updated_at": "2026-08-09T17:31:02.000Z" } ], "meta": { "has_more": true, "next_cursor": "7c4e1d90-3b2a-4f58-9e6d-1a5c8b30f2d4" } } ``` Four conventions hold everywhere, so learning them once is enough: | Convention | Looks like | Why | | ------------------------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | Field names are `snake_case` | `payment_status` | Stable public names, decoupled from our internal columns. | | Money is a **string** with 2 decimals | `"1250.00"` | JSON numbers are IEEE-754 doubles and cannot hold every `Decimal(12,2)` exactly. `1250.10` must not read back as `1250.1`. | | Timestamps are ISO-8601 **UTC** | `"2026-08-09T17:31:02.000Z"` | Always with the `Z`. No local time, ever. | | Lists are cursor-paginated | `meta.next_cursor` | Pages stay stable while the store keeps taking orders. See [Pagination](https://www.shopdeskbd.com/developers/pagination.md). | Fetching a single object returns that object directly — there is no envelope around it, and no `success` field to check. The HTTP status is the answer. ## The base URL > **Note — The base URL is /ext/v1** > > The production base URL is: > > `https://api.shopdeskbd.com/ext/v1` > > There is no shorter `/v1` alias: one was planned, never deployed, and has > been dropped. `/ext/v1` is the path for the life of v1 — see > [Versioning](https://www.shopdeskbd.com/developers/versioning.md). Running the backend locally, the same routes are on your own port with no proxy in the way: ```bash # Default local backend port is 5000 (6003 under Docker Compose). curl -sS "http://localhost:5000/ext/v1/products?limit=1" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` ## When something goes wrong Errors are always the same shape: one `error` object with a stable machine `code`, a human `message`, and a `request_id`. ```json { "error": { "code": "FORBIDDEN_SCOPE", "message": "This API key is missing the `products:read` scope.", "request_id": "4f1c9b02-7d3e-4a85-9c16-b0e2f7a34d59" } } ``` The three you are most likely to hit in the first five minutes: - **`401 UNAUTHORIZED`** — the header is missing or malformed, the key is unknown, or your plan does not include API access. - **`403 FORBIDDEN_SCOPE`** — the key is valid but was not given this scope. Scopes are fixed when a key is created, so make a new key. - **`404 NOT_FOUND`** — the id does not exist *in the store this key can see*. A record belonging to another tenant returns 404 rather than 403, on purpose: a 403 would confirm the id exists. Keep the `request_id`. It is echoed in the `X-Request-Id` response header and is what support needs to find your exact request in our logs. Full list on the [Errors](https://www.shopdeskbd.com/developers/errors.md) page. ## What is not here yet Being straight about the boundaries, so you can plan around them: | Capability | Status | | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Reading products, orders, customers, stock, locations, channels | **Available** | | Creating orders and payments, adjusting stock, writing products and customers | **Available** — see [Writes & idempotency](https://www.shopdeskbd.com/developers/idempotency.md) | | Webhooks (outbound events) | **Available** — see [Webhooks](https://www.shopdeskbd.com/developers/webhooks.md) | | Managing webhook endpoints over the API | Dashboard only. The `webhooks:manage` scope is declared but not grantable | | Reading or writing returns, shipments and POS sessions | Webhook events only — there is no REST endpoint for them yet | | SDKs for any language | Not planned for v1 — the four sample languages are the support level | | A sandbox or test-mode key | Not planned for v1. There is no `shopdesk_sk_test_`; keys act on the data of the store they belong to. A **free developer test store** is available on request — email `support@shopdeskbd.com` | ## Next steps - [Authentication](https://www.shopdeskbd.com/developers/authentication.md) — store vs company keys, rotation without downtime, and revocation. - [Pagination](https://www.shopdeskbd.com/developers/pagination.md) — the `updated_after` pattern every sync integration ends up needing. - [Writes & idempotency](https://www.shopdeskbd.com/developers/idempotency.md) — every write endpoint and the `Idempotency-Key` contract. - [Webhooks](https://www.shopdeskbd.com/developers/webhooks.md) — stop polling for the things you need to know about quickly. - [Guides](https://www.shopdeskbd.com/developers/guides.md) — four end-to-end recipes. - [API reference](https://www.shopdeskbd.com/developers/reference.md) — every endpoint, generated from the published OpenAPI description. --- # Authentication Key format, store vs company keys, rotation, revocation. Every request carries an API key in an `Authorization` header. There is no token exchange, no refresh, and nothing expires unless you ask it to. ```http Authorization: Bearer shopdesk_sk_7f3kq9p2xR4mVnB8tLcYwZ1sD6hJ0gKeQaUiOpNm ``` ## Key format A key is the literal prefix `shopdesk_sk_` followed by 40 random base62 characters — around 238 bits of entropy. The prefix is not decoration. It earns its place three times: secret scanners (GitHub and GitLab push protection among them) match on exactly this shape, so a key committed by accident gets caught; it tells you at a glance which of the tokens in a config file is ours; and it makes a Shopdesk key structurally impossible to confuse with a JWT. Anything that does not start with `shopdesk_sk_` is rejected before we even look in the database, so a stolen dashboard session token can never be replayed here. > **Note — Keys issued before 2026-09-14 start with salaf\_sk\_** > > The prefix changed with the Shopdesk rename. Existing keys keep working > exactly as they are — the hash we store covers the whole secret, and the API > accepts both spellings — so nothing needs to change on your side. A key you > create or rotate from now on carries `shopdesk_sk_`. > **Danger — We cannot recover a key for you** > > Only a SHA-256 hash of your key is stored. Not encrypted — hashed. There is > no route, no admin screen and no support process that can return the > plaintext, because it does not exist anywhere after the response that created > it. Lose it and you rotate. In the dashboard and in our logs a key appears masked, as its prefix sample and last four characters: ```text shopdesk_sk_7f3kq9p2…OpNm ``` ## Store keys and company keys The key type is chosen at creation and can never change. It decides how wide the tenant fence is — never how the fence works. | | Store key | Company key | | ------------------- | ------------------------------------------------- | ------------------------------------- | | Sees | One store | Every live store in the company | | Created by | Anyone with `developer.manage` | Company owners only | | `store_id` on rows | The key's own store | Varies per row — partition on it | | `?store_id=` filter | Must match the key's own store | Narrows to one store in the company | | Best for | A single storefront, a POS bridge, one shop's ERP | A head office reading across branches | **Start with a store key.** A company key is a wider blast radius if it leaks, and most integrations only ever touch one store. Reach for a company key when you genuinely need one credential to read several stores. **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/locations" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/locations"); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/locations", headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` ### Narrowing a company key Pass `store_id` to restrict a read to one store. The value is validated against your company before any query runs — an id belonging to someone else's company returns `404`, not `403`, so the parameter can never be used to probe for which store ids exist. **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/orders?store_id=REPLACE_WITH_STORE_ID&limit=50" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/orders"); Object.entries({ store_id: "REPLACE_WITH_STORE_ID", limit: "50", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/orders", params={ "store_id": "REPLACE_WITH_STORE_ID", "limit": "50", }, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` > **Note — Locations are the exception** > > Locations belong to the **company**, not to a store: several stores share one > warehouse. So a location carries `company_id` instead of `store_id`, and > passing `store_id` does not narrow the list. This is the one resource where a > store key and a company key see the same rows. ## Scopes A key carries a set of scopes, fixed when it is created. Scopes are named `resource:action` and there are only two verbs — `read` and `write`. | Scope | Covers | Endpoints today | | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- | | `products:read` | Products, variants, categories, brands, attributes | Yes | | `orders:read` | Orders, their items and payment state | Yes | | `customers:read` | Customers and their addresses | Yes | | `inventory:read` | Stock levels and the movement ledger | Yes | | `locations:read` | Warehouses and stock locations | Yes | | `channels:read` | Sales channels | Yes | | `store:read` | The store profile: name, domain, currency, timezone, contact details, advance-payment plans | Yes | | `reports:read` | Sales summary; comparisons and top products on plans with Advanced Reports | Yes | | `payments:read` | Payments recorded against an order | Yes | | `products:write` | Create, update and archive products and their variants; add product images by upload or by link, and manage them | Yes | | `catalog:write` | Create and update categories, brands and attributes with their values and images. Granted only together with `products:read` | Yes | | `videos:write` | Upload product videos or import them from a link, set their covers, place them on products and list or delete this key's own uploads, within the plan's video storage | Yes | | `orders:write` | Create orders, change status, cancel, edit the note and follow-up | Yes | | `customers:write` | Create and update customers | Yes | | `inventory:write` | Stock adjustments | Yes | | `payments:write` | Record a payment against an order | Yes | | `webhooks:manage` | Manage webhook endpoints via the API | Declared, **not grantable** | > **Note — Why webhooks:manage cannot be granted** > > Webhook endpoints are created and edited in the dashboard only. A leaked API > key must not be able to point a merchant's event stream at somebody else's > server, and no scope granularity makes that risk acceptable. The scope exists > in the catalogue so the day it becomes grantable is an additive change rather > than a new name. Five rules govern them: - **Write implies read of the same resource.** A key with `orders:write` also satisfies `orders:read`. The reverse is never true, which is the direction that actually matters. - **`catalog:write` comes with `products:read`.** Categories, brands and attributes are read through `products:read` endpoints, so a key that can create them must be able to list them. Creating a key with `catalog:write` but neither `products:read` nor `products:write` is refused, and the dashboard ticks `products:read` for you. - **`videos:write` stands alone.** It is its own opt-in scope, not part of `products:write`, because a video uses the plan's video storage and can be up to 200 MB. It reads only the key's own uploads (`GET /videos`); a product's ready videos are read with the product, under `products:read`. See [Upload a product video](https://www.shopdeskbd.com/developers/guide-upload-a-product-video.md). - **Scopes are immutable.** No endpoint changes a key's scopes, and the dashboard does not offer it either. Need different scopes? Create a new key and revoke the old one. - **You can only grant what you hold.** A staff user can only tick scopes their own role can back. A cashier who cannot read orders in the dashboard cannot mint a key that reads them over the API. Every operation in the [reference](https://www.shopdeskbd.com/developers/reference.md) shows the scope it requires. A key missing it gets `403 FORBIDDEN_SCOPE` — the endpoint is never silently reduced to an empty result. The one exception is `GET /me`, which any valid key may call to describe itself: its scopes, the stores it reaches and its rate limits. **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/me" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/me"); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/me", headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ## Rotation Rotating replaces a key without an outage. Shopdesk issues a **new** key and sets an expiry on the old one instead of killing it, so both work during the overlap: 1. Rotate in the dashboard and copy the new secret. 2. Both keys are valid for the grace window — **24 hours by default**. 3. Deploy the new key wherever the old one lives. 4. The old key stops working when the window closes. Nothing to remember, no second visit to the dashboard. If you rotate because a key **leaked**, choose the immediate option instead: the old key dies at once and the overlap is skipped. A brief outage is the correct trade when the alternative is a live credential in someone else's hands. ## Revocation Revoking is permanent — a revoked key never comes back, and there is no "delete" at all, because the row is audit history. **Revocation takes effect immediately.** Authentication is cached for speed, but every revocation and rotation explicitly evicts that cache rather than waiting for it to expire, so there is no window in which a killed key still works. ## Why a request was rejected All authentication failures return `401` and are told apart by their `code`: | Code | Meaning | | -------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `UNAUTHORIZED` | The header is missing or malformed, the key is unknown, your plan does not include API access, or the store or company is not live. | | `KEY_REVOKED` | The key existed and was deliberately killed. | | `KEY_EXPIRED` | The key's clock ran out — either its own expiry date, or the end of a rotation grace window. | > **Note — Only revoked and expired are distinguished** > > Everything else — an unknown key, a plan without API access, a suspended > store — answers plain `UNAUTHORIZED`. The two that are named are named > deliberately: someone at the merchant chose to kill that key, so telling the > caller why is a kindness rather than a leak. The rest stay > indistinguishable, so the endpoint cannot be used to work out whether a > guessed key exists. Two more you will see: - `403 FORBIDDEN_SCOPE` — the key is fine, but it was not granted this scope. - `404 NOT_FOUND` — for an id in another tenant. Never `403`; a `403` would confirm the record exists. > **Warning — Plan and store changes take about a minute** > > Revocation and rotation are instant. Plan changes and store suspensions are > read through a short-lived cache, so those propagate within roughly a > minute rather than on the very next request. If you are testing an upgrade, > give it a moment before concluding it did not work. ## Keeping keys safe - **Server-side only.** A key grants everything its scopes allow across a whole store. It has no business in a browser, a mobile app, or anything else a customer can open. - **Environment variables, not source.** Every sample in these docs reads `SHOPDESK_API_KEY` from the environment for exactly this reason. - **One key per integration.** Separate keys mean you can revoke the one that leaked without taking down the other four, and `last_used_at` in the dashboard tells you which is which. - **Only the scopes it needs.** A reporting tool that reads orders should not hold `customers:read` as well. --- # Build with AI Markdown twins, llms.txt, the spec URL, and the two MCP servers. These docs are published for two readers: you, and whatever agent is helping you integrate. Everything the human pages say also exists in a machine-shaped form — a markdown twin of every page, an index and a single-file corpus for LLMs, the OpenAPI description the reference is generated from, and two MCP servers: one that answers from the real pages, and one that reads — and, scope permitting, operates — a store's live data through the API itself. This page is where each one lives and how to point your tools at it. None of it is required to use the API. It exists so an agent reads what these docs actually say instead of guessing from APIs that merely resemble ours. ## Markdown for every page Append `.md` to any docs URL and you get that page as raw markdown — no nav chrome, no highlighting markup, just the content: | Human page | Markdown twin | | -------------------------------------------------- | ----------------------------------------------------- | | `https://www.shopdeskbd.com/developers/pagination` | `https://www.shopdeskbd.com/developers/pagination.md` | The overview's twin is `/developers.md`, and the generated reference follows the same rule — `/developers/reference/orders.md` is the orders reference as markdown. Two files cover the corpus as a whole: - [`/llms.txt`](https://www.shopdeskbd.com/llms.txt) — the index, per the llms.txt convention: every page as an absolute `.md` link with a one-line summary. Give this to tools that crawl and follow links. - [`/llms-full.txt`](https://www.shopdeskbd.com/llms-full.txt) — every page concatenated in sidebar reading order. Give this to tools that read: it fits comfortably in a modern context window, so pasting it into a chat hands the model the entire documentation at once. Every page header also carries a **Copy page** button that copies the page's markdown twin, and a menu that opens the page in Claude or ChatGPT with the twin's URL already in the prompt. ## The OpenAPI description The API is described by one published OpenAPI document: ```text https://www.shopdeskbd.com/openapi/public-v1.json ``` It is the same artifact the [reference](https://www.shopdeskbd.com/developers/reference.md) pages are generated from, so the two cannot disagree — anything the spec declares, the reference shows, and nothing in the reference was written by hand. One URL unlocks the usual tooling: generate a typed client, import the collection into Postman or Insomnia, or hand it to a ChatGPT GPT Action, which takes an OpenAPI URL directly. ## Add the docs to your tools **Cursor** — Settings → Indexing & Docs → Add Doc, then paste `https://www.shopdeskbd.com/llms.txt`. Cursor indexes the linked pages and cites them in chat. **Claude Code** — connect the MCP server with the one-liner in the next section. Without MCP, telling it to fetch `https://www.shopdeskbd.com/llms.txt` and follow the links it needs works nearly as well. **ChatGPT** — a GPT Action takes the OpenAPI URL above directly; for questions rather than calls, paste `/llms-full.txt` into the conversation. **Everything else** — any agent that can fetch a URL can read the `.md` twins, and any tool that accepts a document can take `/llms-full.txt`. ## The docs MCP server The docs run a Model Context Protocol server at: ```text https://www.shopdeskbd.com/developers/mcp ``` Transport is Streamable HTTP, and there is no authentication — it serves the same public pages you are reading and nothing else. | Tool | Answers | | ---------------- | -------------------------------------------------------------- | | `search_docs` | Where in the docs a term or topic is covered. | | `get_page` | One page in full, as markdown, by slug. | | `list_endpoints` | Every operation, optionally narrowed to one resource. | | `get_endpoint` | One operation in detail — parameters, scope, examples. | | `list_mcp_tools` | Every store MCP tool, optionally only those one scope unlocks. | | `get_mcp_tool` | One store MCP tool in detail — scopes, arguments, output. | Every tool is read-only and says so (`readOnlyHint`), and an argument it does not know is refused by name. For Claude Code, one command: ```bash claude mcp add --transport http shopdesk-docs https://www.shopdeskbd.com/developers/mcp ``` For other MCP clients, the generic configuration block: ```json { "mcpServers": { "shopdesk-docs": { "type": "http", "url": "https://www.shopdeskbd.com/developers/mcp" } } } ``` > **Note — Docs only — it cannot touch a store** > > This server takes no API key and makes no API calls. It answers questions > about the documentation; creating an order still means calling the API, > with your key, under the same > [idempotency contract](https://www.shopdeskbd.com/developers/idempotency.md) as any other client. ## The store MCP server The API itself also speaks MCP. A second server exposes a store's live data as curated tools, at: ```text https://api.shopdeskbd.com/ext/mcp/v1 ``` Transport is Streamable HTTP, and it authenticates exactly like the REST API: the same `Authorization: Bearer` header, the same [keys](https://www.shopdeskbd.com/developers/authentication.md), the same tenant boundary. The key the connection carries decides which company — and, for a store key, which store — every tool can see, and each tool requires the same [scope](https://www.shopdeskbd.com/developers/authentication.md#scopes) as its REST counterpart (a tool that combines several reads requires all of their scopes). **Each key lists only its own tools**: `tools/list` shows the tools whose scopes the key holds, so an agent never sees a tool it cannot call. Every tool is documented on [MCP tools](https://www.shopdeskbd.com/developers/mcp-tools.md) — scopes, arguments, metering and output — generated from the tool manifest the server publishes at `https://www.shopdeskbd.com/openapi/mcp-tools-v1.json`. Eight tools answer the questions a merchant asks most, in one call each: | Tool | Answers or does | | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `get_store_profile` | The store — currency, timezone, contact — and what this key can reach. | | `get_sales_summary` | Sales for a window (`period` such as `last_7_days`, or dates), with money received and COD to collect; optionally against the previous period. | | `list_low_stock` | What needs restocking, with product names. | | `find_orders` | Orders by order number, customer name or phone number in any form. | | `list_orders_needing_attention` | The call queue: unconfirmed orders, or call-backs now due. | | `get_customer_summary` | A customer by id or phone, with their order history and latest orders. | | `update_order_details` | Sets an order's note and call-back time. | | `record_call_outcome` | Records a confirmation call — confirmed, cancelled, call back or no answer. | These answer **concisely** by default — the fields needed to act, names beside ids — and take `response_format: "detailed"` for the full objects. The [COD confirmation guide](https://www.shopdeskbd.com/developers/guide-cod-confirmation.md#with-the-store-mcp-server) walks through them. Twelve more tools read, one per REST read: | Tool | Answers | | ---------------------------------- | ----------------------------------------------------------------------------------------- | | `list_products` / `get_product` | Products with their variants nested — the sellable units — and their ready videos. | | `list_categories` / `list_brands` | Catalog metadata, searchable by name; ids feed the product filters. | | `list_orders` / `get_order` | Orders, compact by default, expandable with `customer` and `items`. | | `list_order_payments` | Every payment row recorded against one order. | | `list_customers` / `get_customer` | Customer records, searchable; the detail includes addresses and, on request, order stats. | | `get_inventory_levels` | Stock per variant per location, with a `low_stock` filter. | | `list_locations` / `list_channels` | Warehouses and sales channels. | And ten write — one per core [write endpoint](https://www.shopdeskbd.com/developers/idempotency.md), running the same engine the merchant's own staff use, so a tool call cannot do anything the dashboard could not. | Tool | Does | | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | `create_order` | Places an order: stock reserved, totals computed server-side, coupons priced by the real engine. | | `update_order_status` / `cancel_order` | Status moves under the same strict transition map as the desk. | | `record_order_payment` | Records a payment already received, capped at the order's net due. It moves no money. | | `create_customer` / `update_customer` | Customer records, with per-store phone/email uniqueness. | | `adjust_inventory` | A signed, audited stock change — `reason` required, on-hand can never go negative. | | `create_product` / `update_product` / `archive_product` | Catalog writes, variants included. `create_product` also takes up to 10 photo links and each variant's options by name. | Five more build a catalog — attributes, categories and brands, photos by link, and one variant at a time. The [catalog guide](https://www.shopdeskbd.com/developers/guide-build-a-catalog.md#with-the-store-mcp-server) walks through them. | Tool | Does | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `create_attribute` | Makes sure an attribute exists with the values listed; if it does, adds only the missing values. Safe to repeat. | | `create_category` / `create_brand` | A category (parent by name, slug or id) or a brand, with images by link. | | `add_product_images_from_urls` | Adds up to 10 photos to a product from links, all or nothing. | | `update_variant` | Changes one variant's price, sale price, SKU, barcode, weight or photo, by id, SKU or barcode. | One more puts a video on a product. The [video guide](https://www.shopdeskbd.com/developers/guide-upload-a-product-video.md#with-the-store-mcp-server) walks through it. | Tool | Does | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `attach_product_video` | Imports an MP4 from a link and places it on a product, or places a video the key already uploaded — for every variant, or for one option value such as Colour: Red. With no video, it returns the upload steps for a script. | The tools that take links — `create_product`, `create_category`, `create_brand`, `add_product_images_from_urls` and `attach_product_video` — declare `openWorldHint: true`, because they download from outside the store (through the same checks as the REST API: see [Photos by link](https://www.shopdeskbd.com/developers/guide-photos-by-link.md)); every other tool declares `false`. Photo reordering, edits and deletes, multipart uploads, catalog updates, and the video upload session, covers and deletes stay on REST. The write tools are opt-in, and the opt-in is the key: each tool requires the same write scope as its REST twin, so a key minted with read scopes simply cannot write — it does not even list them. Writes carry the [idempotency contract](https://www.shopdeskbd.com/developers/idempotency.md) as an `idempotency_key` argument — required on `create_order`, `record_order_payment`, `adjust_inventory`, and `attach_product_video` when it imports a `video_url`; honoured everywhere else. The rules are the header's, verbatim: one unique value per logical operation, the same value on every retry of it, replays answer with the original result. List tools page exactly like the REST API — pass `meta.next_cursor` back as `starting_after` until it comes back null. Every tool declares an `outputSchema` and answers with `structuredContent`, plus the same value as minified JSON text. The REST mirrors return what their REST twins return, because each calls the same service its twin calls — a write answers with the object re-read through the public serializer (or, for `archive_product`, the same small `{ id, archived }` receipt). One difference is deliberate: **text a shopper wrote 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 — so a note that reads like an instruction reaches the model as a quoted value, never as something to follow. The REST API returns those fields unchanged. Tool calls count against the key's [rate limits](https://www.shopdeskbd.com/developers/rate-limits.md#the-store-mcp-server): reads against the read limit (the two summary tools as 5 reads each), writes against the write limit. A call to a tool the key does not list counts as a write. A call that fails comes back as a tool result with `isError: true`, and its text is the REST error object — `{ "error": { code, message, fields? } }`, with the same stable codes. When a name the call used matches nothing or several rows (an option name on `create_product`, a parent category), the error object also carries `candidates`: the rows it could have meant, names beside ids. A tool the key does not list is refused as an unknown tool. The request id is not in that text: it is in the result's `_meta.request_id`, the same value as the exchange's `X-Request-Id` header, so it reaches your logs without being handed to the model. Failed tool calls count as errors on the dashboard's API usage page. For Claude Code, one command (the key travels as a header): ```bash claude mcp add --transport http shopdesk-store https://api.shopdeskbd.com/ext/mcp/v1 --header "Authorization: Bearer shopdesk_sk_your_key_here" ``` For other MCP clients: ```json { "mcpServers": { "shopdesk-store": { "type": "http", "url": "https://api.shopdeskbd.com/ext/mcp/v1", "headers": { "Authorization": "Bearer shopdesk_sk_your_key_here" } } } } ``` > **Warning — The key in that config is a real credential** > > Anything the agent can do, that config file can do. Create a dedicated key > holding only the scopes you intend the agent to use — read scopes for an > agent that answers questions, write scopes only when you mean for it to > operate the store — keep the config out of version control, and > [revoke](https://www.shopdeskbd.com/developers/authentication.md#rotation) the key if the machine or > the config leaks. ## Drop this in your repo If a coding agent works on your integration, put a short description of this API where your agent reads instructions — an `AGENTS.md`, a `CLAUDE.md`, a system prompt. The block below is deliberately compact: enough to make correct calls, with links to where everything else lives. ```markdown ## Shopdesk Commerce API - REST API over a Shop Desk store's live data: products, orders, customers, inventory, locations and sales channels. JSON in, JSON out. - Base URL: `https://api.shopdeskbd.com/ext/v1` - Auth: `Authorization: Bearer `. Keys start with `shopdesk_sk_` and are created in the dashboard under Settings → Developer. Server-side only. - Errors: every failure is `{ error: { code, message, request_id } }`. Branch on `code` — it is stable. `message` is human copy and may be reworded at any time. Validation failures add a `fields` map. - Writes: send an `Idempotency-Key` header (any unique value up to 255 characters; a UUID is ideal) and reuse the SAME key when retrying the same operation. Required on `POST /orders`, `POST /orders/{id}/payments` and `POST /inventory/adjustments`; honoured on every other write. - Pagination: cursor-based. Pass `limit` (max 100) and `starting_after` = the previous page's `meta.next_cursor`; stop when `next_cursor` is null. - Machine-readable docs: - OpenAPI: https://www.shopdeskbd.com/openapi/public-v1.json - Docs index: https://www.shopdeskbd.com/llms.txt (whole corpus in one file: https://www.shopdeskbd.com/llms-full.txt) - Docs MCP, Streamable HTTP, no auth: https://www.shopdeskbd.com/developers/mcp - Store MCP (live store data through curated read + write tools, gated by the key's scopes, same Bearer key): https://api.shopdeskbd.com/ext/mcp/v1 (tool manifest: https://www.shopdeskbd.com/openapi/mcp-tools-v1.json) ``` The facts in that block are the same ones documented on [Getting started](https://www.shopdeskbd.com/developers/getting-started.md), [Errors](https://www.shopdeskbd.com/developers/errors.md), [Writes & idempotency](https://www.shopdeskbd.com/developers/idempotency.md) and [Pagination](https://www.shopdeskbd.com/developers/pagination.md) — when in doubt, those pages are the authority, and an agent with the MCP server attached can check them itself. --- # Errors The error envelope, every code, and what to do with each. Every failure — from a malformed key to a database conflict — comes back in one shape. Write one error handler, not twelve. ```json { "error": { "code": "NOT_FOUND", "message": "Order not found.", "request_id": "4f1c9b02-7d3e-4a85-9c16-b0e2f7a34d59" } } ``` ## The error object | Field | Always present | What it is for | | ------------ | --------------------------------- | ------------------------------------------------------------------ | | `code` | Yes | A stable machine-readable identifier. **Branch on this.** | | `message` | Yes | A human-readable sentence. Show it to a developer; never parse it. | | `fields` | Only on validation failures | A map of field name to the list of things wrong with it. | | `request_id` | Whenever a request id was stamped | Correlates with our logs. Echoed in the `X-Request-Id` header. | > **Warning — Branch on code, never on message** > > `code` is part of the contract and will not change without a version bump. > `message` is human copy and may be reworded at any time, in any release. Code > that does `if (error.message === "Order not found.")` is code that breaks on > a typo fix. There is no `success` field and no envelope around the error. The HTTP status and the `code` are the answer. > **Note — This differs from the dashboard API on purpose** > > If you have seen our internal API, it wraps everything in > `{ success, message, data, meta }`. The public API deliberately does not. > That `message` is UI copy — it gets reworded whenever a designer asks, which > is fine for a screen and fatal for something your code branches on. Here the > machine-readable code leads and the message is strictly human-facing. ## Every error code ### Authentication and authorization | HTTP | Code | When | | ---- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 401 | `UNAUTHORIZED` | No `Authorization` header, a token that is not `shopdesk_sk_…`, an unknown key, a plan without API access, or a store or company that is not live. | | 401 | `KEY_REVOKED` | The key exists and was deliberately revoked. | | 401 | `KEY_EXPIRED` | The key's expiry date passed, or its rotation grace window closed. | | 403 | `FORBIDDEN_SCOPE` | The key is valid but was not granted the scope this endpoint requires. Scopes are fixed at creation — make a new key. | | 403 | `PLAN_FEATURE_REQUIRED` | The key and its scopes are fine, but the store's plan does not include the feature this request needs — today, Advanced Reports for `compare=previous_period` and `GET /reports/top-products`, and product videos for every video endpoint and for `videos[]` on `POST /products`. A new key will not help; the merchant has to upgrade the plan. The message names the feature. | ### Request problems | HTTP | Code | When | | ---- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 404 | `NOT_FOUND` | No such record **in the data this key can see**. Also what a record belonging to another tenant returns. | | 422 | `VALIDATION_ERROR` | A parameter or body field was missing, the wrong type, out of range, or not in the allowed set. Carries `fields`. | | 400 | `VALIDATION_ERROR` | The one validation failure that stays `400`: a bad `Idempotency-Key` header. It is missing on an endpoint that requires it, or it is sent empty or longer than 255 characters on any write. `fields` names the header. | | 409 | `CONFLICT` | The write collides with an existing record — a duplicate unique value (two variants of a product with the same options among them), or a reference to something that is not there. | | 413 | `VALIDATION_ERROR` | An uploaded file is larger than the endpoint allows (10 MB for any image), or one request's files total more than 50 MB. Only the multipart writes answer this: `POST /products/{id}/images`, the category and brand writes, and a video cover upload (`POST /videos/{id}/poster`, 8 MB for the part — the cover itself must be 2 MB or less, a `422`). | | 429 | `RATE_LIMITED` | Too many requests for this key in the current window — or, on a write that carries images, images that would pass the write limit (each counts as one write), a third such call while two are running for the key, or the company's daily 2,000 links used up — or a daily video cap: 50 uploads started per store, 20 imports per key. Every `429` carries `Retry-After`. See [Rate limits](https://www.shopdeskbd.com/developers/rate-limits.md). | > **Note — 404 rather than 403 for another tenant's data** > > Asking for an id that belongs to a different store or company returns `404`, > identical to an id that does not exist at all. A `403` would confirm the > record is real, which turns the endpoint into an oracle for enumerating other > merchants' ids. Every repository query is scoped by tenant before it runs, so > there is nothing to leak in the first place. ### Ours, not yours | HTTP | Code | When | | ---- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 500 | `INTERNAL` | Something failed on our side. The message is always *"Something went wrong on our end."* — deliberately generic, because the alternative is leaking our internals into your logs. The real cause is recorded against the `request_id`. | Retry a `500` once, with a short delay. If it persists, send us the `request_id`. ### On the write endpoints | HTTP | Code | When | | ---- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | 400 | `INSUFFICIENT_STOCK` | Not enough stock to satisfy the request. `fields.items` names the offending line, with what was requested and what is available. | | 400 | `INVALID_STATUS_TRANSITION` | The order cannot move from its current status to the requested one. `fields.status` says which move was refused. | | 422 | `IDEMPOTENCY_CONFLICT` | The `Idempotency-Key` was reused with a different request body. | | 409 | `IDEMPOTENCY_IN_FLIGHT` | The same `Idempotency-Key` is still being processed. Retry shortly, **with the same key**. | | 409 | `CONFLICT` | A status change or cancel refused because the order's courier parcel is still live. `fields.status` is `["live_parcel"]`. Before the courier picks the parcel up, cancel it in the dashboard (and on the courier's side) first; after pickup it can no longer be cancelled — ask for it back with a Return request in the dashboard, and the order moves when the courier returns it. Or a move to `returned` while a courier that reports pickups (Pathao, RedX, CarryBee, Bahok) has not reported picking the parcel up: `fields.status` is `["parcel_not_picked_up"]`. Or a move to `in_transit` while the order's parcel is cancelled: `fields.status` is `["shipment_cancelled"]` — book it again in the dashboard. | > **Note — These two are 400, not 422** > > `INSUFFICIENT_STOCK` and `INVALID_STATUS_TRANSITION` come from the domain > services with their own status, and it is deliberately not rewritten on the > way out. Branch on the `code`, which is stable, rather than on the status — > which is the advice for every error here, and the reason `code` exists. Full detail on [Writes & idempotency](https://www.shopdeskbd.com/developers/idempotency.md). ### On the video endpoints The `/videos` endpoints, and placing a video on a product, add their own codes. Each tells you what to do next without parsing the message: | HTTP | Code | When | | ---- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 403 | `VIDEO_UPLOADS_DISABLED` | Video uploads are switched off for now: no upload, import, cover or placement (taking a video off a product still works). Nothing is wrong with the key or the plan; try again later. | | 403 | `PLAN_LIMIT_REACHED` | The plan's video storage is full, or the product already shows its plan's number of videos. Delete videos you no longer need, or the merchant upgrades. Only video writes answer this: the plan's limit on the number of products still answers `403 FORBIDDEN_SCOPE` on `POST /products`, as before. | | 409 | `TOO_MANY_OPEN_UPLOADS` | This key already has 4 unfinished uploads or imports, or the store 6. Complete or delete one first. | | 409 | `FILE_CHANGED` | A part was already signed with a different checksum, or a stored part does not match its checksum: this is not the same file. Start a new upload. | | 409 | `PARTS_INCOMPLETE` | `complete` found parts missing from storage. `fields.parts` names them; send them and complete again. | | 409 | `UPLOAD_IS_IMPORT` | Part URLs or `complete` on a video imported from a link: Shopdesk sends its parts. Poll it instead. | | 409 | `UPLOAD_VERIFYING` | The video is still being checked. Wait until it is `ready`. | | 409 | `VIDEO_IN_USE` | A live product shows the video you tried to delete. Take it off the product first. | | 410 | `UPLOAD_NOT_ACTIVE` | The upload expired, was cancelled or already ended. Start a new one. | | 422 | `VIDEO_*` | The file breaks a rule — `VIDEO_HEVC`, `VIDEO_FRAGMENTED`, `VIDEO_TOO_LONG` and eleven more — with how to fix it in `fields.video`. The full list is in [Upload a product video](https://www.shopdeskbd.com/developers/guide-upload-a-product-video.md#reject-codes). | | 502 | `STORAGE_UNAVAILABLE` | Video storage did not answer. Retry in a moment. | A video another key uploaded is `404 NOT_FOUND` on every `/videos` endpoint, like another store's data — unless your key was made by rotating that key, which keeps its uploads. ## Validation errors `fields` maps each parameter to everything wrong with it. Ordinary validation failures are normalised to `422`, so there is one status meaning *"your request was wrong"* — the exceptions are the three `400`s named above, which carry their own codes. ```json { "error": { "code": "VALIDATION_ERROR", "message": "The request was not valid.", "fields": { "limit": [ "limit must not be greater than 100", "limit must be an integer number" ] }, "request_id": "4f1c9b02-7d3e-4a85-9c16-b0e2f7a34d59" } } ``` ## Request ids Every request through the public API is stamped with an id, returned in the `X-Request-Id` response header and included in the body of any error. ```http HTTP/1.1 404 Not Found X-Request-Id: 4f1c9b02-7d3e-4a85-9c16-b0e2f7a34d59 Content-Type: application/json ``` **Log it on every failure.** It is the only handle that finds your exact request in our logs — far more useful than a timestamp and an endpoint name, particularly for a sync that makes thousands of calls an hour. You can also **supply your own**: send an `X-Request-Id` header and we will use your value instead of generating one. That lets you carry a trace id from your own system straight through ours, so one identifier spans both sides. ## Handling errors well - **Switch on `code`, with a default branch.** New codes may be added in v1 — treat the list as open, and let anything unrecognised fall through to "unexpected error, log and alert". - **Do not retry `4xx`.** A `401`, `403`, `404` or `422` will fail exactly the same way the second time. The only retryable statuses are `429` (wait for `Retry-After`) and `500` (once, briefly). - **Distinguish `KEY_REVOKED` from `UNAUTHORIZED` in your alerting.** A revoked key means a human deliberately turned your integration off; it should page someone, not silently retry forever. - **Keep `request_id` in your own error records**, not just in a log line that rotates out after a week. --- # Pagination Cursor paging, the sync pattern, and why sorting is limited. Lists are cursor-paginated. You ask for a page size and a position; you get rows and a cursor for the next page. ```json { "data": ["…25 objects…"], "meta": { "has_more": true, "next_cursor": "c9d8e7f6-1a2b-4c3d-8e9f-0a1b2c3d4e5f" } } ``` ## Parameters | Parameter | Type | Default | Notes | | ---------------- | ----------------------------- | ------------- | --------------------------------------------------------------------------------------------------- | | `limit` | integer | `25` | Maximum `100`. Values outside the range are **clamped**, not rejected — `limit=5000` gives you 100. | | `starting_after` | object id | – | Return the page *after* this object. Pass the previous page's `next_cursor`. | | `ending_before` | object id | – | Return the page *before* this object. Paging backwards. | | `sort` | `created_at` or `-created_at` | `-created_at` | Newest first by default. | `starting_after` and `ending_before` are mutually exclusive — sending both is a `VALIDATION_ERROR` rather than a silently ignored parameter. ## The `meta` block | Field | Type | Meaning | | ------------- | ---------------- | ---------------------------------------------------------------------------------- | | `has_more` | boolean | There is at least one more row after this page. | | `next_cursor` | string or `null` | The id to pass as `starting_after` for the next page. **`null` on the last page.** | `next_cursor` is deliberately `null` rather than the last row's id when the list is exhausted, so `while (cursor)` terminates on its own and nobody has to remember to check `has_more` as well. ## Walking every page **cURL** ```bash # Page 1 — no cursor. curl -sS "https://api.shopdeskbd.com/ext/v1/orders?limit=100" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" # The response ends with: # "meta": { "has_more": true, "next_cursor": "0f0b…c31" } # Feed that cursor back as starting_after, and repeat until # has_more is false. curl -sS "https://api.shopdeskbd.com/ext/v1/orders?limit=100&starting_after=0f0b…c31" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript async function* everyOrder(params = {}) { let cursor = null; do { const url = new URL("https://api.shopdeskbd.com/ext/v1/orders"); url.searchParams.set("limit", "100"); for (const [key, value] of Object.entries(params)) { url.searchParams.set(key, value); } if (cursor) { url.searchParams.set("starting_after", cursor); } const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}` }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); yield* data; // next_cursor is null on the last page, which ends the loop. cursor = meta.has_more ? meta.next_cursor : null; } while (cursor); } for await (const order of everyOrder({ status: "confirmed" })) { console.log(order.number, order.total_amount); } ``` **Python** ```python import os import requests def every_order(**params): cursor = None headers = {"Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}"} while True: query = {"limit": 100, **params} if cursor: query["starting_after"] = cursor response = requests.get( "https://api.shopdeskbd.com/ext/v1/orders", params=query, headers=headers, timeout=30, ) response.raise_for_status() page = response.json() yield from page["data"] if not page["meta"]["has_more"]: return cursor = page["meta"]["next_cursor"] for order in every_order(status="confirmed"): print(order["number"], order["total_amount"]) ``` **PHP** ```php 100], $params); if ($cursor !== null) { $query['starting_after'] = $cursor; } $ch = curl_init('https://api.shopdeskbd.com/ext/v1/orders?' . http_build_query($query)); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $page = json_decode(curl_exec($ch), true); curl_close($ch); yield from $page['data']; $cursor = $page['meta']['has_more'] ? $page['meta']['next_cursor'] : null; } while ($cursor !== null); } foreach (every_order(['status' => 'confirmed']) as $order) { echo $order['number'] . ' ' . $order['total_amount'] . PHP_EOL; } ``` ## Why cursors instead of page numbers Because the primary consumer of this API is a sync, and a store keeps taking orders while your sync runs. With offset pages, one order placed while you are between page 1 and page 2 shifts every row down by one. Page 2 then re-serves a row you already had and **skips one entirely** — silently. The merchant discovers it weeks later as a missing order in their accounts. A cursor is anchored to a row, not to a count. New rows arriving at the head do not move the rows behind it, so the walk stays correct no matter what the store does while you are reading. Deep pages are cheap for the same reason — there is no `OFFSET 40000` for the database to count through. ## Why you can only sort by `created_at` This is the constraint people ask about most, so here is the actual reason. A cursor works by remembering *where you were* in a sorted order. That only holds if the sort key does not move. `created_at` never changes after a row is written, so a cursor over it is stable for as long as you care to page. `updated_at` moves constantly — every status change, every payment, every edit. Paging over it would mean a row you already read gets touched, jumps ahead of your cursor, and is served to you again while another row slips behind it and is never served at all. That is a data-loss bug wearing a feature's clothes, so the sort allow-list contains `created_at` and nothing else. > **Note — What to use instead** > > You almost certainly wanted `updated_after`, not `sort=updated_at`. It gives > you exactly what you were reaching for — everything that changed — without > the instability, because it **filters** on the moving column while still > **paging** on the stable one. ## Syncing changes with `updated_after` `updated_after` is the workhorse of any ongoing integration. It returns everything modified since an instant — including rows created long before it. An order placed last month and confirmed this morning is precisely the row a `created_after` poll misses and an `updated_after` poll catches. **cURL** ```bash # Everything that changed since the last successful sync. # Store the timestamp you STARTED the run at, not the one you finished at. SINCE="2026-08-10T00:00:00Z" curl -sS "https://api.shopdeskbd.com/ext/v1/orders?updated_after=$SINCE&limit=100&expand=items,customer" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript // Read the watermark BEFORE the run: anything modified while the run is // in flight is then picked up next time instead of being skipped. const runStartedAt = new Date().toISOString(); const since = await loadWatermark(); // your storage let cursor = null; do { const url = new URL("https://api.shopdeskbd.com/ext/v1/orders"); url.searchParams.set("updated_after", since); url.searchParams.set("limit", "100"); url.searchParams.set("expand", "items,customer"); if (cursor) { url.searchParams.set("starting_after", cursor); } const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}` }, }); const { data, meta } = await response.json(); for (const order of data) { // Upsert on id — an order can appear in several runs as it moves // through its statuses, and the newest snapshot always wins. await upsertOrder(order); } cursor = meta.has_more ? meta.next_cursor : null; } while (cursor); await saveWatermark(runStartedAt); ``` **Python** ```python import os from datetime import datetime, timezone import requests # Read the watermark BEFORE the run, so rows changed mid-run are caught # by the next one rather than skipped by this one. run_started_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") since = load_watermark() # your storage headers = {"Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}"} cursor = None while True: query = { "updated_after": since, "limit": 100, "expand": "items,customer", } if cursor: query["starting_after"] = cursor page = requests.get( "https://api.shopdeskbd.com/ext/v1/orders", params=query, headers=headers, timeout=30, ).json() for order in page["data"]: # Upsert on id: the same order reappears as its status changes. upsert_order(order) if not page["meta"]["has_more"]: break cursor = page["meta"]["next_cursor"] save_watermark(run_started_at) ``` **PHP** ```php $since, 'limit' => 100, 'expand' => 'items,customer', ]; if ($cursor !== null) { $query['starting_after'] = $cursor; } $ch = curl_init('https://api.shopdeskbd.com/ext/v1/orders?' . http_build_query($query)); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $page = json_decode(curl_exec($ch), true); curl_close($ch); foreach ($page['data'] as $order) { upsert_order($order); // upsert on id } $cursor = $page['meta']['has_more'] ? $page['meta']['next_cursor'] : null; } while ($cursor !== null); save_watermark($runStartedAt); ``` Three rules make an `updated_after` sync correct: 1. **Record the watermark before the run, not after.** Anything modified while your run is in flight then gets picked up by the next run rather than falling into the gap between them. 2. **Upsert on `id`.** The same order legitimately appears in many runs as it moves through its statuses. The newest snapshot always wins. 3. **Overlap slightly.** Subtracting a minute from your stored watermark costs a few duplicate rows — which rule 2 already handles — and protects you from clock skew. > **Warning — Not every resource has updated\_after yet** > > Today it exists on **orders** and **products** only. > > Customers, categories, brands, inventory levels, movements, locations and > channels do not have it. For those, re-read the full list on a schedule — > they are small and change rarely, so a nightly full read is entirely > reasonable. The filter is planned for the remaining resources; it will be > additive, and announced in the [changelog](https://www.shopdeskbd.com/developers/changelog.md). [Webhooks](https://www.shopdeskbd.com/developers/webhooks.md) let you stop polling *frequently*, but not stop polling: they are at-least-once and your receiver can be down, so a periodic `updated_after` pass stays the reconciliation path. Poll hourly or daily instead of every minute, and let events carry the urgency. ## Filtering and search Filters are an explicit allow-list per resource, not a generic query language. That is a deliberate limit: a generic filter syntax would expose our internal schema as a public contract and freeze it there. The exact set for each endpoint is in the [reference](https://www.shopdeskbd.com/developers/reference.md). - **Orders** — `status`, `payment_status`, `fulfillment_status`, `channel` (the slug, not the id), `created_after`, `created_before`, `updated_after`, `search` - **Products** — `status`, `category_id`, `updated_after`, `search` - **Categories** and **Brands** — `status` - **Inventory levels** — `warehouse_id`, `variant_id`, `low_stock` - **Inventory movements** — `warehouse_id`, `variant_id` `search` matches order number, customer name and customer phone on orders; name and SKU on products. It is only offered where an index already supports it. ## Expanding related objects Orders are compact by default — related objects are referenced by id. Ask for them with `expand`: ```bash curl -sS "https://api.shopdeskbd.com/ext/v1/orders?expand=customer,items&limit=50" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` `expand` accepts `customer` and `items`, comma-separated, one level deep — no nesting. It is opt-in rather than always-on because a sync walking ten thousand orders should not pay for line items it is going to throw away. ## The last page ```json { "data": ["…7 objects…"], "meta": { "has_more": false, "next_cursor": null } } ``` An empty list is `data: []` with `has_more: false` — not a `404`. A search that matches nothing is a successful request with no results. --- # Writes & idempotency Every write endpoint, and the Idempotency-Key contract behind them. Thirty-two endpoints write. They run the same code the dashboard runs — the same stock reservation, the same coupon engine, the same totals math, the same status-transition rules, the same image pipeline — so the API cannot do anything the merchant's own staff could not, and cannot skip anything the desk enforces. Because a write can be lost on the way back, every one of them takes an `Idempotency-Key`, and five of them require it. ## The write endpoints | Endpoint | Scope | `Idempotency-Key` | Answers | | ----------------------------------------- | ----------------- | ----------------- | --------------------------------------------------------------------------------------------------- | | `POST /orders` | `orders:write` | **Required** | `201` + the created order | | `PATCH /orders/{id}` | `orders:write` | Honoured | `200` + the updated order (note and follow-up only) | | `PATCH /orders/{id}/status` | `orders:write` | Honoured | `200` + the updated order | | `POST /orders/{id}/cancel` | `orders:write` | Honoured | `200` + the cancelled order | | `POST /orders/{id}/payments` | `payments:write` | **Required** | `201` + the updated order | | `POST /customers` | `customers:write` | Honoured | `201` + the customer | | `PATCH /customers/{id}` | `customers:write` | Honoured | `200` + the customer | | `POST /inventory/adjustments` | `inventory:write` | **Required** | `201` + the resulting stock level | | `POST /products` | `products:write` | Honoured | `201` + the product | | `PATCH /products/{id}` | `products:write` | Honoured | `200` + the product | | `POST /products/{id}/archive` | `products:write` | Honoured | `201` + `{ id, archived }` | | `POST /products/{id}/images` | `products:write` | Honoured | `201` + the image (one multipart file), or `{ data }` for a batch (links in JSON, or several parts) | | `POST /products/{id}/images/reorder` | `products:write` | Honoured | `200` + `{ data }`, the gallery in its new order | | `PATCH /products/{id}/images/{imageId}` | `products:write` | Honoured | `200` + the image | | `DELETE /products/{id}/images/{imageId}` | `products:write` | Honoured | `200` + `{ id, deleted }` | | `PATCH /variants/{identifier}` | `products:write` | Honoured | `200` + the variant | | `POST /categories` | `catalog:write` | Honoured | `201` + the category (JSON or multipart) | | `PATCH /categories/{id}` | `catalog:write` | Honoured | `200` + the category (JSON or multipart) | | `POST /brands` | `catalog:write` | Honoured | `201` + the brand (JSON or multipart) | | `PATCH /brands/{id}` | `catalog:write` | Honoured | `200` + the brand (JSON or multipart) | | `POST /attributes` | `catalog:write` | Honoured | `201` + the attribute with its values | | `PATCH /attributes/{id}` | `catalog:write` | Honoured | `200` + the attribute with its values | | `POST /attributes/{id}/values` | `catalog:write` | Honoured | `201` + the value | | `PATCH /attributes/{id}/values/{valueId}` | `catalog:write` | Honoured | `200` + the value | | `POST /videos` | `videos:write` | **Required** | `201` + the upload session and its part plan | | `POST /videos/import` | `videos:write` | **Required** | `202` + the video, copied from the link in the background | | `POST /videos/{id}/part-urls` | `videos:write` | Honoured | `200` + `{ data }`, a signed URL per part | | `POST /videos/{id}/complete` | `videos:write` | Honoured | `200` + the ready video, or `202` while it is being checked | | `POST /videos/{id}/poster` | `videos:write` | Honoured | `200` + the video with its cover (JSON or multipart) | | `DELETE /videos/{id}` | `videos:write` | Honoured | `200` + `{ id, deleted }` | | `POST /products/{id}/videos` | `videos:write` | Honoured | `200` + the placement | | `DELETE /products/{id}/videos/{videoId}` | `videos:write` | Honoured | `200` + `{ product_id, video_id, removed }` | Request fields, validation rules and every declared status are in the [reference](https://www.shopdeskbd.com/developers/reference.md) — generated from the same decorators that validate the request, so they cannot drift. This page covers the rules that span all of them. > **Note — Write responses are read responses** > > A write answers with the object re-read through the **public serializer** — > the same shape the corresponding `GET` publishes, with orders and customers > fully expanded. You never have to follow a write with a read to learn what > you just created, and you can feed a write response into the same parser as a > `GET`. ## `Idempotency-Key` Send a unique value per **logical operation** — not per HTTP attempt. Every retry of the same operation sends the same key. ```http POST /ext/v1/orders HTTP/1.1 Authorization: Bearer shopdesk_sk_… Idempotency-Key: 3f1a5c8e-0b2d-4f77-9a13-5e6c7d8f9a10 Content-Type: application/json ``` A UUID is ideal. Anything non-empty up to **255 characters** is accepted — an ERP that emits its own request ids should not have to be RFC 4122 about it. An empty or longer value is refused with `400 VALIDATION_ERROR` on every write, including the ones where the header is optional. **cURL** ```bash # The key identifies the ORDER you are placing, not this HTTP attempt. # Generate it once, store it with your own record, and reuse it on retries. IDEMPOTENCY_KEY=$(uuidgen) curl -sS -X POST "https://api.shopdeskbd.com/ext/v1/orders" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: $IDEMPOTENCY_KEY" \ -H "Content-Type: application/json" \ -d '{ "channel": "facebook", "customer": { "phone": "01712345678", "full_name": "Rafiqul Islam" }, "items": [ { "sku": "TSHIRT-RED-M", "quantity": 2 } ], "shipping_charge": 120, "note": "Deliver after 6pm." }' ``` **JavaScript** ```javascript import { randomUUID } from "node:crypto"; // One key per logical order. Persist it next to your own order record BEFORE // the call, so a retry after a process restart still sends the same one. const idempotencyKey = randomUUID(); const response = await fetch("https://api.shopdeskbd.com/ext/v1/orders", { method: "POST", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": idempotencyKey, "Content-Type": "application/json", }, body: JSON.stringify({ channel: "facebook", customer: { phone: "01712345678", full_name: "Rafiqul Islam" }, items: [{ sku: "TSHIRT-RED-M", quantity: 2 }], shipping_charge: 120, note: "Deliver after 6pm.", }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } // 201 with the full order: items and customer expanded, totals computed, // stock already reserved. const order = await response.json(); console.log(order.number, order.total_amount, order.payment_status); // true when this was a replay of an earlier identical call. console.log(response.headers.get("Idempotent-Replayed") === "true"); ``` **Python** ```python import os import uuid import requests # One key per logical order — generated where the order is DECIDED, not # inside the retry loop, and stored with your own record. idempotency_key = str(uuid.uuid4()) response = requests.post( "https://api.shopdeskbd.com/ext/v1/orders", json={ "channel": "facebook", "customer": {"phone": "01712345678", "full_name": "Rafiqul Islam"}, "items": [{"sku": "TSHIRT-RED-M", "quantity": 2}], "shipping_charge": 120, "note": "Deliver after 6pm.", }, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": idempotency_key, }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") order = response.json() print(order["number"], order["total_amount"], order["payment_status"]) print(response.headers.get("Idempotent-Replayed") == "true") ``` **PHP** ```php 'facebook', 'customer' => ['phone' => '01712345678', 'full_name' => 'Rafiqul Islam'], 'items' => [ ['sku' => 'TSHIRT-RED-M', 'quantity' => 2], ], 'shipping_charge' => 120, 'note' => 'Deliver after 6pm.', ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/orders'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('SHOPDESK_API_KEY'), 'Idempotency-Key: ' . $idempotencyKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } echo $response['number'] . ' ' . $response['total_amount'] . PHP_EOL; ``` ### What happens on a repeat | Situation | Result | | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | First time this key is seen | The operation runs normally. | | Same key, same body, already finished | The **stored response is replayed** — original status code, byte-identical body, plus `Idempotent-Replayed: true`. The operation does not run again. | | Same key, **different** body | `422 IDEMPOTENCY_CONFLICT`. A key identifies one specific request; reusing it for another is a bug worth surfacing rather than guessing at. | | Same key, original still running | `409 IDEMPOTENCY_IN_FLIGHT`. Retry in a few seconds. The operation never runs twice concurrently. | | Same key, original **failed** | Runs fresh. Failures are never replayable — you saw the error, and a retry deserves a real attempt. | | Same key, more than 24 hours later | Treated as new. | **cURL** ```bash # Retrying is just sending the SAME key again. KEY="3f1a5c8e-0b2d-4f77-9a13-5e6c7d8f9a10" send() { curl -sS -o body.json -w '%{http_code}' -D headers.txt \ -X POST "https://api.shopdeskbd.com/ext/v1/orders" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: $KEY" \ -H "Content-Type: application/json" \ -d @order.json } STATUS=$(send) # 409 means the first attempt is still running. Wait, then ask again — # do NOT generate a new key, that would create a second order. while [ "$STATUS" = "409" ]; do sleep 2 STATUS=$(send) done # On a replay the body is byte-identical to the original response and this # header is present: grep -i '^idempotent-replayed' headers.txt ``` **JavaScript** ```javascript async function createOrderOnce(order, idempotencyKey, attempt = 0) { const response = await fetch("https://api.shopdeskbd.com/ext/v1/orders", { method: "POST", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": idempotencyKey, "Content-Type": "application/json", }, body: JSON.stringify(order), }); // 201 first time, 201 again on every replay — with the original body. if (response.ok) { return response.json(); } const { error } = await response.json(); // The original request is still in flight. Wait for it; never re-key. if (error.code === "IDEMPOTENCY_IN_FLIGHT" && attempt < 5) { await new Promise((resolve) => setTimeout(resolve, 2000)); return createOrderOnce(order, idempotencyKey, attempt + 1); } // Same key, different body: a bug in YOUR code, not a transient failure. // Retrying cannot fix it — the key is already bound to another request. if (error.code === "IDEMPOTENCY_CONFLICT") { throw new Error( `Key ${idempotencyKey} was already used for a different order.`, ); } throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } ``` **Python** ```python import os import time import requests HEADERS = {"Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}"} def create_order_once(order, idempotency_key, attempts=5): for _ in range(attempts): response = requests.post( "https://api.shopdeskbd.com/ext/v1/orders", json=order, headers={**HEADERS, "Idempotency-Key": idempotency_key}, timeout=30, ) if response.ok: # A replay returns the ORIGINAL response, marked as one. replayed = response.headers.get("Idempotent-Replayed") == "true" return response.json(), replayed error = response.json()["error"] if error["code"] == "IDEMPOTENCY_IN_FLIGHT": # The first attempt is still running. Wait — do not re-key. time.sleep(2) continue if error["code"] == "IDEMPOTENCY_CONFLICT": raise RuntimeError( f"Key {idempotency_key} was already used for a different order." ) raise RuntimeError(f"{error['code']}: {error['message']}") raise RuntimeError("The original request is still in flight.") ``` **PHP** ```php true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('SHOPDESK_API_KEY'), 'Idempotency-Key: ' . $idempotencyKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode($order), CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status < 400) { return $response; } // Still running: wait for the original, never mint a new key. if ($response['error']['code'] === 'IDEMPOTENCY_IN_FLIGHT') { sleep(2); continue; } if ($response['error']['code'] === 'IDEMPOTENCY_CONFLICT') { throw new RuntimeException("Key {$idempotencyKey} was already used for a different order."); } throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } throw new RuntimeException('The original request is still in flight.'); } ``` ### The rules behind that table - **The key is scoped to your API key.** Two of your systems holding different Shopdesk keys cannot collide on the same UUID, and one system's keys cannot be guessed or hijacked by another. - **The body is fingerprinted, not compared literally.** The fingerprint is a hash of the method, the path and a *canonical* form of the body — so re-serialising the same structure with different key order still counts as the same request, while any value change counts as a different one. - **Rows live 24 hours**, then they are purged and the key becomes reusable. - **A crashed request unblocks itself.** If the original process died mid-flight, the row is claimed by a retry after 90 seconds and the operation runs fresh. Until then, duplicates get `409`. - **`GET` requests ignore the header entirely.** Reads are already idempotent. > **Warning — Generate the key where the operation is decided** > > The most common way to defeat idempotency is to generate the key inside the > retry loop — that is a new key every attempt, which is exactly the duplicate > orders the mechanism exists to prevent. > > Generate it when you decide to place the order, **persist it with your own > record**, and reuse it on every attempt including after a process restart. > **Note — Order creation is protected twice** > > `POST /orders` additionally derives a deterministic request id from your API > key and idempotency key and hands it to the order core. So even in the narrow > window where the idempotency row was written but the response never stored — > a crash between the two — a retry converges on the *same order* rather than > creating a second one. Nothing is required of you to get this; it is why > order creation is the one write with belt and braces. ## Writing with a company key A [company key](https://www.shopdeskbd.com/developers/authentication.md#store-keys-and-company-keys) can read every store in the company, but a write has to land in exactly one. - **`store_id` in the body is required.** There is no such thing as a company order. Omitting it is `422` naming the field: *"store\_id is required in the body when writing with a company-scoped API key."* - **It is validated before anything runs.** A store belonging to another company, or one that is suspended, answers `404` — never `403`, so the field cannot be used to discover which store ids exist. - **A store key may repeat its own `store_id`, and may not contradict it.** Naming a different store with a store key is `422`: you filled the field in and got it wrong, which is a fact about your request rather than a hint about someone else's data. Liveness is re-checked on every write either way, so a suspended store stops accepting writes at the same moment it stops serving shoppers. ## Creating orders `POST /orders` is the endpoint with the most surface, and four of its behaviours surprise people. ### Prices are trusted `unit_price`, `discount_price`, `shipping_charge` and `tax_amount` are accepted as your system computed them. This is a **merchant-trusted surface** — the key belongs to the store owner and runs the same path as the admin desk, where staff also override prices. A shopper-facing API would never accept them. Omit `unit_price` and the variant's current catalogue price is used, exactly as at the desk. A `discount_price` is only honoured when it is a *real* discount: above zero and below the regular price. ### `shipping_charge` is never calculated for you Default `0`. If your system charges for delivery, send the number; nothing computes it on this path. (A free-shipping coupon still zeroes it.) ### The customer is found or created by phone Send `customer_id` for an existing customer, or a `customer` object to find-or-create. Never both — that is a `422`. The match key is the **canonical Bangladeshi phone number**: any accepted spelling is normalised first, so `+8801712345678`, `8801712345678` and `01712345678` are one customer, not three. > **Warning — An existing customer is never updated by an order** > > If the phone matches, that customer is used **as-is**. The `full_name` and > `email` you sent are ignored — a sale is not a CRM edit, and silently > renaming a customer because a checkout form was filled in carelessly is worse > than doing nothing. > > `full_name` is required only when the phone matches nobody, because a new > record needs a name. To change an existing customer, call > `PATCH /customers/{id}` deliberately. ### Attribution - **`channel`** takes a channel *slug* (`facebook`, `online-store`), never an id. Omitted, it defaults to the store's `manual` channel. An unknown or inactive slug fails the order rather than mis-attributing it. - **`source` is always `api`** on the resulting order, and you cannot set it. That is how a merchant tells API orders from POS and storefront ones in their own reports. ### Everything else is the desk's behaviour, inherited Stock is reserved with an availability check (`INSUFFICIENT_STOCK` names the line that failed), coupons are priced and redeemed by the same engine, the plan order quota is metered, and the order appears in the dashboard timeline with *"Order placed via API"*. ## Endpoint-specific rules worth knowing **`POST /inventory/adjustments`** — `reason` is always required (the ledger has no unexplained movements). `quantity_change` is a signed delta and may not be zero, may not take on-hand below zero, and may not take it below what is already reserved. `type: "purchase"` requires `unit_cost` — stock cannot enter at no cost — `type: "damage"` must remove stock, and `type: "return"` can only add stock. `unit_cost` is refused on every type except `purchase`: the other types move units at the variant's current average cost, and a decrease filed as `adjustment` or `damage` is booked as a loss on the merchant's P\&L at that cost. `unit_cost` is **write-only**: it is accepted here and never appears in any response. **`POST /orders/{id}/payments`** — the amount is capped at the order's *net* due (refunds counted). `transaction_id` is unique per store, so recording the same provider TrxID twice is a `409`. The paid-in-full events fire exactly once, on the transition. **`PATCH /orders/{id}/status`** — validated against the same strict transition map staff use. An illegal move is refused with `INVALID_STATUS_TRANSITION` rather than being quietly applied. Setting the status it already has is a no-op, not an error. `POST /orders/{id}/cancel` is sugar for the `cancelled` transition, with the same stock unwind. **Product writes** take no specifications and no initial stock — stock enters through `POST /inventory/adjustments`, where the ledger rules live. `cost_price` on a variant is accepted and never returned. A whole product can be built in one `POST /products`: photos by link in `images[]` (up to 10), each variant's `options[]` by attribute and value **name** (created first with `POST /attributes` — an unknown name is a `422` on that exact field and nothing is created), and each variant's photo as `image_index`, a position in the same request's `images[]`. On `PATCH /products/{id}`, `images[]` is the **full gallery**: `image_id` entries keep those photos, `source_url` entries add new ones, and photos left out are deleted. Options are set on new variants only; an existing variant keeps its options. See [Photos by link](https://www.shopdeskbd.com/developers/guide-photos-by-link.md). **Product images have their own endpoints.** `POST /products/{id}/images` adds photos: one file as a `multipart/form-data` `image` part (PNG, JPEG, WebP or GIF, at most 10 MB and 8000 px on the longest side — the type is read from the bytes, not the file name) with `alt_text`, `position`, `is_thumbnail` and, for a company key, `store_id` as text fields; up to ten `image` parts with per-file options as a JSON string in one `images` field; or up to ten links as JSON, `{ "images": [{ "source_url": "https://…" }] }`. A batch is one gallery change, all or nothing, and answers `{ data }` — the new images in the order sent. Stored copies are re-encoded to WebP with metadata stripped, exactly as a dashboard upload is. A gallery holds at most 30 images; the first image a product receives becomes its thumbnail. `POST …/images/reorder` takes every image id once, in the new order. `PATCH …/images/{imageId}` changes alt text, position or the thumbnail flag (the bytes are immutable — upload a new image and delete the old one), and `DELETE …/images/{imageId}` is permanent: the object leaves storage, variants that featured it lose the link, and a deleted thumbnail falls back to the first remaining image. A company key names the store on a `DELETE` with the `store_id` **query** parameter, since a DELETE carries no body. Each image in an answer is exactly what the next `GET /products/{id}` lists in `images[]` — `id`, `url`, `position`, `alt_text`, `is_thumbnail`. `Idempotency-Key` is honoured on all of them. On a JSON body the fingerprint covers the links, and a replay answers without downloading again; on a multipart upload it covers the route only — neither the files nor the text fields — so a second upload under a used key replays the first answer. Reuse a key only when retrying the same upload. **`PATCH /variants/{identifier}`** edits one variant — `price`, `discount_price`, `sku`, `barcode`, `weight` and its photo (`image_id`) — by id, SKU or barcode, through the dashboard's own single-variant edit: the sale price must stay below the price, and the SKU locks once the variant has stock or order history (`409`). `null` clears the sale price, barcode, weight or photo. It emits `product.updated`. **Categories, brands and attributes** (`catalog:write`) are written by the dashboard's own services, so slugs, the category tree check (a category cannot move under itself or its descendants), duplicate values and the rule that an attribute's `type` cannot change while products use it all behave exactly as in the dashboard. Category and brand images come either as **links** in a JSON body (`image_url`, `logo_url`, `banner_url`) or as **parts** of a `multipart/form-data` body (`image`, `logo`, `banner`, with the other fields as text and `seo` as a JSON string). A link is downloaded by Shopdesk, checked (PNG, JPEG, WebP or GIF read from the bytes, at most 10 MB and 8000 px on the longest side, https only, public addresses only) and stored as our own copy; the link itself is never kept. A link that fails fails the whole call with `422` naming the field, and nothing is written. A replayed `Idempotency-Key` answers from the stored response without downloading again. An attribute value's `swatch_image_url` is handled the same way. At most two calls that download images run at once per key, and a company may have 2,000 links downloaded per UTC day — beyond either, `429 RATE_LIMITED`. Nothing on this surface deletes a category, brand, attribute or value. **Product videos** (`videos:write`) upload straight to storage: the API signs a URL per part, your code `PUT`s the bytes there, and Shopdesk checks the finished file itself. On `POST /videos` and `POST /videos/import` the `Idempotency-Key` is required and is also the upload's name. Within the 24 hours a stored response is kept, a retry replays the first answer as it was (`resumed: false`, the parts as they were then); **after** those 24 hours the same key still returns the same upload, now with `resumed: true`. Either way read `GET /videos/{id}` for the upload's live state. So reuse a key only for the same file, and use a new one once that upload has ended. Send no key on `POST /videos/{id}/part-urls`: asking again is always safe, and a replay would hand back URLs that may have expired. A replayed `POST /videos/{id}/complete` answers what the first call answered, `202` included: to check a video that stayed `verifying`, call `complete` again without the header, or poll `GET /videos/{id}`. On the multipart cover (`POST /videos/{id}/poster` with a `file` part) the key covers the form fields, not the file, so send each new cover with a new key or none. `POST /products/{id}/videos` places one of the key's videos and changes nothing else on the product; `videos[]` on `POST /products` does the same for a new product and needs `videos:write` beside `products:write`. A key sees and changes only the videos it (or a key it replaced by rotation) started. See [Upload a product video](https://www.shopdeskbd.com/developers/guide-upload-a-product-video.md). **`PATCH /products/{id}` reconciles the whole variant set** when `variants` is present: entries with an `id` are patched, entries without one are created, and existing variants missing from the list are **removed** (refused if they carry stock history). Send the full set, not a partial one. The same applies to `categories`, which replaces the product's category links. **Customer writes** always produce a `manual` customer — there is no `type` field — and the store's walk-in system record cannot be created or edited. Phone and email are unique per store, so a collision is a `409`. ## Errors specific to writes | HTTP | Code | Means | | ---- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 400 | `VALIDATION_ERROR` | A required `Idempotency-Key` is missing, or one sent to any write is empty or longer than 255 characters. `fields` names the header. | | 400 | `INSUFFICIENT_STOCK` | Not enough stock for a line. `fields.items` says which. | | 400 | `INVALID_STATUS_TRANSITION` | The order cannot move from its current status to the requested one. | | 409 | `IDEMPOTENCY_IN_FLIGHT` | The same key is still being processed. Wait and retry with the same key. | | 409 | `CONFLICT` | A duplicate unique value — a SKU, a phone, a provider transaction id, an attribute value — two variants of a product with the same options, a SKU change on a variant with stock or order history, a status change or cancel on an order whose courier parcel is still live (`fields.status` is `["live_parcel"]`), a move to `returned` before the courier has reported picking the parcel up (`fields.status` is `["parcel_not_picked_up"]`), a move to `in_transit` while the order's parcel is cancelled (`fields.status` is `["shipment_cancelled"]`), or an attribute `type` change while products use it (`fields.type`). | | 413 | `VALIDATION_ERROR` | An image upload (a multipart part) larger than 10 MB. Resize it and retry. | | 429 | `RATE_LIMITED` | Besides the per-key limit: a third call that downloads images while two are running, the company's 2,000 image links for the day (category, brand and swatch links, photo links on products, and video cover links), or a daily video cap (50 uploads started per store, 20 imports per key). | | 422 | `IDEMPOTENCY_CONFLICT` | The same key was used for a different body. | | 422 | `VALIDATION_ERROR` | Anything else about the request that is wrong, with `fields`. | Full list on the [Errors](https://www.shopdeskbd.com/developers/errors.md) page. ## Rate limits Writes count against a **separate, smaller bucket**: 30 per minute per key by default, against 120 for reads. A bulk catalogue import should pace itself accordingly — or run on its own key, since buckets are per key. See [Rate limits](https://www.shopdeskbd.com/developers/rate-limits.md). --- # Rate limits Per-key buckets, headers, and handling 429. Requests are counted **per API key**, in a fixed 60-second window, across two separate buckets. | Bucket | Counts | Default | | ------ | ------------------------ | ------------------ | | Reads | `GET`, `HEAD`, `OPTIONS` | **120 per minute** | | Writes | Everything else | **30 per minute** | The two buckets are counted separately, so a bulk catalogue import cannot starve the reads a dashboard is making with the same key — and neither can exhaust the other's allowance. The write bucket is the smaller one on purpose: writes cost far more work per request, and 30 a minute is well above what a correctly batched integration needs. Most requests count as one. The two report endpoints, `GET /reports/sales-summary` and `GET /reports/top-products`, count as **5 reads** each, because a report runs several queries over up to a year of orders. `X-RateLimit-Remaining` drops by 5 on each of them. ## Headers on every response You never have to guess where you stand — the numbers are on every response, not just the ones that fail. ```http HTTP/1.1 200 OK X-RateLimit-Limit: 120 X-RateLimit-Remaining: 117 X-RateLimit-Reset: 1786521600 ``` | Header | Meaning | | ----------------------- | -------------------------------------------------------- | | `X-RateLimit-Limit` | Requests allowed in the current window, for this bucket. | | `X-RateLimit-Remaining` | How many are left. Floors at `0`. | | `X-RateLimit-Reset` | Unix timestamp, in **seconds**, when the window resets. | > **Note — You get these headers even on a 403** > > The rate-limit check runs before the scope check, deliberately. A caller > hammering an endpoint their key is not allowed to touch is still consuming > our capacity, so they are still counted — and they still get the headers > telling them so. A `401` is the exception: a request whose key was missing or refused never reached a key's bucket, so it carries no `X-RateLimit-*` headers at all. Any `X-RateLimit-*` header you do receive describes your key. ## When you exceed it ```http HTTP/1.1 429 Too Many Requests Retry-After: 24 X-RateLimit-Limit: 120 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1786521600 ``` ```json { "error": { "code": "RATE_LIMITED", "message": "Rate limit exceeded: 120 read requests per minute. Retry in 24s.", "request_id": "4f1c9b02-7d3e-4a85-9c16-b0e2f7a34d59" } } ``` **`Retry-After` is exact.** It is the number of seconds left in your window, not a suggestion and not a rough backoff hint. Sleeping for less means spending another request to be told the same thing; sleeping for more just wastes your own time. **cURL** ```bash # --retry 5 makes curl honour Retry-After on a 429 by itself. curl -sS --retry 5 --retry-all-errors \ "https://api.shopdeskbd.com/ext/v1/orders?limit=100" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -D headers.txt # The headers on EVERY response tell you how much room is left: # X-RateLimit-Limit: 120 # X-RateLimit-Remaining: 3 # X-RateLimit-Reset: 1786521600 grep -i '^x-ratelimit' headers.txt ``` **JavaScript** ```javascript async function call(url, attempt = 0) { const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}` }, }); if (response.status === 429 && attempt < 5) { // Retry-After is authoritative — it is the exact number of seconds // until this key's window resets. Guessing a backoff just means // retrying too early and burning another request on a 429. const wait = Number(response.headers.get("Retry-After") ?? 1); await new Promise((resolve) => setTimeout(resolve, wait * 1000)); return call(url, attempt + 1); } if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } return response.json(); } ``` **Python** ```python import os import time import requests HEADERS = {"Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}"} def call(url, params=None, attempts=5): for attempt in range(attempts): response = requests.get(url, params=params, headers=HEADERS, timeout=30) if response.status_code == 429: # Retry-After is exact — it is the seconds left in this key's # fixed window. Sleeping less just spends another request. time.sleep(int(response.headers.get("Retry-After", "1"))) continue if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") return response.json() raise RuntimeError("Still rate limited after retrying.") ``` **PHP** ```php true, CURLOPT_HEADER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $raw = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); curl_close($ch); $headers = substr($raw, 0, $headerSize); $payload = json_decode(substr($raw, $headerSize), true); if ($status === 429) { // Retry-After is the exact seconds left in the window. preg_match('/retry-after:\s*(\d+)/i', $headers, $m); sleep((int) ($m[1] ?? 1)); continue; } if ($status >= 400) { throw new RuntimeException("{$payload['error']['code']}: {$payload['error']['message']}"); } return $payload; } throw new RuntimeException('Still rate limited after retrying.'); } ``` ## How the window works The window is **fixed**, not sliding: the minute is divided into fixed blocks and each one starts your count at zero. One consequence is worth knowing — you can briefly do up to twice the limit across a boundary, by spending your full allowance at the end of one window and again at the start of the next. That forgiveness is intentional. A sliding window would be stricter and would cost more to run, and nobody has yet demonstrated that the edge behaviour matters here. If that changes, tightening it is a change to our side only. ## The store MCP server Tool calls over the [store MCP server](https://www.shopdeskbd.com/developers/ai.md#the-store-mcp-server) use the same key's same two buckets. Every MCP request is an HTTP `POST`, so the bucket is chosen from the JSON-RPC message inside it, not from the method: | Message | Counts as | | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | | `initialize`, discovery, `ping`, `tools/list` | 1 read | | A read-only tool | 1 read | | `get_sales_summary`, `get_customer_summary` | 5 reads | | A write tool | 1 write | | A write tool carrying image links (`create_product`, `add_product_images_from_urls`, `create_category`, `create_brand`) | 1 write per link, at least 1 ([Image downloads](#image-downloads)) | | An unknown tool or method, a tool outside the key's scopes (it is not in the key's `tools/list`), a batch, anything that is not one JSON-RPC message | 1 write | The `Mcp-Method` and `Mcp-Name` headers play no part in this. Each tool's cost is listed on [MCP tools](https://www.shopdeskbd.com/developers/mcp-tools.md), and the `X-RateLimit-*` headers on the HTTP response describe the bucket the call counted against. ## Image downloads Writes that carry images — product photos (`images[].source_url` on `POST /products`, `PATCH /products/{id}` and `POST /products/{id}/images`, or `image` parts on that last route), category and brand images (by link or as parts), attribute value swatches — make our servers download, check and store them, so they carry extra limits on top of the write bucket: - **Each image counts as one write.** A call carrying ten photos takes ten units from the key's per-minute write bucket (the request itself is one of them). A call whose images would pass the limit is refused with `429 RATE_LIMITED` before anything is downloaded, and takes nothing. The `X-RateLimit-*` headers show the bucket as it stood when the call arrived. - **Two at a time per key.** A third call that downloads links or uploads files while two are still running is refused with `429 RATE_LIMITED` (`Retry-After: 5`); retry when one finishes. - **50 MB per call.** At most 50 MB downloaded across a call's links, and at most 50 MB of files in one multipart request (`413` past it) — 10 MB per image either way. Ten 9 MB photos go over two calls. - **2,000 links per company per UTC day,** across all its keys. Each link in a call counts once, whether or not it downloads. A call that would pass the limit is refused before anything is downloaded and uses none of it; its `Retry-After` is the seconds to midnight UTC. A call with no images (a product write without new photos, a reorder) is not metered here at all. The store MCP server's link-taking tools — `create_product`, `add_product_images_from_urls`, `create_category` and `create_brand` — share these limits with REST: a key's two image calls may be any mix of the two, and each link a tool call carries counts as one write, as on REST. A video cover sent as a link or an uploaded file (`POST /videos/{id}/poster`) is one image under these limits too. The video itself is not: it goes straight to storage and is limited as below. ## Video uploads Each call on the `/videos` endpoints counts once against the read or write bucket, like any other call. A part's bytes go to storage, not to the API, so the part `PUT`s count against nothing. On top of that, the plan's video limits apply: | Limit | Value | Past it | | ------------------------------------------------ | ---------------------------------------------------------- | ------------------------------------------------- | | Uploads started, per store per UTC day | 50 (imports included) | `429 RATE_LIMITED`, `Retry-After` to midnight UTC | | Imports from a link started, per key per UTC day | 20 | `429 RATE_LIMITED`, `Retry-After` to midnight UTC | | Unfinished uploads and imports | 4 per key, 6 per store | `409 TOO_MANY_OPEN_UPLOADS` | | Video storage | the plan's, reserved from the start | `403 PLAN_LIMIT_REACHED` | | Imports copied at once | one per company; an import not started within an hour ends | `aborted`, `expired` | | Import download speed | at least 64 KB a second on average, after the first minute | `aborted`, `import_download_failed` | Starting again with an `Idempotency-Key` you already used returns the same upload and does not count as a new start — a retry is answered even when the day's cap is used up. A key made by rotating another counts that key's unfinished uploads against its 4. A finished video that no product shows 48 hours after it is ready stops counting against video storage and is removed 7 days later. See [Upload a product video](https://www.shopdeskbd.com/developers/guide-upload-a-product-video.md#quotas-and-limits). ## Per key, not per company Each key gets its own buckets. A company key counts as one key, exactly like a store key. This has a practical implication worth planning around: **splitting one integration across several keys multiplies its throughput.** That is a legitimate way to run a nightly bulk sync and a real-time listener side by side without one starving the other — and separate keys also mean you can revoke one without touching the other. There is also a per-IP ceiling in front of everything as a denial-of-service backstop. It is set high enough that ordinary integration traffic never approaches it, including several tenants sharing one office NAT. It sends no `X-RateLimit-*` headers of its own; if it ever refuses you, the `429` carries `Retry-After`. ## Plan limits The defaults above are floors, not ceilings. Both limits are plan features (`api_rate_limit_reads` and `api_rate_limit_writes`), so a higher tier raises them with no change on your side — your key simply starts reporting a larger `X-RateLimit-Limit`. Read the limit from the header rather than hard-coding `120`. Your plan may already be higher than the default, and it can change without a release on either side. ## Staying under the limit - **Ask for bigger pages.** `limit=100` fetches the same 1,000 rows in 10 requests instead of 40. The rate limit counts requests, not rows. - **Filter with `updated_after`.** A sync that reads only what changed makes a fraction of the calls of one that re-reads everything. - **Do not poll tightly.** Once a minute is plenty for almost every integration; once every few minutes is plenty for most. - **Watch `X-RateLimit-Remaining` and slow down before you hit zero.** Backing off at 10 remaining is cheaper than a `429` and a retry. - **Do not expand what you will not read.** `expand=items` on a page you only need totals from is work for both of us. --- # Versioning What we may change without warning, and what we may not. The version is in the path. Everything served under `/ext/v1` keeps its promises for as long as v1 exists. ```text https://api.shopdeskbd.com/ext/v1/orders ``` There is no version header, no date pinning per request, and no account-level version. One product team and a modest surface do not justify that machinery — a path segment is the whole strategy, and it is legible from a log line. There is no shorter `/v1` alias either. One was planned and never deployed; it has been dropped, so `/ext/v1` is the base path for the life of v1. ## What we may change inside v1 These are **additive** and can ship any time. Your integration must tolerate them. | Change | Example | | --------------------------------- | ------------------------------------------------- | | A new field on an existing object | An order gains `delivery_window` | | A new endpoint | `GET /ext/v1/returns` | | A new query parameter or filter | `updated_after` arriving on customers | | A new value in an existing enum | An order status we do not have today | | A new error `code` | A more specific failure than the one you get now | | A new webhook event type | An endpoint subscribed to `*` starts receiving it | > **Warning — Treat every enum as an open set** > > This is the single most common way an integration breaks on an additive > change. Code that switches exhaustively on `order.status` — or worse, throws > on an unrecognised value — will fail the day a new status ships, and that day > is not a breaking change on our side. > > Handle the values you care about; let everything else fall through a default > branch. ## What we will not change inside v1 These are **breaking** and require a new major version: - Removing a field, or renaming one - Changing a field's type — a money string will never become a number - Changing what a field means while keeping its name - Removing an endpoint or a query parameter - Removing an enum value - Making an optional parameter required - Changing the meaning of an HTTP status or an error `code` That last rule is why `INSUFFICIENT_STOCK` and `INVALID_STATUS_TRANSITION` stay `400` — not `422` like other validation failures — for as long as v1 exists. See [Errors](https://www.shopdeskbd.com/developers/errors.md). A v2 would mount **beside** v1 at `/ext/v2`, never over it. v1 does not change underneath you the day v2 appears. ## Writing an integration that survives - **Ignore fields you do not recognise.** Never validate a response against a closed schema that rejects unknown keys. - **Default-branch every enum.** See the warning above. - **Do not depend on field order**, or on the absence of a field. - **Do not depend on the exact wording of `message`.** Branch on `code`. - **Do not parse ids.** They are opaque strings. Their format is not a contract, and code that assumes a shape will break when a resource adopts a different one. - **Do not assume a field is missing forever** because it is `null` today. ## Deprecation If something in v1 ever has to go, it goes slowly and loudly: 1. **Announced in the [changelog](https://www.shopdeskbd.com/developers/changelog.md)** with the replacement and a date. Behaviour changes that are not breaking are announced there too. 2. **`Deprecation`, `Sunset` and `Link` headers** appear on every response from the affected endpoint, so your logs can tell you that you are using something on the way out even if nobody read the changelog. 3. **At least twelve months** between the deprecation and the shutdown. The server refuses to start with a shorter window, so this is enforced rather than remembered. ```http HTTP/1.1 200 OK Deprecation: @1790812800 Sunset: Fri, 01 Oct 2027 00:00:00 GMT Link: ; rel="deprecation"; type="text/html" ``` | Header | Format | Meaning | | ------------- | ----------------------------- | -------------------------------------------------- | | `Deprecation` | `@` + Unix seconds (RFC 9745) | When the endpoint was, or will be, deprecated. | | `Sunset` | HTTP date (RFC 8594) | After this moment the endpoint may stop answering. | | `Link` | `rel="deprecation"` | The changelog entry naming the replacement. | All three are readable from a browser (they are in `Access-Control-Expose-Headers`). A breaking change is only ever delivered as a new major version, mounted beside v1, with v1 kept running for the whole overlap. Nothing is deprecated today. ## The version tag Responses and this documentation carry a date-based version tag — currently **2026-08**. For the REST endpoints it is informational: the path segment is what governs compatibility. For [webhooks](https://www.shopdeskbd.com/developers/webhooks.md) it is load-bearing. Each webhook endpoint is pinned to a version **when it is created**, and nothing afterwards changes it — not editing the endpoint, not rotating its secret. A change to payload shape mints a new date tag, and existing endpoints keep receiving the shape they were built against. Every delivery states its version twice: in the `X-Shopdesk-Api-Version` header and as `api_version` in the body. Event *names* are governed by a stricter rule still: they are additive forever. A new event type is a new name, and an existing name is never renamed and never changes meaning — a rename would be a silent outage that no deprecation header could reach, because merchants subscribe by string. See the [event catalog](https://www.shopdeskbd.com/developers/webhook-events.md). ## What is not covered by any of this **Data is not a contract.** A store can rename a product, archive a category or change a price at any moment; a status your integration has never seen may appear because a merchant started using a workflow they had not used before. Versioning governs the *shape* of the API, not the values flowing through it. Rate limits are also outside the versioning promise — they are plan configuration, and a higher plan changing your quota is not a breaking change. Read the limit from `X-RateLimit-Limit` rather than hard-coding it. --- # Shopdesk Commerce API — reference Version `2026-08`, generated from the [published OpenAPI description](https://www.shopdeskbd.com/openapi/public-v1.json). 57 endpoints — 25 reads and 32 writes. Paths are shown exactly as the server routes them — prefix them with `https://api.shopdeskbd.com`. Authenticate with two headers, no handshake: ```http Authorization: Bearer shopdesk_sk_YOUR_API_KEY Accept: application/json ``` A key with `:write` also satisfies `:read`; the reverse is never true. Writes take an `Idempotency-Key` — see [Writes & idempotency](https://www.shopdeskbd.com/developers/idempotency.md). ## Resources - **[Products](https://www.shopdeskbd.com/developers/reference/products.md)** — 11 endpoints - `GET /ext/v1/products` — [List products](https://www.shopdeskbd.com/developers/reference/products.md#list-products) · scope `products:read` - `POST /ext/v1/products` — [Create a product](https://www.shopdeskbd.com/developers/reference/products.md#create-a-product) · scope `products:write` - `GET /ext/v1/products/{id}` — [Get a product by id](https://www.shopdeskbd.com/developers/reference/products.md#get-a-product-by-id) · scope `products:read` - `PATCH /ext/v1/products/{id}` — [Update a product](https://www.shopdeskbd.com/developers/reference/products.md#update-a-product) · scope `products:write` - `POST /ext/v1/products/{id}/archive` — [Archive a product](https://www.shopdeskbd.com/developers/reference/products.md#archive-a-product) · scope `products:write` - `POST /ext/v1/products/{id}/images` — [Add product images](https://www.shopdeskbd.com/developers/reference/products.md#add-product-images) · scope `products:write` - `PATCH /ext/v1/products/{id}/images/{imageId}` — [Update a product image](https://www.shopdeskbd.com/developers/reference/products.md#update-a-product-image) · scope `products:write` - `DELETE /ext/v1/products/{id}/images/{imageId}` — [Delete a product image](https://www.shopdeskbd.com/developers/reference/products.md#delete-a-product-image) · scope `products:write` - `POST /ext/v1/products/{id}/images/reorder` — [Reorder product images](https://www.shopdeskbd.com/developers/reference/products.md#reorder-product-images) · scope `products:write` - `GET /ext/v1/variants/{identifier}` — [Get a variant by id, SKU or barcode](https://www.shopdeskbd.com/developers/reference/products.md#get-a-variant-by-id-sku-or-barcode) · scope `products:read` - `PATCH /ext/v1/variants/{identifier}` — [Update a variant](https://www.shopdeskbd.com/developers/reference/products.md#update-a-variant) · scope `products:write` - **[Categories](https://www.shopdeskbd.com/developers/reference/categories.md)** — 4 endpoints - `GET /ext/v1/categories` — [List categories](https://www.shopdeskbd.com/developers/reference/categories.md#list-categories) · scope `products:read` - `POST /ext/v1/categories` — [Create a category](https://www.shopdeskbd.com/developers/reference/categories.md#create-a-category) · scope `catalog:write` - `GET /ext/v1/categories/{id}` — [Get a category by id](https://www.shopdeskbd.com/developers/reference/categories.md#get-a-category-by-id) · scope `products:read` - `PATCH /ext/v1/categories/{id}` — [Update a category](https://www.shopdeskbd.com/developers/reference/categories.md#update-a-category) · scope `catalog:write` - **[Brands](https://www.shopdeskbd.com/developers/reference/brands.md)** — 4 endpoints - `GET /ext/v1/brands` — [List brands](https://www.shopdeskbd.com/developers/reference/brands.md#list-brands) · scope `products:read` - `POST /ext/v1/brands` — [Create a brand](https://www.shopdeskbd.com/developers/reference/brands.md#create-a-brand) · scope `catalog:write` - `GET /ext/v1/brands/{id}` — [Get a brand by id](https://www.shopdeskbd.com/developers/reference/brands.md#get-a-brand-by-id) · scope `products:read` - `PATCH /ext/v1/brands/{id}` — [Update a brand](https://www.shopdeskbd.com/developers/reference/brands.md#update-a-brand) · scope `catalog:write` - **[Attributes](https://www.shopdeskbd.com/developers/reference/attributes.md)** — 6 endpoints - `GET /ext/v1/attributes` — [List attributes](https://www.shopdeskbd.com/developers/reference/attributes.md#list-attributes) · scope `products:read` - `POST /ext/v1/attributes` — [Create an attribute](https://www.shopdeskbd.com/developers/reference/attributes.md#create-an-attribute) · scope `catalog:write` - `GET /ext/v1/attributes/{id}` — [Get an attribute by id](https://www.shopdeskbd.com/developers/reference/attributes.md#get-an-attribute-by-id) · scope `products:read` - `PATCH /ext/v1/attributes/{id}` — [Update an attribute](https://www.shopdeskbd.com/developers/reference/attributes.md#update-an-attribute) · scope `catalog:write` - `POST /ext/v1/attributes/{id}/values` — [Add a value to an attribute](https://www.shopdeskbd.com/developers/reference/attributes.md#add-a-value-to-an-attribute) · scope `catalog:write` - `PATCH /ext/v1/attributes/{id}/values/{valueId}` — [Update an attribute value](https://www.shopdeskbd.com/developers/reference/attributes.md#update-an-attribute-value) · scope `catalog:write` - **[Orders](https://www.shopdeskbd.com/developers/reference/orders.md)** — 8 endpoints - `GET /ext/v1/orders` — [List orders](https://www.shopdeskbd.com/developers/reference/orders.md#list-orders) · scope `orders:read` - `POST /ext/v1/orders` — [Create an order](https://www.shopdeskbd.com/developers/reference/orders.md#create-an-order) · scope `orders:write` - `GET /ext/v1/orders/{id}` — [Get an order by id](https://www.shopdeskbd.com/developers/reference/orders.md#get-an-order-by-id) · scope `orders:read` - `PATCH /ext/v1/orders/{id}` — [Update an order's note and follow-up](https://www.shopdeskbd.com/developers/reference/orders.md#update-an-orders-note-and-follow-up) · scope `orders:write` - `POST /ext/v1/orders/{id}/cancel` — [Cancel an order](https://www.shopdeskbd.com/developers/reference/orders.md#cancel-an-order) · scope `orders:write` - `GET /ext/v1/orders/{id}/payments` — [List an order's payments](https://www.shopdeskbd.com/developers/reference/orders.md#list-an-orders-payments) · scope `payments:read` - `POST /ext/v1/orders/{id}/payments` — [Record a payment against an order](https://www.shopdeskbd.com/developers/reference/orders.md#record-a-payment-against-an-order) · scope `payments:write` - `PATCH /ext/v1/orders/{id}/status` — [Change an order status](https://www.shopdeskbd.com/developers/reference/orders.md#change-an-order-status) · scope `orders:write` - **[Customers](https://www.shopdeskbd.com/developers/reference/customers.md)** — 4 endpoints - `GET /ext/v1/customers` — [List customers](https://www.shopdeskbd.com/developers/reference/customers.md#list-customers) · scope `customers:read` - `POST /ext/v1/customers` — [Create a customer](https://www.shopdeskbd.com/developers/reference/customers.md#create-a-customer) · scope `customers:write` - `GET /ext/v1/customers/{id}` — [Get a customer by id, with their addresses](https://www.shopdeskbd.com/developers/reference/customers.md#get-a-customer-by-id-with-their-addresses) · scope `customers:read` - `PATCH /ext/v1/customers/{id}` — [Update a customer](https://www.shopdeskbd.com/developers/reference/customers.md#update-a-customer) · scope `customers:write` - **[Inventory](https://www.shopdeskbd.com/developers/reference/inventory.md)** — 3 endpoints - `POST /ext/v1/inventory/adjustments` — [Adjust stock at a location](https://www.shopdeskbd.com/developers/reference/inventory.md#adjust-stock-at-a-location) · scope `inventory:write` - `GET /ext/v1/inventory/levels` — [List stock levels (one row per variant per location)](https://www.shopdeskbd.com/developers/reference/inventory.md#list-stock-levels-one-row-per-variant-per-location) · scope `inventory:read` - `GET /ext/v1/inventory/movements` — [List stock movements (the append-only ledger behind the levels)](https://www.shopdeskbd.com/developers/reference/inventory.md#list-stock-movements-the-append-only-ledger-behind-the-levels) · scope `inventory:read` - **[Locations](https://www.shopdeskbd.com/developers/reference/locations.md)** — 2 endpoints - `GET /ext/v1/locations` — [List locations](https://www.shopdeskbd.com/developers/reference/locations.md#list-locations) · scope `locations:read` - `GET /ext/v1/locations/{id}` — [Get a location by id](https://www.shopdeskbd.com/developers/reference/locations.md#get-a-location-by-id) · scope `locations:read` - **[Channels](https://www.shopdeskbd.com/developers/reference/channels.md)** — 1 endpoint - `GET /ext/v1/channels` — [List sales channels](https://www.shopdeskbd.com/developers/reference/channels.md#list-sales-channels) · scope `channels:read` - **[Store](https://www.shopdeskbd.com/developers/reference/store.md)** — 2 endpoints - `GET /ext/v1/me` — [Describe the calling API key](https://www.shopdeskbd.com/developers/reference/store.md#describe-the-calling-api-key) - `GET /ext/v1/store` — [Get the store profile](https://www.shopdeskbd.com/developers/reference/store.md#get-the-store-profile) · scope `store:read` - **[Reports](https://www.shopdeskbd.com/developers/reference/reports.md)** — 2 endpoints - `GET /ext/v1/reports/sales-summary` — [Get a sales summary for a window](https://www.shopdeskbd.com/developers/reference/reports.md#get-a-sales-summary-for-a-window) · scope `reports:read` - `GET /ext/v1/reports/top-products` — [List the top products for a window](https://www.shopdeskbd.com/developers/reference/reports.md#list-the-top-products-for-a-window) · scope `reports:read` - **[Videos](https://www.shopdeskbd.com/developers/reference/videos.md)** — 10 endpoints - `POST /ext/v1/products/{id}/videos` — [Place a video on a product](https://www.shopdeskbd.com/developers/reference/videos.md#place-a-video-on-a-product) · scope `videos:write` - `DELETE /ext/v1/products/{id}/videos/{videoId}` — [Take a video off a product](https://www.shopdeskbd.com/developers/reference/videos.md#take-a-video-off-a-product) · scope `videos:write` - `GET /ext/v1/videos` — [List your video uploads](https://www.shopdeskbd.com/developers/reference/videos.md#list-your-video-uploads) · scope `videos:write` - `POST /ext/v1/videos` — [Start a video upload](https://www.shopdeskbd.com/developers/reference/videos.md#start-a-video-upload) · scope `videos:write` - `GET /ext/v1/videos/{id}` — [Get a video upload](https://www.shopdeskbd.com/developers/reference/videos.md#get-a-video-upload) · scope `videos:write` - `DELETE /ext/v1/videos/{id}` — [Delete a video upload](https://www.shopdeskbd.com/developers/reference/videos.md#delete-a-video-upload) · scope `videos:write` - `POST /ext/v1/videos/{id}/complete` — [Complete a video upload](https://www.shopdeskbd.com/developers/reference/videos.md#complete-a-video-upload) · scope `videos:write` - `POST /ext/v1/videos/{id}/part-urls` — [Get upload URLs for video parts](https://www.shopdeskbd.com/developers/reference/videos.md#get-upload-urls-for-video-parts) · scope `videos:write` - `POST /ext/v1/videos/{id}/poster` — [Set a video cover](https://www.shopdeskbd.com/developers/reference/videos.md#set-a-video-cover) · scope `videos:write` - `POST /ext/v1/videos/import` — [Import a video from a link](https://www.shopdeskbd.com/developers/reference/videos.md#import-a-video-from-a-link) · scope `videos:write` --- # Products Part of the [Shopdesk Commerce API reference](https://www.shopdeskbd.com/developers/reference.md). Paths below are shown exactly as the server routes them — prefix them with `https://api.shopdeskbd.com`. Authenticate every request with `Authorization: Bearer shopdesk_sk_YOUR_API_KEY`; each operation names the scope its key must hold. Every failure uses the one error envelope — see [Errors](https://www.shopdeskbd.com/developers/errors.md). ## List products `GET /ext/v1/products` · Requires scope `products:read` Variants are nested in each product — they are the commercial unit. Use `updated_after` to sync only what changed. ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | `number` (default `25`, max 100) | No | Page size. | | `starting_after` | `string` | No | Return the page AFTER this object id (the previous page's `next_cursor`). | | `ending_before` | `string` | No | Return the page BEFORE this object id. | | `sort` | `string` (default `-created_at`) | No | Newest first by default. Only `created_at` is sortable — a cursor over a mutable key (like `updated_at`) cannot page reliably. Use `updated_after` to sync changes. One of: `created_at`, `-created_at`. | | `store_id` | `string` | No | Restrict to one store. Required breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | | `status` | `string` | No | Filter to one of the values below. One of: `draft`, `active`, `archived`. | | `category_id` | `string` | No | Only products in this category. | | `updated_after` | `date-time` | No | Only products changed since this instant — the sync filter. Pair it with the default `-created_at` order and page with the cursor. | | `search` | `string` | No | Match on product name or variant SKU. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/products?limit=25&updated_after=2026-08-01T00%3A00%3A00Z" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/products"); Object.entries({ limit: "25", updated_after: "2026-08-01T00:00:00Z", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/products", params={ "limit": "25", "updated_after": "2026-08-01T00:00:00Z", }, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` ### Responses - `200` — A cursor page of products. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "data": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Classic Cotton T-Shirt", "slug": "classic-cotton-t-shirt", "short_description": null, "description": null, "thumbnail": null, "status": "active", "delivery_charge": "1250.00", "rating_average": "4.50", "rating_count": 12, "brand": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Acme", "slug": "acme" }, "categories": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "T-Shirts", "slug": "t-shirts", "is_primary": true } ], "images": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "url": "https://cdn.example.com/p/1.webp", "position": 0, "alt_text": "Red cotton tee, front view", "is_thumbnail": true } ], "variants": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "sku": "TSHIRT-RED-M", "barcode": "0123456789012", "name": "Red / M", "price": "1250.00", "discount_price": "1250.00", "weight": "0.25", "dimensions": null, "low_stock_threshold": 5, "status": "active", "image": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "url": "https://cdn.example.com/p/1.webp" }, "options": [ { "attribute_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "attribute": "Colour", "value_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "value": "Red" } ], "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ], "videos": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "url": "https://cdn.example.com/companies/c/videos/v/video.mp4", "poster_url": "https://cdn.example.com/companies/c/videos/v/poster-1.webp", "width": 1080, "height": 1920, "duration_ms": 24000, "has_audio": true, "title": "Red saree drape", "gallery_slot": 1, "sort_order": 0, "option_value_id": "3c2b1a09-8f7e-4d6c-9b5a-4e3f2d1c0b9a" } ], "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ], "meta": { "has_more": true, "next_cursor": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001" } } ``` ## Create a product `POST /ext/v1/products` · Requires scope `products:write` At least one variant (a simple product has exactly one). Slugs auto-generate from the name and auto-suffix when taken; a duplicate SKU is a 409. A whole product can be built in this one call: photos by link in `images[]` (downloaded and stored before the call answers), each variant’s `options[]` by attribute and value name (create them first with `POST /attributes`), and each variant’s photo as `image_index` into `images[]`. One photo link that fails fails the whole call and nothing is written. Initial stock is not set here — it enters through `POST /v1/inventory/adjustments`. Image links: https only, PNG/JPEG/WebP/GIF read from the bytes (never SVG), at most 10 MB and 8000 px on the longest side (and 40 megapixels in all; 16 for a GIF), fetched within 10 s each and 30 s for the call, at most 50 MB downloaded per call. One failing link fails the whole call and nothing is written. Each image a call carries — link or file — counts as one write against the key's per-minute write limit; calls that download or upload images run at most 2 at a time per key; and a company may have 2,000 links downloaded per UTC day. Past any of these the answer is `429 RATE_LIMITED` with `Retry-After`. ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `name` | `string` | Yes | — | | `slug` | `string` | No | URL-safe slug. Auto-generated from the name when omitted; auto-suffixed when taken. | | `brand_id` | `uuid` | No | — | | `short_description` | `string` | No | — | | `description` | `string` | No | Rich description, stored as sanitized HTML. `ProductsService` — the same service the staff endpoints call — strips anything outside the allowlist, so an integration cannot store markup the admin UI could not. Plain text is accepted and kept as-is. An `` is kept only when its `src` is on this platform’s own image origin (upload one through the admin `POST /products/description-images`); an image hosted anywhere else is removed, tag and all. | | `status` | `string` (default `draft`) | No | — One of: `draft`, `active`, `archived`. | | `delivery_charge` | `number` | No | Per-product delivery-charge override: 0 = free delivery, N = flat charge; omit for the standard shipping rules. | | `categories` | `ProductCategoryLinkDto[]` | No | — | | `variants` | `ProductVariantWriteDto[]` | Yes | — | | `images` | `ProductImageWriteDto[]` | No | The gallery, in display order: up to 10 photos by `source_url` (send more with `POST /products/{id}/images`). The call returns once every photo is stored; one link that fails fails the whole call and nothing is written. Variants point at a photo with `image_index`. | | `videos` | `ProductVideoWriteDto[]` | No | E1c: videos to place on the new product — each a video THIS key (or a key it replaced by rotation) started (`POST /videos` or `POST /videos/import`), which may still be uploading or verifying; the product shows it once it is `ready`. Needs the `videos:write` scope as well as `products:write`, a plan with product videos, video uploads switched on, and no more than the plan's videos per product. Later changes go through `POST /products/{id}/videos`. | ### `categories[]` fields | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | — | | `is_primary` | `boolean` (default `false`) | No | — | ### `variants[]` fields | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | No | Update only: patches this existing variant. Omit to create a new one. | | `sku` | `string` | Yes | — | | `barcode` | `string` | No | — | | `name` | `string` | Yes | — | | `price` | `number` | Yes | — | | `discount_price` | `number` | No | Sale price — must be strictly below `price`. | | `cost_price` | `number` | No | What the unit costs the store. Write-only (never serialized, Q6). Omitted on a new variant = 0; omitted on an existing variant (`id` sent) keeps its current cost. A changed cost on stock in hand is recorded as Stock revaluation on the P&L (only when the old and the new cost are both above 0). | | `weight` | `number` | No | — | | `low_stock_threshold` | `number` (default `0`) | No | — | | `status` | `string` (default `active`) | No | — One of: `active`, `inactive`. | | `options` | `VariantOptionWriteDto[]` | No | The options that define this variant, e.g. `[{ "attribute": "Colour", "value": "Red" }, { "attribute": "Size", "value": "M" }]`. Set when the variant is CREATED only (a product create, or an update entry without `id`); the options of an existing variant cannot be changed here, and an update keeps them. Two variants of a product cannot have the same combination. | | `image_index` | `integer` | No | This variant’s photo, as a zero-based position in THIS request’s `images[]` — the way to point at a photo sent in the same call. Null clears it. Use `image_id` instead for a photo the product already has. | | `image_id` | `uuid` | No | Update only: this variant’s photo, as the id of one of the product’s existing photos. Null clears it. Not together with `image_index`. | ### `images[]` fields | Name | Type | Required | Description | | --- | --- | --- | --- | | `source_url` | `string` | No | An https link to a new photo. Shopdesk downloads it, checks the bytes (PNG, JPEG, WebP or GIF; at most 10 MB and 8000 px on the longest side) and stores a re-encoded copy — the link itself is not kept. A link that cannot be used fails the whole call with `422` naming `images[i].source_url`, and nothing is written. | | `image_id` | `uuid` | No | Update only: an existing photo of this product (an id from `images[]`), kept in the gallery at this position. | | `alt_text` | `string` | No | Screen-reader alt text. On a kept photo, omit it to leave the stored text alone, or send null to clear it. | | `is_thumbnail` | `boolean` (default `false`) | No | Make this photo the product thumbnail (at most one entry). Without one, a new product takes its first photo, and an update keeps the current thumbnail if it stays in the gallery (else the first photo). | ### `videos[]` fields | Name | Type | Required | Description | | --- | --- | --- | --- | | `video_id` | `uuid` | Yes | A video THIS API key started (`POST /videos` or `POST /videos/import`). It may still be uploading or verifying: the product shows it once it is `ready`. | | `gallery_slot` | `number` (default `1`, max 30) | No | How many of the product's photos come before the video: 0 = before every photo, 1 (default) = right after the main photo. Clamped to the number of photos. | | `sort_order` | `number` (default `0`, max 99) | No | Order among the product's videos; breaks ties within one slot. | | `option_value_id` | `uuid` | No | Show the video for one option value (e.g. Colour: Red — the value id from `GET /attributes`, or a variant’s `options[].value_id`). A value one of the product's active variants uses; null or absent = every variant. | | `title` | `string` | No | Its accessible name. Empty or null falls back to the product name. | ### `options[]` fields | Name | Type | Required | Description | | --- | --- | --- | --- | | `attribute` | `string` | Yes | A variant attribute of this store: its id, slug or name (any case). | | `value` | `string` | Yes | One of that attribute’s values: its id or its text (any case). | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X POST "https://api.shopdeskbd.com/ext/v1/products" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \ -H "Content-Type: application/json" \ -d '{ "name": "Classic Cotton T-Shirt", "status": "active", "variants": [ { "sku": "TSHIRT-RED-M", "name": "Red / M", "price": 1250, "cost_price": 850 } ] }' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/products"); const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", "Content-Type": "application/json", }, body: JSON.stringify({ "name": "Classic Cotton T-Shirt", "status": "active", "variants": [ { "sku": "TSHIRT-RED-M", "name": "Red / M", "price": 1250, "cost_price": 850 } ] }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "name": "Classic Cotton T-Shirt", "status": "active", "variants": [ { "sku": "TSHIRT-RED-M", "name": "Red / M", "price": 1250, "cost_price": 850, }, ], } response = requests.post( "https://api.shopdeskbd.com/ext/v1/products", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 'Classic Cotton T-Shirt', 'status' => 'active', 'variants' => [ [ 'sku' => 'TSHIRT-RED-M', 'name' => 'Red / M', 'price' => 1250, 'cost_price' => 850, ], ], ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/products'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `201` — The created product, variants nested. - `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `409` — A variant SKU is already in use in this store, or two variants have the same options; or a request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `422` — Validation failed: an unknown option name (`variants[i].options[j].attribute` / `.value` — nothing is created), a photo link that cannot be downloaded or is not an allowed image (`images[i].source_url`), or a variant photo that is not in `images[]`. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Classic Cotton T-Shirt", "slug": "classic-cotton-t-shirt", "short_description": null, "description": null, "thumbnail": null, "status": "active", "delivery_charge": "1250.00", "rating_average": "4.50", "rating_count": 12, "brand": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Acme", "slug": "acme" }, "categories": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "T-Shirts", "slug": "t-shirts", "is_primary": true } ], "images": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "url": "https://cdn.example.com/p/1.webp", "position": 0, "alt_text": "Red cotton tee, front view", "is_thumbnail": true } ], "variants": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "sku": "TSHIRT-RED-M", "barcode": "0123456789012", "name": "Red / M", "price": "1250.00", "discount_price": "1250.00", "weight": "0.25", "dimensions": null, "low_stock_threshold": 5, "status": "active", "image": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "url": "https://cdn.example.com/p/1.webp" }, "options": [ { "attribute_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "attribute": "Colour", "value_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "value": "Red" } ], "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ], "videos": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "url": "https://cdn.example.com/companies/c/videos/v/video.mp4", "poster_url": "https://cdn.example.com/companies/c/videos/v/poster-1.webp", "width": 1080, "height": 1920, "duration_ms": 24000, "has_audio": true, "title": "Red saree drape", "gallery_slot": 1, "sort_order": 0, "option_value_id": "3c2b1a09-8f7e-4d6c-9b5a-4e3f2d1c0b9a" } ], "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ``` ## Get a product by id `GET /ext/v1/products/{id}` · Requires scope `products:read` The full product object, variants nested. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The product id. | ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Restrict the lookup to one store. Breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID"); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID", headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The product. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Product not found. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Classic Cotton T-Shirt", "slug": "classic-cotton-t-shirt", "short_description": null, "description": null, "thumbnail": null, "status": "active", "delivery_charge": "1250.00", "rating_average": "4.50", "rating_count": 12, "brand": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Acme", "slug": "acme" }, "categories": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "T-Shirts", "slug": "t-shirts", "is_primary": true } ], "images": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "url": "https://cdn.example.com/p/1.webp", "position": 0, "alt_text": "Red cotton tee, front view", "is_thumbnail": true } ], "variants": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "sku": "TSHIRT-RED-M", "barcode": "0123456789012", "name": "Red / M", "price": "1250.00", "discount_price": "1250.00", "weight": "0.25", "dimensions": null, "low_stock_threshold": 5, "status": "active", "image": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "url": "https://cdn.example.com/p/1.webp" }, "options": [ { "attribute_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "attribute": "Colour", "value_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "value": "Red" } ], "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ], "videos": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "url": "https://cdn.example.com/companies/c/videos/v/video.mp4", "poster_url": "https://cdn.example.com/companies/c/videos/v/poster-1.webp", "width": 1080, "height": 1920, "duration_ms": 24000, "has_audio": true, "title": "Red saree drape", "gallery_slot": 1, "sort_order": 0, "option_value_id": "3c2b1a09-8f7e-4d6c-9b5a-4e3f2d1c0b9a" } ], "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ``` ## Update a product `PATCH /ext/v1/products/{id}` · Requires scope `products:write` Partial update of the product fields. `categories` (when present) replaces the link set; `variants` (when present) reconciles the FULL variant set — patch by id, create without one, delete the rest unless stock history forbids it. Same semantics as the dashboard. A variant keeps its options; `options[]` is taken on NEW entries only. `images` (when present) is the FULL gallery: `image_id` entries keep those photos, `source_url` entries add new ones, and photos left out are deleted — to only add photos, use `POST /products/{id}/images`. Image links: https only, PNG/JPEG/WebP/GIF read from the bytes (never SVG), at most 10 MB and 8000 px on the longest side (and 40 megapixels in all; 16 for a GIF), fetched within 10 s each and 30 s for the call, at most 50 MB downloaded per call. One failing link fails the whole call and nothing is written. Each image a call carries — link or file — counts as one write against the key's per-minute write limit; calls that download or upload images run at most 2 at a time per key; and a company may have 2,000 links downloaded per UTC day. Past any of these the answer is `429 RATE_LIMITED` with `Retry-After`. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The product id. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `name` | `string` | No | — | | `slug` | `string` | No | — | | `brand_id` | `uuid` | No | Set null to detach the brand. | | `short_description` | `string` | No | — | | `description` | `string` | No | — | | `status` | `string` | No | — One of: `draft`, `active`, `archived`. | | `delivery_charge` | `object` | No | Set null to return to the standard shipping rules. | | `categories` | `ProductCategoryLinkDto[]` | No | When present, REPLACES the product's category links. | | `variants` | `ProductVariantWriteDto[]` | No | When present, reconciles the FULL variant set in one transaction — entries with an `id` are patched, entries without one are created, and existing variants missing from the list are removed (refused when they carry stock history). Same semantics as the dashboard. | | `images` | `ProductImageWriteDto[]` | No | When present, the FULL gallery in its new order, like `variants`: entries with `image_id` keep that photo, entries with `source_url` add a new one (at most 10 per call), and photos missing from the list are DELETED — variants that showed them lose the link. `[]` empties the gallery. At most 30 photos. To add photos without restating the gallery, use `POST /products/{id}/images`. | ### `categories[]` fields | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | — | | `is_primary` | `boolean` (default `false`) | No | — | ### `variants[]` fields | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | No | Update only: patches this existing variant. Omit to create a new one. | | `sku` | `string` | Yes | — | | `barcode` | `string` | No | — | | `name` | `string` | Yes | — | | `price` | `number` | Yes | — | | `discount_price` | `number` | No | Sale price — must be strictly below `price`. | | `cost_price` | `number` | No | What the unit costs the store. Write-only (never serialized, Q6). Omitted on a new variant = 0; omitted on an existing variant (`id` sent) keeps its current cost. A changed cost on stock in hand is recorded as Stock revaluation on the P&L (only when the old and the new cost are both above 0). | | `weight` | `number` | No | — | | `low_stock_threshold` | `number` (default `0`) | No | — | | `status` | `string` (default `active`) | No | — One of: `active`, `inactive`. | | `options` | `VariantOptionWriteDto[]` | No | The options that define this variant, e.g. `[{ "attribute": "Colour", "value": "Red" }, { "attribute": "Size", "value": "M" }]`. Set when the variant is CREATED only (a product create, or an update entry without `id`); the options of an existing variant cannot be changed here, and an update keeps them. Two variants of a product cannot have the same combination. | | `image_index` | `integer` | No | This variant’s photo, as a zero-based position in THIS request’s `images[]` — the way to point at a photo sent in the same call. Null clears it. Use `image_id` instead for a photo the product already has. | | `image_id` | `uuid` | No | Update only: this variant’s photo, as the id of one of the product’s existing photos. Null clears it. Not together with `image_index`. | ### `images[]` fields | Name | Type | Required | Description | | --- | --- | --- | --- | | `source_url` | `string` | No | An https link to a new photo. Shopdesk downloads it, checks the bytes (PNG, JPEG, WebP or GIF; at most 10 MB and 8000 px on the longest side) and stores a re-encoded copy — the link itself is not kept. A link that cannot be used fails the whole call with `422` naming `images[i].source_url`, and nothing is written. | | `image_id` | `uuid` | No | Update only: an existing photo of this product (an id from `images[]`), kept in the gallery at this position. | | `alt_text` | `string` | No | Screen-reader alt text. On a kept photo, omit it to leave the stored text alone, or send null to clear it. | | `is_thumbnail` | `boolean` (default `false`) | No | Make this photo the product thumbnail (at most one entry). Without one, a new product takes its first photo, and an update keeps the current thumbnail if it stays in the gallery (else the first photo). | ### `options[]` fields | Name | Type | Required | Description | | --- | --- | --- | --- | | `attribute` | `string` | Yes | A variant attribute of this store: its id, slug or name (any case). | | `value` | `string` | Yes | One of that attribute’s values: its id or its text (any case). | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X PATCH "https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \ -H "Content-Type: application/json" \ -d '{ "status": "active" }' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID"); const response = await fetch(url, { method: "PATCH", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", "Content-Type": "application/json", }, body: JSON.stringify({ "status": "active" }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "status": "active", } response = requests.patch( "https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 'active', ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The updated product, variants nested. - `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Product not found. - `409` — A variant SKU is already in use in this store, or two variants have the same options; or a request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `422` — Validation failed: `options` sent on an existing variant, an unknown option name, an `image_id` that is not a photo of this product, or a photo link that cannot be downloaded or is not an allowed image — `error.fields` names the field. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Classic Cotton T-Shirt", "slug": "classic-cotton-t-shirt", "short_description": null, "description": null, "thumbnail": null, "status": "active", "delivery_charge": "1250.00", "rating_average": "4.50", "rating_count": 12, "brand": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Acme", "slug": "acme" }, "categories": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "T-Shirts", "slug": "t-shirts", "is_primary": true } ], "images": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "url": "https://cdn.example.com/p/1.webp", "position": 0, "alt_text": "Red cotton tee, front view", "is_thumbnail": true } ], "variants": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "sku": "TSHIRT-RED-M", "barcode": "0123456789012", "name": "Red / M", "price": "1250.00", "discount_price": "1250.00", "weight": "0.25", "dimensions": null, "low_stock_threshold": 5, "status": "active", "image": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "url": "https://cdn.example.com/p/1.webp" }, "options": [ { "attribute_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "attribute": "Colour", "value_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "value": "Red" } ], "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ], "videos": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "url": "https://cdn.example.com/companies/c/videos/v/video.mp4", "poster_url": "https://cdn.example.com/companies/c/videos/v/poster-1.webp", "width": 1080, "height": 1920, "duration_ms": 24000, "has_audio": true, "title": "Red saree drape", "gallery_slot": 1, "sort_order": 0, "option_value_id": "3c2b1a09-8f7e-4d6c-9b5a-4e3f2d1c0b9a" } ], "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ``` ## Archive a product `POST /ext/v1/products/{id}/archive` · Requires scope `products:write` Non-destructive: variants, images and inventory survive with their status parked, and the dashboard can restore. Archived products drop out of `GET /v1/products`. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The product id. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X POST "https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/archive" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \ -H "Content-Type: application/json" \ -d '{}' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/archive"); const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", "Content-Type": "application/json", }, body: JSON.stringify({}), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = {} response = requests.post( "https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/archive", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `201` — Archival confirmed. - `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Product not found. - `409` — A request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "archived": true } ``` ## Add product images `POST /ext/v1/products/{id}/images` · Requires scope `products:write` Adds photos to the product gallery, in one of three ways. **One file:** `multipart/form-data` with the file in the `image` part and its options (`alt_text`, `position`, `is_thumbnail`) as text fields; the answer is that image. **Several files:** up to 10 `image` parts (50 MB in all), with per-file options as a JSON array in one `images` text field (one entry per part, same order). **Photos by link:** a JSON body `{ "images": [{ "source_url": "https://…", "alt_text": "…" }] }` with up to 10 links, which Shopdesk downloads and stores (the links themselves are not kept). A batch (anything with `images`, or several parts) answers `{ "data": [...] }`: the new images in the order sent, with their ids. It is all or nothing — one refused file or link fails the call and nothing is added. Images are appended unless `position` says otherwise; `is_thumbnail: true` makes one the product thumbnail, and a product with no images yet takes its first image as the thumbnail anyway. A gallery holds at most 30 images. Every file is read from its bytes: PNG, JPEG, WebP or GIF, at most 10 MB and 8000 px on the longest side. Image links: https only, PNG/JPEG/WebP/GIF read from the bytes (never SVG), at most 10 MB and 8000 px on the longest side (and 40 megapixels in all; 16 for a GIF), fetched within 10 s each and 30 s for the call, at most 50 MB downloaded per call. One failing link fails the whole call and nothing is written. Each image a call carries — link or file — counts as one write against the key's per-minute write limit; calls that download or upload images run at most 2 at a time per key; and a company may have 2,000 links downloaded per UTC day. Past any of these the answer is `429 RATE_LIMITED` with `Retry-After`. `Idempotency-Key` is honoured here as on every write: on a JSON body the fingerprint covers the links, and a replay answers without downloading again; on a multipart request it covers the route only — reuse a key only when retrying the same files. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The product id. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `images` | `ProductImageUploadEntryDto[]` | Yes | Up to 10 photos, each with the `source_url` to download, in the order to add them. The call answers `{ data: [...] }`: the new photos in the order sent. | ### `images[]` fields | Name | Type | Required | Description | | --- | --- | --- | --- | | `source_url` | `string` | No | JSON body: the https link to download (required). Not allowed in a multipart form, where the entry describes the image part at its index. | | `alt_text` | `string` | No | Screen-reader alt text for this photo. | | `position` | `integer` | No | Zero-based position in the gallery. Existing images shift to make room; a value past the end appends. Applied in list order, as if each photo were uploaded on its own; omit to append. | | `is_thumbnail` | `boolean` (default `false`) | No | Make this photo the product thumbnail (at most one entry). | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X POST "https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/images" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \ -H "Content-Type: application/json" \ -d '{ "images": [ { "source_url": "https://cdn.example.com/tees/red-front.jpg", "alt_text": "Red cotton tee, front view", "is_thumbnail": true } ] }' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/images"); const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", "Content-Type": "application/json", }, body: JSON.stringify({ "images": [ { "source_url": "https://cdn.example.com/tees/red-front.jpg", "alt_text": "Red cotton tee, front view", "is_thumbnail": true } ] }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "images": [ { "source_url": "https://cdn.example.com/tees/red-front.jpg", "alt_text": "Red cotton tee, front view", "is_thumbnail": True, }, ], } response = requests.post( "https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/images", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php [ [ 'source_url' => 'https://cdn.example.com/tees/red-front.jpg', 'alt_text' => 'Red cotton tee, front view', 'is_thumbnail' => true, ], ], ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/images'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `201` — A single upload: the stored image, as it appears in `images[]`. A batch: `{ data }`, the new images in the order sent. - `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Product not found, or the image is not on this product. - `409` — A request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `413` — A file is larger than 10 MB, or the files of the request total more than 50 MB. - `422` — No image at all, a file or link that is not a readable PNG/JPEG/WebP/GIF (a link that cannot be downloaded is named as `images[i].source_url`), an invalid text field, or a gallery that would pass 30 images — `error.fields` names the field. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "url": "https://cdn.example.com/p/1.webp", "position": 0, "alt_text": "Red cotton tee, front view", "is_thumbnail": true } ``` ## Update a product image `PATCH /ext/v1/products/{id}/images/{imageId}` · Requires scope `products:write` Changes the alt text, the gallery position and/or which image is the product thumbnail. The bytes cannot be replaced — upload a new image and delete the old one. Fields left out are left alone. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The product id. | | `imageId` | `uuid` | Yes | The image id, as listed in the product’s `images[]`. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `alt_text` | `object` | No | Screen-reader alt text. Set null to clear it. | | `position` | `integer` | No | Zero-based position in the gallery. Existing images shift to make room; a value past the end appends. | | `is_thumbnail` | `boolean` | No | `true` makes this image the product thumbnail. `false` on the current thumbnail hands the role to the first image in the gallery. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X PATCH "https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/images/REPLACE_WITH_ID" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \ -H "Content-Type: application/json" \ -d '{ "alt_text": "Red cotton tee, front view", "position": 0 }' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/images/REPLACE_WITH_ID"); const response = await fetch(url, { method: "PATCH", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", "Content-Type": "application/json", }, body: JSON.stringify({ "alt_text": "Red cotton tee, front view", "position": 0 }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "alt_text": "Red cotton tee, front view", "position": 0, } response = requests.patch( "https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/images/REPLACE_WITH_ID", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 'Red cotton tee, front view', 'position' => 0, ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/images/REPLACE_WITH_ID'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The updated image, as it appears in `images[]`. - `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Product not found, or the image is not on this product. - `409` — A request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "url": "https://cdn.example.com/p/1.webp", "position": 0, "alt_text": "Red cotton tee, front view", "is_thumbnail": true } ``` ## Delete a product image `DELETE /ext/v1/products/{id}/images/{imageId}` · Requires scope `products:write` Removes the image from the gallery and from storage — this is permanent, unlike archiving a product. Variants that featured it lose the link; if it was the thumbnail, the first remaining image takes over (or `thumbnail` becomes null). A company-scoped key names the store with the `store_id` query parameter, since a DELETE carries no body. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The product id. | | `imageId` | `uuid` | Yes | The image id, as listed in the product’s `images[]`. | ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Restrict the lookup to one store. Breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X DELETE "https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/images/REPLACE_WITH_ID" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/images/REPLACE_WITH_ID"); const response = await fetch(url, { method: "DELETE", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests response = requests.delete( "https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/images/REPLACE_WITH_ID", headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47'], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — Deletion confirmed. - `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Product not found, or the image is not on this product. - `409` — A request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "deleted": true } ``` ## Reorder product images `POST /ext/v1/products/{id}/images/reorder` · Requires scope `products:write` Puts the whole gallery in a new order: `image_ids` lists every image of the product exactly once, in the order to show them. A list that leaves an image out or names one twice is refused, so a reorder never removes a photo (delete one with its own `DELETE`). The thumbnail and alt texts stay as they are. Answers the gallery in its new order. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The product id. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `image_ids` | `string[]` | Yes | Every image id of the product, each once, in the new display order. A list that leaves one out or repeats one is refused, so a reorder never removes a photo. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X POST "https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/images/reorder" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \ -H "Content-Type: application/json" \ -d '{ "image_ids": [ "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "9c1e4b2a-7d3f-4e58-b0a1-2f6d8c9e1a45" ] }' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/images/reorder"); const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", "Content-Type": "application/json", }, body: JSON.stringify({ "image_ids": [ "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "9c1e4b2a-7d3f-4e58-b0a1-2f6d8c9e1a45" ] }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data } = await response.json(); ``` **Python** ```python import os import requests payload = { "image_ids": [ "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "9c1e4b2a-7d3f-4e58-b0a1-2f6d8c9e1a45", ], } response = requests.post( "https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/images/reorder", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows = page["data"] ``` **PHP** ```php [ '5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001', '9c1e4b2a-7d3f-4e58-b0a1-2f6d8c9e1a45', ], ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/images/reorder'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; ``` ### Responses - `200` — The gallery in its new order. - `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Product not found. - `409` — A request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `422` — `image_ids` is not exactly the product’s images — one is missing, repeated or not on this product (`error.fields.image_ids`). - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "data": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "url": "https://cdn.example.com/p/1.webp", "position": 0, "alt_text": "Red cotton tee, front view", "is_thumbnail": true } ] } ``` ## Get a variant by id, SKU or barcode `GET /ext/v1/variants/{identifier}` · Requires scope `products:read` Answers "what is this thing in my hand?" for a scanned barcode or a SKU from a spreadsheet. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `identifier` | `string` | Yes | A variant id, SKU or barcode. All three resolve here. | ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Restrict the lookup to one store. Breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/variants/SKU-001" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/variants/SKU-001"); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/variants/SKU-001", headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The variant. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Variant not found. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "sku": "TSHIRT-RED-M", "barcode": "0123456789012", "name": "Red / M", "price": "1250.00", "discount_price": "1250.00", "weight": "0.25", "dimensions": null, "low_stock_threshold": 5, "status": "active", "image": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "url": "https://cdn.example.com/p/1.webp" }, "options": [ { "attribute_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "attribute": "Colour", "value_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "value": "Red" } ], "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ``` ## Update a variant `PATCH /ext/v1/variants/{identifier}` · Requires scope `products:write` Changes one variant’s `price`, `discount_price`, `sku`, `barcode`, `weight` or photo (`image_id`) and leaves the rest alone — the narrow edit beside `PATCH /products/{id}`, which restates the whole variant set. `null` clears the sale price, barcode, weight or photo. The same rules as the dashboard: a sale price must stay below the price, and the SKU is locked once the variant has stock or order history. Emits `product.updated`. Answers the variant as `GET /variants/{identifier}` does. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `identifier` | `string` | Yes | The variant id, SKU or barcode — tried in that order, each as an exact match, inside the write store. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `price` | `number` | No | — | | `discount_price` | `number` | No | Sale price — must be strictly below the price (the new one, or the stored one when `price` is not sent). Null removes the sale price. | | `sku` | `string` | No | A new SKU, unique in the store. Refused (`409`) once the variant has stock or order history. | | `barcode` | `string` | No | Null removes the barcode. | | `weight` | `number` | No | Shipping weight in kg. Null removes it. | | `image_id` | `uuid` | No | This variant’s photo: the id of one of the product’s photos (from `images[]`). Null clears it, and the storefront falls back to the product thumbnail. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X PATCH "https://api.shopdeskbd.com/ext/v1/variants/SKU-001" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \ -H "Content-Type: application/json" \ -d '{ "price": 1350, "discount_price": 1199 }' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/variants/SKU-001"); const response = await fetch(url, { method: "PATCH", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", "Content-Type": "application/json", }, body: JSON.stringify({ "price": 1350, "discount_price": 1199 }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "price": 1350, "discount_price": 1199, } response = requests.patch( "https://api.shopdeskbd.com/ext/v1/variants/SKU-001", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 1350, 'discount_price' => 1199, ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/variants/SKU-001'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The updated variant. - `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Variant not found (or its product is archived). - `409` — The SKU is taken by another variant, or locked by stock or order history; or a request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "sku": "TSHIRT-RED-M", "barcode": "0123456789012", "name": "Red / M", "price": "1250.00", "discount_price": "1250.00", "weight": "0.25", "dimensions": null, "low_stock_threshold": 5, "status": "active", "image": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "url": "https://cdn.example.com/p/1.webp" }, "options": [ { "attribute_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "attribute": "Colour", "value_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "value": "Red" } ], "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ``` --- # Categories Part of the [Shopdesk Commerce API reference](https://www.shopdeskbd.com/developers/reference.md). Paths below are shown exactly as the server routes them — prefix them with `https://api.shopdeskbd.com`. Authenticate every request with `Authorization: Bearer shopdesk_sk_YOUR_API_KEY`; each operation names the scope its key must hold. Every failure uses the one error envelope — see [Errors](https://www.shopdeskbd.com/developers/errors.md). ## List categories `GET /ext/v1/categories` · Requires scope `products:read` Catalog structure; `parent_id` builds the tree client-side. ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | `number` (default `25`, max 100) | No | Page size. | | `starting_after` | `string` | No | Return the page AFTER this object id (the previous page's `next_cursor`). | | `ending_before` | `string` | No | Return the page BEFORE this object id. | | `sort` | `string` (default `-created_at`) | No | Newest first by default. Only `created_at` is sortable — a cursor over a mutable key (like `updated_at`) cannot page reliably. Use `updated_after` to sync changes. One of: `created_at`, `-created_at`. | | `store_id` | `string` | No | Restrict to one store. Required breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | | `status` | `string` | No | Filter to one of the values below. One of: `active`, `inactive`. | | `search` | `string` | No | Match on name or slug (case-insensitive, partial) — how a name resolves to an id. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/categories?limit=25" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/categories"); Object.entries({ limit: "25", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/categories", params={ "limit": "25", }, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` ### Responses - `200` — A cursor page of categories. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "data": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "parent_id": null, "name": "T-Shirts", "slug": "t-shirts", "image": "https://cdn.example.com/companies/c1/categories/k1/image.webp", "banner_image": "https://cdn.example.com/companies/c1/categories/k1/banner.webp", "description": null, "seo": { "title": "Cotton T-Shirts", "description": "Breathable cotton tees in every size." }, "status": "active", "position": 0, "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ], "meta": { "has_more": true, "next_cursor": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001" } } ``` ## Create a category `POST /ext/v1/categories` · Requires scope `catalog:write` Creates a category, top-level or under `parent_id`, with an optional listing image (`image_url` or an `image` part) and banner (`banner_url` or a `banner` part). Send JSON, with images by link, or `multipart/form-data` with the files as parts (at most 10 MB each, 50 MB in all) and the other fields as text (`seo` as a JSON string). An image comes by link or as a part, not both. Images are stored re-encoded as WebP. On a multipart request the `Idempotency-Key` covers the route only — a retry under the same key replays the first answer whatever the form holds — so send a new key for a different request. Image links: https only, PNG/JPEG/WebP/GIF read from the bytes (never SVG), at most 10 MB and 8000 px on the longest side (and 40 megapixels in all; 16 for a GIF), fetched within 10 s each and 30 s for the call, at most 50 MB downloaded per call. One failing link fails the whole call and nothing is written. Each image a call carries — link or file — counts as one write against the key's per-minute write limit; calls that download or upload images run at most 2 at a time per key; and a company may have 2,000 links downloaded per UTC day. Past any of these the answer is `429 RATE_LIMITED` with `Retry-After`. ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `slug` | `string` | No | URL slug: lowercase letters, digits and single hyphens (anything else is refused with `422`). Derived from the name when omitted, and suffixed (`-1`, `-2`) when taken. A rename keeps the slug; send one to change it. | | `status` | `string` (default `active`) | No | — One of: `active`, `inactive`. | | `seo` | `SeoBodyDto` | No | Search-engine overrides. Send `null` to clear them. In a multipart form, send the object as a JSON string. | | `banner_url` | `string` | No | Details-page hero banner, by link. An https link the server downloads and stores a copy of (PNG, JPEG, WebP or GIF, at most 10 MB and 8000 px on the longest side; read from the bytes, never the extension). The link itself is not kept. A link that cannot be fetched or is not such an image fails the whole call with `422` naming this field, and nothing is written. Or upload the file in a `banner` part of a multipart request — not both. | | `position` | `integer` | No | Sort order among siblings, lowest first. | | `image_url` | `string` | No | Listing image, by link. An https link the server downloads and stores a copy of (PNG, JPEG, WebP or GIF, at most 10 MB and 8000 px on the longest side; read from the bytes, never the extension). The link itself is not kept. A link that cannot be fetched or is not such an image fails the whole call with `422` naming this field, and nothing is written. Or upload the file in an `image` part of a multipart request — not both. | | `name` | `string` | Yes | — | | `parent_id` | `uuid` | No | Parent category, in the same store. Omit for a top-level category. | | `description` | `string` | No | Shown on the category page. | ### `seo` fields | Name | Type | Required | Description | | --- | --- | --- | --- | | `title` | `string` | No | Page title for search engines; stored cut to 200 characters. | | `description` | `string` | No | Meta description; stored cut to 500 characters. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X POST "https://api.shopdeskbd.com/ext/v1/categories" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \ -H "Content-Type: application/json" \ -d '{ "name": "T-Shirts", "image_url": "https://cdn.example.com/categories/t-shirts.jpg" }' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/categories"); const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", "Content-Type": "application/json", }, body: JSON.stringify({ "name": "T-Shirts", "image_url": "https://cdn.example.com/categories/t-shirts.jpg" }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "name": "T-Shirts", "image_url": "https://cdn.example.com/categories/t-shirts.jpg", } response = requests.post( "https://api.shopdeskbd.com/ext/v1/categories", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 'T-Shirts', 'image_url' => 'https://cdn.example.com/categories/t-shirts.jpg', ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/categories'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `201` — The new category. - `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `409` — A request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `413` — A file part is larger than 10 MB, or the parts total more than 50 MB. - `422` — Validation failed (an unknown `parent_id` is named in `error.fields.parent_id`). An image link that cannot be downloaded, or a link or file that is not an allowed image, is refused here with `error.fields` naming it. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "parent_id": null, "name": "T-Shirts", "slug": "t-shirts", "image": "https://cdn.example.com/companies/c1/categories/k1/image.webp", "banner_image": "https://cdn.example.com/companies/c1/categories/k1/banner.webp", "description": null, "seo": { "title": "Cotton T-Shirts", "description": "Breathable cotton tees in every size." }, "status": "active", "position": 0, "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ``` ## Get a category by id `GET /ext/v1/categories/{id}` · Requires scope `products:read` One category node; follow `parent_id` for its ancestors. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The category id. | ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Restrict the lookup to one store. Breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/categories/REPLACE_WITH_ID" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/categories/REPLACE_WITH_ID"); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/categories/REPLACE_WITH_ID", headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The category. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Category not found. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "parent_id": null, "name": "T-Shirts", "slug": "t-shirts", "image": "https://cdn.example.com/companies/c1/categories/k1/image.webp", "banner_image": "https://cdn.example.com/companies/c1/categories/k1/banner.webp", "description": null, "seo": { "title": "Cotton T-Shirts", "description": "Breathable cotton tees in every size." }, "status": "active", "position": 0, "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ``` ## Update a category `PATCH /ext/v1/categories/{id}` · Requires scope `catalog:write` Changes the fields sent and leaves the rest alone. A new image or banner replaces the old one, which is deleted only after the new one is saved; `remove_image` / `remove_banner` delete them. `parent_id: null` makes the category top-level; it cannot move under itself or its own descendants. Send JSON, with images by link, or `multipart/form-data` with the files as parts (at most 10 MB each, 50 MB in all) and the other fields as text (`seo` as a JSON string). An image comes by link or as a part, not both. Images are stored re-encoded as WebP. On a multipart request the `Idempotency-Key` covers the route only — a retry under the same key replays the first answer whatever the form holds — so send a new key for a different request. Image links: https only, PNG/JPEG/WebP/GIF read from the bytes (never SVG), at most 10 MB and 8000 px on the longest side (and 40 megapixels in all; 16 for a GIF), fetched within 10 s each and 30 s for the call, at most 50 MB downloaded per call. One failing link fails the whole call and nothing is written. Each image a call carries — link or file — counts as one write against the key's per-minute write limit; calls that download or upload images run at most 2 at a time per key; and a company may have 2,000 links downloaded per UTC day. Past any of these the answer is `429 RATE_LIMITED` with `Retry-After`. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The category id. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `slug` | `string` | No | URL slug: lowercase letters, digits and single hyphens (anything else is refused with `422`). Derived from the name when omitted, and suffixed (`-1`, `-2`) when taken. A rename keeps the slug; send one to change it. | | `status` | `string` (default `active`) | No | — One of: `active`, `inactive`. | | `seo` | `SeoBodyDto` | No | Search-engine overrides. Send `null` to clear them. In a multipart form, send the object as a JSON string. | | `banner_url` | `string` | No | Details-page hero banner, by link. An https link the server downloads and stores a copy of (PNG, JPEG, WebP or GIF, at most 10 MB and 8000 px on the longest side; read from the bytes, never the extension). The link itself is not kept. A link that cannot be fetched or is not such an image fails the whole call with `422` naming this field, and nothing is written. Or upload the file in a `banner` part of a multipart request — not both. | | `position` | `integer` | No | Sort order among siblings, lowest first. | | `image_url` | `string` | No | Listing image, by link. An https link the server downloads and stores a copy of (PNG, JPEG, WebP or GIF, at most 10 MB and 8000 px on the longest side; read from the bytes, never the extension). The link itself is not kept. A link that cannot be fetched or is not such an image fails the whole call with `422` naming this field, and nothing is written. Or upload the file in an `image` part of a multipart request — not both. | | `name` | `string` | No | — | | `parent_id` | `uuid` | No | Move under another category of the store, or `null` to make it top-level. A category cannot move under itself or its own descendants. | | `description` | `string` | No | Send `null` to clear it. | | `remove_image` | `boolean` | No | Remove the current listing image. Cannot be combined with a new one. | | `remove_banner` | `boolean` | No | Remove the current banner. Cannot be combined with a new one. | ### `seo` fields | Name | Type | Required | Description | | --- | --- | --- | --- | | `title` | `string` | No | Page title for search engines; stored cut to 200 characters. | | `description` | `string` | No | Meta description; stored cut to 500 characters. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X PATCH "https://api.shopdeskbd.com/ext/v1/categories/REPLACE_WITH_ID" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \ -H "Content-Type: application/json" \ -d '{ "name": "Tees", "position": 2 }' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/categories/REPLACE_WITH_ID"); const response = await fetch(url, { method: "PATCH", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", "Content-Type": "application/json", }, body: JSON.stringify({ "name": "Tees", "position": 2 }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "name": "Tees", "position": 2, } response = requests.patch( "https://api.shopdeskbd.com/ext/v1/categories/REPLACE_WITH_ID", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 'Tees', 'position' => 2, ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/categories/REPLACE_WITH_ID'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The updated category. - `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Category not found. - `409` — A request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `413` — A file part is larger than 10 MB, or the parts total more than 50 MB. - `422` — Validation failed: an unknown `parent_id` or a move into its own subtree (`error.fields.parent_id`), or a remove flag sent with a new image. An image link that cannot be downloaded, or a link or file that is not an allowed image, is refused here with `error.fields` naming it. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "parent_id": null, "name": "T-Shirts", "slug": "t-shirts", "image": "https://cdn.example.com/companies/c1/categories/k1/image.webp", "banner_image": "https://cdn.example.com/companies/c1/categories/k1/banner.webp", "description": null, "seo": { "title": "Cotton T-Shirts", "description": "Breathable cotton tees in every size." }, "status": "active", "position": 0, "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ``` --- # Brands Part of the [Shopdesk Commerce API reference](https://www.shopdeskbd.com/developers/reference.md). Paths below are shown exactly as the server routes them — prefix them with `https://api.shopdeskbd.com`. Authenticate every request with `Authorization: Bearer shopdesk_sk_YOUR_API_KEY`; each operation names the scope its key must hold. Every failure uses the one error envelope — see [Errors](https://www.shopdeskbd.com/developers/errors.md). ## List brands `GET /ext/v1/brands` · Requires scope `products:read` Every brand of the store; `search` resolves a brand name to its id. ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | `number` (default `25`, max 100) | No | Page size. | | `starting_after` | `string` | No | Return the page AFTER this object id (the previous page's `next_cursor`). | | `ending_before` | `string` | No | Return the page BEFORE this object id. | | `sort` | `string` (default `-created_at`) | No | Newest first by default. Only `created_at` is sortable — a cursor over a mutable key (like `updated_at`) cannot page reliably. Use `updated_after` to sync changes. One of: `created_at`, `-created_at`. | | `store_id` | `string` | No | Restrict to one store. Required breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | | `status` | `string` | No | Filter to one of the values below. One of: `active`, `inactive`. | | `search` | `string` | No | Match on name or slug (case-insensitive, partial) — how a name resolves to an id. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/brands?limit=25" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/brands"); Object.entries({ limit: "25", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/brands", params={ "limit": "25", }, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` ### Responses - `200` — A cursor page of brands. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "data": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Acme", "slug": "acme", "logo": "https://cdn.example.com/companies/c1/brands/b1/logo.webp", "banner_image": "https://cdn.example.com/companies/c1/brands/b1/banner.webp", "description": null, "seo": { "title": "Cotton T-Shirts", "description": "Breathable cotton tees in every size." }, "status": "active", "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ], "meta": { "has_more": true, "next_cursor": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001" } } ``` ## Create a brand `POST /ext/v1/brands` · Requires scope `catalog:write` Creates a brand with an optional logo (`logo_url` or a `logo` part) and banner (`banner_url` or a `banner` part). Send JSON, with images by link, or `multipart/form-data` with the files as parts (at most 10 MB each, 50 MB in all) and the other fields as text (`seo` as a JSON string). An image comes by link or as a part, not both. Images are stored re-encoded as WebP. On a multipart request the `Idempotency-Key` covers the route only — a retry under the same key replays the first answer whatever the form holds — so send a new key for a different request. Image links: https only, PNG/JPEG/WebP/GIF read from the bytes (never SVG), at most 10 MB and 8000 px on the longest side (and 40 megapixels in all; 16 for a GIF), fetched within 10 s each and 30 s for the call, at most 50 MB downloaded per call. One failing link fails the whole call and nothing is written. Each image a call carries — link or file — counts as one write against the key's per-minute write limit; calls that download or upload images run at most 2 at a time per key; and a company may have 2,000 links downloaded per UTC day. Past any of these the answer is `429 RATE_LIMITED` with `Retry-After`. ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `slug` | `string` | No | URL slug: lowercase letters, digits and single hyphens (anything else is refused with `422`). Derived from the name when omitted, and suffixed (`-1`, `-2`) when taken. A rename keeps the slug; send one to change it. | | `status` | `string` (default `active`) | No | — One of: `active`, `inactive`. | | `seo` | `SeoBodyDto` | No | Search-engine overrides. Send `null` to clear them. In a multipart form, send the object as a JSON string. | | `banner_url` | `string` | No | Details-page hero banner, by link. An https link the server downloads and stores a copy of (PNG, JPEG, WebP or GIF, at most 10 MB and 8000 px on the longest side; read from the bytes, never the extension). The link itself is not kept. A link that cannot be fetched or is not such an image fails the whole call with `422` naming this field, and nothing is written. Or upload the file in a `banner` part of a multipart request — not both. | | `logo_url` | `string` | No | Brand logo, by link. An https link the server downloads and stores a copy of (PNG, JPEG, WebP or GIF, at most 10 MB and 8000 px on the longest side; read from the bytes, never the extension). The link itself is not kept. A link that cannot be fetched or is not such an image fails the whole call with `422` naming this field, and nothing is written. Or upload the file in a `logo` part of a multipart request — not both. | | `name` | `string` | Yes | — | | `description` | `string` | No | Shown on the brand page. | ### `seo` fields | Name | Type | Required | Description | | --- | --- | --- | --- | | `title` | `string` | No | Page title for search engines; stored cut to 200 characters. | | `description` | `string` | No | Meta description; stored cut to 500 characters. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X POST "https://api.shopdeskbd.com/ext/v1/brands" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \ -H "Content-Type: application/json" \ -d '{ "name": "Nukta", "logo_url": "https://cdn.example.com/brands/nukta-logo.png" }' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/brands"); const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", "Content-Type": "application/json", }, body: JSON.stringify({ "name": "Nukta", "logo_url": "https://cdn.example.com/brands/nukta-logo.png" }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "name": "Nukta", "logo_url": "https://cdn.example.com/brands/nukta-logo.png", } response = requests.post( "https://api.shopdeskbd.com/ext/v1/brands", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 'Nukta', 'logo_url' => 'https://cdn.example.com/brands/nukta-logo.png', ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/brands'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `201` — The new brand. - `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `409` — A request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `413` — A file part is larger than 10 MB, or the parts total more than 50 MB. - `422` — Validation failed. An image link that cannot be downloaded, or a link or file that is not an allowed image, is refused here with `error.fields` naming it. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Acme", "slug": "acme", "logo": "https://cdn.example.com/companies/c1/brands/b1/logo.webp", "banner_image": "https://cdn.example.com/companies/c1/brands/b1/banner.webp", "description": null, "seo": { "title": "Cotton T-Shirts", "description": "Breathable cotton tees in every size." }, "status": "active", "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ``` ## Get a brand by id `GET /ext/v1/brands/{id}` · Requires scope `products:read` One brand record. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The brand id. | ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Restrict the lookup to one store. Breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/brands/REPLACE_WITH_ID" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/brands/REPLACE_WITH_ID"); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/brands/REPLACE_WITH_ID", headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The brand. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Brand not found. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Acme", "slug": "acme", "logo": "https://cdn.example.com/companies/c1/brands/b1/logo.webp", "banner_image": "https://cdn.example.com/companies/c1/brands/b1/banner.webp", "description": null, "seo": { "title": "Cotton T-Shirts", "description": "Breathable cotton tees in every size." }, "status": "active", "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ``` ## Update a brand `PATCH /ext/v1/brands/{id}` · Requires scope `catalog:write` Changes the fields sent and leaves the rest alone. A new logo or banner replaces the old one, which is deleted only after the new one is saved; `remove_logo` / `remove_banner` delete them. Send JSON, with images by link, or `multipart/form-data` with the files as parts (at most 10 MB each, 50 MB in all) and the other fields as text (`seo` as a JSON string). An image comes by link or as a part, not both. Images are stored re-encoded as WebP. On a multipart request the `Idempotency-Key` covers the route only — a retry under the same key replays the first answer whatever the form holds — so send a new key for a different request. Image links: https only, PNG/JPEG/WebP/GIF read from the bytes (never SVG), at most 10 MB and 8000 px on the longest side (and 40 megapixels in all; 16 for a GIF), fetched within 10 s each and 30 s for the call, at most 50 MB downloaded per call. One failing link fails the whole call and nothing is written. Each image a call carries — link or file — counts as one write against the key's per-minute write limit; calls that download or upload images run at most 2 at a time per key; and a company may have 2,000 links downloaded per UTC day. Past any of these the answer is `429 RATE_LIMITED` with `Retry-After`. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The brand id. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `slug` | `string` | No | URL slug: lowercase letters, digits and single hyphens (anything else is refused with `422`). Derived from the name when omitted, and suffixed (`-1`, `-2`) when taken. A rename keeps the slug; send one to change it. | | `status` | `string` (default `active`) | No | — One of: `active`, `inactive`. | | `seo` | `SeoBodyDto` | No | Search-engine overrides. Send `null` to clear them. In a multipart form, send the object as a JSON string. | | `banner_url` | `string` | No | Details-page hero banner, by link. An https link the server downloads and stores a copy of (PNG, JPEG, WebP or GIF, at most 10 MB and 8000 px on the longest side; read from the bytes, never the extension). The link itself is not kept. A link that cannot be fetched or is not such an image fails the whole call with `422` naming this field, and nothing is written. Or upload the file in a `banner` part of a multipart request — not both. | | `logo_url` | `string` | No | Brand logo, by link. An https link the server downloads and stores a copy of (PNG, JPEG, WebP or GIF, at most 10 MB and 8000 px on the longest side; read from the bytes, never the extension). The link itself is not kept. A link that cannot be fetched or is not such an image fails the whole call with `422` naming this field, and nothing is written. Or upload the file in a `logo` part of a multipart request — not both. | | `name` | `string` | No | — | | `description` | `string` | No | Send `null` to clear it. | | `remove_logo` | `boolean` | No | Remove the current logo. Cannot be combined with a new one. | | `remove_banner` | `boolean` | No | Remove the current banner. Cannot be combined with a new one. | ### `seo` fields | Name | Type | Required | Description | | --- | --- | --- | --- | | `title` | `string` | No | Page title for search engines; stored cut to 200 characters. | | `description` | `string` | No | Meta description; stored cut to 500 characters. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X PATCH "https://api.shopdeskbd.com/ext/v1/brands/REPLACE_WITH_ID" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \ -H "Content-Type: application/json" \ -d '{ "description": "Cotton basics since 2019." }' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/brands/REPLACE_WITH_ID"); const response = await fetch(url, { method: "PATCH", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", "Content-Type": "application/json", }, body: JSON.stringify({ "description": "Cotton basics since 2019." }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "description": "Cotton basics since 2019.", } response = requests.patch( "https://api.shopdeskbd.com/ext/v1/brands/REPLACE_WITH_ID", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 'Cotton basics since 2019.', ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/brands/REPLACE_WITH_ID'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The updated brand. - `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Brand not found. - `409` — A request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `413` — A file part is larger than 10 MB, or the parts total more than 50 MB. - `422` — Validation failed, or a remove flag sent with a new image. An image link that cannot be downloaded, or a link or file that is not an allowed image, is refused here with `error.fields` naming it. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Acme", "slug": "acme", "logo": "https://cdn.example.com/companies/c1/brands/b1/logo.webp", "banner_image": "https://cdn.example.com/companies/c1/brands/b1/banner.webp", "description": null, "seo": { "title": "Cotton T-Shirts", "description": "Breathable cotton tees in every size." }, "status": "active", "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ``` --- # Attributes Part of the [Shopdesk Commerce API reference](https://www.shopdeskbd.com/developers/reference.md). Paths below are shown exactly as the server routes them — prefix them with `https://api.shopdeskbd.com`. Authenticate every request with `Authorization: Bearer shopdesk_sk_YOUR_API_KEY`; each operation names the scope its key must hold. Every failure uses the one error envelope — see [Errors](https://www.shopdeskbd.com/developers/errors.md). ## List attributes `GET /ext/v1/attributes` · Requires scope `products:read` Every attribute with its `values[]`. `search` resolves a name such as "Colour" to its id; `type` keeps only variant or specification attributes. ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | `number` (default `25`, max 100) | No | Page size. | | `starting_after` | `string` | No | Return the page AFTER this object id (the previous page's `next_cursor`). | | `ending_before` | `string` | No | Return the page BEFORE this object id. | | `sort` | `string` (default `-created_at`) | No | Newest first by default. Only `created_at` is sortable — a cursor over a mutable key (like `updated_at`) cannot page reliably. Use `updated_after` to sync changes. One of: `created_at`, `-created_at`. | | `store_id` | `string` | No | Restrict to one store. Required breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | | `search` | `string` | No | Match on name or slug (case-insensitive, partial) — how a name resolves to an id. | | `type` | `string` | No | Only `variant` or only `specification` attributes. One of: `variant`, `specification`. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/attributes?limit=25" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/attributes"); Object.entries({ limit: "25", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/attributes", params={ "limit": "25", }, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` ### Responses - `200` — A cursor page of attributes. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "data": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Colour", "slug": "colour", "type": "variant", "input_type": "color", "is_filterable": true, "is_required": false, "values": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "attribute_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "value": "Red", "position": 0, "swatch_hex": "#FF0000", "swatch_image_url": "https://cdn.example.com/companies/c1/attributes/swatches/swatch.webp", "created_at": "2026-08-11T10:00:00.000Z" } ], "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ], "meta": { "has_more": true, "next_cursor": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001" } } ``` ## Create an attribute `POST /ext/v1/attributes` · Requires scope `catalog:write` Creates an attribute, optionally with its first `values`. A swatch tile sent as `swatch_image_url` is downloaded and stored by Shopdesk before anything is written. Image links: https only, PNG/JPEG/WebP/GIF read from the bytes (never SVG), at most 10 MB and 8000 px on the longest side (and 40 megapixels in all; 16 for a GIF), fetched within 10 s each and 30 s for the call, at most 50 MB downloaded per call. One failing link fails the whole call and nothing is written. Each image a call carries — link or file — counts as one write against the key's per-minute write limit; calls that download or upload images run at most 2 at a time per key; and a company may have 2,000 links downloaded per UTC day. Past any of these the answer is `429 RATE_LIMITED` with `Retry-After`. ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `name` | `string` | Yes | — | | `slug` | `string` | No | Lowercase letters, digits and single hyphens (anything else is refused with `422`). Derived from the name when omitted, and suffixed when taken. | | `type` | `string` | Yes | `variant` attributes make variants (Colour, Size); `specification` attributes only describe a product (Material). It can be changed later only while no product uses the attribute. One of: `variant`, `specification`. | | `input_type` | `string` (default `text`) | No | How values are shown: `text`, `color` (uses `swatch_hex`) or `image` (uses `swatch_image_url`). Free to change later. One of: `text`, `color`, `image`. | | `is_filterable` | `boolean` (default `false`) | No | Offer it as a storefront filter. | | `is_required` | `boolean` (default `false`) | No | — | | `values` | `AttributeValueInputDto[]` | No | Initial values, in order. A value repeated (ignoring case) is refused with `409`. | ### `values[]` fields | Name | Type | Required | Description | | --- | --- | --- | --- | | `value` | `string` | Yes | — | | `position` | `integer` | No | Display order, lowest first. Inline values default to their place in the list; a value added later defaults to 0. | | `swatch_hex` | `string` | No | Swatch colour, `#RRGGBB` (stored upper-case). Only on an attribute whose `input_type` is `color` or `image`; sent to a `text` attribute it is refused with `422`, never dropped. | | `swatch_image_url` | `string` | No | A swatch tile for patterns and fabrics, by https link. The server downloads it, checks it is a PNG, JPEG, WebP or GIF (at most 10 MB and 8000 px on the longest side) and stores a copy; the link itself is never kept. A link that fails fails the whole call with `422` naming this field, and nothing is written. At most 10 links per call. Only on an attribute whose `input_type` is `color` or `image`; sent to a `text` attribute it is refused with `422`, never dropped. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X POST "https://api.shopdeskbd.com/ext/v1/attributes" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \ -H "Content-Type: application/json" \ -d '{ "name": "Colour", "type": "variant", "values": [ { "value": "Red" }, { "value": "Blue" } ] }' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/attributes"); const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", "Content-Type": "application/json", }, body: JSON.stringify({ "name": "Colour", "type": "variant", "values": [ { "value": "Red" }, { "value": "Blue" } ] }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "name": "Colour", "type": "variant", "values": [ { "value": "Red", }, { "value": "Blue", }, ], } response = requests.post( "https://api.shopdeskbd.com/ext/v1/attributes", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 'Colour', 'type' => 'variant', 'values' => [ [ 'value' => 'Red', ], [ 'value' => 'Blue', ], ], ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/attributes'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `201` — The new attribute with its values. - `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `409` — Two values are the same, ignoring case (`CONFLICT`, `error.fields.values`); or a request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `422` — Validation failed, or a swatch on a `text` attribute. A `swatch_image_url` that cannot be downloaded or is not an allowed image is refused here with `error.fields` naming it (`values[2].swatch_image_url` on create). - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Colour", "slug": "colour", "type": "variant", "input_type": "color", "is_filterable": true, "is_required": false, "values": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "attribute_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "value": "Red", "position": 0, "swatch_hex": "#FF0000", "swatch_image_url": "https://cdn.example.com/companies/c1/attributes/swatches/swatch.webp", "created_at": "2026-08-11T10:00:00.000Z" } ], "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ``` ## Get an attribute by id `GET /ext/v1/attributes/{id}` · Requires scope `products:read` One attribute with every value, in display order. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The attribute id. | ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Restrict the lookup to one store. Breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/attributes/REPLACE_WITH_ID" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/attributes/REPLACE_WITH_ID"); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/attributes/REPLACE_WITH_ID", headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The attribute. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Attribute not found. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Colour", "slug": "colour", "type": "variant", "input_type": "color", "is_filterable": true, "is_required": false, "values": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "attribute_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "value": "Red", "position": 0, "swatch_hex": "#FF0000", "swatch_image_url": "https://cdn.example.com/companies/c1/attributes/swatches/swatch.webp", "created_at": "2026-08-11T10:00:00.000Z" } ], "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ``` ## Update an attribute `PATCH /ext/v1/attributes/{id}` · Requires scope `catalog:write` Renames it or changes its slug, `input_type` or flags. `type` can change only while no product variant or specification uses the attribute. Values are added and edited through `/attributes/{id}/values`. Fields left out are left alone. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The attribute id. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `name` | `string` | No | — | | `slug` | `string` | No | A rename keeps the slug; send one to change it. | | `type` | `string` | No | Refused with `409` while any product variant or specification uses the attribute — create a new attribute instead. One of: `variant`, `specification`. | | `input_type` | `string` | No | Changes only how values are shown. Switching back to `text` keeps the stored swatches, hidden. One of: `text`, `color`, `image`. | | `is_filterable` | `boolean` | No | — | | `is_required` | `boolean` | No | — | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X PATCH "https://api.shopdeskbd.com/ext/v1/attributes/REPLACE_WITH_ID" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \ -H "Content-Type: application/json" \ -d '{ "name": "Colour", "is_filterable": true }' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/attributes/REPLACE_WITH_ID"); const response = await fetch(url, { method: "PATCH", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", "Content-Type": "application/json", }, body: JSON.stringify({ "name": "Colour", "is_filterable": true }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "name": "Colour", "is_filterable": True, } response = requests.patch( "https://api.shopdeskbd.com/ext/v1/attributes/REPLACE_WITH_ID", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 'Colour', 'is_filterable' => true, ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/attributes/REPLACE_WITH_ID'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The updated attribute with its values. - `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Attribute not found. - `409` — `type` cannot change while products use the attribute (`CONFLICT`, `error.fields.type`); or a request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Colour", "slug": "colour", "type": "variant", "input_type": "color", "is_filterable": true, "is_required": false, "values": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "attribute_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "value": "Red", "position": 0, "swatch_hex": "#FF0000", "swatch_image_url": "https://cdn.example.com/companies/c1/attributes/swatches/swatch.webp", "created_at": "2026-08-11T10:00:00.000Z" } ], "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ``` ## Add a value to an attribute `POST /ext/v1/attributes/{id}/values` · Requires scope `catalog:write` Adds one value, such as "XL" to Size. A `swatch_image_url` is downloaded and stored by Shopdesk before the value is written. Image links: https only, PNG/JPEG/WebP/GIF read from the bytes (never SVG), at most 10 MB and 8000 px on the longest side (and 40 megapixels in all; 16 for a GIF), fetched within 10 s each and 30 s for the call, at most 50 MB downloaded per call. One failing link fails the whole call and nothing is written. Each image a call carries — link or file — counts as one write against the key's per-minute write limit; calls that download or upload images run at most 2 at a time per key; and a company may have 2,000 links downloaded per UTC day. Past any of these the answer is `429 RATE_LIMITED` with `Retry-After`. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The attribute id. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `value` | `string` | Yes | — | | `position` | `integer` | No | Display order, lowest first. Defaults to 0. | | `swatch_hex` | `string` | No | Swatch colour, `#RRGGBB` (stored upper-case). Only on an attribute whose `input_type` is `color` or `image`; sent to a `text` attribute it is refused with `422`, never dropped. | | `swatch_image_url` | `string` | No | A swatch tile for patterns and fabrics, by https link. The server downloads it, checks it is a PNG, JPEG, WebP or GIF (at most 10 MB and 8000 px on the longest side) and stores a copy; the link itself is never kept. A link that fails fails the whole call with `422` naming this field, and nothing is written. At most 10 links per call. Only on an attribute whose `input_type` is `color` or `image`; sent to a `text` attribute it is refused with `422`, never dropped. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X POST "https://api.shopdeskbd.com/ext/v1/attributes/REPLACE_WITH_ID/values" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \ -H "Content-Type: application/json" \ -d '{ "value": "XL" }' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/attributes/REPLACE_WITH_ID/values"); const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", "Content-Type": "application/json", }, body: JSON.stringify({ "value": "XL" }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "value": "XL", } response = requests.post( "https://api.shopdeskbd.com/ext/v1/attributes/REPLACE_WITH_ID/values", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 'XL', ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/attributes/REPLACE_WITH_ID/values'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `201` — The new value. - `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Attribute not found. - `409` — The attribute already has this value (`CONFLICT`, `error.fields.value`); or a request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `422` — Validation failed, or a swatch on a `text` attribute. A `swatch_image_url` that cannot be downloaded or is not an allowed image is refused here with `error.fields` naming it (`values[2].swatch_image_url` on create). - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "attribute_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "value": "Red", "position": 0, "swatch_hex": "#FF0000", "swatch_image_url": "https://cdn.example.com/companies/c1/attributes/swatches/swatch.webp", "created_at": "2026-08-11T10:00:00.000Z" } ``` ## Update an attribute value `PATCH /ext/v1/attributes/{id}/values/{valueId}` · Requires scope `catalog:write` Renames a value, moves it, or sets or clears its swatch. Renaming relabels every variant and product that shows it. A new `swatch_image_url` is downloaded and stored by Shopdesk. Image links: https only, PNG/JPEG/WebP/GIF read from the bytes (never SVG), at most 10 MB and 8000 px on the longest side (and 40 megapixels in all; 16 for a GIF), fetched within 10 s each and 30 s for the call, at most 50 MB downloaded per call. One failing link fails the whole call and nothing is written. Each image a call carries — link or file — counts as one write against the key's per-minute write limit; calls that download or upload images run at most 2 at a time per key; and a company may have 2,000 links downloaded per UTC day. Past any of these the answer is `429 RATE_LIMITED` with `Retry-After`. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The attribute id. | | `valueId` | `uuid` | Yes | The value id, as listed in the attribute's `values[]`. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `value` | `string` | No | Renaming relabels every variant and product that shows this value. | | `position` | `integer` | No | — | | `swatch_hex` | `string` | No | Swatch colour, `#RRGGBB`; `null` clears it. Only on an attribute whose `input_type` is `color` or `image`; sent to a `text` attribute it is refused with `422`, never dropped. | | `swatch_image_url` | `string` | No | A swatch tile for patterns and fabrics, by https link. The server downloads it, checks it is a PNG, JPEG, WebP or GIF (at most 10 MB and 8000 px on the longest side) and stores a copy; the link itself is never kept. A link that fails fails the whole call with `422` naming this field, and nothing is written. At most 10 links per call. `null` clears it. Only on an attribute whose `input_type` is `color` or `image`; sent to a `text` attribute it is refused with `422`, never dropped. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X PATCH "https://api.shopdeskbd.com/ext/v1/attributes/REPLACE_WITH_ID/values/REPLACE_WITH_ID" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \ -H "Content-Type: application/json" \ -d '{ "value": "Extra large", "position": 4 }' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/attributes/REPLACE_WITH_ID/values/REPLACE_WITH_ID"); const response = await fetch(url, { method: "PATCH", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", "Content-Type": "application/json", }, body: JSON.stringify({ "value": "Extra large", "position": 4 }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "value": "Extra large", "position": 4, } response = requests.patch( "https://api.shopdeskbd.com/ext/v1/attributes/REPLACE_WITH_ID/values/REPLACE_WITH_ID", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 'Extra large', 'position' => 4, ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/attributes/REPLACE_WITH_ID/values/REPLACE_WITH_ID'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The updated value. - `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Attribute not found, or the value is not on it. - `409` — Another value of the attribute already has this name (`CONFLICT`, `error.fields.value`); or a request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `422` — Validation failed, or a swatch on a `text` attribute. A `swatch_image_url` that cannot be downloaded or is not an allowed image is refused here with `error.fields` naming it (`values[2].swatch_image_url` on create). - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "attribute_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "value": "Red", "position": 0, "swatch_hex": "#FF0000", "swatch_image_url": "https://cdn.example.com/companies/c1/attributes/swatches/swatch.webp", "created_at": "2026-08-11T10:00:00.000Z" } ``` --- # Orders Part of the [Shopdesk Commerce API reference](https://www.shopdeskbd.com/developers/reference.md). Paths below are shown exactly as the server routes them — prefix them with `https://api.shopdeskbd.com`. Authenticate every request with `Authorization: Bearer shopdesk_sk_YOUR_API_KEY`; each operation names the scope its key must hold. Every failure uses the one error envelope — see [Errors](https://www.shopdeskbd.com/developers/errors.md). ## List orders `GET /ext/v1/orders` · Requires scope `orders:read` Compact by default. Add `?expand=customer,items` for the full object. `updated_after` is the sync filter — it catches orders placed before your last poll but changed since. ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | `number` (default `25`, max 100) | No | Page size. | | `starting_after` | `string` | No | Return the page AFTER this object id (the previous page's `next_cursor`). | | `ending_before` | `string` | No | Return the page BEFORE this object id. | | `sort` | `string` (default `-created_at`) | No | Newest first by default. Only `created_at` is sortable — a cursor over a mutable key (like `updated_at`) cannot page reliably. Use `updated_after` to sync changes. One of: `created_at`, `-created_at`. | | `store_id` | `string` | No | Restrict to one store. Required breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | | `status` | `string` | No | Filter to one of the values below. One of: `pending`, `follow_up`, `confirmed`, `processing`, `ready_to_ship`, `in_transit`, `delivered`, `returned`, `failed_delivery`, `cancelled`, `hold`, `spam`. | | `payment_status` | `string` | No | Filter to one of the values below. One of: `unpaid`, `partially_paid`, `paid`, `refunded`. | | `fulfillment_status` | `string` | No | Filter to one of the values below. One of: `unfulfilled`, `partially_fulfilled`, `fulfilled`, `returned`. | | `channel` | `string` | No | Sales channel SLUG (the stable per-store key), not its id. | | `created_after` | `date-time` | No | ISO-8601 timestamp. Inclusive lower bound. | | `created_before` | `date-time` | No | ISO-8601 timestamp. Exclusive upper bound. | | `updated_after` | `date-time` | No | Only orders changed since this instant. THE sync filter: an order placed last month and confirmed this morning is exactly the row a `created_after` poll would miss. | | `follow_up_before` | `date-time` | No | Only orders with a follow-up (call-back) scheduled BEFORE this instant — pass now for the overdue call list. Orders with no follow-up never match. | | `follow_up_after` | `date-time` | No | Only orders with a follow-up scheduled AFTER this instant. Combine with `follow_up_before` for a window (today, this week). | | `assignee_id` | `uuid` | No | Only orders assigned to this staff member (the order's `assignee.id`). | | `customer_id` | `uuid` | No | Only this customer's orders — their order history. | | `search` | `string` | No | Match on order number, customer name or customer phone. A phone matches in any Bangladeshi form: `+8801712345678`, `8801712345678` and `01712345678` find the same orders, separators ignored. | | `expand` | `string[]` | No | Include related objects. Allowed: `customer`, `items`. Comma-separated (`customer,items`), one level. Omitted by default so a sync walking pages stays cheap. One of: `customer`, `items`. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/orders?limit=25&updated_after=2026-08-01T00%3A00%3A00Z&expand=customer%2Citems" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/orders"); Object.entries({ limit: "25", updated_after: "2026-08-01T00:00:00Z", expand: "customer,items", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/orders", params={ "limit": "25", "updated_after": "2026-08-01T00:00:00Z", "expand": "customer,items", }, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` ### Responses - `200` — A cursor page of orders. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "data": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "number": "ORD-20260811-1042", "source": "api", "channel": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Facebook", "slug": "facebook", "type": "facebook" }, "customer_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "location_id": null, "status": "pending", "payment_status": "unpaid", "fulfillment_status": "unfulfilled", "subtotal": "1250.00", "discount_amount": "1250.00", "shipping_charge": "1250.00", "tax_amount": "1250.00", "total_amount": "1250.00", "paid_amount": "1250.00", "due_amount": "1250.00", "refunded_amount": "1250.00", "shipping_address": null, "coupon": null, "custom_fields": null, "note": null, "follow_up_at": "2026-08-11T10:00:00.000Z", "follow_up_note": "Call after salary day.", "assignee": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Nusrat Jahan" }, "delivered_at": "2026-08-11T10:00:00.000Z", "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ], "meta": { "has_more": true, "next_cursor": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001" } } ``` ## Create an order `POST /ext/v1/orders` · Requires scope `orders:write` Runs the SAME order-creation core as the dashboard and storefront: availability-checked stock reservation, coupon engine, totals math and plan-quota metering are all inherited, never re-implemented. Lines reference variants by id or SKU; the customer is an existing id or a find-or-create by phone. Price overrides are honored — this is a trusted-merchant surface. Company-scoped keys must send `store_id`. ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | Yes | REQUIRED. A unique value per logical request (a UUID is ideal); reuse it verbatim when retrying. A retry replays the original response with `Idempotent-Replayed: true` instead of running twice. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `channel` | `string` | No | A sales-channel SLUG for attribution. Defaults to the store's `manual` channel (Decision Q11 — tagging is optional, never forced). | | `location_id` | `uuid` | No | Location (warehouse) to allocate stock from. Defaults to the company's default location — the admin-desk rule. | | `customer_id` | `uuid` | No | An existing customer. Provide this OR `customer`. | | `customer` | `CreateOrderCustomerDto` | No | Find-or-create by phone. Provide this OR `customer_id`. | | `items` | `CreateOrderLineDto[]` | Yes | — | | `shipping_address` | `object` | No | Free-form shipping address object, stored verbatim. | | `shipping_charge` | `number` | No | Delivery charge as YOUR system computed it (trusted surface). A free-shipping coupon still zeroes it. Default 0. | | `tax_amount` | `number` | No | — | | `coupon_code` | `string` | No | Priced and redeemed by the same engine as every other surface; an unusable code fails the order rather than mispricing it. | | `note` | `string` | No | — | | `payment` | `CreateOrderPaymentDto` | No | An upfront settled payment (like the admin desk's "paid amount"). Omit for an unpaid order; record later payments via `POST /v1/orders/{id}/payments`. | ### `customer` fields | Name | Type | Required | Description | | --- | --- | --- | --- | | `phone` | `string` | Yes | Bangladeshi mobile, any accepted spelling — stored canonical. THE find-or-create match key. | | `full_name` | `string` | No | Required when the phone matches no existing customer (a new record needs a name); ignored when one exists — a sale never silently renames a customer. | | `email` | `string` | No | — | ### `items[]` fields | Name | Type | Required | Description | | --- | --- | --- | --- | | `variant_id` | `uuid` | No | The variant to sell. Provide this OR `sku`. | | `sku` | `string` | No | Resolve the variant by SKU instead of id. | | `quantity` | `number` | Yes | — | | `unit_price` | `number` | No | Override the regular unit price (trusted surface). Omitted = the variant's current catalog price — the admin-desk rule, verbatim. | | `discount_price` | `number` | No | Override the discounted unit price. Only honored when it is a REAL discount (above 0, below the regular price) — same rule as the desk. | ### `payment` fields | Name | Type | Required | Description | | --- | --- | --- | --- | | `payment_method_id` | `uuid` | No | One of the store's payment methods (must be active). | | `amount` | `number` | Yes | — | | `transaction_id` | `string` | No | — | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X POST "https://api.shopdeskbd.com/ext/v1/orders" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \ -H "Content-Type: application/json" \ -d '{ "channel": "facebook", "customer": { "phone": "01712345678", "full_name": "Rafiqul Islam" }, "items": [ { "sku": "TSHIRT-RED-M", "quantity": 2 } ], "shipping_charge": 120, "note": "Deliver after 6pm." }' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/orders"); const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", "Content-Type": "application/json", }, body: JSON.stringify({ "channel": "facebook", "customer": { "phone": "01712345678", "full_name": "Rafiqul Islam" }, "items": [ { "sku": "TSHIRT-RED-M", "quantity": 2 } ], "shipping_charge": 120, "note": "Deliver after 6pm." }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "channel": "facebook", "customer": { "phone": "01712345678", "full_name": "Rafiqul Islam", }, "items": [ { "sku": "TSHIRT-RED-M", "quantity": 2, }, ], "shipping_charge": 120, "note": "Deliver after 6pm.", } response = requests.post( "https://api.shopdeskbd.com/ext/v1/orders", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 'facebook', 'customer' => [ 'phone' => '01712345678', 'full_name' => 'Rafiqul Islam', ], 'items' => [ [ 'sku' => 'TSHIRT-RED-M', 'quantity' => 2, ], ], 'shipping_charge' => 120, 'note' => 'Deliver after 6pm.', ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/orders'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `201` — The created order, in the same shape as `GET /v1/orders/{id}` with customer and items expanded. - `400` — The required `Idempotency-Key` header is missing, empty, or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header), or a line cannot be reserved from stock (`INSUFFICIENT_STOCK`; `error.fields.items` names it). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — Missing scope, or the plan order quota is exhausted (`plan_limit_reached`). - `409` — A request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "number": "ORD-20260811-1042", "source": "api", "channel": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Facebook", "slug": "facebook", "type": "facebook" }, "customer_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "location_id": null, "status": "pending", "payment_status": "unpaid", "fulfillment_status": "unfulfilled", "subtotal": "1250.00", "discount_amount": "1250.00", "shipping_charge": "1250.00", "tax_amount": "1250.00", "total_amount": "1250.00", "paid_amount": "1250.00", "due_amount": "1250.00", "refunded_amount": "1250.00", "shipping_address": null, "coupon": null, "custom_fields": null, "note": null, "follow_up_at": "2026-08-11T10:00:00.000Z", "follow_up_note": "Call after salary day.", "assignee": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Nusrat Jahan" }, "delivered_at": "2026-08-11T10:00:00.000Z", "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z", "items": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "variant_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_name": "Classic Cotton T-Shirt", "variant_name": "Red / M", "sku": "TSHIRT-RED-M", "quantity": 2, "unit_price": "1250.00", "discount_price": "1250.00", "subtotal": "1250.00" } ], "customer": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "full_name": "Rafiqul Islam", "email": null, "phone": "01712345678", "type": "manual", "status": "active" } } ``` ## Get an order by id `GET /ext/v1/orders/{id}` · Requires scope `orders:read` Add `?expand=customer,items` for the related objects. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The order id. | ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `expand` | `string[]` | No | Include related objects. Allowed: `customer`, `items`. Comma-separated (`customer,items`), one level. Omitted by default so a sync walking pages stays cheap. One of: `customer`, `items`. | | `store_id` | `string` | No | Restrict the lookup to one store (company-scoped keys). | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ID?expand=customer%2Citems" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ID"); Object.entries({ expand: "customer,items", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ID", params={ "expand": "customer,items", }, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The order. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Order not found. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "number": "ORD-20260811-1042", "source": "api", "channel": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Facebook", "slug": "facebook", "type": "facebook" }, "customer_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "location_id": null, "status": "pending", "payment_status": "unpaid", "fulfillment_status": "unfulfilled", "subtotal": "1250.00", "discount_amount": "1250.00", "shipping_charge": "1250.00", "tax_amount": "1250.00", "total_amount": "1250.00", "paid_amount": "1250.00", "due_amount": "1250.00", "refunded_amount": "1250.00", "shipping_address": null, "coupon": null, "custom_fields": null, "note": null, "follow_up_at": "2026-08-11T10:00:00.000Z", "follow_up_note": "Call after salary day.", "assignee": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Nusrat Jahan" }, "delivered_at": "2026-08-11T10:00:00.000Z", "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z", "items": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "variant_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_name": "Classic Cotton T-Shirt", "variant_name": "Red / M", "sku": "TSHIRT-RED-M", "quantity": 2, "unit_price": "1250.00", "discount_price": "1250.00", "subtotal": "1250.00" } ], "customer": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "full_name": "Rafiqul Islam", "email": null, "phone": "01712345678", "type": "manual", "status": "active" } } ``` ## Update an order's note and follow-up `PATCH /ext/v1/orders/{id}` · Requires scope `orders:write` The call-desk edit: the order note and the follow-up (when to call the customer back, and why). Legal in ANY status, exactly like the dashboard's order-details edit, and it runs the same code: nothing about the money, lines or stock moves. `null` clears a field; an omitted field is left alone; send at least one. Emits `order.updated` with `data.previous` holding the old values when something actually changed. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The order id. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `note` | `string` | No | The order note. `null` clears it. | | `follow_up_at` | `date-time` | No | When to call the customer back (ISO 8601). `null` clears the follow-up. | | `follow_up_note` | `string` | No | Why the call-back is needed. `null` clears it. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X PATCH "https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ID" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \ -H "Content-Type: application/json" \ -d '{ "follow_up_at": "2026-10-02T10:00:00.000Z", "follow_up_note": "Call after salary day." }' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ID"); const response = await fetch(url, { method: "PATCH", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", "Content-Type": "application/json", }, body: JSON.stringify({ "follow_up_at": "2026-10-02T10:00:00.000Z", "follow_up_note": "Call after salary day." }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "follow_up_at": "2026-10-02T10:00:00.000Z", "follow_up_note": "Call after salary day.", } response = requests.patch( "https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ID", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php '2026-10-02T10:00:00.000Z', 'follow_up_note' => 'Call after salary day.', ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ID'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The updated order (customer and items expanded). - `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Order not found. - `409` — A request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "number": "ORD-20260811-1042", "source": "api", "channel": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Facebook", "slug": "facebook", "type": "facebook" }, "customer_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "location_id": null, "status": "pending", "payment_status": "unpaid", "fulfillment_status": "unfulfilled", "subtotal": "1250.00", "discount_amount": "1250.00", "shipping_charge": "1250.00", "tax_amount": "1250.00", "total_amount": "1250.00", "paid_amount": "1250.00", "due_amount": "1250.00", "refunded_amount": "1250.00", "shipping_address": null, "coupon": null, "custom_fields": null, "note": null, "follow_up_at": "2026-08-11T10:00:00.000Z", "follow_up_note": "Call after salary day.", "assignee": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Nusrat Jahan" }, "delivered_at": "2026-08-11T10:00:00.000Z", "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z", "items": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "variant_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_name": "Classic Cotton T-Shirt", "variant_name": "Red / M", "sku": "TSHIRT-RED-M", "quantity": 2, "unit_price": "1250.00", "discount_price": "1250.00", "subtotal": "1250.00" } ], "customer": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "full_name": "Rafiqul Islam", "email": null, "phone": "01712345678", "type": "manual", "status": "active" } } ``` ## Cancel an order `POST /ext/v1/orders/{id}/cancel` · Requires scope `orders:write` Sugar for the `cancelled` transition: same map, same stock unwind (release an open reservation, reverse a committed sale). ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The order id. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `note` | `string` | No | Recorded on the order's timeline. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X POST "https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ID/cancel" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \ -H "Content-Type: application/json" \ -d '{ "note": "Customer changed their mind." }' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ID/cancel"); const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", "Content-Type": "application/json", }, body: JSON.stringify({ "note": "Customer changed their mind." }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "note": "Customer changed their mind.", } response = requests.post( "https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ID/cancel", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 'Customer changed their mind.', ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ID/cancel'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The cancelled order (customer and items expanded). - `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header), or the order cannot be cancelled from its current status (`INVALID_STATUS_TRANSITION`; `error.fields.status` names it). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Order not found. - `409` — A courier parcel for this order is still live (`CONFLICT`; `error.fields.status` is `["live_parcel"]`): before the courier picks it up it has to be cancelled from the dashboard first; after, it is asked back with a Return request in the dashboard; or a request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "number": "ORD-20260811-1042", "source": "api", "channel": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Facebook", "slug": "facebook", "type": "facebook" }, "customer_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "location_id": null, "status": "pending", "payment_status": "unpaid", "fulfillment_status": "unfulfilled", "subtotal": "1250.00", "discount_amount": "1250.00", "shipping_charge": "1250.00", "tax_amount": "1250.00", "total_amount": "1250.00", "paid_amount": "1250.00", "due_amount": "1250.00", "refunded_amount": "1250.00", "shipping_address": null, "coupon": null, "custom_fields": null, "note": null, "follow_up_at": "2026-08-11T10:00:00.000Z", "follow_up_note": "Call after salary day.", "assignee": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Nusrat Jahan" }, "delivered_at": "2026-08-11T10:00:00.000Z", "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z", "items": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "variant_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_name": "Classic Cotton T-Shirt", "variant_name": "Red / M", "sku": "TSHIRT-RED-M", "quantity": 2, "unit_price": "1250.00", "discount_price": "1250.00", "subtotal": "1250.00" } ], "customer": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "full_name": "Rafiqul Islam", "email": null, "phone": "01712345678", "type": "manual", "status": "active" } } ``` ## List an order's payments `GET /ext/v1/orders/{id}/payments` · Requires scope `payments:read` Every settled, pending and refunded row against the order, oldest first. Refunds are negative amounts with status `refunded`. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The order id. | ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Restrict the lookup to one store. Breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ID/payments" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ID/payments"); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ID/payments", headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows = page["data"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; ``` ### Responses - `200` — The order's payment rows. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Order not found. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "data": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "order_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "amount": "1250.00", "status": "paid", "transaction_id": "TRX9F3K2", "payment_method": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "bKash", "type": "online" }, "paid_at": "2026-08-11T10:00:00.000Z", "created_at": "2026-08-11T10:00:00.000Z" } ] } ``` ## Record a payment against an order `POST /ext/v1/orders/{id}/payments` · Requires scope `payments:write` The staff path verbatim: the amount is capped at the order's NET due (refunds counted), the paid/due rollup recomputes under a row lock, and the paid-in-full events fire exactly once. A provider transaction id is unique per store — recording the same TrxID twice is a 409 (`duplicate_transaction_id`, naming the order that has it). A payment method set to require a transaction id refuses a payment without one (`transaction_id_required`, 400). While a courier parcel with a cash-on-delivery amount is out, a payment is a 409 `cod_parcel_active`. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The order id. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | Yes | REQUIRED. A unique value per logical request (a UUID is ideal); reuse it verbatim when retrying. A retry replays the original response with `Idempotent-Replayed: true` instead of running twice. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `amount` | `number` | Yes | — | | `payment_method_id` | `uuid` | No | One of the store's payment methods (must be active). | | `transaction_id` | `string` | No | Provider transaction id. Unique per store — recording the same TrxID twice is a 409. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X POST "https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ID/payments" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \ -H "Content-Type: application/json" \ -d '{ "amount": 500, "transaction_id": "TRX9F3K2" }' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ID/payments"); const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", "Content-Type": "application/json", }, body: JSON.stringify({ "amount": 500, "transaction_id": "TRX9F3K2" }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "amount": 500, "transaction_id": "TRX9F3K2", } response = requests.post( "https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ID/payments", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 500, 'transaction_id' => 'TRX9F3K2', ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ID/payments'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `201` — The updated order (customer and items expanded). - `400` — The required `Idempotency-Key` header is missing, empty, or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Order not found. - `409` — This transaction id is already recorded in the store; or a request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "number": "ORD-20260811-1042", "source": "api", "channel": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Facebook", "slug": "facebook", "type": "facebook" }, "customer_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "location_id": null, "status": "pending", "payment_status": "unpaid", "fulfillment_status": "unfulfilled", "subtotal": "1250.00", "discount_amount": "1250.00", "shipping_charge": "1250.00", "tax_amount": "1250.00", "total_amount": "1250.00", "paid_amount": "1250.00", "due_amount": "1250.00", "refunded_amount": "1250.00", "shipping_address": null, "coupon": null, "custom_fields": null, "note": null, "follow_up_at": "2026-08-11T10:00:00.000Z", "follow_up_note": "Call after salary day.", "assignee": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Nusrat Jahan" }, "delivered_at": "2026-08-11T10:00:00.000Z", "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z", "items": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "variant_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_name": "Classic Cotton T-Shirt", "variant_name": "Red / M", "sku": "TSHIRT-RED-M", "quantity": 2, "unit_price": "1250.00", "discount_price": "1250.00", "subtotal": "1250.00" } ], "customer": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "full_name": "Rafiqul Islam", "email": null, "phone": "01712345678", "type": "manual", "status": "active" } } ``` ## Change an order status `PATCH /ext/v1/orders/{id}/status` · Requires scope `orders:write` Validated against the SAME strict transition map staff use — an illegal move is refused with `INVALID_STATUS_TRANSITION`. A `delivered` order cannot be moved to `returned`: returns of delivered orders are made in the dashboard. Stock side effects are the staff path’s, inherited: commit on delivery, release on cancel. A `returned` order keeps its stock held (not for sale) until the parcel is received in the dashboard ("RTO received"). On a never-delivered order, `returned` keeps up to the delivery charge from an advance only when the parcel's pickup is recorded; otherwise everything paid is owed back to the customer and no courier charge is booked. `returned` is refused (`CONFLICT`; `error.fields.status` is `["parcel_not_picked_up"]`) while a courier that reports pickups (Pathao, RedX, CarryBee, Bahok) has not reported picking the parcel up. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The order id. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `status` | `string` | Yes | — One of: `pending`, `follow_up`, `confirmed`, `processing`, `ready_to_ship`, `in_transit`, `delivered`, `returned`, `failed_delivery`, `cancelled`, `hold`, `spam`. | | `note` | `string` | No | Recorded on the order's timeline. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X PATCH "https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ID/status" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \ -H "Content-Type: application/json" \ -d '{ "status": "confirmed", "note": "Confirmed over the phone." }' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ID/status"); const response = await fetch(url, { method: "PATCH", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", "Content-Type": "application/json", }, body: JSON.stringify({ "status": "confirmed", "note": "Confirmed over the phone." }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "status": "confirmed", "note": "Confirmed over the phone.", } response = requests.patch( "https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ID/status", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 'confirmed', 'note' => 'Confirmed over the phone.', ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ID/status'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The updated order (customer and items expanded). - `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header), or the transition map forbids this move (`INVALID_STATUS_TRANSITION`; `error.fields.status` names it). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Order not found. - `409` — A courier parcel for this order is still live (`CONFLICT`; `error.fields.status` is `["live_parcel"]`): before the courier picks it up it has to be cancelled from the dashboard first; after, it is asked back with a Return request in the dashboard; or, for `returned`, the courier has not reported picking the parcel up (`error.fields.status` is `["parcel_not_picked_up"]`); or, for `in_transit`, the order’s parcel was cancelled and has to be booked again (`["shipment_cancelled"]`); or a request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "number": "ORD-20260811-1042", "source": "api", "channel": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Facebook", "slug": "facebook", "type": "facebook" }, "customer_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "location_id": null, "status": "pending", "payment_status": "unpaid", "fulfillment_status": "unfulfilled", "subtotal": "1250.00", "discount_amount": "1250.00", "shipping_charge": "1250.00", "tax_amount": "1250.00", "total_amount": "1250.00", "paid_amount": "1250.00", "due_amount": "1250.00", "refunded_amount": "1250.00", "shipping_address": null, "coupon": null, "custom_fields": null, "note": null, "follow_up_at": "2026-08-11T10:00:00.000Z", "follow_up_note": "Call after salary day.", "assignee": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Nusrat Jahan" }, "delivered_at": "2026-08-11T10:00:00.000Z", "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z", "items": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "variant_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_name": "Classic Cotton T-Shirt", "variant_name": "Red / M", "sku": "TSHIRT-RED-M", "quantity": 2, "unit_price": "1250.00", "discount_price": "1250.00", "subtotal": "1250.00" } ], "customer": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "full_name": "Rafiqul Islam", "email": null, "phone": "01712345678", "type": "manual", "status": "active" } } ``` --- # Customers Part of the [Shopdesk Commerce API reference](https://www.shopdeskbd.com/developers/reference.md). Paths below are shown exactly as the server routes them — prefix them with `https://api.shopdeskbd.com`. Authenticate every request with `Authorization: Bearer shopdesk_sk_YOUR_API_KEY`; each operation names the scope its key must hold. Every failure uses the one error envelope — see [Errors](https://www.shopdeskbd.com/developers/errors.md). ## List customers `GET /ext/v1/customers` · Requires scope `customers:read` Addresses are on the detail read only — a page of 100 customers should not carry every address any of them ever saved. `search` finds a customer by name, email or phone, the phone in any Bangladeshi form; `phone` finds only the customer whose phone is exactly that number. ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | `number` (default `25`, max 100) | No | Page size. | | `starting_after` | `string` | No | Return the page AFTER this object id (the previous page's `next_cursor`). | | `ending_before` | `string` | No | Return the page BEFORE this object id. | | `sort` | `string` (default `-created_at`) | No | Newest first by default. Only `created_at` is sortable — a cursor over a mutable key (like `updated_at`) cannot page reliably. Use `updated_after` to sync changes. One of: `created_at`, `-created_at`. | | `store_id` | `string` | No | Restrict to one store. Required breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | | `search` | `string` | No | Match on name, phone or email. A phone matches in any Bangladeshi form: `+8801712345678`, `8801712345678` and `01712345678` find the same customer, separators ignored — the way phones are stored. | | `phone` | `string` | No | Only customers whose phone IS this number — an exact match, not a `search`: no name or email can match it. Any Bangladeshi mobile spelling (`+8801712345678`, `8801712345678`, `01712345678`); anything that is not a valid mobile is a 422. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/customers?limit=25" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/customers"); Object.entries({ limit: "25", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/customers", params={ "limit": "25", }, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` ### Responses - `200` — A cursor page of customers. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "data": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "full_name": "Rafiqul Islam", "email": null, "phone": "01712345678", "status": "active", "type": "manual", "gender": null, "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ], "meta": { "has_more": true, "next_cursor": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001" } } ``` ## Create a customer `POST /ext/v1/customers` · Requires scope `customers:write` Phones are canonicalized (`+880…` spellings collapse to `01…`) and unique per store, as is email — a duplicate is a 409. API-created customers are always type `manual`. ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `full_name` | `string` | Yes | — | | `phone` | `string` | No | Bangladeshi mobile, any accepted spelling — stored canonical (`01712345678`). Unique per store. | | `email` | `string` | No | — | | `status` | `string` (default `active`) | No | — One of: `active`, `inactive`, `blocked`. | | `gender` | `string` | No | — One of: `male`, `female`. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X POST "https://api.shopdeskbd.com/ext/v1/customers" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \ -H "Content-Type: application/json" \ -d '{ "full_name": "Rafiqul Islam", "phone": "01712345678" }' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/customers"); const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", "Content-Type": "application/json", }, body: JSON.stringify({ "full_name": "Rafiqul Islam", "phone": "01712345678" }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "full_name": "Rafiqul Islam", "phone": "01712345678", } response = requests.post( "https://api.shopdeskbd.com/ext/v1/customers", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 'Rafiqul Islam', 'phone' => '01712345678', ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/customers'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `201` — The created customer, addresses included. - `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `409` — A customer with this phone or email already exists; or a request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "full_name": "Rafiqul Islam", "email": null, "phone": "01712345678", "status": "active", "type": "manual", "gender": null, "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z", "addresses": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "label": "Home", "recipient_name": "Rafiqul Islam", "recipient_phone": "01712345678", "address_line": "House 12, Road 5", "city": "Dhaka", "area": "Dhanmondi", "postal_code": "1209", "is_default": true, "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ], "stats": { "orders_count": 4, "total_spent": "1250.00", "last_order_at": "2026-08-11T10:00:00.000Z", "returned_count": 1 } } ``` ## Get a customer by id, with their addresses `GET /ext/v1/customers/{id}` · Requires scope `customers:read` The customer plus every saved address, default address first. Add `?expand=stats` for their order history (order count, total spent, last order, returns) — that needs the `orders:read` scope as well, and `stats` is simply omitted for a key without it. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The customer id. | ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Restrict the lookup to one store. Breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | | `expand` | `string[]` | No | Include `stats` (order count, total spent, last order, returns). Needs the `orders:read` scope too; omitted from the response when the key lacks it. One of: `stats`. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/customers/REPLACE_WITH_ID?expand=stats" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/customers/REPLACE_WITH_ID"); Object.entries({ expand: "stats", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/customers/REPLACE_WITH_ID", params={ "expand": "stats", }, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The customer. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Customer not found. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "full_name": "Rafiqul Islam", "email": null, "phone": "01712345678", "status": "active", "type": "manual", "gender": null, "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z", "addresses": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "label": "Home", "recipient_name": "Rafiqul Islam", "recipient_phone": "01712345678", "address_line": "House 12, Road 5", "city": "Dhaka", "area": "Dhanmondi", "postal_code": "1209", "is_default": true, "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ], "stats": { "orders_count": 4, "total_spent": "1250.00", "last_order_at": "2026-08-11T10:00:00.000Z", "returned_count": 1 } } ``` ## Update a customer `PATCH /ext/v1/customers/{id}` · Requires scope `customers:write` Same rules as creation (canonical phone, per-store uniqueness). The seeded walk-in system record cannot be edited through any surface, this one included. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The customer id. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `full_name` | `string` | No | — | | `phone` | `string` | No | — | | `email` | `string` | No | — | | `status` | `string` | No | — One of: `active`, `inactive`, `blocked`. | | `gender` | `string` | No | — One of: `male`, `female`. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X PATCH "https://api.shopdeskbd.com/ext/v1/customers/REPLACE_WITH_ID" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \ -H "Content-Type: application/json" \ -d '{ "email": "rafiq@example.com" }' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/customers/REPLACE_WITH_ID"); const response = await fetch(url, { method: "PATCH", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", "Content-Type": "application/json", }, body: JSON.stringify({ "email": "rafiq@example.com" }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "email": "rafiq@example.com", } response = requests.patch( "https://api.shopdeskbd.com/ext/v1/customers/REPLACE_WITH_ID", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 'rafiq@example.com', ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/customers/REPLACE_WITH_ID'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The updated customer, addresses included. - `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Customer not found. - `409` — A customer with this phone or email already exists; or a request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "full_name": "Rafiqul Islam", "email": null, "phone": "01712345678", "status": "active", "type": "manual", "gender": null, "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z", "addresses": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "label": "Home", "recipient_name": "Rafiqul Islam", "recipient_phone": "01712345678", "address_line": "House 12, Road 5", "city": "Dhaka", "area": "Dhanmondi", "postal_code": "1209", "is_default": true, "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ], "stats": { "orders_count": 4, "total_spent": "1250.00", "last_order_at": "2026-08-11T10:00:00.000Z", "returned_count": 1 } } ``` --- # Inventory Part of the [Shopdesk Commerce API reference](https://www.shopdeskbd.com/developers/reference.md). Paths below are shown exactly as the server routes them — prefix them with `https://api.shopdeskbd.com`. Authenticate every request with `Authorization: Bearer shopdesk_sk_YOUR_API_KEY`; each operation names the scope its key must hold. Every failure uses the one error envelope — see [Errors](https://www.shopdeskbd.com/developers/errors.md). ## Adjust stock at a location `POST /ext/v1/inventory/adjustments` · Requires scope `inventory:write` The staff adjustment path verbatim: row-locked, ledgered, activity-logged. Address the variant by `variant_id` OR `sku` — a stock sync holding SKUs needs no catalog lookup first. A `reason` is always required here; on-hand can never go below zero or below the reserved quantity; `damage` must remove stock; `return` can only add stock; `purchase` requires a `unit_cost` and folds into the moving-average cost, and `unit_cost` is refused on every other type. ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | Yes | REQUIRED. A unique value per logical request (a UUID is ideal); reuse it verbatim when retrying. A retry replays the original response with `Idempotent-Replayed: true` instead of running twice. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `location_id` | `uuid` | Yes | Location (warehouse) to adjust. | | `variant_id` | `uuid` | No | The variant being adjusted. Provide this OR `sku`. | | `sku` | `string` | No | Resolve the variant by SKU instead of id — unique within the store being written to. Ignored when `variant_id` is also given. | | `quantity_change` | `number` | No | Signed change to on-hand. Positive adds stock, negative removes; never zero. Cannot take on-hand below zero or below reservations — the same rules staff adjustments obey. Provide this OR `set_on_hand`. | | `set_on_hand` | `number` | No | The absolute on-hand this location should END AT — what a stock-take or a spreadsheet actually says. The delta is computed server-side from the quantity read under the row lock, so nothing can slip in between the read and the write. Provide this OR `quantity_change`. A target that already matches is a successful no-op, not an error. | | `expected_on_hand` | `number` | No | Optimistic-concurrency guard: the on-hand you believe is there. Compared under the row lock; if it no longer matches, the write is refused with 409 `CONFLICT` carrying the actual value, instead of applying against a number that moved. Works with either mode. | | `type` | `string` (default `adjustment`) | No | Ledger category. `purchase` adds stock at a required `unit_cost`; `damage` must remove stock; `return` can only add stock; default `adjustment`. A decrease filed as `adjustment` or `damage` is booked as a loss on the P&L at the current average cost. One of: `purchase`, `return`, `adjustment`, `damage`. | | `reason` | `string` | Yes | Stored on the ledger entry. Required. | | `unit_cost` | `number` | No | Per-unit cost of the moved units. REQUIRED for `purchase` (stock cannot enter at no cost) and refused on any other type, which moves units at the variant's current average cost. Write-only — cost never appears in any response (Q6). | | `low_stock_threshold` | `number` | No | Optionally update the low-stock threshold for this level. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X POST "https://api.shopdeskbd.com/ext/v1/inventory/adjustments" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \ -H "Content-Type: application/json" \ -d '{ "location_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "variant_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "quantity_change": -5, "reason": "Cycle count correction — shelf B4." }' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/inventory/adjustments"); const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", "Content-Type": "application/json", }, body: JSON.stringify({ "location_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "variant_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "quantity_change": -5, "reason": "Cycle count correction — shelf B4." }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "location_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "variant_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "quantity_change": -5, "reason": "Cycle count correction — shelf B4.", } response = requests.post( "https://api.shopdeskbd.com/ext/v1/inventory/adjustments", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php '5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001', 'variant_id' => '5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001', 'quantity_change' => -5, 'reason' => 'Cycle count correction — shelf B4.', ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/inventory/adjustments'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `201` — The adjusted stock level, in the same shape as `GET /v1/inventory/levels` rows. - `400` — The required `Idempotency-Key` header is missing, empty, or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Unknown location, or no variant with that id/SKU in the store. - `409` — `expected_on_hand` no longer matches what is on hand; `fields` carries the actual quantity so the retry needs no extra read; or a request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "location_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "variant_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "sku": "TSHIRT-RED-M", "on_hand": 10, "reserved": 7, "available": 3, "low_stock_threshold": 5, "is_low_stock": false, "status": "active", "location": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Main Warehouse", "code": "WH-01" }, "variant": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "sku": "TSHIRT-RED-M", "barcode": null, "name": "Red / M", "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_name": "Cotton T-shirt" }, "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ``` ## List stock levels (one row per variant per location) `GET /ext/v1/inventory/levels` · Requires scope `inventory:read` Each row carries `on_hand`, `reserved` and the `available` difference — sell against `available`, not `on_hand`. ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | `number` (default `25`, max 100) | No | Page size. | | `starting_after` | `string` | No | Return the page AFTER this object id (the previous page's `next_cursor`). | | `ending_before` | `string` | No | Return the page BEFORE this object id. | | `sort` | `string` (default `-created_at`) | No | Newest first by default. Only `created_at` is sortable — a cursor over a mutable key (like `updated_at`) cannot page reliably. Use `updated_after` to sync changes. One of: `created_at`, `-created_at`. | | `store_id` | `string` | No | Restrict to one store. Required breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | | `warehouse_id` | `string` | No | Only levels at this location. | | `variant_id` | `string` | No | Only levels for this variant. | | `low_stock` | `boolean` | No | Only levels at or below the variant's low-stock threshold — the reorder query, answered server-side so an integrator does not page the whole catalog to find twelve rows. | | `missing_only` | `boolean` | No | Only variants that have NO stock level at `warehouse_id` yet — never stocked there, not stocked at zero. The migration question ("what is still left to set?"), answered without paging products and levels separately and diffing them. Requires `warehouse_id`; cannot be combined with `low_stock`. Rows come back in the level shape with `id`, `created_at` and `updated_at` null, zero quantities, and `status: "stock_not_set"`. Archived products are excluded. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/inventory/levels?limit=25" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/inventory/levels"); Object.entries({ limit: "25", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/inventory/levels", params={ "limit": "25", }, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` ### Responses - `200` — A cursor page of stock levels. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "data": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "location_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "variant_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "sku": "TSHIRT-RED-M", "on_hand": 10, "reserved": 7, "available": 3, "low_stock_threshold": 5, "is_low_stock": false, "status": "active", "location": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Main Warehouse", "code": "WH-01" }, "variant": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "sku": "TSHIRT-RED-M", "barcode": null, "name": "Red / M", "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_name": "Cotton T-shirt" }, "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ], "meta": { "has_more": true, "next_cursor": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001" } } ``` ## List stock movements (the append-only ledger behind the levels) `GET /ext/v1/inventory/movements` · Requires scope `inventory:read` Every quantity change with before/after readings — reservations, sales, adjustments, transfers. Costs never appear here. ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | `number` (default `25`, max 100) | No | Page size. | | `starting_after` | `string` | No | Return the page AFTER this object id (the previous page's `next_cursor`). | | `ending_before` | `string` | No | Return the page BEFORE this object id. | | `sort` | `string` (default `-created_at`) | No | Newest first by default. Only `created_at` is sortable — a cursor over a mutable key (like `updated_at`) cannot page reliably. Use `updated_after` to sync changes. One of: `created_at`, `-created_at`. | | `store_id` | `string` | No | Restrict to one store. Required breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | | `warehouse_id` | `string` | No | Only movements at this location. | | `variant_id` | `string` | No | Only movements for this variant. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/inventory/movements?limit=25" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/inventory/movements"); Object.entries({ limit: "25", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/inventory/movements", params={ "limit": "25", }, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` ### Responses - `200` — A cursor page of movements. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "data": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "location_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "variant_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "type": "adjustment", "quantity": -2, "quantity_before": 12, "quantity_after": 10, "reference_type": "manual", "reference_id": null, "note": "Damaged in transit", "created_at": "2026-08-11T10:00:00.000Z" } ], "meta": { "has_more": true, "next_cursor": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001" } } ``` --- # Locations Part of the [Shopdesk Commerce API reference](https://www.shopdeskbd.com/developers/reference.md). Paths below are shown exactly as the server routes them — prefix them with `https://api.shopdeskbd.com`. Authenticate every request with `Authorization: Bearer shopdesk_sk_YOUR_API_KEY`; each operation names the scope its key must hold. Every failure uses the one error envelope — see [Errors](https://www.shopdeskbd.com/developers/errors.md). ## List locations `GET /ext/v1/locations` · Requires scope `locations:read` Locations belong to the COMPANY and are shared across its stores, so they carry `company_id` rather than `store_id` and `store_id=` does not narrow them. ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | `number` (default `25`, max 100) | No | Page size. | | `starting_after` | `string` | No | Return the page AFTER this object id (the previous page's `next_cursor`). | | `ending_before` | `string` | No | Return the page BEFORE this object id. | | `sort` | `string` (default `-created_at`) | No | Newest first by default. Only `created_at` is sortable — a cursor over a mutable key (like `updated_at`) cannot page reliably. Use `updated_after` to sync changes. One of: `created_at`, `-created_at`. | | `store_id` | `string` | No | Restrict to one store. Required breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/locations?limit=25" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/locations"); Object.entries({ limit: "25", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/locations", params={ "limit": "25", }, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` ### Responses - `200` — A cursor page of locations. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "data": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "company_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Main Warehouse", "code": "WH-01", "address": null, "status": "active", "is_default": true, "latitude": "23.8103", "longitude": "90.4125", "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ], "meta": { "has_more": true, "next_cursor": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001" } } ``` ## Get a location by id `GET /ext/v1/locations/{id}` · Requires scope `locations:read` One location (warehouse) record. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The location id. | ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Restrict the lookup to one store. Breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/locations/REPLACE_WITH_ID" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/locations/REPLACE_WITH_ID"); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/locations/REPLACE_WITH_ID", headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The location. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Location not found. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "company_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Main Warehouse", "code": "WH-01", "address": null, "status": "active", "is_default": true, "latitude": "23.8103", "longitude": "90.4125", "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ``` --- # Channels Part of the [Shopdesk Commerce API reference](https://www.shopdeskbd.com/developers/reference.md). Paths below are shown exactly as the server routes them — prefix them with `https://api.shopdeskbd.com`. Authenticate every request with `Authorization: Bearer shopdesk_sk_YOUR_API_KEY`; each operation names the scope its key must hold. Every failure uses the one error envelope — see [Errors](https://www.shopdeskbd.com/developers/errors.md). ## List sales channels `GET /ext/v1/channels` · Requires scope `channels:read` The `slug` values here are what `GET /v1/orders?channel=` filters on and what `POST /v1/orders` accepts as `channel`. ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | `number` (default `25`, max 100) | No | Page size. | | `starting_after` | `string` | No | Return the page AFTER this object id (the previous page's `next_cursor`). | | `ending_before` | `string` | No | Return the page BEFORE this object id. | | `sort` | `string` (default `-created_at`) | No | Newest first by default. Only `created_at` is sortable — a cursor over a mutable key (like `updated_at`) cannot page reliably. Use `updated_after` to sync changes. One of: `created_at`, `-created_at`. | | `store_id` | `string` | No | Restrict to one store. Required breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/channels?limit=25" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/channels"); Object.entries({ limit: "25", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/channels", params={ "limit": "25", }, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` ### Responses - `200` — A cursor page of channels. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "data": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "type": "facebook", "name": "Facebook", "slug": "facebook", "status": "active", "created_at": "2026-08-11T10:00:00.000Z", "updated_at": "2026-08-11T10:00:00.000Z" } ], "meta": { "has_more": true, "next_cursor": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001" } } ``` --- # Store Part of the [Shopdesk Commerce API reference](https://www.shopdeskbd.com/developers/reference.md). Paths below are shown exactly as the server routes them — prefix them with `https://api.shopdeskbd.com`. Authenticate every request with `Authorization: Bearer shopdesk_sk_YOUR_API_KEY`; each operation names the scope its key must hold. Every failure uses the one error envelope — see [Errors](https://www.shopdeskbd.com/developers/errors.md). ## Describe the calling API key `GET /ext/v1/me` The key (id, label, type, prefix, scopes, expiry), its company, the live stores it can reach and its per-minute rate limits. Needs no scope — any valid key may ask about itself — and returns nothing secret. ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/me" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/me"); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/me", headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The key. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "key": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Call-centre integration", "type": "store", "prefix": "shopdesk_sk_7f3kq9p2", "last4": "a1b2", "scopes": [ "orders:read", "store:read" ], "expires_at": "2026-08-11T10:00:00.000Z", "created_at": "2026-08-11T10:00:00.000Z" }, "company": { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Nukta Fashion" }, "stores": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Nukta Fashion" } ], "rate_limits": { "reads_per_minute": 120, "writes_per_minute": 30 } } ``` ## Get the store profile `GET /ext/v1/store` · Requires scope `store:read` Name, primary domain, currency, business timezone, public contact details, storefront localization and the advance-payment plans offered at checkout — what the storefront already shows shoppers. Company-scoped keys must pass `store_id`. ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Restrict the lookup to one store. Breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/store" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/store"); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/store", headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The store. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — The key's scopes do not cover this endpoint. - `404` — Store not found (a `store_id` outside this key). - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "name": "Nukta Fashion", "slug": "nukta-fashion", "domain": "shop.example.com", "currency": "BDT", "timezone": "Asia/Dhaka", "contact": { "phone": "01712345678", "email": "hello@example.com", "whatsapp": "01712345678", "address": "House 12, Road 5, Dhaka", "map_url": null }, "localization": { "currency_code": "BDT", "currency_symbol": "৳", "symbol_position": "before", "timezone": "Asia/Dhaka", "date_format": "DD MMM YYYY" }, "advance_payment_plans": [ { "percent": 20, "note": null } ] } ``` --- # Reports Part of the [Shopdesk Commerce API reference](https://www.shopdeskbd.com/developers/reference.md). Paths below are shown exactly as the server routes them — prefix them with `https://api.shopdeskbd.com`. Authenticate every request with `Authorization: Bearer shopdesk_sk_YOUR_API_KEY`; each operation names the scope its key must hold. Every failure uses the one error envelope — see [Errors](https://www.shopdeskbd.com/developers/errors.md). ## Get a sales summary for a window `GET /ext/v1/reports/sales-summary` · Requires scope `reports:read` Orders, gross and net sales, net revenue and average order value for the orders DELIVERED in the window, the money received (payments settled in the window) and the cash on delivery still to collect (as of today) — each figure the same number the Reports pages in the dashboard show, with its basis stated in `basis`. Available on every plan with API access; `compare=previous_period` needs Advanced Reports. The window spans at most 366 days; answers are cached for 5 minutes. A call counts as 5 reads against the key's read limit. Company-scoped keys must pass `store_id`. ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `period` | `string` | No | A named window instead of `from`/`to`, resolved on the store's own calendar: `today`, `yesterday`, `last_7_days` (today and the six days before), `last_30_days`, `this_month` (the 1st to today) or `last_month`. The response echoes the concrete `from` and `to`. Not combinable with `from` or `to`. One of: `today`, `yesterday`, `last_7_days`, `last_30_days`, `this_month`, `last_month`. | | `from` | `string` | No | First day of the window (inclusive, a store-local calendar day). Defaults to the first day of the current month. | | `to` | `string` | No | Last day of the window (inclusive). Defaults to today. The window may span at most 366 days. | | `store_id` | `uuid` | No | The store to report on. REQUIRED with a company-scoped key (a report answers for one store); with a store-scoped key it may only repeat the key's own store. | | `channel` | `string` | No | Only this sales channel (its SLUG, from `GET /channels`). With a channel filter, `money_received` and `cod_outstanding` are null: payments are not split by channel. | | `compare` | `string` | No | `previous_period` adds `previous_period`: the same figures for the window of equal length just before. Needs a plan with Advanced Reports. One of: `previous_period`. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/reports/sales-summary" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/reports/sales-summary"); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/reports/sales-summary", headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The summary. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — `FORBIDDEN_SCOPE`: the key's scopes do not cover this endpoint. `PLAN_FEATURE_REQUIRED`: `compare` was asked for on a plan without Advanced Reports. - `404` — Store not found (a `store_id` outside this key). - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "from": "2026-09-01", "to": "2026-09-30", "orders": 42, "gross_sales": "1250.00", "net_sales": "1250.00", "net_revenue": "1250.00", "average_order_value": "1250.00", "money_received": "1250.00", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "time_zone": "Asia/Dhaka", "channel": null, "basis": { "sales": "delivered", "money_received": "settled", "cod_outstanding": "as_of_today" }, "cod_outstanding": { "as_of": "2026-09-29", "amount": "1250.00", "orders": 11, "not_shipped": { "orders": 3, "amount": "1250.00" }, "with_courier": { "orders": 3, "amount": "1250.00" }, "delivered_unpaid": { "orders": 3, "amount": "1250.00" } }, "previous_period": { "from": "2026-09-01", "to": "2026-09-30", "orders": 42, "gross_sales": "1250.00", "net_sales": "1250.00", "net_revenue": "1250.00", "average_order_value": "1250.00", "money_received": "1250.00" } } ``` ## List the top products for a window `GET /ext/v1/reports/top-products` · Requires scope `reports:read` Product variants ranked by the revenue of units delivered in the window and KEPT (returns netted) — the Products report ranking in the dashboard, with revenue and units only: no cost, profit or margin is ever returned. Needs a plan with Advanced Reports. The window spans at most 366 days; answers are cached for 5 minutes. A call counts as 5 reads against the key's read limit. Company-scoped keys must pass `store_id`. ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `period` | `string` | No | A named window instead of `from`/`to`, resolved on the store's own calendar: `today`, `yesterday`, `last_7_days` (today and the six days before), `last_30_days`, `this_month` (the 1st to today) or `last_month`. The response echoes the concrete `from` and `to`. Not combinable with `from` or `to`. One of: `today`, `yesterday`, `last_7_days`, `last_30_days`, `this_month`, `last_month`. | | `from` | `string` | No | First day of the window (inclusive, a store-local calendar day). Defaults to the first day of the current month. | | `to` | `string` | No | Last day of the window (inclusive). Defaults to today. The window may span at most 366 days. | | `store_id` | `uuid` | No | The store to report on. REQUIRED with a company-scoped key (a report answers for one store); with a store-scoped key it may only repeat the key's own store. | | `limit` | `number` (default `10`, max 50) | No | How many products to rank. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/reports/top-products?limit=25" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/reports/top-products"); Object.entries({ limit: "25", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/reports/top-products", params={ "limit": "25", }, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows = page["data"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; ``` ### Responses - `200` — The ranking. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — `FORBIDDEN_SCOPE`: the key's scopes do not cover this endpoint. `PLAN_FEATURE_REQUIRED`: the plan does not include Advanced Reports. - `404` — Store not found (a `store_id` outside this key). - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "from": "2026-09-01", "to": "2026-09-30", "time_zone": "Asia/Dhaka", "basis": { "revenue": "delivered_kept" }, "data": [ { "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "variant_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "product_name": "Classic Cotton T-Shirt", "variant_name": "Red / M", "sku": "TSHIRT-RED-M", "units_sold": 18, "units_returned": 2, "revenue": "1250.00" } ] } ``` --- # Videos Part of the [Shopdesk Commerce API reference](https://www.shopdeskbd.com/developers/reference.md). Paths below are shown exactly as the server routes them — prefix them with `https://api.shopdeskbd.com`. Authenticate every request with `Authorization: Bearer shopdesk_sk_YOUR_API_KEY`; each operation names the scope its key must hold. Every failure uses the one error envelope — see [Errors](https://www.shopdeskbd.com/developers/errors.md). ## Place a video on a product `POST /ext/v1/products/{id}/videos` · Requires scope `videos:write` Puts one of your videos (from `POST /videos` or `POST /videos/import`) on the product: `gallery_slot` says how many photos come before it (default 1, right after the main photo), `sort_order` orders it among the product’s videos, `option_value_id` shows it only for one option value such as Colour: Red (a value one of the product’s active variants uses), and `title` names it. A video may be placed while it is still uploading or verifying; the product shows it (`GET /products/{id}` → `videos[]`, the storefront) once it is `ready`, and a `product.updated` webhook is sent then. Placing a video the product already shows moves it instead. Nothing else on the product changes. The plan decides how many videos one product may show. A key made by rotating another may place that key’s videos too. Company-scoped keys must send `store_id`. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The product id. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `video_id` | `uuid` | Yes | A video THIS API key started (`POST /videos` or `POST /videos/import`). It may still be uploading or verifying: the product shows it once it is `ready`. | | `gallery_slot` | `number` (default `1`, max 30) | No | How many of the product's photos come before the video: 0 = before every photo, 1 (default) = right after the main photo. Clamped to the number of photos. | | `sort_order` | `number` (default `0`, max 99) | No | Order among the product's videos; breaks ties within one slot. | | `option_value_id` | `uuid` | No | Show the video for one option value (e.g. Colour: Red — the value id from `GET /attributes`, or a variant’s `options[].value_id`). A value one of the product's active variants uses; null or absent = every variant. | | `title` | `string` | No | Its accessible name. Empty or null falls back to the product name. | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X POST "https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/videos" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \ -H "Content-Type: application/json" \ -d '{ "video_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "gallery_slot": 1, "option_value_id": "2c7e9f41-5b3a-4d8e-9f10-6a2b3c4d5e6f", "title": "Red saree drape" }' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/videos"); const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", "Content-Type": "application/json", }, body: JSON.stringify({ "video_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "gallery_slot": 1, "option_value_id": "2c7e9f41-5b3a-4d8e-9f10-6a2b3c4d5e6f", "title": "Red saree drape" }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "video_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "gallery_slot": 1, "option_value_id": "2c7e9f41-5b3a-4d8e-9f10-6a2b3c4d5e6f", "title": "Red saree drape", } response = requests.post( "https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/videos", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php '5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001', 'gallery_slot' => 1, 'option_value_id' => '2c7e9f41-5b3a-4d8e-9f10-6a2b3c4d5e6f', 'title' => 'Red saree drape', ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/videos'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The placement, as saved. - `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — `FORBIDDEN_SCOPE`: the key's scopes do not cover this endpoint. `PLAN_FEATURE_REQUIRED`: the plan does not include product videos. `PLAN_LIMIT_REACHED`: the product already shows its plan's number of videos (or a released video would not fit the plan’s video storage again). `VIDEO_UPLOADS_DISABLED`: video uploads are switched off on this platform — no video can be placed (taking one off still works). - `404` — Product not found. - `409` — A request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `422` — Validation failed — `error.fields` names the field: `video_id` names no video this key started in this store, or one that was rejected or whose upload ended; `option_value_id` is used by none of the product’s active variants. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "video_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "status": "verifying", "gallery_slot": 1, "sort_order": 0, "option_value_id": null, "title": "Red saree drape" } ``` ## Take a video off a product `DELETE /ext/v1/products/{id}/videos/{videoId}` · Requires scope `videos:write` The product stops showing the video; nothing else on it changes. The video itself stays — delete your own upload with `DELETE /videos/{id}` once no product shows it. Any video the product shows may be taken off, whoever uploaded it — even while video uploads are switched off. A company-scoped key names the store with the `store_id` query parameter, since a DELETE carries no body. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The product id. | | `videoId` | `uuid` | Yes | The video id, as listed in the product’s `videos[]`. | ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Restrict the lookup to one store. Breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X DELETE "https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/videos/REPLACE_WITH_ID" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/videos/REPLACE_WITH_ID"); const response = await fetch(url, { method: "DELETE", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests response = requests.delete( "https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/videos/REPLACE_WITH_ID", headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47'], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The video is off the product. - `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — `FORBIDDEN_SCOPE`: the key's scopes do not cover this endpoint. `PLAN_FEATURE_REQUIRED`: the plan does not include product videos. - `404` — Product not found, or the product does not show the video. - `409` — A request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "video_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "removed": true } ``` ## List your video uploads `GET /ext/v1/videos` · Requires scope `videos:write` The uploads THIS API key started — and, for a key made by rotating another, the uploads of the keys it replaced — newest first, in every status unless `status` narrows it. Uploads made in the dashboard or by another key are not listed. An unfinished upload past its expiry is left out until it is recorded as `aborted`. `uploaded_parts` is empty in a list — read one upload for its parts. ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | `number` (default `25`, max 100) | No | Page size. | | `starting_after` | `string` | No | Return the page AFTER this object id (the previous page's `next_cursor`). | | `ending_before` | `string` | No | Return the page BEFORE this object id. | | `sort` | `string` (default `-created_at`) | No | Newest first by default. Only `created_at` is sortable — a cursor over a mutable key (like `updated_at`) cannot page reliably. Use `updated_after` to sync changes. One of: `created_at`, `-created_at`. | | `store_id` | `string` | No | Restrict to one store. Required breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | | `status` | `string` | No | Only uploads in this status. Default: every status. One of: `uploading`, `verifying`, `ready`, `rejected`, `aborted`. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/videos?limit=25" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/videos"); Object.entries({ limit: "25", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/videos", params={ "limit": "25", }, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` ### Responses - `200` — A page of uploads. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — `FORBIDDEN_SCOPE`: the key's scopes do not cover this endpoint. `PLAN_FEATURE_REQUIRED`: the plan does not include product videos. `VIDEO_UPLOADS_DISABLED`: video uploads are switched off for now. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. **Example 200 response** ```json { "data": [ { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "status": "uploading", "source": "upload", "file_name": "red-saree-drape.mp4", "size_bytes": 48000000, "part_size": 8388608, "part_count": 6, "checksum_algorithm": "SHA256", "uploaded_parts": [ { "part_number": 1, "size": 8388608, "checksum_sha256": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=" } ], "uploaded_bytes": 16777216, "expires_at": "2026-08-11T10:00:00.000Z", "product_ids": [], "url": "https://cdn.example.com/companies/c/videos/v/video.mp4", "poster_url": null, "facts": { "duration_ms": 24000, "width": 1080, "height": 1920, "rotation": 0, "codecs": "avc1.640028, mp4a.40.2", "has_audio": true, "frame_rate": 29.97, "bitrate_kbps": 4200, "faststart": true }, "warnings": [ "NOT_FASTSTART" ], "failure": { "code": "VIDEO_HEVC", "message": "This video is HEVC (H.265).", "fix": "Export as H.264." }, "created_at": "2026-08-11T10:00:00.000Z", "completed_at": "2026-08-11T10:00:00.000Z", "verified_at": "2026-08-11T10:00:00.000Z" } ], "meta": { "has_more": true, "next_cursor": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001" } } ``` ## Start a video upload `POST /ext/v1/videos` · Requires scope `videos:write` Opens an upload session for one H.264 MP4 of at most 200 MB (3 s to 5 min; AAC, MP3, Opus or no audio) and reserves its size against the plan's video storage. Then: ask `POST /videos/{id}/part-urls` for part URLs, `PUT` each part (exactly `part_size` bytes, the last one the rest) to its URL with the headers given, and call `POST /videos/{id}/complete`. `Idempotency-Key` is REQUIRED and is also the resume handle. A retry with the same key within 24 hours replays the first answer as it was (`Idempotent-Replayed: true`, `resumed: false`, its `uploaded_parts` as they were then); after that, the same key returns the same session with `resumed: true`. Either way the session is the same — read `GET /videos/{id}` for the parts storage holds now. Reuse a key only to retry the same file; once that session has ended (`aborted`), start over with a new key. A key made by rotating another keeps its uploads, and the same `Idempotency-Key` still names the same session. A finished video no product places within 48 hours is released (it stops counting against video storage) and removed 7 days later. At most 50 uploads start per store per day. Company-scoped keys must send `store_id`. ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | Yes | REQUIRED. A unique value per logical request (a UUID is ideal); reuse it verbatim when retrying. A retry replays the original response with `Idempotent-Replayed: true` instead of running twice. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `file_name` | `string` | Yes | For display only; never part of a storage key. | | `size_bytes` | `number` (max 200000000) | Yes | The exact size of the file in bytes — at most 200,000,000 (200 MB). It is reserved against the plan's video storage until the upload finishes or is cancelled. | | `part_size` | `number` | Yes | Bytes per part: 5, 8 or 16 MiB. Every part but the last is exactly this long; the answer says how many parts there are. One of: `5242880`, `8388608`, `16777216`. | | `fingerprint` | `string` | No | Optional. Hex sha256 of `:` followed by the sha256 of the file's first MiB and of its last MiB. When sent, starting again with the same file (under a new `Idempotency-Key`) resumes the unfinished upload instead of opening another. | | `product_id` | `uuid` | No | The product the video is meant for, for an early check of its video limit. It does not place the video. | | `client_probe` | `object` | No | Optional. A client-side check of the file in the dashboard's `ClientProbeReport` shape (at most 32 KB as JSON); a file it already rejects is refused before any byte is sent. The server reads and verifies every file itself either way. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X POST "https://api.shopdeskbd.com/ext/v1/videos" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \ -H "Content-Type: application/json" \ -d '{ "file_name": "red-saree-drape.mp4", "size_bytes": 48000000, "part_size": 8388608 }' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/videos"); const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", "Content-Type": "application/json", }, body: JSON.stringify({ "file_name": "red-saree-drape.mp4", "size_bytes": 48000000, "part_size": 8388608 }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "file_name": "red-saree-drape.mp4", "size_bytes": 48000000, "part_size": 8388608, } response = requests.post( "https://api.shopdeskbd.com/ext/v1/videos", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 'red-saree-drape.mp4', 'size_bytes' => 48000000, 'part_size' => 8388608, ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/videos'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `201` — The upload session, with its part plan. - `400` — The required `Idempotency-Key` header is missing, empty, or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — `FORBIDDEN_SCOPE`: the key's scopes do not cover this endpoint. `PLAN_FEATURE_REQUIRED`: the plan does not include product videos. `VIDEO_UPLOADS_DISABLED`: video uploads are switched off for now. `PLAN_LIMIT_REACHED`: the plan's video storage is full (or `product_id`’s product already has its plan’s number of videos). - `404` — `product_id` names no product of this store. - `409` — `TOO_MANY_OPEN_UPLOADS`: this key already has 4 unfinished uploads, or the store 6 — complete or delete one first; or a request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `422` — Validation failed — `error.fields` names the field. With `client_probe`: A file that breaks a rule gets its own code — `VIDEO_NOT_MP4`, `VIDEO_HEVC`, `VIDEO_CODEC_UNSUPPORTED`, `VIDEO_PROFILE_UNSUPPORTED`, `VIDEO_AUDIO_UNSUPPORTED`, `VIDEO_FRAGMENTED`, `VIDEO_NO_VIDEO_TRACK`, `VIDEO_TOO_LARGE`, `VIDEO_TOO_LONG`, `VIDEO_TOO_SHORT`, `VIDEO_LOW_RES`, `VIDEO_FRAME_TOO_BIG`, `VIDEO_ROTATION_UNSUPPORTED` or `VIDEO_CORRUPT` — with how to fix it in `error.fields.video`. - `429` — Rate limit exceeded for this key, or the store started 50 uploads today (`RATE_LIMITED`, `Retry-After` to the next UTC midnight). - `502` — `STORAGE_UNAVAILABLE`: video storage refused or was unreachable — retry in a moment. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "status": "uploading", "source": "upload", "file_name": "red-saree-drape.mp4", "size_bytes": 48000000, "part_size": 8388608, "part_count": 6, "checksum_algorithm": "SHA256", "uploaded_parts": [ { "part_number": 1, "size": 8388608, "checksum_sha256": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=" } ], "uploaded_bytes": 16777216, "expires_at": "2026-08-11T10:00:00.000Z", "product_ids": [], "url": "https://cdn.example.com/companies/c/videos/v/video.mp4", "poster_url": null, "facts": { "duration_ms": 24000, "width": 1080, "height": 1920, "rotation": 0, "codecs": "avc1.640028, mp4a.40.2", "has_audio": true, "frame_rate": 29.97, "bitrate_kbps": 4200, "faststart": true }, "warnings": [ "NOT_FASTSTART" ], "failure": { "code": "VIDEO_HEVC", "message": "This video is HEVC (H.265).", "fix": "Export as H.264." }, "created_at": "2026-08-11T10:00:00.000Z", "completed_at": "2026-08-11T10:00:00.000Z", "verified_at": "2026-08-11T10:00:00.000Z", "resumed": false } ``` ## Get a video upload `GET /ext/v1/videos/{id}` · Requires scope `videos:write` The upload and its status. While `uploading`, `uploaded_parts` lists the parts storage already holds — resume by sending the rest. Poll this after a `202` from complete until `ready` or `rejected`. A finished video no product places within 48 hours is released and, 7 days later, removed — then this answers `404`. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The video id `POST /videos` returned. | ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Restrict the lookup to one store. Breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID"); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID", headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The upload. - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — `FORBIDDEN_SCOPE`: the key's scopes do not cover this endpoint. `PLAN_FEATURE_REQUIRED`: the plan does not include product videos. `VIDEO_UPLOADS_DISABLED`: video uploads are switched off for now. - `404` — No video with this id was started by this API key (another key’s or a dashboard upload is not visible here). - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. - `502` — `STORAGE_UNAVAILABLE`: video storage refused or was unreachable — retry in a moment. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "status": "uploading", "source": "upload", "file_name": "red-saree-drape.mp4", "size_bytes": 48000000, "part_size": 8388608, "part_count": 6, "checksum_algorithm": "SHA256", "uploaded_parts": [ { "part_number": 1, "size": 8388608, "checksum_sha256": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=" } ], "uploaded_bytes": 16777216, "expires_at": "2026-08-11T10:00:00.000Z", "product_ids": [], "url": "https://cdn.example.com/companies/c/videos/v/video.mp4", "poster_url": null, "facts": { "duration_ms": 24000, "width": 1080, "height": 1920, "rotation": 0, "codecs": "avc1.640028, mp4a.40.2", "has_audio": true, "frame_rate": 29.97, "bitrate_kbps": 4200, "faststart": true }, "warnings": [ "NOT_FASTSTART" ], "failure": { "code": "VIDEO_HEVC", "message": "This video is HEVC (H.265).", "fix": "Export as H.264." }, "created_at": "2026-08-11T10:00:00.000Z", "completed_at": "2026-08-11T10:00:00.000Z", "verified_at": "2026-08-11T10:00:00.000Z" } ``` ## Delete a video upload `DELETE /ext/v1/videos/{id}` · Requires scope `videos:write` Cancels an unfinished upload (its reserved storage is released at once), or releases a finished video no live product shows: it stops counting against the plan's video storage now and is removed from storage after 7 days. A video only archived products show is taken off them. Deleting again changes nothing. A company-scoped key may narrow with the `store_id` query parameter. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The video id `POST /videos` returned. | ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Restrict the lookup to one store. Breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X DELETE "https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID"); const response = await fetch(url, { method: "DELETE", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests response = requests.delete( "https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID", headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47'], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — Deletion confirmed. - `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — `FORBIDDEN_SCOPE`: the key's scopes do not cover this endpoint. `PLAN_FEATURE_REQUIRED`: the plan does not include product videos. `VIDEO_UPLOADS_DISABLED`: video uploads are switched off for now. - `404` — No video with this id was started by this API key (another key’s or a dashboard upload is not visible here). - `409` — `VIDEO_IN_USE`: a live product shows the video — take it off the product first; `UPLOAD_VERIFYING`: the video is still being checked; or a request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. - `502` — `STORAGE_UNAVAILABLE`: video storage refused or was unreachable — retry in a moment. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "deleted": true } ``` ## Complete a video upload `POST /ext/v1/videos/{id}/complete` · Requires scope `videos:write` Completes the file from the parts storage holds, then the server reads and checks it. `200` with `status: ready` when the verdict is in; `202` with `status: verifying` while the check is still running — poll `GET /videos/{id}`. Calling it again is safe: a `ready` video answers `200` again, a rejected one `422` again. Send no `Idempotency-Key` on a repeat meant to re-check a video that stayed `verifying`: a replay answers the first result. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The video id `POST /videos` returned. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X POST "https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID/complete" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID/complete"); const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests response = requests.post( "https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID/complete", headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47'], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The video, `ready`. - `202` — The video, still `verifying`. - `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — `FORBIDDEN_SCOPE`: the key's scopes do not cover this endpoint. `PLAN_FEATURE_REQUIRED`: the plan does not include product videos. `VIDEO_UPLOADS_DISABLED`: video uploads are switched off for now. - `404` — No video with this id was started by this API key (another key’s or a dashboard upload is not visible here). - `409` — `PARTS_INCOMPLETE`: some parts are not in storage yet (`error.fields` names them) — upload them and complete again; `FILE_CHANGED`: a stored part does not match its checksum; or a request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `410` — `UPLOAD_NOT_ACTIVE`: the upload expired or was cancelled — start a new one. - `422` — A file that breaks a rule gets its own code — `VIDEO_NOT_MP4`, `VIDEO_HEVC`, `VIDEO_CODEC_UNSUPPORTED`, `VIDEO_PROFILE_UNSUPPORTED`, `VIDEO_AUDIO_UNSUPPORTED`, `VIDEO_FRAGMENTED`, `VIDEO_NO_VIDEO_TRACK`, `VIDEO_TOO_LARGE`, `VIDEO_TOO_LONG`, `VIDEO_TOO_SHORT`, `VIDEO_LOW_RES`, `VIDEO_FRAME_TOO_BIG`, `VIDEO_ROTATION_UNSUPPORTED` or `VIDEO_CORRUPT` — with how to fix it in `error.fields.video`. The file is deleted and its storage released. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. - `502` — `STORAGE_UNAVAILABLE`: video storage refused or was unreachable — retry in a moment. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "status": "uploading", "source": "upload", "file_name": "red-saree-drape.mp4", "size_bytes": 48000000, "part_size": 8388608, "part_count": 6, "checksum_algorithm": "SHA256", "uploaded_parts": [ { "part_number": 1, "size": 8388608, "checksum_sha256": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=" } ], "uploaded_bytes": 16777216, "expires_at": "2026-08-11T10:00:00.000Z", "product_ids": [], "url": "https://cdn.example.com/companies/c/videos/v/video.mp4", "poster_url": null, "facts": { "duration_ms": 24000, "width": 1080, "height": 1920, "rotation": 0, "codecs": "avc1.640028, mp4a.40.2", "has_audio": true, "frame_rate": 29.97, "bitrate_kbps": 4200, "faststart": true }, "warnings": [ "NOT_FASTSTART" ], "failure": { "code": "VIDEO_HEVC", "message": "This video is HEVC (H.265).", "fix": "Export as H.264." }, "created_at": "2026-08-11T10:00:00.000Z", "completed_at": "2026-08-11T10:00:00.000Z", "verified_at": "2026-08-11T10:00:00.000Z" } ``` ## Get upload URLs for video parts `POST /ext/v1/videos/{id}/part-urls` · Requires scope `videos:write` Presigned `PUT` URLs for up to 10 parts, each valid for 60 minutes. Send each part’s base64 SHA-256: it is signed into the URL (storage refuses other bytes) and pinned — the same part asked for again with a different checksum is `409 FILE_CHANGED`. Ask again for fresh URLs whenever they expire — and send no `Idempotency-Key` here: asking again is always safe, and a replayed answer would hand back the first URLs, which may have expired. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The video id `POST /videos` returned. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `parts` | `VideoPartUrlRequestDto[]` | Yes | — | ### `parts[]` fields | Name | Type | Required | Description | | --- | --- | --- | --- | | `part_number` | `number` | Yes | — | | `sha256` | `string` | Yes | Base64 SHA-256 of the part's bytes. It is signed into the URL, and the first checksum sent for a part is pinned: asking again with a different one is `409 FILE_CHANGED`. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X POST "https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID/part-urls" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \ -H "Content-Type: application/json" \ -d '{ "parts": [ { "part_number": 1, "sha256": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=" } ] }' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID/part-urls"); const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", "Content-Type": "application/json", }, body: JSON.stringify({ "parts": [ { "part_number": 1, "sha256": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=" } ] }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data } = await response.json(); ``` **Python** ```python import os import requests payload = { "parts": [ { "part_number": 1, "sha256": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=", }, ], } response = requests.post( "https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID/part-urls", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows = page["data"] ``` **PHP** ```php [ [ 'part_number' => 1, 'sha256' => 'n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=', ], ], ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID/part-urls'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; ``` ### Responses - `200` — One URL per part asked for. - `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — `FORBIDDEN_SCOPE`: the key's scopes do not cover this endpoint. `PLAN_FEATURE_REQUIRED`: the plan does not include product videos. `VIDEO_UPLOADS_DISABLED`: video uploads are switched off for now. - `404` — No video with this id was started by this API key (another key’s or a dashboard upload is not visible here). - `409` — `FILE_CHANGED`: a part was already signed with a different checksum — this is not the same file; start a new upload; or a request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `410` — `UPLOAD_NOT_ACTIVE`: the upload expired, was cancelled or already completed — start a new one. - `422` — Validation failed — `error.fields` maps each offending field to its messages. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. - `502` — `STORAGE_UNAVAILABLE`: video storage refused or was unreachable — retry in a moment. **Example 200 response** ```json { "data": [ { "part_number": 1, "url": "https://media.example.com/companies/c/videos/v/video.mp4?partNumber=1&uploadId=2a7f&X-Amz-Signature=9c1e", "headers": { "x-amz-checksum-sha256": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=" }, "content_length": 8388608, "expires_at": "2026-08-11T10:00:00.000Z" } ] } ``` ## Set a video cover `POST /ext/v1/videos/{id}/poster` · Requires scope `videos:write` Sets the image shown before the video plays; with none, product pages show the product thumbnail. Three sources: **`upload`** — `multipart/form-data` with the image in a `file` part and `source` as a text field; **`product_image`** — JSON `{ "source": "product_image", "image_id": "…" }` with one of a product’s photos (copied beside the video, so deleting the photo later keeps the cover); **`url`** — JSON `{ "source": "url", "url": "https://…" }`, which Shopdesk downloads and stores (the link is not kept). The image must be a JPEG, PNG or WebP of at most 2 MB, read from its bytes. A cover may be set while the video is still uploading, and replaced any time; a video a product shows gets its new cover on the product page at once. Send each NEW cover with a new `Idempotency-Key`, or none: a key is matched on the form fields, not the file’s bytes, so reusing one with a different file replays the first answer. The answer is the video with its `poster_url`. ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | `uuid` | Yes | The video id `POST /videos` returned. | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `source` | `string` | Yes | `upload`: a JPEG, PNG or WebP of at most 2 MB in a multipart `file` part. `product_image`: one of the product photos (`image_id`), copied beside the video. `url`: an https link to the image, which Shopdesk downloads and stores (the link is not kept). One of: `upload`, `product_image`, `url`. | | `image_id` | `uuid` | No | `product_image` only: a photo of a product in this store, as listed in its `images[]`. | | `url` | `string` | No | `url` only: an https link to a JPEG, PNG or WebP image. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X POST "https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID/poster" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \ -H "Content-Type: application/json" \ -d '{ "source": "product_image", "image_id": "9c1e4b2a-7d3f-4e58-b0a1-2f6d8c9e1a45" }' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID/poster"); const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", "Content-Type": "application/json", }, body: JSON.stringify({ "source": "product_image", "image_id": "9c1e4b2a-7d3f-4e58-b0a1-2f6d8c9e1a45" }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "source": "product_image", "image_id": "9c1e4b2a-7d3f-4e58-b0a1-2f6d8c9e1a45", } response = requests.post( "https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID/poster", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 'product_image', 'image_id' => '9c1e4b2a-7d3f-4e58-b0a1-2f6d8c9e1a45', ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID/poster'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `200` — The video, with its new `poster_url`. - `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — `FORBIDDEN_SCOPE`: the key's scopes do not cover this endpoint. `PLAN_FEATURE_REQUIRED`: the plan does not include product videos. `VIDEO_UPLOADS_DISABLED`: video uploads are switched off for now. - `404` — No video with this id was started by this API key (another key’s or a dashboard upload is not visible here). - `409` — A request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `410` — `UPLOAD_NOT_ACTIVE`: the upload was rejected, cancelled or expired — it takes no cover. - `413` — The `file` part is larger than 8 MB. - `422` — No usable image — `error.fields` names `file`, `url` or `image_id`: not a JPEG/PNG/WebP, over 2 MB, a link that cannot be downloaded, a photo id of no product in this store, or a missing or extra `file` part for the source. - `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers. - `502` — `STORAGE_UNAVAILABLE`: video storage refused or was unreachable — retry in a moment. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "status": "uploading", "source": "upload", "file_name": "red-saree-drape.mp4", "size_bytes": 48000000, "part_size": 8388608, "part_count": 6, "checksum_algorithm": "SHA256", "uploaded_parts": [ { "part_number": 1, "size": 8388608, "checksum_sha256": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=" } ], "uploaded_bytes": 16777216, "expires_at": "2026-08-11T10:00:00.000Z", "product_ids": [], "url": "https://cdn.example.com/companies/c/videos/v/video.mp4", "poster_url": null, "facts": { "duration_ms": 24000, "width": 1080, "height": 1920, "rotation": 0, "codecs": "avc1.640028, mp4a.40.2", "has_audio": true, "frame_rate": 29.97, "bitrate_kbps": 4200, "faststart": true }, "warnings": [ "NOT_FASTSTART" ], "failure": { "code": "VIDEO_HEVC", "message": "This video is HEVC (H.265).", "fix": "Export as H.264." }, "created_at": "2026-08-11T10:00:00.000Z", "completed_at": "2026-08-11T10:00:00.000Z", "verified_at": "2026-08-11T10:00:00.000Z" } ``` ## Import a video from a link `POST /ext/v1/videos/import` · Requires scope `videos:write` Shopdesk downloads an H.264 MP4 from an https link to the file itself (not a player page such as YouTube) and stores its own copy — the link is not kept. Always answered `202` at once: the copy runs in the background, so poll `GET /videos/{id}` until `ready` or `rejected` (or `aborted` with an `import_*` failure code). The link must be public (so must any of the up to 3 redirects it takes) and must declare its size (`Content-Length`): an import is refused before its body is downloaded when that is over 200 MB or over the plan's remaining video storage, which is reserved from the start and released if the import fails; a download longer or shorter than declared is refused too. Nothing stays in storage from a failed import. The file is then verified exactly like an upload. Imports run one at a time per company; one whose copy has not started within an hour ends `aborted` (`expired`), and one whose link sends under 64 KB a second on average after its first minute ends `import_download_failed`. If the key is revoked or expires, or the plan loses product videos, before the copy starts, the import ends `aborted` (`cancelled`). Without `file_name` the video is named after the link’s last path segment when that reads as a file name (`red-saree.mp4`), else `video.mp4`. `Idempotency-Key` is REQUIRED and names the import: within 24 hours a retry replays the first answer; after that the same key returns the same video (`resumed: true`) — and a retry is never refused by the daily cap. At most 20 imports start per key per day, within the store’s 50 uploads a day. Company-scoped keys must send `store_id`. ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | Yes | REQUIRED. A unique value per logical request (a UUID is ideal); reuse it verbatim when retrying. A retry replays the original response with `Idempotent-Replayed: true` instead of running twice. | ### Body parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. | | `source_url` | `string` | Yes | An https link to the MP4 file itself (not a player page such as YouTube). Shopdesk downloads it and stores its own copy; the link is not kept. | | `file_name` | `string` | No | For display only. Defaults to the link's last path segment. | | `product_id` | `uuid` | No | The product the video is meant for, for an early check of its video limit. It does not place the video — `POST /products/{id}/videos` does. | ### Request **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X POST "https://api.shopdeskbd.com/ext/v1/videos/import" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \ -H "Content-Type: application/json" \ -d '{ "source_url": "https://cdn.example.com/videos/red-saree-drape.mp4" }' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/videos/import"); const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", "Content-Type": "application/json", }, body: JSON.stringify({ "source_url": "https://cdn.example.com/videos/red-saree-drape.mp4" }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "source_url": "https://cdn.example.com/videos/red-saree-drape.mp4", } response = requests.post( "https://api.shopdeskbd.com/ext/v1/videos/import", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 'https://cdn.example.com/videos/red-saree-drape.mp4', ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/videos/import'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` ### Responses - `202` — The video, `source: import`, still `uploading` while it is copied. - `400` — The required `Idempotency-Key` header is missing, empty, or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header). - `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store. - `403` — `FORBIDDEN_SCOPE`: the key's scopes do not cover this endpoint. `PLAN_FEATURE_REQUIRED`: the plan does not include product videos. `VIDEO_UPLOADS_DISABLED`: video uploads are switched off for now. `PLAN_LIMIT_REACHED`: the plan's video storage is full (or `product_id`’s product already has its plan’s number of videos). - `404` — `product_id` names no product of this store. - `409` — `TOO_MANY_OPEN_UPLOADS`: this key already has 4 unfinished uploads or imports, or the store 6 — wait for one to finish or delete one; or a request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished. - `422` — Validation failed — `error.fields` names the field. `source_url` must be an https link without a username or password, on port 443 or from 1024 up, and not a video player page. - `429` — Rate limit exceeded for this key, or this key started 20 imports (or the store 50 uploads) today (`RATE_LIMITED`, `Retry-After` to the next UTC midnight). - `502` — `STORAGE_UNAVAILABLE`: video storage refused or was unreachable — retry in a moment. **Example 200 response** ```json { "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001", "status": "uploading", "source": "upload", "file_name": "red-saree-drape.mp4", "size_bytes": 48000000, "part_size": 8388608, "part_count": 6, "checksum_algorithm": "SHA256", "uploaded_parts": [ { "part_number": 1, "size": 8388608, "checksum_sha256": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=" } ], "uploaded_bytes": 16777216, "expires_at": "2026-08-11T10:00:00.000Z", "product_ids": [], "url": "https://cdn.example.com/companies/c/videos/v/video.mp4", "poster_url": null, "facts": { "duration_ms": 24000, "width": 1080, "height": 1920, "rotation": 0, "codecs": "avc1.640028, mp4a.40.2", "has_audio": true, "frame_rate": 29.97, "bitrate_kbps": 4200, "faststart": true }, "warnings": [ "NOT_FASTSTART" ], "failure": { "code": "VIDEO_HEVC", "message": "This video is HEVC (H.265).", "fix": "Export as H.264." }, "created_at": "2026-08-11T10:00:00.000Z", "completed_at": "2026-08-11T10:00:00.000Z", "verified_at": "2026-08-11T10:00:00.000Z", "resumed": false } ``` --- # MCP tools Every tool the store MCP server at `https://api.shopdeskbd.com/ext/mcp/v1` offers — 36 tools, 18 read-only and 18 writes — generated from the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json), which the server's own tool registry produces. Authentication: Authorization: Bearer — the same keys and scopes as the REST API. Scope-filtered: a key lists only the tools all of whose scopes it holds. See [Build with AI](https://www.shopdeskbd.com/developers/ai.md) for setup. A full-scope key lists at most 50 tools. Read tools count against the key's read limit (report and summary tools as several reads); write tools, and anything unrecognised, count against the write limit — see [Rate limits](https://www.shopdeskbd.com/developers/rate-limits.md). `create_product`, `create_category`, `create_brand`, `add_product_images_from_urls`, `attach_product_video` declare `openWorldHint: true`: they download the image links they are given, through the same checks and limits as the REST API — see [Photos by link](https://www.shopdeskbd.com/developers/guide-photos-by-link.md). Every other tool declares `false`. When a name a tool was given matches nothing or several rows, the error object carries `candidates` beside `fields`. ## Read tools ### list_products A cursor page of products with their variants nested — the variant is the sellable unit (SKU, price). Filters: `status`, `category_id` (UUID), `search` (product name or variant SKU), `updated_after` (ISO 8601) to sync only what changed. Use `get_product` instead when you already hold an id. Returns `{ data: [...], meta: { has_more, next_cursor } }`; pass `meta.next_cursor` as `starting_after` to fetch the next page. - **Title:** List products - **Kind:** Read-only - **openWorldHint:** `false` - **Scopes:** `products:read` - **Idempotency key:** not taken - **Metering:** 1 read per call - **REST twin:** `GET /ext/v1/products` | Argument | Type | Description | | --- | --- | --- | | `limit` | integer | Page size, 1–100 (default 25). | | `starting_after` | uuid | Cursor: the previous page's `meta.next_cursor`. Loop until `meta.next_cursor` is null. Do not combine with `ending_before`. | | `ending_before` | uuid | Cursor for paging backwards: the id of your current first row. | | `sort` | "created_at" \| "-created_at" | `-created_at` (newest first, the default) or `created_at`. Use `updated_after` — not sort — to sync changes. | | `store_id` | uuid | Restrict to one store (UUID). Breadth control for company-scoped keys; a store-scoped key may omit it (when sent it must match the key's own store). Narrows the tenant fence, never widens it. | | `status` | "draft" \| "active" \| "archived" | — | | `category_id` | uuid | Only products in this category. | | `updated_after` | string | ISO 8601 instant. Only rows changed since then — THE sync filter: it catches rows created earlier but modified since your last poll. | | `search` | string | Match on product name or variant SKU. | Returns `structuredContent` with `data`, `meta` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### get_product One product by id (UUID), variants nested. An id outside this key's stores is a plain not-found — indistinguishable from a nonexistent one. - **Title:** Get a product - **Kind:** Read-only - **openWorldHint:** `false` - **Scopes:** `products:read` - **Idempotency key:** not taken - **Metering:** 1 read per call - **REST twin:** `GET /ext/v1/products/{id}` | Argument | Type | Description | | --- | --- | --- | | `id` (required) | uuid | The product id. | | `store_id` | uuid | Restrict to one store (UUID). Breadth control for company-scoped keys; a store-scoped key may omit it (when sent it must match the key's own store). Narrows the tenant fence, never widens it. | Returns `structuredContent` with `id`, `store_id`, `name`, `slug`, `short_description`, `description`, `thumbnail`, `status`, `delivery_charge`, `rating_average`, `rating_count`, `brand`, `categories`, `images`, `variants`, `videos`, `created_at`, `updated_at` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### list_categories A cursor page of product categories (id, name, slug, status). `search` matches name or slug — how a category name resolves to its id. Category ids feed `list_products`’ `category_id` filter. Returns `{ data: [...], meta: { has_more, next_cursor } }`; pass `meta.next_cursor` as `starting_after` to fetch the next page. - **Title:** List categories - **Kind:** Read-only - **openWorldHint:** `false` - **Scopes:** `products:read` - **Idempotency key:** not taken - **Metering:** 1 read per call - **REST twin:** `GET /ext/v1/categories` | Argument | Type | Description | | --- | --- | --- | | `limit` | integer | Page size, 1–100 (default 25). | | `starting_after` | uuid | Cursor: the previous page's `meta.next_cursor`. Loop until `meta.next_cursor` is null. Do not combine with `ending_before`. | | `ending_before` | uuid | Cursor for paging backwards: the id of your current first row. | | `sort` | "created_at" \| "-created_at" | `-created_at` (newest first, the default) or `created_at`. Use `updated_after` — not sort — to sync changes. | | `store_id` | uuid | Restrict to one store (UUID). Breadth control for company-scoped keys; a store-scoped key may omit it (when sent it must match the key's own store). Narrows the tenant fence, never widens it. | | `status` | "active" \| "inactive" | — | | `search` | string | Match on name or slug (partial, case-insensitive). | Returns `structuredContent` with `data`, `meta` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### list_brands A cursor page of brands (id, name, slug, status). `search` matches name or slug. Returns `{ data: [...], meta: { has_more, next_cursor } }`; pass `meta.next_cursor` as `starting_after` to fetch the next page. - **Title:** List brands - **Kind:** Read-only - **openWorldHint:** `false` - **Scopes:** `products:read` - **Idempotency key:** not taken - **Metering:** 1 read per call - **REST twin:** `GET /ext/v1/brands` | Argument | Type | Description | | --- | --- | --- | | `limit` | integer | Page size, 1–100 (default 25). | | `starting_after` | uuid | Cursor: the previous page's `meta.next_cursor`. Loop until `meta.next_cursor` is null. Do not combine with `ending_before`. | | `ending_before` | uuid | Cursor for paging backwards: the id of your current first row. | | `sort` | "created_at" \| "-created_at" | `-created_at` (newest first, the default) or `created_at`. Use `updated_after` — not sort — to sync changes. | | `store_id` | uuid | Restrict to one store (UUID). Breadth control for company-scoped keys; a store-scoped key may omit it (when sent it must match the key's own store). Narrows the tenant fence, never widens it. | | `status` | "active" \| "inactive" | — | | `search` | string | Match on name or slug (partial, case-insensitive). | Returns `structuredContent` with `data`, `meta` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### list_orders A cursor page of orders, compact by default — pass `expand: ['customer','items']` for the full objects (keep pages compact when walking many). Filters: `status`, `payment_status`, `fulfillment_status`, `channel` (channel SLUG from `list_channels`, not an id), `created_after`/`created_before`, `search` (order number, customer name or phone — any Bangladeshi phone form), `customer_id`, `assignee_id`, `follow_up_before`/`follow_up_after` (the call-back schedule) and `updated_after` (ISO 8601) — THE sync filter: it catches an order placed last month but confirmed this morning, which a `created_after` poll would miss. For a phone lookup or the call list, `find_orders` and `list_orders_needing_attention` answer in one call. Returns `{ data: [...], meta: { has_more, next_cursor } }`; pass `meta.next_cursor` as `starting_after` to fetch the next page. - **Title:** List orders - **Kind:** Read-only - **openWorldHint:** `false` - **Scopes:** `orders:read` - **Idempotency key:** not taken - **Metering:** 1 read per call - **REST twin:** `GET /ext/v1/orders` | Argument | Type | Description | | --- | --- | --- | | `limit` | integer | Page size, 1–100 (default 25). | | `starting_after` | uuid | Cursor: the previous page's `meta.next_cursor`. Loop until `meta.next_cursor` is null. Do not combine with `ending_before`. | | `ending_before` | uuid | Cursor for paging backwards: the id of your current first row. | | `sort` | "created_at" \| "-created_at" | `-created_at` (newest first, the default) or `created_at`. Use `updated_after` — not sort — to sync changes. | | `store_id` | uuid | Restrict to one store (UUID). Breadth control for company-scoped keys; a store-scoped key may omit it (when sent it must match the key's own store). Narrows the tenant fence, never widens it. | | `status` | "pending" \| "follow_up" \| "confirmed" \| "processing" \| "ready_to_ship" \| "in_transit" \| "delivered" \| "returned" \| "failed_delivery" \| "cancelled" \| "hold" \| "spam" | — | | `payment_status` | "unpaid" \| "partially_paid" \| "paid" \| "refunded" | — | | `fulfillment_status` | "unfulfilled" \| "partially_fulfilled" \| "fulfilled" \| "returned" | — | | `channel` | string | Sales channel slug (e.g. `facebook`), not its id. | | `created_after` | string | ISO 8601 instant. | | `created_before` | string | ISO 8601 instant. | | `updated_after` | string | ISO 8601 instant. Only rows changed since then — THE sync filter: it catches rows created earlier but modified since your last poll. | | `search` | string | Match on order number, customer name or phone. | | `customer_id` | uuid | Only this customer's orders. | | `assignee_id` | uuid | Only orders assigned to this staff member. | | `follow_up_before` | string | ISO 8601. Only orders with a call-back due before it. | | `follow_up_after` | string | ISO 8601. Only orders with a call-back due after it. | | `expand` | "customer" \| "items"[] | Related objects to include: `customer`, `items`. | Returns `structuredContent` with `data`, `meta` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### get_order One order by id (UUID). Pass `expand: ["customer","items"]` for the customer and line items; payment ROWS live behind `list_order_payments` (a separate scope). - **Title:** Get an order - **Kind:** Read-only - **openWorldHint:** `false` - **Scopes:** `orders:read` - **Idempotency key:** not taken - **Metering:** 1 read per call - **REST twin:** `GET /ext/v1/orders/{id}` | Argument | Type | Description | | --- | --- | --- | | `id` (required) | uuid | The order id. | | `expand` | "customer" \| "items"[] | Related objects to include: `customer`, `items`. | | `store_id` | uuid | Restrict to one store (UUID). Breadth control for company-scoped keys; a store-scoped key may omit it (when sent it must match the key's own store). Narrows the tenant fence, never widens it. | Returns `structuredContent` with `id`, `store_id`, `number`, `source`, `channel`, `customer_id`, `location_id`, `status`, `payment_status`, `fulfillment_status`, `subtotal`, `discount_amount`, `shipping_charge`, `tax_amount`, `total_amount`, `paid_amount`, `due_amount`, `refunded_amount`, `shipping_address`, `coupon`, `custom_fields`, `note`, `follow_up_at`, `follow_up_note`, `assignee`, `delivered_at`, `created_at`, `updated_at`, `items`, `customer` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### list_order_payments Every payment recorded against one order, oldest first: settled, pending and refunded rows (refunds are negative amounts with status `refunded`). Small and unpaginated. Requires the `payments:read` scope — `orders:read` alone is not enough. - **Title:** List an order's payments - **Kind:** Read-only - **openWorldHint:** `false` - **Scopes:** `payments:read` - **Idempotency key:** not taken - **Metering:** 1 read per call - **REST twin:** `GET /ext/v1/orders/{id}/payments` | Argument | Type | Description | | --- | --- | --- | | `order_id` (required) | uuid | The order id. | | `store_id` | uuid | Restrict to one store (UUID). Breadth control for company-scoped keys; a store-scoped key may omit it (when sent it must match the key's own store). Narrows the tenant fence, never widens it. | Returns `structuredContent` with `data` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### list_customers A cursor page of customer records (name, phone, email, status). `search` matches name, email or phone (any Bangladeshi phone form). Use `get_customer` for one record with its addresses, or `get_customer_summary` for a customer with their order history. Returns `{ data: [...], meta: { has_more, next_cursor } }`; pass `meta.next_cursor` as `starting_after` to fetch the next page. - **Title:** List customers - **Kind:** Read-only - **openWorldHint:** `false` - **Scopes:** `customers:read` - **Idempotency key:** not taken - **Metering:** 1 read per call - **REST twin:** `GET /ext/v1/customers` | Argument | Type | Description | | --- | --- | --- | | `limit` | integer | Page size, 1–100 (default 25). | | `starting_after` | uuid | Cursor: the previous page's `meta.next_cursor`. Loop until `meta.next_cursor` is null. Do not combine with `ending_before`. | | `ending_before` | uuid | Cursor for paging backwards: the id of your current first row. | | `sort` | "created_at" \| "-created_at" | `-created_at` (newest first, the default) or `created_at`. Use `updated_after` — not sort — to sync changes. | | `store_id` | uuid | Restrict to one store (UUID). Breadth control for company-scoped keys; a store-scoped key may omit it (when sent it must match the key's own store). Narrows the tenant fence, never widens it. | | `search` | string | Match on name, email or phone. | Returns `structuredContent` with `data`, `meta` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### get_customer One customer by id (UUID), addresses included — the detail shape, a superset of the `list_customers` rows. `expand: ["stats"]` adds their order history in four numbers (needs `orders:read` too; omitted without it). - **Title:** Get a customer - **Kind:** Read-only - **openWorldHint:** `false` - **Scopes:** `customers:read` - **Idempotency key:** not taken - **Metering:** 1 read per call - **REST twin:** `GET /ext/v1/customers/{id}` | Argument | Type | Description | | --- | --- | --- | | `id` (required) | uuid | The customer id. | | `expand` | "stats"[] | `stats`: order count, total spent, last order, returns. | | `store_id` | uuid | Restrict to one store (UUID). Breadth control for company-scoped keys; a store-scoped key may omit it (when sent it must match the key's own store). Narrows the tenant fence, never widens it. | Returns `structuredContent` with `id`, `store_id`, `full_name`, `email`, `phone`, `status`, `type`, `gender`, `created_at`, `updated_at`, `addresses`, `stats` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### get_inventory_levels A cursor page of stock levels, one row per variant per location. Filters: `warehouse_id`, `variant_id` (both UUIDs), and `low_stock: true` for only the rows at or below their variant’s low-stock threshold — the reorder question answered server-side. `missing_only: true` (needs `warehouse_id`) instead lists the variants that have NEVER had stock set at that location — rows with null `id` and `status: "stock_not_set"`; use it to find what is left to stock. Returns `{ data: [...], meta: { has_more, next_cursor } }`; pass `meta.next_cursor` as `starting_after` to fetch the next page. - **Title:** Get inventory levels - **Kind:** Read-only - **openWorldHint:** `false` - **Scopes:** `inventory:read` - **Idempotency key:** not taken - **Metering:** 1 read per call - **REST twin:** `GET /ext/v1/inventory/levels` | Argument | Type | Description | | --- | --- | --- | | `limit` | integer | Page size, 1–100 (default 25). | | `starting_after` | uuid | Cursor: the previous page's `meta.next_cursor`. Loop until `meta.next_cursor` is null. Do not combine with `ending_before`. | | `ending_before` | uuid | Cursor for paging backwards: the id of your current first row. | | `sort` | "created_at" \| "-created_at" | `-created_at` (newest first, the default) or `created_at`. Use `updated_after` — not sort — to sync changes. | | `store_id` | uuid | Restrict to one store (UUID). Breadth control for company-scoped keys; a store-scoped key may omit it (when sent it must match the key's own store). Narrows the tenant fence, never widens it. | | `warehouse_id` | uuid | Only levels at this location. | | `variant_id` | uuid | Only levels for this variant. | | `low_stock` | boolean | Only rows at/below the low-stock threshold. | | `missing_only` | boolean | Only variants with no stock level at `warehouse_id` yet. Requires `warehouse_id`; not combinable with `low_stock`. | Returns `structuredContent` with `data`, `meta` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### list_locations A cursor page of warehouses and other stock locations. These are COMPANY-scoped: both key types see the same set and `store_id` does not narrow them. Location ids feed `get_inventory_levels`’ `warehouse_id` filter. Returns `{ data: [...], meta: { has_more, next_cursor } }`; pass `meta.next_cursor` as `starting_after` to fetch the next page. - **Title:** List locations - **Kind:** Read-only - **openWorldHint:** `false` - **Scopes:** `locations:read` - **Idempotency key:** not taken - **Metering:** 1 read per call - **REST twin:** `GET /ext/v1/locations` | Argument | Type | Description | | --- | --- | --- | | `limit` | integer | Page size, 1–100 (default 25). | | `starting_after` | uuid | Cursor: the previous page's `meta.next_cursor`. Loop until `meta.next_cursor` is null. Do not combine with `ending_before`. | | `ending_before` | uuid | Cursor for paging backwards: the id of your current first row. | | `sort` | "created_at" \| "-created_at" | `-created_at` (newest first, the default) or `created_at`. Use `updated_after` — not sort — to sync changes. | | `store_id` | uuid | Restrict to one store (UUID). Breadth control for company-scoped keys; a store-scoped key may omit it (when sent it must match the key's own store). Narrows the tenant fence, never widens it. | Returns `structuredContent` with `data`, `meta` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### list_channels A cursor page of sales channels (id, name, slug) — order attribution metadata. The SLUG is what `list_orders`’ `channel` filter takes. Returns `{ data: [...], meta: { has_more, next_cursor } }`; pass `meta.next_cursor` as `starting_after` to fetch the next page. - **Title:** List sales channels - **Kind:** Read-only - **openWorldHint:** `false` - **Scopes:** `channels:read` - **Idempotency key:** not taken - **Metering:** 1 read per call - **REST twin:** `GET /ext/v1/channels` | Argument | Type | Description | | --- | --- | --- | | `limit` | integer | Page size, 1–100 (default 25). | | `starting_after` | uuid | Cursor: the previous page's `meta.next_cursor`. Loop until `meta.next_cursor` is null. Do not combine with `ending_before`. | | `ending_before` | uuid | Cursor for paging backwards: the id of your current first row. | | `sort` | "created_at" \| "-created_at" | `-created_at` (newest first, the default) or `created_at`. Use `updated_after` — not sort — to sync changes. | | `store_id` | uuid | Restrict to one store (UUID). Breadth control for company-scoped keys; a store-scoped key may omit it (when sent it must match the key's own store). Narrows the tenant fence, never widens it. | Returns `structuredContent` with `data`, `meta` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### get_store_profile Who this connection works for: the store (name, currency, timezone, contact, languages, advance-payment plans) and this key's access (company, the stores it reaches, its scopes and per-minute limits). A company-wide key must pass `store_id` to get a store; without it `store` is null and `access.stores` lists the stores to choose from. - **Title:** Get the store profile - **Kind:** Read-only - **openWorldHint:** `false` - **Scopes:** `store:read` - **Idempotency key:** not taken - **Metering:** 1 read per call - **REST twin:** `GET /ext/v1/store`, `GET /ext/v1/me` | Argument | Type | Description | | --- | --- | --- | | `store_id` | uuid | Restrict to one store (UUID). Breadth control for company-scoped keys; a store-scoped key may omit it (when sent it must match the key's own store). Narrows the tenant fence, never widens it. | Returns `structuredContent` with `store`, `access` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### get_sales_summary Sales for one store over a window of store-local days: orders, net sales, net revenue and average order value, money received, and the cash on delivery still to collect. The figures run on three clocks, stated in `basis`: sales count orders DELIVERED in the window, `money_received` counts payments settled in the window, and `cod_outstanding` is a balance as of today. Name the window with `period` or `from`/`to` (default: this month so far). `compare_to: "previous_period"` adds the same figures for the window just before (plans with Advanced Reports only). A company-wide key must pass `store_id`. - **Title:** Get a sales summary - **Kind:** Read-only - **openWorldHint:** `false` - **Scopes:** `reports:read` - **Idempotency key:** not taken - **Metering:** 5 reads per call - **response_format:** `concise` or `detailed` (default `concise`) - **REST twin:** `GET /ext/v1/reports/sales-summary` | Argument | Type | Description | | --- | --- | --- | | `period` | "today" \| "yesterday" \| "last_7_days" \| "last_30_days" \| "this_month" \| "last_month" | A named window on the store calendar. | | `from` | string | First day, YYYY-MM-DD (inclusive). Not with `period`. | | `to` | string | Last day, YYYY-MM-DD (inclusive). At most 366 days. | | `compare_to` | "previous_period" | Adds `previous_period`. | | `channel` | string | Only this sales channel (its slug, see `list_channels`). Money received and COD to collect are then null: payments are not split by channel. | | `store_id` | uuid | Restrict to one store (UUID). Breadth control for company-scoped keys; a store-scoped key may omit it (when sent it must match the key's own store). Narrows the tenant fence, never widens it. | | `response_format` | "concise" \| "detailed" | `concise` (default): the fields needed to act, names beside ids. `detailed`: the full objects, as the REST API returns them. | Returns `structuredContent` with `from`, `to`, `orders`, `gross_sales`, `net_sales`, `net_revenue`, `average_order_value`, `money_received`, `store_id`, `time_zone`, `channel`, `basis`, `cod_outstanding`, `previous_period` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### list_low_stock What needs restocking: stock rows at or below their low-stock threshold, with product and variant names, on-hand, reserved and available units. Optionally one location (`location_id`, see `list_locations`). Pages like every list: pass `meta.next_cursor` as `starting_after` for more. - **Title:** List low-stock items - **Kind:** Read-only - **openWorldHint:** `false` - **Scopes:** `inventory:read` - **Idempotency key:** not taken - **Metering:** 1 read per call - **response_format:** `concise` or `detailed` (default `concise`) - **REST twin:** `GET /ext/v1/inventory/levels` | Argument | Type | Description | | --- | --- | --- | | `location_id` | uuid | Only this location (warehouse). | | `limit` | integer | Page size, 1–100 (default 25). | | `starting_after` | uuid | The previous page's `meta.next_cursor`, to fetch the next page. Stop when it is null. | | `store_id` | uuid | Restrict to one store (UUID). Breadth control for company-scoped keys; a store-scoped key may omit it (when sent it must match the key's own store). Narrows the tenant fence, never widens it. | | `response_format` | "concise" \| "detailed" | `concise` (default): the fields needed to act, names beside ids. `detailed`: the full objects, as the REST API returns them. | Returns `structuredContent` with `data`, `meta` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### find_orders Finds orders by an order number, a customer phone number or a customer name, newest first. A phone matches in any Bangladeshi form (+8801…, 8801…, 01…). Each row carries the customer, the lines, what is due and the call-back state. Optionally narrowed by `status`. - **Title:** Find orders - **Kind:** Read-only - **openWorldHint:** `false` - **Scopes:** `orders:read` - **Idempotency key:** not taken - **Metering:** 1 read per call - **response_format:** `concise` or `detailed` (default `concise`) - **REST twin:** `GET /ext/v1/orders` | Argument | Type | Description | | --- | --- | --- | | `query` (required) | string | An order number, a phone number or a customer name. | | `status` | "pending" \| "follow_up" \| "confirmed" \| "processing" \| "ready_to_ship" \| "in_transit" \| "delivered" \| "returned" \| "failed_delivery" \| "cancelled" \| "hold" \| "spam" | — | | `limit` | integer | Page size, 1–100 (default 25). | | `starting_after` | uuid | The previous page's `meta.next_cursor`, to fetch the next page. Stop when it is null. | | `store_id` | uuid | Restrict to one store (UUID). Breadth control for company-scoped keys; a store-scoped key may omit it (when sent it must match the key's own store). Narrows the tenant fence, never widens it. | | `response_format` | "concise" \| "detailed" | `concise` (default): the fields needed to act, names beside ids. `detailed`: the full objects, as the REST API returns them. | Returns `structuredContent` with `data`, `meta` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### list_orders_needing_attention A work queue, oldest first. `unconfirmed`: new orders still `pending` — the confirmation-call list. `follow_up_due`: orders in `follow_up` whose call-back time has passed — the same queue the dashboard's overdue follow-ups count. Record each call with `record_call_outcome`. - **Title:** List orders needing attention - **Kind:** Read-only - **openWorldHint:** `false` - **Scopes:** `orders:read` - **Idempotency key:** not taken - **Metering:** 1 read per call - **response_format:** `concise` or `detailed` (default `concise`) - **REST twin:** `GET /ext/v1/orders` | Argument | Type | Description | | --- | --- | --- | | `kind` (required) | "unconfirmed" \| "follow_up_due" | Which queue. | | `limit` | integer | Page size, 1–100 (default 25). | | `starting_after` | uuid | The previous page's `meta.next_cursor`, to fetch the next page. Stop when it is null. | | `store_id` | uuid | Restrict to one store (UUID). Breadth control for company-scoped keys; a store-scoped key may omit it (when sent it must match the key's own store). Narrows the tenant fence, never widens it. | | `response_format` | "concise" \| "detailed" | `concise` (default): the fields needed to act, names beside ids. `detailed`: the full objects, as the REST API returns them. | Returns `structuredContent` with `data`, `meta` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### get_customer_summary One customer and their history: order count, total spent on delivered orders, last order, returns, and their latest 5 orders. Name them by `customer_id` or by `phone` (any Bangladeshi form). When a phone matches more than one customer (a company-wide key sees several stores), `customer` is null and `candidates` lists them — call again with a `customer_id`. - **Title:** Get a customer's history - **Kind:** Read-only - **openWorldHint:** `false` - **Scopes:** `customers:read` + `orders:read` - **Idempotency key:** not taken - **Metering:** 5 reads per call - **response_format:** `concise` or `detailed` (default `concise`) - **REST twin:** `GET /ext/v1/customers`, `GET /ext/v1/customers/{id}`, `GET /ext/v1/orders` | Argument | Type | Description | | --- | --- | --- | | `customer_id` | uuid | The customer id. | | `phone` | string | A Bangladeshi mobile number. Or use `customer_id`. | | `store_id` | uuid | Restrict to one store (UUID). Breadth control for company-scoped keys; a store-scoped key may omit it (when sent it must match the key's own store). Narrows the tenant fence, never widens it. | | `response_format` | "concise" \| "detailed" | `concise` (default): the fields needed to act, names beside ids. `detailed`: the full objects, as the REST API returns them. | Returns `structuredContent` with `customer`, `recent_orders`, `candidates` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ## Write tools ### create_order Places an order through the same engine as the merchant’s own staff: stock is reserved, totals are computed server-side, and any coupon is priced by the real coupon engine (an unusable code fails the order rather than mispricing it). Identify the buyer with `customer_id` OR an inline `customer` (find-or-create by phone) — never both. Each line needs `variant_id` or `sku`, plus a positive `quantity`. Returns the created order, the same shape `get_order` returns. - **Title:** Create an order - **Kind:** Write (adds data; safe to repeat) - **openWorldHint:** `false` - **Scopes:** `orders:write` - **Idempotency key:** required - **Metering:** 1 write per call - **REST twin:** `POST /ext/v1/orders` | Argument | Type | Description | | --- | --- | --- | | `idempotency_key` (required) | string | A value unique to this LOGICAL operation (a UUID is ideal) — reuse the SAME value when retrying it, so a lost response can never double-run the write. Required here. | | `store_id` | uuid | Which store to write into (UUID). REQUIRED for a company-scoped key; a store-scoped key may omit it (when sent it must match the key's own store). | | `customer_id` | uuid | An existing customer. Provide this OR `customer`. | | `customer` | object | Find-or-create by phone. Provide this OR `customer_id`. | | `items` (required) | object[] | The order lines — at least one. | | `channel` | string | Sales-channel SLUG for attribution (see `list_channels`); defaults to the store’s `manual` channel. | | `location_id` | uuid | Warehouse to allocate stock from; defaults to the company default location. | | `shipping_address` | object | Free-form address object, stored verbatim. | | `shipping_charge` | number | Delivery charge as your system computed it; default 0. | | `tax_amount` | number | — | | `coupon_code` | string | — | | `note` | string | — | | `payment` | object | An upfront settled payment; omit for an unpaid order and record later payments with `record_order_payment`. | Returns `structuredContent` with `id`, `store_id`, `number`, `source`, `channel`, `customer_id`, `location_id`, `status`, `payment_status`, `fulfillment_status`, `subtotal`, `discount_amount`, `shipping_charge`, `tax_amount`, `total_amount`, `paid_amount`, `due_amount`, `refunded_amount`, `shipping_address`, `coupon`, `custom_fields`, `note`, `follow_up_at`, `follow_up_note`, `assignee`, `delivered_at`, `created_at`, `updated_at`, `items`, `customer` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### update_order_status Moves one order to a new status under the SAME strict transition map the merchant’s staff use — an illegal move is refused as `INVALID_STATUS_TRANSITION`. A `delivered` order cannot be moved to `returned`: returns of delivered orders are made in the dashboard. A `returned` order keeps its stock held (not for sale) until the parcel is received in the dashboard; on a never-delivered order it keeps up to the delivery charge from an advance only when the parcel's pickup is recorded, else everything paid is owed back and no courier charge is booked. `returned` is refused as `CONFLICT` while a courier that reports pickups has not reported picking the parcel up; `in_transit` is refused while the order’s parcel is cancelled (book it again in the dashboard). `note` lands on the order’s timeline. Use `cancel_order` for cancellation. Returns the updated order. - **Title:** Update an order’s status - **Kind:** Write (changes existing data) - **openWorldHint:** `false` - **Scopes:** `orders:write` - **Idempotency key:** honoured - **Metering:** 1 write per call - **REST twin:** `PATCH /ext/v1/orders/{id}/status` | Argument | Type | Description | | --- | --- | --- | | `id` (required) | uuid | The order id. | | `status` (required) | "pending" \| "follow_up" \| "confirmed" \| "processing" \| "ready_to_ship" \| "in_transit" \| "delivered" \| "returned" \| "failed_delivery" \| "cancelled" \| "hold" \| "spam" | The target status. | | `note` | string | — | | `idempotency_key` | string | A value unique to this LOGICAL operation (a UUID is ideal) — reuse the SAME value when retrying it, so a lost response can never double-run the write. Optional here, but recommended for automated callers. | | `store_id` | uuid | Which store to write into (UUID). REQUIRED for a company-scoped key; a store-scoped key may omit it (when sent it must match the key's own store). | Returns `structuredContent` with `id`, `store_id`, `number`, `source`, `channel`, `customer_id`, `location_id`, `status`, `payment_status`, `fulfillment_status`, `subtotal`, `discount_amount`, `shipping_charge`, `tax_amount`, `total_amount`, `paid_amount`, `due_amount`, `refunded_amount`, `shipping_address`, `coupon`, `custom_fields`, `note`, `follow_up_at`, `follow_up_note`, `assignee`, `delivered_at`, `created_at`, `updated_at`, `items`, `customer` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### cancel_order Cancels one order — sugar for the `cancelled` transition, so the same transition map decides whether THIS order can still be cancelled. Reserved stock is released by the same lifecycle rules as the desk. Returns the cancelled order. - **Title:** Cancel an order - **Kind:** Write (changes existing data) - **openWorldHint:** `false` - **Scopes:** `orders:write` - **Idempotency key:** honoured - **Metering:** 1 write per call - **REST twin:** `POST /ext/v1/orders/{id}/cancel` | Argument | Type | Description | | --- | --- | --- | | `id` (required) | uuid | The order id. | | `note` | string | Recorded on the order’s timeline. | | `idempotency_key` | string | A value unique to this LOGICAL operation (a UUID is ideal) — reuse the SAME value when retrying it, so a lost response can never double-run the write. Optional here, but recommended for automated callers. | | `store_id` | uuid | Which store to write into (UUID). REQUIRED for a company-scoped key; a store-scoped key may omit it (when sent it must match the key's own store). | Returns `structuredContent` with `id`, `store_id`, `number`, `source`, `channel`, `customer_id`, `location_id`, `status`, `payment_status`, `fulfillment_status`, `subtotal`, `discount_amount`, `shipping_charge`, `tax_amount`, `total_amount`, `paid_amount`, `due_amount`, `refunded_amount`, `shipping_address`, `coupon`, `custom_fields`, `note`, `follow_up_at`, `follow_up_note`, `assignee`, `delivered_at`, `created_at`, `updated_at`, `items`, `customer` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### record_order_payment Records a payment that has ALREADY been received — it moves no money itself and contacts no payment provider. The staff payment path verbatim: the amount is capped at the order’s net due (`payment_exceeds_due` otherwise), and recording the same `transaction_id` twice in a store is a conflict (`duplicate_transaction_id`); a method set to require a transaction id refuses a payment without one (`transaction_id_required`). When a courier has already collected this order at the door, the collection is posted automatically and the order owes nothing — a payment above the due is then refused as `cod_already_collected`, naming the courier account the cash is sitting in; do not retry it, and record the courier’s remittance instead when it reaches the bank. Requires the `payments:write` scope — `orders:write` alone is not enough. Returns the updated order with its payment rollup. - **Title:** Record a payment on an order - **Kind:** Write (adds data; safe to repeat) - **openWorldHint:** `false` - **Scopes:** `payments:write` - **Idempotency key:** required - **Metering:** 1 write per call - **REST twin:** `POST /ext/v1/orders/{id}/payments` | Argument | Type | Description | | --- | --- | --- | | `order_id` (required) | uuid | The order id. | | `amount` (required) | number | Settled amount — above 0. | | `payment_method_id` | uuid | One of the store’s ACTIVE payment methods. | | `transaction_id` | string | Provider transaction id — unique per store. | | `idempotency_key` (required) | string | A value unique to this LOGICAL operation (a UUID is ideal) — reuse the SAME value when retrying it, so a lost response can never double-run the write. Required here. | | `store_id` | uuid | Which store to write into (UUID). REQUIRED for a company-scoped key; a store-scoped key may omit it (when sent it must match the key's own store). | Returns `structuredContent` with `id`, `store_id`, `number`, `source`, `channel`, `customer_id`, `location_id`, `status`, `payment_status`, `fulfillment_status`, `subtotal`, `discount_amount`, `shipping_charge`, `tax_amount`, `total_amount`, `paid_amount`, `due_amount`, `refunded_amount`, `shipping_address`, `coupon`, `custom_fields`, `note`, `follow_up_at`, `follow_up_note`, `assignee`, `delivered_at`, `created_at`, `updated_at`, `items`, `customer` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### create_customer Creates one customer record. `phone` (Bangladeshi mobile, stored canonical) and `email` are each unique per store — a duplicate is a conflict, not a merge. When placing an order for a possibly-new buyer, prefer `create_order`’s inline `customer` (find-or-create) over calling this first. Returns the customer, addresses included. - **Title:** Create a customer - **Kind:** Write (adds data) - **openWorldHint:** `false` - **Scopes:** `customers:write` - **Idempotency key:** honoured - **Metering:** 1 write per call - **REST twin:** `POST /ext/v1/customers` | Argument | Type | Description | | --- | --- | --- | | `full_name` (required) | string | — | | `phone` | string | Bangladeshi mobile, any accepted spelling. | | `email` | string | — | | `status` | "active" \| "inactive" \| "blocked" | Default active. | | `gender` | "male" \| "female" | — | | `idempotency_key` | string | A value unique to this LOGICAL operation (a UUID is ideal) — reuse the SAME value when retrying it, so a lost response can never double-run the write. Optional here, but recommended for automated callers. | | `store_id` | uuid | Which store to write into (UUID). REQUIRED for a company-scoped key; a store-scoped key may omit it (when sent it must match the key's own store). | Returns `structuredContent` with `id`, `store_id`, `full_name`, `email`, `phone`, `status`, `type`, `gender`, `created_at`, `updated_at`, `addresses`, `stats` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### update_customer Patches one customer record — only the fields you send change. The same per-store uniqueness rules as `create_customer` apply to `phone` and `email`. Returns the updated customer. - **Title:** Update a customer - **Kind:** Write (changes existing data; safe to repeat) - **openWorldHint:** `false` - **Scopes:** `customers:write` - **Idempotency key:** honoured - **Metering:** 1 write per call - **REST twin:** `PATCH /ext/v1/customers/{id}` | Argument | Type | Description | | --- | --- | --- | | `id` (required) | uuid | The customer id. | | `full_name` | string | — | | `phone` | string | — | | `email` | string | — | | `status` | "active" \| "inactive" \| "blocked" | — | | `gender` | "male" \| "female" | — | | `idempotency_key` | string | A value unique to this LOGICAL operation (a UUID is ideal) — reuse the SAME value when retrying it, so a lost response can never double-run the write. Optional here, but recommended for automated callers. | | `store_id` | uuid | Which store to write into (UUID). REQUIRED for a company-scoped key; a store-scoped key may omit it (when sent it must match the key's own store). | Returns `structuredContent` with `id`, `store_id`, `full_name`, `email`, `phone`, `status`, `type`, `gender`, `created_at`, `updated_at`, `addresses`, `stats` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### adjust_inventory Sets stock for one variant at one location, with a required audit `reason`. Name the variant by `variant_id` or by `sku`, and the quantity by `quantity_change` (a delta) or `set_on_hand` (the resulting total — prefer this when you know the count, and pass `expected_on_hand` to be refused rather than overwrite a number that moved). `quantity_change` is never zero, and cannot take on-hand below zero or below reservations. `type` picks the ledger category: `purchase` ADDS stock at a required `unit_cost`, `damage` must REMOVE stock, `return` can only ADD stock, default `adjustment`. A decrease filed as `adjustment` or `damage` is booked as a loss on the P&L at the current average cost. Returns the resulting stock level, the same row shape `get_inventory_levels` pages. - **Title:** Adjust stock - **Kind:** Write (changes existing data; safe to repeat) - **openWorldHint:** `false` - **Scopes:** `inventory:write` - **Idempotency key:** required - **Metering:** 1 write per call - **REST twin:** `POST /ext/v1/inventory/adjustments` | Argument | Type | Description | | --- | --- | --- | | `location_id` (required) | uuid | The warehouse to adjust. | | `variant_id` | uuid | The variant being adjusted. Or resolve by `sku`. | | `sku` | string | Resolve the variant by SKU instead of id. | | `quantity_change` | integer | Signed units: positive adds, negative removes. Or use `set_on_hand` to state the resulting total instead. | | `set_on_hand` | integer | The absolute on-hand this location should end at — what a stock-take says. The delta is computed under the row lock. | | `expected_on_hand` | integer | The on-hand you believe is there; a mismatch refuses the write with CONFLICT instead of applying against a moved number. | | `type` | "purchase" \| "return" \| "adjustment" \| "damage" | Ledger category; default `adjustment`. | | `reason` (required) | string | Stored on the ledger entry. Required — always. | | `unit_cost` | number | Per-unit cost. REQUIRED for `purchase` and refused on any other type; write-only, never appears in any response. | | `low_stock_threshold` | integer | Optionally update the level’s low-stock threshold. | | `idempotency_key` (required) | string | A value unique to this LOGICAL operation (a UUID is ideal) — reuse the SAME value when retrying it, so a lost response can never double-run the write. Required here. | | `store_id` | uuid | Which store to write into (UUID). REQUIRED for a company-scoped key; a store-scoped key may omit it (when sent it must match the key's own store). | Returns `structuredContent` with `id`, `store_id`, `location_id`, `variant_id`, `sku`, `on_hand`, `reserved`, `available`, `low_stock_threshold`, `is_low_stock`, `status`, `location`, `variant`, `created_at`, `updated_at` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### create_product Creates one product with at least one variant (the sellable unit: SKU + price) — optionally with its photos and each variant’s options, in one call. `images`: up to 10 https links, which Shopdesk downloads, checks and stores before answering; one link that cannot be used fails the whole call and nothing is created. A variant’s `options` name an attribute and a value by name (any case), slug or id, e.g. `[{"attribute":"Colour","value":"Red"}]`. Nothing is created from a name: an unknown one fails the call with `candidates` (the store’s variant attributes, or that attribute’s values) — add what is missing with `create_attribute`. `image_index` points a variant at a photo by its position in `images`. `slug` auto-generates from the name and auto-suffixes when taken. `description` is stored as sanitized HTML. Stock is NOT set here — new stock enters through `adjust_inventory`, where the ledger rules live. Returns the product, the same shape `get_product` returns. - **Title:** Create a product - **Kind:** Write (adds data) - **openWorldHint:** `true` — downloads the links you send - **Scopes:** `products:write` - **Idempotency key:** honoured - **Metering:** 1 write per call - **REST twin:** `GET /ext/v1/attributes`, `POST /ext/v1/products` | Argument | Type | Description | | --- | --- | --- | | `name` (required) | string | — | | `variants` (required) | object[] | At least one variant. | | `slug` | string | — | | `brand_id` | uuid | — | | `short_description` | string | — | | `description` | string | Rich description; sanitized server-side. | | `status` | "draft" \| "active" \| "archived" | Default draft. | | `delivery_charge` | number | Per-product override: 0 = free delivery, N = flat charge; omit for the standard shipping rules. | | `categories` | object[] | — | | `images` | object[] | The gallery, in display order, by https link. Add more later with `add_product_images_from_urls`. | | `idempotency_key` | string | A value unique to this LOGICAL operation (a UUID is ideal) — reuse the SAME value when retrying it, so a lost response can never double-run the write. Optional here, but recommended for automated callers. | | `store_id` | uuid | Which store to write into (UUID). REQUIRED for a company-scoped key; a store-scoped key may omit it (when sent it must match the key's own store). | Returns `structuredContent` with `id`, `store_id`, `name`, `slug`, `short_description`, `description`, `thumbnail`, `status`, `delivery_charge`, `rating_average`, `rating_count`, `brand`, `categories`, `images`, `variants`, `videos`, `created_at`, `updated_at` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### update_product Patches one product — only the fields you send change, with two REPLACE semantics to know: `categories` (when present) replaces the whole link set, and `variants` (when present) reconciles the FULL variant set — entries with an `id` are patched, entries without one are created, and existing variants missing from the list are REMOVED (refused when they carry stock history). A NEW variant takes `options` by name, like `create_product`; an existing one keeps its options. Photos are not edited here — use `add_product_images_from_urls`, and `update_variant` for a variant’s photo. `brand_id: null` detaches the brand; `delivery_charge: null` returns to the standard shipping rules. Returns the updated product. - **Title:** Update a product - **Kind:** Write (changes existing data) - **openWorldHint:** `false` - **Scopes:** `products:write` - **Idempotency key:** honoured - **Metering:** 1 write per call - **REST twin:** `GET /ext/v1/attributes`, `PATCH /ext/v1/products/{id}` | Argument | Type | Description | | --- | --- | --- | | `id` (required) | uuid | The product id. | | `name` | string | — | | `slug` | string | — | | `brand_id` | uuid \| null | Set null to detach the brand. | | `short_description` | string | — | | `description` | string | — | | `status` | "draft" \| "active" \| "archived" | — | | `delivery_charge` | number \| null | Set null to return to the standard shipping rules. | | `categories` | object[] | When present, REPLACES the category links. | | `variants` | object[] | When present, reconciles the FULL variant set — see the tool description before sending a partial list. | | `idempotency_key` | string | A value unique to this LOGICAL operation (a UUID is ideal) — reuse the SAME value when retrying it, so a lost response can never double-run the write. Optional here, but recommended for automated callers. | | `store_id` | uuid | Which store to write into (UUID). REQUIRED for a company-scoped key; a store-scoped key may omit it (when sent it must match the key's own store). | Returns `structuredContent` with `id`, `store_id`, `name`, `slug`, `short_description`, `description`, `thumbnail`, `status`, `delivery_charge`, `rating_average`, `rating_count`, `brand`, `categories`, `images`, `variants`, `videos`, `created_at`, `updated_at` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### archive_product Archives one product — it leaves the sellable catalog but its history stays. There is no delete on this surface; archiving is the end state. Returns `{ id, archived }`. - **Title:** Archive a product - **Kind:** Write (changes existing data; safe to repeat) - **openWorldHint:** `false` - **Scopes:** `products:write` - **Idempotency key:** honoured - **Metering:** 1 write per call - **REST twin:** `POST /ext/v1/products/{id}/archive` | Argument | Type | Description | | --- | --- | --- | | `id` (required) | uuid | The product id. | | `idempotency_key` | string | A value unique to this LOGICAL operation (a UUID is ideal) — reuse the SAME value when retrying it, so a lost response can never double-run the write. Optional here, but recommended for automated callers. | | `store_id` | uuid | Which store to write into (UUID). REQUIRED for a company-scoped key; a store-scoped key may omit it (when sent it must match the key's own store). | Returns `structuredContent` with `id`, `archived` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### update_order_details Sets an order’s note and call-back (`follow_up_at`, `follow_up_note`) — nothing that changes money or stock, legal in any status. Only the fields you send change; `null` clears one. `note` REPLACES the whole note: text these tools show is shortened to 500 characters, so never rebuild a note from a value ending in "…". Returns the updated order. - **Title:** Update an order’s note or call-back - **Kind:** Write (changes existing data; safe to repeat) - **openWorldHint:** `false` - **Scopes:** `orders:write` - **Idempotency key:** honoured - **Metering:** 1 write per call - **REST twin:** `PATCH /ext/v1/orders/{id}` | Argument | Type | Description | | --- | --- | --- | | `order_id` (required) | uuid | The order id (UUID). Find it with `find_orders`. | | `note` | string \| null | The order note. `null` clears it. | | `follow_up_at` | string \| null | When to call back (ISO 8601). `null` clears it. | | `follow_up_note` | string \| null | Why the call-back is needed. `null` clears it. | | `idempotency_key` | string | A value unique to this LOGICAL operation (a UUID is ideal) — reuse the SAME value when retrying it, so a lost response can never double-run the write. Optional here, but recommended for automated callers. | | `store_id` | uuid | Which store to write into (UUID). REQUIRED for a company-scoped key; a store-scoped key may omit it (when sent it must match the key's own store). | Returns `structuredContent` with `id`, `store_id`, `number`, `source`, `channel`, `customer_id`, `location_id`, `status`, `payment_status`, `fulfillment_status`, `subtotal`, `discount_amount`, `shipping_charge`, `tax_amount`, `total_amount`, `paid_amount`, `due_amount`, `refunded_amount`, `shipping_address`, `coupon`, `custom_fields`, `note`, `follow_up_at`, `follow_up_note`, `assignee`, `delivered_at`, `created_at`, `updated_at`, `items`, `customer` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### record_call_outcome Records the result of a confirmation call on one order, using the same status rules as the dashboard. `confirmed` moves it to confirmed; `cancelled` cancels it (stock is released). `call_back` (needs `call_back_at`) and `no_answer` (`call_back_at` optional) move it to `follow_up` — the dashboard’s call-back queue — and set the call-back time and `note` as the reason. `note` also goes on the order timeline with a status change. A status the order cannot move to is refused before anything changes. Repeating the same outcome changes nothing more. Returns the steps taken and the order. - **Title:** Record a call outcome - **Kind:** Write (changes existing data) - **openWorldHint:** `false` - **Scopes:** `orders:write` - **Idempotency key:** honoured - **Metering:** 1 write per call - **response_format:** `concise` or `detailed` (default `concise`) - **REST twin:** `GET /ext/v1/orders/{id}`, `PATCH /ext/v1/orders/{id}/status`, `POST /ext/v1/orders/{id}/cancel`, `PATCH /ext/v1/orders/{id}` | Argument | Type | Description | | --- | --- | --- | | `order_id` (required) | uuid | The order id (UUID). Find it with `find_orders`. | | `outcome` (required) | "confirmed" \| "cancelled" \| "call_back" \| "no_answer" | What the call decided. | | `note` | string | What was said, in a sentence. | | `call_back_at` | string | When to call again (ISO 8601). Required for `call_back`; not allowed with `confirmed` or `cancelled`. | | `idempotency_key` | string | A value unique to this LOGICAL operation (a UUID is ideal) — reuse the SAME value when retrying it, so a lost response can never double-run the write. Optional here, but recommended for automated callers. | | `store_id` | uuid | Which store to write into (UUID). REQUIRED for a company-scoped key; a store-scoped key may omit it (when sent it must match the key's own store). | | `response_format` | "concise" \| "detailed" | `concise` (default): the fields needed to act, names beside ids. `detailed`: the full objects, as the REST API returns them. | Returns `structuredContent` with `outcome`, `steps`, `order` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### create_attribute Makes sure an attribute (Colour, Size) exists with the values you list, and returns it with all its values. If the store already has an attribute with this `name` and `type` (or this `slug`), only the values it lacks are added, compared ignoring case — nothing is renamed, changed or removed, so calling it twice adds nothing the second time; added values go after the existing ones unless `position` places them. Otherwise the attribute is created with the values in order. `type`: `variant` (default) for options that define variants (what `create_product` options name), `specification` for details that only describe a product. `input_type: "color"` lets values carry `swatch_hex`. Returns `{ attribute, created, added_values }`. - **Title:** Create an attribute (or add its missing values) - **Kind:** Write (adds data; safe to repeat) - **openWorldHint:** `false` - **Scopes:** `catalog:write` + `products:read` - **Idempotency key:** honoured - **Metering:** 1 write per call - **REST twin:** `GET /ext/v1/attributes`, `POST /ext/v1/attributes`, `POST /ext/v1/attributes/{id}/values`, `GET /ext/v1/attributes/{id}` | Argument | Type | Description | | --- | --- | --- | | `name` (required) | string | e.g. `Colour`. | | `slug` | string | Look up (and create) by this slug instead of the name: lowercase letters, digits and single hyphens. | | `type` | "variant" \| "specification" | `variant` (default) or `specification`. | | `input_type` | "text" \| "color" \| "image" | On create only: `text` (default), `color` (values take `swatch_hex`) or `image`. | | `is_filterable` | boolean | On create only: offer it as a storefront filter. | | `is_required` | boolean | On create only. | | `values` | object[] | The values it must have, in order. Each value once. | | `idempotency_key` | string | A value unique to this LOGICAL operation (a UUID is ideal) — reuse the SAME value when retrying it, so a lost response can never double-run the write. Optional here, but recommended for automated callers. | | `store_id` | uuid | Which store to write into (UUID). REQUIRED for a company-scoped key; a store-scoped key may omit it (when sent it must match the key's own store). | Returns `structuredContent` with `attribute`, `created`, `added_values` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### create_category Creates one category, top-level or under `parent` — the parent category’s name, slug or id. A parent name that matches no category, or several, fails with `candidates`; send the slug or id. Images come by https link (`image_url` for listings, `banner_url` for the category page): Shopdesk downloads and checks them before answering, and a link that cannot be used fails the call with nothing created. Each call creates a new category — check `list_categories` first. `slug` derives from the name and is suffixed when taken. Returns the category. - **Title:** Create a category - **Kind:** Write (adds data) - **openWorldHint:** `true` — downloads the links you send - **Scopes:** `catalog:write` + `products:read` - **Idempotency key:** honoured - **Metering:** 1 write per call - **REST twin:** `GET /ext/v1/categories`, `POST /ext/v1/categories` | Argument | Type | Description | | --- | --- | --- | | `name` (required) | string | — | | `parent` | string | The parent category: name (any case), slug or id. Omit for a top-level category. | | `slug` | string | — | | `description` | string | — | | `status` | "active" \| "inactive" | Default active. | | `position` | integer | Sort order among siblings, lowest first. | | `seo` | object | — | | `image_url` | string | Listing image, by https link. Shopdesk downloads it, checks it is a PNG, JPEG, WebP or GIF (at most 10 MB) and stores its own copy. | | `banner_url` | string | Category-page banner, by https link. Shopdesk downloads it, checks it is a PNG, JPEG, WebP or GIF (at most 10 MB) and stores its own copy. | | `idempotency_key` | string | A value unique to this LOGICAL operation (a UUID is ideal) — reuse the SAME value when retrying it, so a lost response can never double-run the write. Optional here, but recommended for automated callers. | | `store_id` | uuid | Which store to write into (UUID). REQUIRED for a company-scoped key; a store-scoped key may omit it (when sent it must match the key's own store). | Returns `structuredContent` with `id`, `store_id`, `parent_id`, `name`, `slug`, `image`, `banner_image`, `description`, `seo`, `status`, `position`, `created_at`, `updated_at` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### create_brand Creates one brand, with an optional logo (`logo_url`) and banner (`banner_url`) by https link: Shopdesk downloads and checks them before answering, and a link that cannot be used fails the call with nothing created. Each call creates a new brand — check `list_brands` first. `slug` derives from the name and is suffixed when taken. Returns the brand. - **Title:** Create a brand - **Kind:** Write (adds data) - **openWorldHint:** `true` — downloads the links you send - **Scopes:** `catalog:write` - **Idempotency key:** honoured - **Metering:** 1 write per call - **REST twin:** `POST /ext/v1/brands` | Argument | Type | Description | | --- | --- | --- | | `name` (required) | string | — | | `slug` | string | — | | `description` | string | — | | `status` | "active" \| "inactive" | Default active. | | `seo` | object | — | | `logo_url` | string | Brand logo, by https link. Shopdesk downloads it, checks it is a PNG, JPEG, WebP or GIF (at most 10 MB) and stores its own copy. | | `banner_url` | string | Brand-page banner, by https link. Shopdesk downloads it, checks it is a PNG, JPEG, WebP or GIF (at most 10 MB) and stores its own copy. | | `idempotency_key` | string | A value unique to this LOGICAL operation (a UUID is ideal) — reuse the SAME value when retrying it, so a lost response can never double-run the write. Optional here, but recommended for automated callers. | | `store_id` | uuid | Which store to write into (UUID). REQUIRED for a company-scoped key; a store-scoped key may omit it (when sent it must match the key's own store). | Returns `structuredContent` with `id`, `store_id`, `name`, `slug`, `logo`, `banner_image`, `description`, `seo`, `status`, `created_at`, `updated_at` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### add_product_images_from_urls Adds up to 10 photos to one product’s gallery from https links, in one change. Shopdesk downloads each link, checks it is a PNG, JPEG, WebP or GIF (at most 10 MB and 8000 px) and stores its own copy; the link is not kept. All or nothing: one link that cannot be used fails the call, naming `images[i].source_url`, and no photo is added. Photos are appended unless `position` says otherwise; `is_thumbnail: true` makes one the product thumbnail. A gallery holds at most 30 photos. With an `idempotency_key`, a retry answers without downloading again. Returns `{ data }`: the new photos in the order sent, with their ids. - **Title:** Add product photos from links - **Kind:** Write (adds data) - **openWorldHint:** `true` — downloads the links you send - **Scopes:** `products:write` - **Idempotency key:** honoured - **Metering:** 1 write per call - **REST twin:** `POST /ext/v1/products/{id}/images` | Argument | Type | Description | | --- | --- | --- | | `product_id` (required) | uuid | The product id. | | `images` (required) | object[] | The photos, in order. | | `idempotency_key` | string | A value unique to this LOGICAL operation (a UUID is ideal) — reuse the SAME value when retrying it, so a lost response can never double-run the write. Optional here, but recommended for automated callers. | | `store_id` | uuid | Which store to write into (UUID). REQUIRED for a company-scoped key; a store-scoped key may omit it (when sent it must match the key's own store). | Returns `structuredContent` with `data` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### update_variant Edits one variant: `price`, `discount_price`, `sku`, `barcode`, `weight` and its photo (`image_id`, one of the product’s photos from `get_product`), with the dashboard’s rules — a sale price must be below the price, and the SKU is locked once the variant has stock or orders. Name the variant by id, SKU or barcode. Only the fields you send change; `null` clears `discount_price`, `barcode`, `weight` or `image_id`. Options and cost are not edited here. Returns the variant. - **Title:** Update a variant - **Kind:** Write (changes existing data; safe to repeat) - **openWorldHint:** `false` - **Scopes:** `products:write` - **Idempotency key:** honoured - **Metering:** 1 write per call - **REST twin:** `PATCH /ext/v1/variants/{identifier}` | Argument | Type | Description | | --- | --- | --- | | `variant` (required) | string | The variant’s id, SKU or barcode. | | `price` | number | — | | `discount_price` | number \| null | Sale price, below the price. `null` removes it. | | `sku` | string | — | | `barcode` | string \| null | `null` removes it. | | `weight` | number \| null | Shipping weight in kg. `null` removes it. | | `image_id` | uuid \| null | One of the product’s photo ids. `null` falls back to the product thumbnail. | | `idempotency_key` | string | A value unique to this LOGICAL operation (a UUID is ideal) — reuse the SAME value when retrying it, so a lost response can never double-run the write. Optional here, but recommended for automated callers. | | `store_id` | uuid | Which store to write into (UUID). REQUIRED for a company-scoped key; a store-scoped key may omit it (when sent it must match the key's own store). | Returns `structuredContent` with `id`, `store_id`, `product_id`, `sku`, `barcode`, `name`, `price`, `discount_price`, `weight`, `dimensions`, `low_stock_threshold`, `status`, `image`, `options`, `created_at`, `updated_at` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ### attach_product_video Puts a video on a product. With `video_url` (an https link to an H.264 MP4 file — not a YouTube or other player page) Shopdesk imports the file in the background and places it at once: it shows on the product once verified, and `video.status` / `placement.status` say where it is (poll with get_product, whose `videos[]` lists ready videos). `idempotency_key` is required with `video_url`. With `video_id` (a video this key uploaded) it only places it. With neither it changes nothing and returns `upload_steps`, the calls a script makes to upload a file. `option` shows the video only for one option value of the product’s active variants, by name (e.g. `{ "attribute": "Colour", "value": "Red" }` — this reads the product, so the key needs `products:read` too; a name the product does not use answers with `candidates`), or `option_value_id` by id. If an import started but the video could not then be placed, the error names the imported video id: call again with that `video_id`. `gallery_slot` is how many photos come before it (default 1, after the main photo). Placing a video the product already shows moves it. Returns `{ video, placement, upload_steps }`. - **Title:** Attach a video to a product - **Kind:** Write (adds data) - **openWorldHint:** `true` — downloads the links you send - **Scopes:** `videos:write` - **Idempotency key:** honoured - **Metering:** 1 write per call - **REST twin:** `GET /ext/v1/products/{id}`, `POST /ext/v1/videos/import`, `POST /ext/v1/products/{id}/videos` | Argument | Type | Description | | --- | --- | --- | | `product_id` (required) | uuid | The product id. | | `video_url` | string | An https link to the MP4 file itself, at most 200 MB. Shopdesk downloads it and keeps its own copy (the link is not kept). | | `video_id` | uuid | A video this key already uploaded or imported. | | `option` | object | Show the video only for this option value of the product’s variants, by name (needs products:read). Omit for every variant. | | `option_value_id` | uuid | The same by id: a variant’s `options[].value_id` from get_product. | | `title` | string | Its accessible name; defaults to the product name. | | `gallery_slot` | integer | Photos before the video: 0 = first, default 1. | | `sort_order` | integer | Order among the product's videos (default 0). | | `idempotency_key` | string | A value unique to this LOGICAL operation (a UUID is ideal) — reuse the SAME value when retrying it, so a lost response can never double-run the write. Optional here, but recommended for automated callers. | | `store_id` | uuid | Which store to write into (UUID). REQUIRED for a company-scoped key; a store-scoped key may omit it (when sent it must match the key's own store). | Returns `structuredContent` with `video`, `placement`, `upload_steps` (full JSON Schema in the [tool manifest](https://www.shopdeskbd.com/openapi/mcp-tools-v1.json)), and the same value as minified JSON text. ## REST operations with no tool - `GET /ext/v1/variants/{identifier}` — get_product returns every variant of the product nested, and list_products searches by variant SKU; a per-variant lookup would be a second tool answering the same question. - `GET /ext/v1/categories/{id}` — list_categories returns the same rows; one category is never worth a tool of its own. - `GET /ext/v1/brands/{id}` — list_brands returns the same rows; one brand is never worth a tool of its own. - `GET /ext/v1/locations/{id}` — list_locations returns the same rows; a store has a handful of locations. - `GET /ext/v1/inventory/movements` — The stock ledger is sync data for ERPs and accounting, read over REST; get_inventory_levels answers the conversational question (how much is there). - `POST /ext/v1/products/{id}/images/reorder` — Reordering photos is install-time catalog work (§5.1: photo delete, reorder and alt-text edits stay REST-only), and it restates the whole gallery, which a model gets wrong too easily. - `PATCH /ext/v1/products/{id}/images/{imageId}` — Photo alt-text, order and main-photo edits are install-time catalog work (§5.1); add_product_images_from_urls sets alt text, position and the main photo when it adds a photo. - `DELETE /ext/v1/products/{id}/images/{imageId}` — A permanent photo delete is kept to REST (§5.1) so a conversation cannot remove catalog media. - `PATCH /ext/v1/attributes/{id}` — Renaming an attribute or changing its type or flags is install-time catalog work; create_attribute never renames or removes anything. - `PATCH /ext/v1/attributes/{id}/values/{valueId}` — Renaming a value relabels every variant that shows it — catalog correction work, kept to REST and the dashboard. - `PATCH /ext/v1/categories/{id}` — Category edits (moves, renames, image swaps) are install-time catalog work; create_category covers the conversational case. - `PATCH /ext/v1/brands/{id}` — Brand edits (renames, logo swaps) are install-time catalog work; create_brand covers the conversational case. - `POST /ext/v1/videos` — Starting an upload session is binary work for a script or the SDK: the caller then PUTs file parts to storage, which a model cannot do (§5.1). attach_product_video takes a finished upload’s video_id. - `POST /ext/v1/videos/{id}/part-urls` — Part URLs exist only to PUT file bytes to storage — binary is not tool-shaped (§5.1). - `POST /ext/v1/videos/{id}/complete` — Completing an upload follows the byte transfer a script makes; it is part of the same upload session (§5.1). - `GET /ext/v1/videos` — Upload-session status for the script doing the upload (§5.1); get_product shows a product’s ready videos. - `GET /ext/v1/videos/{id}` — Upload-session status for the script doing the upload (§5.1); get_product shows a product’s ready videos. - `DELETE /ext/v1/videos/{id}` — Video deletes are kept to REST (§5.1: video posters and deletes), so a conversation cannot remove catalog media. - `POST /ext/v1/videos/{id}/poster` — Video covers are kept to REST (§5.1: video posters and deletes): an uploaded cover is binary, and a product page falls back to the product thumbnail without one. - `DELETE /ext/v1/products/{id}/videos/{videoId}` — Taking a video off a product is a media delete, kept to REST (§5.1: video posters and deletes) so a conversation cannot remove catalog media; attach_product_video only adds or moves a video. - `GET /ext/v1/reports/top-products` — EXPANSION_PLAN §5.1 lists no tool for the ranking: get_sales_summary answers the conversational sales question, and the tool budget is held for the order desk. A BI sync reads the ranking over REST; adding a tool later means moving this line to MCP_REST_TWIN. --- # Overview Outbound events: the envelope, the guarantees, the rules. A webhook is a signed `POST` from us to a URL you own, sent when something happens in a store. It carries the same object shape the REST API returns, so an `order.created` webhook and `GET /orders/{id}` can never disagree about what an order looks like — they are produced by the same serializer. Webhooks replace polling for the things you need to know about *quickly*. They do not replace polling entirely: `updated_after` remains the reconciliation path for anything you missed while your receiver was down. ## How an event reaches you ```text your store does something (a sale, a stock move, a status change) │ ├─ in the SAME database transaction ──▶ the event is recorded │ (with a snapshot of the object) ├─ just after the commit ─────────────▶ fan-out to your subscribed endpoints │ (a 30s sweeper re-drives anything missed) └─ per endpoint ──────────────────────▶ signed POST → 2xx, or the retry ladder ``` The first step is the one that matters. The event row is written inside the same transaction as the fact it describes, so "the database committed but the event vanished" cannot happen: they commit together or not at all. Everything after that — the queue, the sweeper, the retries — is about speed, not correctness. ## The payload Every delivery has the same envelope. Only `data` changes between event types. ```json { "id": "evt_01J4X8G9ABCDEFGHJKMNPQRSTV", "type": "order.updated", "api_version": "2026-08", "created_at": "2026-08-11T10:00:00.000Z", "company_id": "e07b4a29-6c13-4d85-b920-3f8e1c65d704", "store_id": "b21c5f04-9d7e-4c11-8f3a-6e0d2b915a77", "data": { "object": { "id": "c9d8e7f6-1a2b-4c3d-8e9f-0a1b2c3d4e5f", "number": "ORD-20260811-1042", "status": "confirmed", "payment_status": "partially_paid", "total_amount": "2310.00" }, "previous": { "status": "pending" } } } ``` | Field | What it is | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | | `id` | `evt_` + a ULID. Globally unique, sortable by creation time. **This is your de-duplication key.** | | `type` | The event type, from the [catalog](https://www.shopdeskbd.com/developers/webhook-events.md). Treat it as an open set. | | `api_version` | The payload version pinned to *your endpoint* when it was created. See [Versioning](https://www.shopdeskbd.com/developers/versioning.md). | | `created_at` | When the event was recorded — not when it was delivered. A retry two days later still carries the original. | | `company_id`, `store_id` | Which tenant this happened in. Load-bearing if one endpoint receives several stores. | | `data.object` | The full object, from the same serializer the REST API uses. | | `data.previous` | Only on `*.updated` events: the old values of what changed. The keys for `order.updated` are listed below. | ### `data.previous` on `order.updated` Which keys appear depends on what changed. Two of the four changes use the camelCase keys they shipped with in v1, so they are **not** named as in `data.object`: | What changed | Keys in `data.previous` | Casing | | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------- | | The status moved | `status` | As in `data.object` | | The note, follow-up or delivery address was edited | Only the keys that changed, from `note`, `follow_up_at`, `follow_up_note` and `shipping_address` (the whole stored address) | As in `data.object` | | The lines were edited before fulfilment | All of `subtotal`, `discountAmount`, `shippingCharge`, `taxAmount`, `totalAmount` and `itemCount` (the old line count), as numbers | camelCase | | The delivery charge was changed on the shipment booking form | `shippingCharge` and `totalAmount`, as numbers | camelCase | So to diff a lines edit against `data.object`, compare `previous.discountAmount` with `object.discount_amount`, and so on. Check which keys `data.previous` holds rather than assuming one shape. > **Note — Orders always arrive expanded** > > The REST list endpoint makes `items` and `customer` opt-in, because a sync > walking a hundred orders a page does not want them. A webhook is the opposite > situation — one event, delivered once, to a consumer who would otherwise need > a second round trip — so order payloads always carry both. ## What we guarantee, and what we do not | | | | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | **At-least-once delivery** | You will receive every event at least once, and occasionally more than once. A network timeout is indistinguishable from a slow success, so we retry. | | **No ordering guarantee** | Deliveries run in parallel and retries reshuffle everything. `order.updated` can arrive before `order.created`. | | **A snapshot, not a pointer** | `data.object` is the object as it was inside the transaction that produced the event — not the state at delivery time, and not something you must re-fetch. | | **Byte-identical redelivery** | A retry sends exactly the same bytes as the first attempt, so a signature you stored still verifies. | Those three properties add up to one rule, which is the whole of writing a correct receiver: **de-duplicate on `id`, and treat every handler as an upsert.** ## The four rules of a receiver 1. **Verify the signature before you trust the body.** With a constant-time comparison, against the raw bytes. → [Verifying signatures](https://www.shopdeskbd.com/developers/webhook-signatures.md) 2. **De-duplicate on `X-Shopdesk-Event-Id`.** A unique index on that id, and a `DO NOTHING` on conflict, is the entire mechanism. 3. **Upsert from `data.object`.** Never patch field-by-field from an event, and never infer state from arrival order. When in doubt, `GET` the resource — the REST API is always authoritative. 4. **Answer 2xx fast, work asynchronously.** Anything is a success if it is `2xx`; we give you **10 seconds** before we call it a timeout and retry. Queue the work, then answer. > **Warning — A slow 200 costs you a duplicate** > > Ten seconds is generous for "I have it" and deliberately hostile to "let me > do the work first". If your handler talks to a payment provider, rebuilds a > cache, or sends an email inline, one slow dependency turns into a timeout on > our side and a second delivery on yours — of an event you already processed. ## Registering an endpoint Endpoints are created in the dashboard, under **Settings → Developer → Webhooks**. You give a name, an `https://` URL, and the events you want. - **The signing secret is shown once**, at creation, and again only when you rotate it. It starts with `whsec_`. - **Subscribe to specific types, or to `*`.** The wildcard means *every* event, including types added later. It cannot be mixed with an explicit list — `["*", "order.created"]` is refused, because it reads as if the second entry narrowed something, and it does not. - **Five endpoints per store** by default (`max_webhook_endpoints`, raised by plan). > **Note — Webhook endpoints cannot be managed over the API** > > There is no public endpoint for creating or editing them, and the > `webhooks:manage` scope in the catalogue is not grantable. That is > deliberate: a leaked API key must not be able to point a merchant's event > stream at somebody else's server. ### URL requirements Your URL is fetched by our servers, which makes it a [server-side request forgery](https://owasp.org/www-community/attacks/Server_Side_Request_Forgery) target. Four rules follow, and they are enforced both when you save an endpoint and again on **every** delivery, because DNS can change under a name that validated last week: - **`https://` only.** A payload carries customer data and a signature; `http` would send both in the clear. - **No credentials in the URL**, and no privileged port other than `443`. - **Public addresses only.** The hostname is resolved and the actual IP is checked — a name that points at `127.0.0.1` or `169.254.169.254` is refused. - **Redirects are never followed.** A `3xx` is recorded as a failed delivery with the message *"Redirects are not followed. Register the final URL directly."* Register the URL you actually want called. ## Next - [Event catalog](https://www.shopdeskbd.com/developers/webhook-events.md) — every type we send. - [Verifying signatures](https://www.shopdeskbd.com/developers/webhook-signatures.md) — with test vectors you can check your code against before going live. - [Retries & ordering](https://www.shopdeskbd.com/developers/webhook-retries.md) — the ladder, dead deliveries, and the three-day auto-disable. - [Testing](https://www.shopdeskbd.com/developers/webhook-testing.md) — `shopdesk.ping` and a local loop. - [Handle webhooks idempotently](https://www.shopdeskbd.com/developers/guide-handle-webhooks.md) — the whole receiver, end to end. --- # Event catalog Every event type we send, and what each one carries. Twenty-four event types, in eight groups. Every one of them maps to a real write in the product — nothing here fires from a place that does not exist, and nothing is emitted "for completeness". Subscribe to the ones you need, or to `*` for everything including types added later. > **Warning — Treat the catalog as an open set** > > Event names are **additive forever**: a new event is a new entry, but an > existing name is never renamed and never changes meaning. A rename would be a > silent outage on your side that no deprecation header could reach. > > The corollary is that your handler must ignore types it does not recognise > rather than throw on them — a `default:` branch that logs and returns. ## Orders One fact, one event. An order rung up on a till, typed in at the desk, placed on a storefront or posted through this API is the same fact — *an order was created* — and all four emit `order.created`. Tell them apart with `data.object.source` (`admin`, `storefront`, `pos`, `channel`, `api`) or `data.object.channel`. | Event | `data.object` | Fires when | | ----------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `order.created` | order | An order was placed on **any** surface. | | `order.updated` | order | The order status changed — whether a human moved it or a courier webhook did — carrying `data.previous.status`; or its note, follow-up or delivery address changed (in any status), carrying the old `note`, `follow_up_at`, `follow_up_note` or `shipping_address`, named as in `data.object`; or its lines were edited before fulfilment, carrying the old `subtotal`, `discountAmount`, `shippingCharge`, `taxAmount`, `totalAmount` and `itemCount` (camelCase, as shipped in v1); or its delivery charge was changed on the booking form, carrying the old `shippingCharge` and `totalAmount`. Check which keys `data.previous` holds — see [the payload](https://www.shopdeskbd.com/developers/webhooks.md#dataprevious-on-orderupdated). | | `order.paid` | order | The order crossed into fully-paid. **Edge-triggered**: it fires on the transition, not on every subsequent payment. | | `order.payment.created` | order\_payment | A payment row was written against an order. Refunds appear here too, as negative amounts — read `data.object.amount`, do not assume a sign. | | `order.cancelled` | order | The order moved to cancelled and its stock was released. | | `order.fulfilled` | order | The order reached delivered — the point where the sale is finalised and stock is committed. | | `order.refund.created` | order\_payment | A refund was paid out against a return. | ## Returns | Event | `data.object` | Fires when | | ---------------- | ------------- | -------------------------------------------------------------------------------------------------- | | `return.created` | return | A customer return was opened. | | `return.updated` | return | A return changed status (approved, received, completed, rejected). Carries `data.previous.status`. | ## Shipping | Event | `data.object` | Fires when | | ------------------ | ------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `shipment.created` | shipment | A shipment was booked for an order, with or without a courier. | | `shipment.updated` | shipment | A shipment changed status — including the courier-webhook-driven transitions that also move the order. Carries `data.previous.status`. | ## Inventory | Event | `data.object` | Fires when | | ----------------------- | ----------------- | ---------------------------------------------------------------------------------------------- | | `inventory.adjusted` | stock\_movement | A manual stock adjustment was made (correction, damage, initial stock) — always with a reason. | | `inventory.transferred` | stock\_transfer | Stock moved between two locations. One event for the whole transfer, never one per line. | | `inventory.updated` | variant level set | The sellable level for a variant at a location changed, for any reason. | | `inventory.low_stock` | inventory\_level | A variant **crossed** its low-stock threshold at a location. | > **Note — Two inventory events that behave unusually — on purpose** > > **`inventory.updated` is coalesced**: at most one event per variant per ten > seconds, so a 500-line stock import does not become 500 events describing > levels that were superseded before the first one arrived. The payload carries > every location for that variant, so it is always internally consistent — read > the level from `data.object`, never a delta. > > **`inventory.low_stock` is edge-triggered**: it fires on the crossing, once. > Staying below the threshold does not re-fire it, and rising back above it > re-arms the trigger. A variant with a threshold of `0` never fires it at all — > that means "not configured", not "always low". ## Catalog | Event | `data.object` | Fires when | | ------------------ | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `product.created` | product | A product and its variants were created. | | `product.updated` | product | A product was edited, or a video it shows changed without an edit: the video turned ready (it joins `videos[]`), a ready video got a new cover, or a released video was taken off the archived product. A bulk save emits one event per product, not one for the batch. | | `product.archived` | product | A product was archived and is no longer sellable. | ## Customers | Event | `data.object` | Fires when | | ------------------ | --------------- | ----------------------------------------------------------------------------------------------------------------------- | | `customer.created` | customer | A customer record was created — including the find-or-create that happens at storefront checkout and on `POST /orders`. | | `customer.updated` | customer | A customer record was edited. | | `customer.merged` | customer\_merge | Two customer records were merged. | > **Warning — customer.merged means an id you hold may be stale** > > `data.object` is the **survivor**, and `data.object.merged_from_id` is now a > pointer rather than a record. Repoint anything you hold against it. > > Nothing breaks if you do not: the merged-away id still resolves to the > survivor on every lookup path. But your own reports will double-count a > customer that Shopdesk now considers one person. ## POS | Event | `data.object` | Fires when | | -------------------- | ------------- | --------------------------------------------------------------------------------- | | `pos.session.opened` | pos\_session | A cashier opened a register with a starting float. | | `pos.session.closed` | pos\_session | A register was counted and closed. Carries the expected/counted/variance summary. | ## System | Event | `data.object` | Fires when | | --------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `shopdesk.ping` | ping | Sent by the **Send test event** button. It rides the real pipeline — same signing, same retries, same delivery log — so a ping that arrives proves a real event would. See [Testing](https://www.shopdeskbd.com/developers/webhook-testing.md). | ## Object shapes Most `data.object` shapes are the ones the REST API already publishes, field for field: | Object | Documented at | | ---------------- | ---------------------------------------------------------------------------------------------- | | order | [Orders](https://www.shopdeskbd.com/developers/reference/orders.md) | | order\_payment | [Orders → List an order's payments](https://www.shopdeskbd.com/developers/reference/orders.md) | | product | [Products](https://www.shopdeskbd.com/developers/reference/products.md) | | customer | [Customers](https://www.shopdeskbd.com/developers/reference/customers.md) | | inventory\_level | [Inventory](https://www.shopdeskbd.com/developers/reference/inventory.md) | | stock\_movement | [Inventory](https://www.shopdeskbd.com/developers/reference/inventory.md) | Six shapes are webhook-only, because the v1 REST surface has no endpoint for them yet. They follow the same conventions — `snake_case`, whitelisted fields, money as two-decimal strings, ISO-8601 UTC timestamps — and when a REST endpoint for them lands, it will publish exactly these: **`return`** — `id`, `store_id`, `order_id`, `order_number`, `number`, `status`, `reason`, `location_id`, `refund_amount`, `restock`, `note`, `received_at`, `refunded_at`, `created_at`, `updated_at`, and `items[]` of `{ id, order_item_id, variant_id, quantity, restock }`. **`shipment`** — `id`, `store_id`, `order_id`, `order_number`, `location_id`, `courier` (the slug, or `null` for a self-delivered shipment), `tracking_number`, `external_id` (the courier's own consignment id), `status`, `shipping_charge`, `shipped_at`, `delivered_at`, `returned_at`, `created_at`, `updated_at`. **The `inventory.updated` variant level set** — `variant_id`, `store_id`, `sku`, `total_on_hand`, `total_available` (summed across locations), and `levels[]`, one full inventory-level object per location. Note that this is a *set*, not a single level: a transfer changes two locations at once, and a consumer receiving them separately would briefly believe stock had vanished. The catalog endpoint reports this event's `objectType` as `inventory_level`, which describes the entries in `levels[]` rather than the wrapper. **`stock_transfer`** — `reference_id` (the id the two ledger entries share), `reference_type`, `store_id`, `from_location_id`, `to_location_id`, `note`, `occurred_at`, and `lines[]` of `{ variant_id, quantity }`. **`customer_merge`** — the full survivor customer object, plus `merged_from_id`, `moved` (a count of the rows of each kind that moved across) and `merged_at`. **`pos_session`** — `id`, `store_id`, `register` (`{ id, name, code }`), `number`, `status`, `opened_at`, `opening_float`, `closed_at`, `created_at`, `updated_at`, and `cash_summary` — `null` while the session is open, and otherwise `{ expected_cash, counted_cash, variance, variance_note, needs_approval }`. **`ping`** — `store_id`, `message`, `sent_at`. > **Note — Who closed the till is deliberately absent** > > `pos_session` carries no staff user ids. They are internal principals with no > meaning outside our system, and there is no users API to resolve them > against, so publishing them would be an id you could store and never use. ## Fetching the catalog programmatically The dashboard's event picker, the validation that runs when you save an endpoint, and this page are all fed by one typed constant in the backend. You can read it too, from the dashboard API: ```http GET /api/v1/developer/webhook-endpoints/events ``` It answers with `wildcard`, the current `apiVersion`, and an `events` array of `{ type, group, label, description, objectType, carriesPrevious }`. > **Warning — That is the dashboard API, not the public one** > > This endpoint lives on `/api/v1` and authenticates with a **staff session** > (`developer.read` permission, plus a plan carrying `api_access`) — not with a > `shopdesk_sk_` API key. It is there so a tool building a subscription UI can > read the same list the dashboard does. Everything it returns is on this page. --- # Verifying signatures The signing scheme, code in three languages, and test vectors. Anyone can `POST` JSON at your endpoint. The signature is what tells you a delivery is ours — verify it before you read the body, not after. ## The scheme HMAC-SHA256 over `"{timestamp}.{rawBody}"`, keyed with your endpoint's signing secret, hex-encoded, and sent in one header: ```http POST /webhooks/shopdesk HTTP/1.1 Content-Type: application/json X-Shopdesk-Signature: t=1754899200,v1=a24d2da1ccf592fe5dfc94e9e051d460cc58814580a5541e4608e52abee28d9c X-Shopdesk-Event: order.created X-Shopdesk-Event-Id: evt_01J4X8G9ABCDEFGHJKMNPQRSTV ``` | Part | Meaning | | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `t` | The Unix timestamp, in **seconds**, at which the request was signed. | | `v1` | A hex HMAC-SHA256 digest. `v1` names the *scheme*, not the payload version — a future `v2=` would mean a different algorithm, and receivers that only understand `v1` would keep working. | There may be **two `v1=` values**. That is a secret rotation in progress: the old secret keeps co-signing for 24 hours so you can deploy the new one at your own pace. Accept the delivery if *either* matches — see [Rotating a secret](https://www.shopdeskbd.com/developers/webhook-rotation.md). Three details carry the whole security argument, and skipping any one of them produces code that looks like it verifies and does not: - **The timestamp is inside the signed string.** Signing the body alone would let anyone who captured one delivery replay it forever with a fresh `t=`, because the header would still verify. Because `t` is signed, changing it invalidates the signature — which is what makes your freshness check meaningful rather than decorative. - **The raw bytes are signed, not a parsed object.** See [the raw-body trap](#the-raw-body-trap). - **The comparison must be constant-time.** `a === b` on a digest leaks, through timing, how many leading characters an attacker guessed right. That turns forging a 64-character hex string from impossible into a few thousand requests. ## Verifying **Node.js** ```javascript import crypto from "node:crypto"; const TOLERANCE_SECONDS = 300; // 5 minutes /** * @param rawBody the EXACT bytes we sent, as a string or Buffer — never a * re-serialised object. */ export function verifyShopdeskSignature(rawBody, header, secret) { if (!header) return false; let timestamp = null; const signatures = []; for (const part of header.split(",")) { const [key, value] = part.split("="); if (key?.trim() === "t") timestamp = Number(value?.trim()); if (key?.trim() === "v1") signatures.push(value?.trim()); } if (!Number.isFinite(timestamp) || signatures.length === 0) return false; // Reject anything captured and replayed later. The timestamp is INSIDE the // signed string, so an attacker cannot simply put a fresh one in the header. const now = Math.floor(Date.now() / 1000); if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false; const expected = crypto .createHmac("sha256", secret) .update(`${timestamp}.${rawBody}`, "utf8") .digest("hex"); // During a secret rotation there are TWO v1= values; either may match. return signatures.some((candidate) => timingSafeEqual(expected, candidate)); } // Never use === on a signature: the early exit leaks, through timing, how // many leading characters an attacker guessed right. function timingSafeEqual(a, b) { if (a.length !== b.length) return false; return crypto.timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")); } ``` **Python** ```python import hashlib import hmac import time TOLERANCE_SECONDS = 300 # 5 minutes def verify_shopdesk_signature(raw_body: bytes, header: str, secret: str) -> bool: """raw_body is the exact request body — bytes, not a parsed dict.""" if not header: return False timestamp = None signatures = [] for part in header.split(","): key, _, value = part.partition("=") if key.strip() == "t": timestamp = value.strip() elif key.strip() == "v1": signatures.append(value.strip()) if timestamp is None or not signatures: return False # Replay window. The timestamp is signed, so it cannot be swapped. if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS: return False signed_payload = timestamp.encode() + b"." + raw_body expected = hmac.new( secret.encode(), signed_payload, hashlib.sha256 ).hexdigest() # compare_digest, never ==: a plain comparison leaks the match length. # Two v1= values appear during a rotation; either may match. return any(hmac.compare_digest(expected, s) for s in signatures) ``` **PHP** ```php SHOPDESK_TOLERANCE_SECONDS) { return false; } $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); // hash_equals, never ===. Two signatures ride along during a rotation. foreach ($signatures as $candidate) { if (hash_equals($expected, $candidate)) { return true; } } return false; } ``` Then reject the delivery if verification fails. A `400` is the right answer: we will retry it, but no number of retries will make an unsigned request valid, and the failure is visible in the merchant's delivery log where they can act on it. ## The five-minute window Reject any delivery whose `t` is more than **300 seconds** from your own clock, in either direction. We do not enforce this — you do, and the code above shows where. That window is a compromise between two real problems: a captured request should not be replayable tomorrow, and two machines nobody synchronised will disagree by a few seconds. Five minutes is comfortably more than clock skew and comfortably less than useful to an attacker. > **Note — A retried delivery still carries a fresh timestamp** > > Signing happens per attempt, not per event. A delivery retried 24 hours later > is signed at the moment it is sent, so it passes your window check — while > `created_at` in the body still tells you when the event actually happened. ## The raw-body trap This is the single most common way a working integration breaks intermittently, so it is worth stating bluntly: **verify against the exact bytes you received.** Re-serialising JSON produces different bytes on different platforms — key order, unicode escaping, whitespace, number formatting. A receiver that verifies against `JSON.stringify(req.body)` will match on most payloads and fail on the ones containing a Bengali product name or a decimal that round-trips differently. It looks like an intermittent network problem and it is not. Every popular framework parses the body for you by default, so getting the raw bytes takes one deliberate line: **Express** ```javascript import express from "express"; const app = express(); // Option A — keep the parsed body AND capture the raw bytes beside it. app.use( express.json({ verify: (req, _res, buffer) => { req.rawBody = buffer; }, }), ); // Option B — for the webhook route only, do not parse at all. app.post( "/webhooks/shopdesk", express.raw({ type: "application/json" }), (req, res) => { // req.body is a Buffer here — exactly the bytes we signed. const ok = verifyShopdeskSignature( req.body, req.get("X-Shopdesk-Signature"), process.env.SHOPDESK_WEBHOOK_SECRET, ); if (!ok) return res.sendStatus(400); const event = JSON.parse(req.body.toString("utf8")); res.sendStatus(200); void handleLater(event); }, ); // What NOT to do: JSON.stringify(req.body). Re-serialising changes key // order, unicode escaping and whitespace, so the digest will differ from // ours on some payloads and match on others — the worst kind of bug. ``` **Django / Flask** ```python # Django — request.body is the raw bytes, before any parsing. from django.http import HttpResponse from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST @csrf_exempt @require_POST def shopdesk_webhook(request): ok = verify_shopdesk_signature( request.body, # bytes — do NOT use request.POST or json.dumps() request.headers.get("X-Shopdesk-Signature"), settings.SHOPDESK_WEBHOOK_SECRET, ) if not ok: return HttpResponse(status=400) event = json.loads(request.body) enqueue_event(event) # queue it; answer immediately return HttpResponse(status=200) # Flask — request.get_data() with cache=True, so calling .json later still # works. request.get_json() alone would give you a dict you cannot verify. @app.post("/webhooks/shopdesk") def shopdesk_webhook_flask(): raw = request.get_data(cache=True) if not verify_shopdesk_signature( raw, request.headers.get("X-Shopdesk-Signature"), SECRET ): return "", 400 enqueue_event(json.loads(raw)) return "", 200 ``` **Laravel** ```php withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]); class ShopdeskWebhookController extends Controller { public function handle(Request $request) { // getContent() is the raw body. $request->all() and $request->json() // are parsed structures — re-encoding them changes the bytes. $raw = $request->getContent(); $ok = verify_shopdesk_signature( $raw, $request->header('X-Shopdesk-Signature'), config('services.shopdesk.webhook_secret'), ); if (! $ok) { return response()->noContent(400); } $event = json_decode($raw, true); ShopdeskEventJob::dispatch($event); // queue, then answer return response()->noContent(200); } } ``` ## Verify your implementation These vectors are pinned by a test in our own codebase, so they cannot drift away from what the server does. Run your verification code against them before you point it at a live endpoint — if it disagrees here, it will disagree in production, and you will be debugging it against real orders. | | | | ----------------- | ---------------------------------------------------------------------------------- | | **Secret** | `whsec_TestSecretForDocumentationVectorsOnly1` | | **Timestamp** | `1754899200` | | **Raw body** | `{"id":"evt_01J4X8G9ABCDEFGHJKMNPQRSTV","type":"shopdesk.ping"}` | | **Signed string** | `1754899200.{"id":"evt_01J4X8G9ABCDEFGHJKMNPQRSTV","type":"shopdesk.ping"}` | | **Expected `v1`** | `a24d2da1ccf592fe5dfc94e9e051d460cc58814580a5541e4608e52abee28d9c` | | **Full header** | `t=1754899200,v1=a24d2da1ccf592fe5dfc94e9e051d460cc58814580a5541e4608e52abee28d9c` | **Node.js** ```javascript import crypto from "node:crypto"; const secret = "whsec_TestSecretForDocumentationVectorsOnly1"; const timestamp = 1754899200; const body = '{"id":"evt_01J4X8G9ABCDEFGHJKMNPQRSTV","type":"shopdesk.ping"}'; const signature = crypto .createHmac("sha256", secret) .update(`${timestamp}.${body}`, "utf8") .digest("hex"); console.log(signature); // a24d2da1ccf592fe5dfc94e9e051d460cc58814580a5541e4608e52abee28d9c console.assert( signature === "a24d2da1ccf592fe5dfc94e9e051d460cc58814580a5541e4608e52abee28d9c", "Your signing does not match Shopdesk's. Check that you sign " + "`timestamp + '.' + rawBody` and hex-encode the digest.", ); ``` **Python** ```python import hashlib import hmac secret = "whsec_TestSecretForDocumentationVectorsOnly1" timestamp = "1754899200" body = '{"id":"evt_01J4X8G9ABCDEFGHJKMNPQRSTV","type":"shopdesk.ping"}' signature = hmac.new( secret.encode(), timestamp.encode() + b"." + body.encode(), hashlib.sha256, ).hexdigest() print(signature) # a24d2da1ccf592fe5dfc94e9e051d460cc58814580a5541e4608e52abee28d9c assert signature == ( "a24d2da1ccf592fe5dfc94e9e051d460cc58814580a5541e4608e52abee28d9c" ), "Your signing does not match Shopdesk's." ``` **PHP** ```php **Warning — Note the body has no spaces** > > The raw body above is exactly 59 bytes with no whitespace between tokens — > which is what we send, and what you must hash. If you retyped it from this > page with pretty-printing, your digest will not match, and that mismatch is > the raw-body trap in miniature. If your digest matches, your signing is correct. The remaining things to check in your own code are the ones the vector cannot exercise: that you compare in constant time, that you enforce the timestamp window, and that you accept either `v1` value when two are present. ## Where the secret lives - **Shown once**, when the endpoint is created, and again only when you rotate it. It starts with `whsec_`. - **Store it like a password** — an environment variable or a secret manager, never in source. The `whsec_` prefix exists so secret scanners can catch it if it does end up in a commit. - **One secret per endpoint.** If you receive events at two URLs, they have two different secrets, and verifying with the wrong one fails exactly as if the request were forged. - **We cannot show it to you again.** It is encrypted rather than hashed (signing needs the plaintext back), but there is no screen and no support process that prints it. If you lose it, rotate. --- # Retries & ordering The retry ladder, dead deliveries, and auto-disable. A delivery succeeds on any `2xx`. Anything else — a `4xx`, a `5xx`, a redirect, a connection failure, or no answer within **10 seconds** — is a failure, and failures are retried on a fixed ladder. ## What counts as success | Response | Outcome | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | | `200`–`299` | **Delivered.** Nothing else is read; the body is kept as a snippet for the log. | | `3xx` | Failed. Redirects are never followed — register the final URL directly. | | `4xx` | Failed, and retried. A `404` from a receiver that has not deployed yet is indistinguishable from a `404` from one that never will. | | `5xx` | Failed, and retried. | | No response in 10s | Failed, and retried. | The body of your response is never interpreted — only the status. The first kilobyte of it is stored in the delivery log so the merchant can see what your server said, with any header-shaped lines (`Set-Cookie:`, `Authorization:`) stripped before storage. ## The retry ladder An initial attempt plus eight retries, spanning roughly 45 hours: | Attempt | Sent | | ------- | -------------------------------------------- | | 1 | Immediately | | 2 | 30 seconds after attempt 1 | | 3 | 2 minutes later | | 4 | 10 minutes later | | 5 | 30 minutes later | | 6 | 2 hours later | | 7 | 6 hours later | | 8 | 12 hours later | | 9 | 24 hours later | | — | if attempt 9 fails, the delivery is **dead** | The shape is deliberate rather than exponential. The first rungs are minutes, because most failures are a deploy or a blip. The last are hours, because the rest are an outage somebody has to wake up for. Pure exponential backoff gives you neither: it is too impatient at the start and absurd at the end. Spanning \~45 hours means a Friday-evening outage is still being retried on Sunday morning. `next_retry_at` on the delivery record always says exactly when the next attempt is due, so you never have to infer your position on the ladder from this table. ## Delivery states | Status | Meaning | | ----------- | --------------------------------------------------------------------------------------- | | `pending` | Created, not yet attempted — or waiting for its next rung. | | `delivered` | A `2xx` was received. Terminal. | | `failed` | An attempt failed and **another is scheduled**. The dashboard shows this as *Retrying*. | | `dead` | The ladder is exhausted. Nothing further happens automatically. | A `dead` delivery is not lost. The event and its exact payload are kept for **30 days**, and the merchant can re-send it from **Settings → Developer → Webhooks → delivery history**. A manual retry restarts the ladder at rung one, so a receiver that has just been fixed does not wait out the 24-hour rung the automatic schedule had reached. > **Note — A disabled endpoint closes its deliveries rather than retrying them** > > If an endpoint is disabled while deliveries are queued for it, those > deliveries are marked `dead` with the reason recorded, instead of > accumulating attempts against a receiver nobody wants contacted. Turning the > endpoint back on does not resurrect them — retry the ones you want from the > log. ## Auto-disable after three days If **every** delivery to an endpoint fails continuously for three days, we turn it off. The endpoint's status becomes `auto_disabled`, and the merchant gets a dashboard notification: > **Webhook endpoint disabled** — *\* failed every delivery for 3 > days and has been turned off. Fix the receiver, then re-enable it under > Settings → Developer → Webhooks. Three things about this are worth planning around: - **It is measured from the first failure in the current failing streak**, not from a failure count. A count cannot tell "broken since Tuesday" apart from "fails its one event a month". A single success resets the clock. - **Re-enabling is manual.** An endpoint that resumed by itself would go straight back to failing, and the notification would become noise the merchant learns to ignore. - **Re-enabling clears the health verdict** — the failure streak resets, so one bad attempt after a fix does not immediately disable it again. While an endpoint is disabled, events are still recorded for the store; they are simply not delivered to that endpoint. Use `updated_after` polling to backfill whatever you missed — that is the reconciliation path webhooks never remove the need for. ## Ordering is not guaranteed It is not "usually in order" or "in order except under load". It is **not guaranteed**, and you should design as if events arrive shuffled. Deliveries run on parallel workers, each subscriber carries its own retry ladder, and a delivery that failed once is by definition behind one that did not. So `order.updated` can arrive before `order.created`; two updates to the same order can arrive in either order. Promising ordering would mean a single serialised queue per store — one slow receiver would then stall every other event for that merchant, and a permanent failure would stall them forever. That is a worse system, honestly described. **What to do instead**, and it is genuinely less work than handling order: - **Upsert, do not patch.** Every payload carries the whole object as of its own transaction, so writing it wholesale is always safe. - **Ignore arrival order; use the data.** `data.object.updated_at` and `created_at` on the envelope tell you when things actually happened. If you keep the newest snapshot per id, out-of-order delivery becomes a non-event. - **`GET` when in doubt.** The REST API is authoritative at the moment you call it. An event tells you *that* something changed; the API tells you what is true now. - **Handle the create you never saw.** If `order.updated` arrives for an id you do not know, create the record from `data.object` rather than dropping the event — the `order.created` you were waiting for may be two retries behind. > **Warning — Duplicates are not an edge case** > > A timeout on our side is indistinguishable from a slow success on yours: you > may have processed the event perfectly and lost the acknowledgement. We > retry, so you get it twice. > > De-duplicating on `X-Shopdesk-Event-Id` — a unique column and an > insert-or-ignore — is the entire defence, and it is three lines. See > [Handle webhooks idempotently](https://www.shopdeskbd.com/developers/guide-handle-webhooks.md). ## Retention | | | | -------------------------------- | --------------------------------------- | | Events and their payloads | **30 days** | | Delivery attempts and their logs | **30 days** (deleted with the endpoint) | After that a delivery cannot be replayed from the dashboard. If your receiver was down for longer than that, reconcile with [`updated_after`](https://www.shopdeskbd.com/developers/pagination.md#syncing-changes-with-updated_after) rather than waiting for events that no longer exist. --- # Testing The shopdesk.ping event and how to develop against it. There is no sandbox tenant and no test-mode key (a free developer test store is available on request, from `support@shopdeskbd.com`), so the way to prove a receiver works is to send it a real event that changes nothing: `shopdesk.ping`. ## Send a test event In the dashboard: **Settings → Developer → Webhooks**, open an endpoint, and choose **Send test event**. The ping is not a shortcut. It is recorded in the outbox, fanned out, signed with your endpoint's actual secret, delivered by the same worker, retried on the same ladder, and written to the same delivery log as any other event. A ping that arrives is therefore real evidence that an `order.created` would — and a ping that fails is debuggable with the same detail view, showing the exact payload sent and the response received. ```json { "id": "evt_01J4X8G9ABCDEFGHJKMNPQRSTV", "type": "shopdesk.ping", "api_version": "2026-08", "created_at": "2026-08-11T10:00:00.000Z", "company_id": "e07b4a29-6c13-4d85-b920-3f8e1c65d704", "store_id": "b21c5f04-9d7e-4c11-8f3a-6e0d2b915a77", "data": { "object": { "store_id": "b21c5f04-9d7e-4c11-8f3a-6e0d2b915a77", "message": "This is a test event from Shopdesk. If you can read this, your endpoint and signature verification are working.", "sent_at": "2026-08-11T10:00:00.000Z" } } } ``` > **Warning — Subscribe to shopdesk.ping, or the button refuses** > > The ping goes through the same subscription gate as every other event, so an > endpoint that is not subscribed to `shopdesk.ping` has nothing to send. You get: > > *"This endpoint is not subscribed to shopdesk.ping. Add it to the endpoint (or > subscribe to all events) and try again."* > > Add `shopdesk.ping` to the endpoint's event list — or use the `*` wildcard, > which includes it. It is worth keeping subscribed permanently: it is the only > event you can produce on demand when something looks wrong in production. The endpoint must also be **active**. Sending a test to a disabled or auto-disabled endpoint answers *"Enable the endpoint before sending a test event."* ## Reading the result The delivery history for an endpoint shows every attempt: status, HTTP code, attempt number, duration, next retry, and the response your server sent (first kilobyte). Open one and you also get the exact payload we signed — byte for byte, which is what makes a signature mismatch diagnosable rather than mysterious. The failures worth recognising: | What you see | What it usually is | | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `No response within 10s` | The handler is doing the work inline. Queue it and answer first. | | HTTP `400` with your own error text | Your signature check is rejecting us. Run the [test vectors](https://www.shopdeskbd.com/developers/webhook-signatures.md#verify-your-implementation). | | `Redirects are not followed…` | The URL redirects (often `http` → `https`, or a trailing-slash rule). Register the final URL. | | A DNS or address error | The hostname does not resolve publicly. Tunnels and private addresses are refused, deliberately. | | HTTP `404` | The route exists in your framework but not for `POST`, or a CSRF/auth middleware is in front of it. | ## Developing locally Your URL must be public `https` — we resolve the hostname and refuse private addresses, so `localhost`, `127.0.0.1` and anything on a private range cannot be registered. Two approaches that work: 1. **A public tunnel** (ngrok, Cloudflare Tunnel, Tailscale Funnel) pointed at your dev server. Register the tunnel's `https` URL as an endpoint, subscribe it to `*`, and develop against real events. Remember to delete the endpoint when the tunnel dies, or it will fail for three days and auto-disable. 2. **A replayed payload, offline.** Copy the exact payload and the `X-Shopdesk-Signature` header from a delivery in the log, and post them at your own handler from a test. This is the one that belongs in your test suite: it needs no network, and it catches the raw-body mistakes that only show up on payloads containing non-ASCII text. > **Note — Do not re-sign a captured payload with a new timestamp** > > If you replay a real delivery in a test, the timestamp is old, so your > five-minute window check will reject it — correctly. Test the window by > freezing your clock (or injecting `now`) rather than by loosening the > tolerance. A receiver that accepts a day-old timestamp is a receiver anyone > can replay. ## A checklist before you go live - The endpoint is subscribed to the events you actually handle, plus `shopdesk.ping`. - Signature verification runs against the **raw body** and rejects on failure. - The five-minute timestamp window is enforced. - Both `v1=` values are accepted, so a [rotation](https://www.shopdeskbd.com/developers/webhook-rotation.md) does not take you down. - Events are de-duplicated on `X-Shopdesk-Event-Id`. - Handlers upsert, and tolerate an update arriving before its create. - The endpoint answers `2xx` in well under 10 seconds and does its work asynchronously. - Unknown event types are ignored rather than throwing. --- # Headers Every header on a delivery, and what to do with it. Every delivery carries the same seven headers. Nothing else is sent, and no header is ever omitted. ```http POST /webhooks/shopdesk HTTP/1.1 Content-Type: application/json User-Agent: Shopdesk-Webhooks/1.0 X-Shopdesk-Signature: t=1754899200,v1=a24d2da1ccf592fe5dfc94e9e051d460cc58814580a5541e4608e52abee28d9c X-Shopdesk-Event: order.created X-Shopdesk-Event-Id: evt_01J4X8G9ABCDEFGHJKMNPQRSTV X-Shopdesk-Delivery: whd_01J4X8GB2CDEFGHJKMNPQRSTVW X-Shopdesk-Api-Version: 2026-08 ``` | Header | Value | What to do with it | | ------------------------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `X-Shopdesk-Signature` | `t=,v1=[,v1=]` | **Verify it before reading the body.** Two `v1` values mean a rotation is in progress; accept either. See [Verifying signatures](https://www.shopdeskbd.com/developers/webhook-signatures.md). | | `X-Shopdesk-Event` | `order.created` | Route on it if it saves you parsing the body first. It is always identical to `type` in the payload. | | `X-Shopdesk-Event-Id` | `evt_` + ULID | **Your de-duplication key.** Identical to `id` in the payload. Stable across every retry of the same event. | | `X-Shopdesk-Delivery` | `whd_` + ULID | This *attempt's* record. Changes between retries, unlike the event id. Quote it to support; never de-duplicate on it. | | `X-Shopdesk-Api-Version` | `2026-08` | The payload version pinned to your endpoint. Identical to `api_version` in the body. | | `Content-Type` | `application/json` | The body is always JSON, always UTF-8. | | `User-Agent` | `Shopdesk-Webhooks/1.0` | Useful in access logs. **Not** an authentication signal — anyone can send it. | > **Note — The X-Salaf-\* names are still sent, until 2026-12-31** > > These headers were called `X-Salaf-Signature`, `X-Salaf-Event`, > `X-Salaf-Event-Id`, `X-Salaf-Delivery` and `X-Salaf-Api-Version` before the > Shopdesk rename on 2026-09-14. Every delivery still carries each header under > its old name too, with the identical value, so a receiver written against the > old docs keeps verifying. Read the `X-Shopdesk-*` names in new code; the > duplicates stop on 2026-12-31. > **Warning — Event id and delivery id are not the same thing** > > One event fanned out to three endpoints produces three deliveries, and each > retry of any of them keeps its own delivery id. So: > > - **De-duplicate on `X-Shopdesk-Event-Id`.** It identifies the *fact*, and it is > the same on every retry — which is exactly what makes at-least-once > delivery safe. > - **Log `X-Shopdesk-Delivery`.** It identifies the *attempt*, and it is what > matches a row in the merchant's delivery history when you need someone to > look at their side. > > A receiver that de-duplicates on the delivery id de-duplicates nothing: every > retry has a fresh one, so every retry runs again. ## What is not sent - **No authentication header.** The signature is the authentication. Do not expect a bearer token, and do not put a secret in your webhook URL's query string — it would end up in logs and proxies. If your receiver needs an extra layer, put it behind mTLS or an allow-list, not a URL secret. - **No custom headers.** There is no way to configure additional headers on an endpoint. - **No compression or chunking.** Bodies are small and sent whole. ## Your response Only the status line is interpreted. `2xx` is success; everything else is a failure that gets [retried](https://www.shopdeskbd.com/developers/webhook-retries.md). The first kilobyte of your response body **is** stored, and shown to the merchant in the delivery log — so a plain-text error message from your handler is genuinely useful there. Header-shaped lines (`Set-Cookie:`, `Authorization:`, `X-Api-Key:` and friends) are stripped before storage, in case a framework echoes the request back in a debug response; but the simplest protection is not to echo secrets in an error body in the first place. Response headers you send are not read at all — including `Retry-After`. The retry schedule is ours and is not negotiable per delivery. --- # Rotating a secret The dual-signing window, and what a receiver does during it. Rotating a signing secret replaces it without dropping a single delivery. The old secret keeps co-signing for 24 hours, so you deploy the new one whenever your own release process allows. ## What happens when you rotate In the dashboard: **Settings → Developer → Webhooks**, open the endpoint, and choose **Rotate signing secret**. 1. A new `whsec_` secret is generated and shown to you **once**. 2. The old secret keeps signing alongside it for **24 hours**. 3. Every delivery in that window carries **two** `v1=` values — one from each secret: ```http X-Shopdesk-Signature: t=1754899200,v1=,v1= ``` 4. When the window closes, the old secret stops signing and deliveries carry one `v1=` again. A receiver that checks the incoming signature against *its* secret and accepts any match therefore keeps working throughout — before the deploy it matches the second value, after the deploy the first. Nothing has to be swapped atomically on two machines at once. > **Warning — This only works if you loop over the v1 values** > > A verifier that reads the *first* `v1=` and stops will break the moment a > rotation starts, because during the window the first value is signed with the > secret you have not deployed yet. > > Every sample on the > [signature page](https://www.shopdeskbd.com/developers/webhook-signatures.md#verifying) loops. If yours > does not, fix it before you rotate, not during. ## The rotation you should be doing 1. **Rotate** in the dashboard and copy the new secret. 2. **Deploy it** to your receiver — any time in the next 24 hours. No coordination, no maintenance window. 3. **Confirm** with [a test event](https://www.shopdeskbd.com/developers/webhook-testing.md): a `shopdesk.ping` that verifies with your new secret proves the deploy landed. There is nothing to do at the end of the window. The old secret expires on its own; you never have to come back and turn something off. ## Rotating immediately The dialog offers an **immediate** option, which drops the old secret at once instead of co-signing. Use it for exactly one situation: **the secret leaked.** Anything else — a routine rotation, a policy schedule, a departing contractor whose access you have already removed — should use the window, because immediate rotation means every delivery fails from the moment you click until the moment your new secret is live. Those failures are retried, so nothing is lost, but you have chosen an outage you did not need. > **Danger — A leaked signing secret is a forgery risk, not a data leak** > > The secret does not grant access to anything. What it does is let whoever > holds it produce requests your receiver will believe came from Shopdesk — > invented orders, fabricated payment events, stock levels that never existed. > > If a secret is exposed, rotate immediately and then check what your receiver > processed in the interim. ## Verifying during a rotation Nothing about verification changes — you still hash `"{timestamp}.{rawBody}"` with the one secret you hold and compare against each `v1=` in constant time. The only requirement is that you compare against **all** of them: **Node.js** ```javascript import crypto from "node:crypto"; const TOLERANCE_SECONDS = 300; // 5 minutes /** * @param rawBody the EXACT bytes we sent, as a string or Buffer — never a * re-serialised object. */ export function verifyShopdeskSignature(rawBody, header, secret) { if (!header) return false; let timestamp = null; const signatures = []; for (const part of header.split(",")) { const [key, value] = part.split("="); if (key?.trim() === "t") timestamp = Number(value?.trim()); if (key?.trim() === "v1") signatures.push(value?.trim()); } if (!Number.isFinite(timestamp) || signatures.length === 0) return false; // Reject anything captured and replayed later. The timestamp is INSIDE the // signed string, so an attacker cannot simply put a fresh one in the header. const now = Math.floor(Date.now() / 1000); if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false; const expected = crypto .createHmac("sha256", secret) .update(`${timestamp}.${rawBody}`, "utf8") .digest("hex"); // During a secret rotation there are TWO v1= values; either may match. return signatures.some((candidate) => timingSafeEqual(expected, candidate)); } // Never use === on a signature: the early exit leaks, through timing, how // many leading characters an attacker guessed right. function timingSafeEqual(a, b) { if (a.length !== b.length) return false; return crypto.timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")); } ``` **Python** ```python import hashlib import hmac import time TOLERANCE_SECONDS = 300 # 5 minutes def verify_shopdesk_signature(raw_body: bytes, header: str, secret: str) -> bool: """raw_body is the exact request body — bytes, not a parsed dict.""" if not header: return False timestamp = None signatures = [] for part in header.split(","): key, _, value = part.partition("=") if key.strip() == "t": timestamp = value.strip() elif key.strip() == "v1": signatures.append(value.strip()) if timestamp is None or not signatures: return False # Replay window. The timestamp is signed, so it cannot be swapped. if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS: return False signed_payload = timestamp.encode() + b"." + raw_body expected = hmac.new( secret.encode(), signed_payload, hashlib.sha256 ).hexdigest() # compare_digest, never ==: a plain comparison leaks the match length. # Two v1= values appear during a rotation; either may match. return any(hmac.compare_digest(expected, s) for s in signatures) ``` **PHP** ```php SHOPDESK_TOLERANCE_SECONDS) { return false; } $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); // hash_equals, never ===. Two signatures ride along during a rotation. foreach ($signatures as $candidate) { if (hash_equals($expected, $candidate)) { return true; } } return false; } ``` ## What rotation does not change - **`api_version` is pinned at creation and never changes**, not by rotation and not by editing the endpoint. Your payload shape is stable for the life of the endpoint. - **The endpoint's URL, subscriptions and delivery history are untouched.** - **In-flight retries are re-signed at each attempt**, so a delivery that was queued before the rotation and retried after it is signed with whatever secrets are active at the moment it is sent — never with a stale one. - **The endpoint id stays the same**, so anything you have keyed on it is fine. ## If you lose the secret There is no way to display it again — it is stored encrypted so the delivery worker can sign with it, but no screen and no support process prints it back. Rotate, and treat it as an ordinary rotation with the 24-hour window: the old secret you have lost is still the one that works, so nothing breaks while you deploy the new one. --- # Overview End-to-end recipes for the common integrations. Nine end-to-end recipes, each written against endpoints that exist today. Every field name is the real one, and every sample runs. ## The guides | Guide | What it covers | You need | | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | | [Create an order](https://www.shopdeskbd.com/developers/guide-create-an-order.md) | Resolving a customer and variants, placing the order idempotently, and reading what comes back. | `orders:write`, `products:read` | | [Sync inventory to an ERP](https://www.shopdeskbd.com/developers/guide-sync-inventory.md) | The `updated_after` + cursor pattern, checkpoints that survive a crash, and why offset paging is not offered. | `products:read`, `inventory:read` | | [Handle webhooks idempotently](https://www.shopdeskbd.com/developers/guide-handle-webhooks.md) | Verify, de-duplicate, upsert, answer fast — the whole receiver. | A webhook endpoint | | [Build a custom storefront](https://www.shopdeskbd.com/developers/guide-custom-storefront.md) | Reading the catalogue, checking availability, taking an order and recording payment. | Read scopes, `orders:write`, `payments:write` | | [Build a COD confirmation tool](https://www.shopdeskbd.com/developers/guide-cod-confirmation.md) | The call queue, finding an order by any phone form, customer history, and recording the outcome. | `orders:write`, `customers:read` | | [Sales and reports](https://www.shopdeskbd.com/developers/guide-sales-reports.md) | The sales summary, comparisons and top products, and the basis of each figure. | `reports:read` | | [Build a catalog through the API](https://www.shopdeskbd.com/developers/guide-build-a-catalog.md) | An attribute, a category and a brand, then a product with variants, options and photos — in four calls. | `products:write`, `catalog:write` | | [Photos by link](https://www.shopdeskbd.com/developers/guide-photos-by-link.md) | Where image links are taken, what is checked, all-or-nothing calls, the limits and safe retries. | `products:write` or `catalog:write` | | [Upload a product video](https://www.shopdeskbd.com/developers/guide-upload-a-product-video.md) | An upload with raw calls from start to finish, a cover, a video shown for one option, imports by link, reject codes and limits. | `videos:write`, `products:read` | ## Before any of them 1. **Create a key** with only the scopes that guide needs ([Authentication](https://www.shopdeskbd.com/developers/authentication.md)). Start with a store key. 2. **Export it** as `SHOPDESK_API_KEY` — every sample in these docs reads it from the environment rather than inlining it. 3. **Point at `/ext/v1`** — `https://api.shopdeskbd.com/ext/v1`. There is no shorter `/v1` alias. ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/products?limit=1" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` If that returns a product, you are ready for any of them. ## The patterns they share Every integration in this list ends up leaning on the same four things, so they are worth learning once: - **Cursor pagination** — [`limit` + `starting_after` + `meta.has_more`](https://www.shopdeskbd.com/developers/pagination.md#walking-every-page). - **Incremental sync** — [`updated_after` with a watermark](https://www.shopdeskbd.com/developers/pagination.md#syncing-changes-with-updated_after). - **Idempotent writes** — [one key per logical operation](https://www.shopdeskbd.com/developers/idempotency.md#idempotency-key). - **One error shape** — [branch on `code`, log the `request_id`](https://www.shopdeskbd.com/developers/errors.md#handling-errors-well). --- # Create an order Customer, variants, idempotency key, and what comes back. Your system took an order — on a Facebook page, in a call centre, through your own checkout — and it needs to exist in Shopdesk, with stock reserved and the merchant's dashboard showing it. This is that call, start to finish. **You need** a key with `orders:write` (and `products:read` if you resolve variants by id). `orders:write` implies `orders:read`, so you can read the order back with the same key. ## 1. Decide the idempotency key first Before you build the request, generate the key and **store it with your own order record**. That ordering is the whole point. If you generate the key at the moment of the HTTP call, a process restart between attempts produces a new key — and a new order. Generated first and persisted, the same key survives a crash, a queue redelivery, and a manual re-run. ```text your_order.shopdesk_idempotency_key = "3f1a5c8e-0b2d-4f77-9a13-5e6c7d8f9a10" ``` ## 2. Resolve the line items Lines reference a variant by **`variant_id` or `sku`** — one or the other, per line. If your own system already stores SKUs, use them and skip this step entirely; that is what they are for. If you need to look one up — to check it exists before placing the order, or to read its current price: **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/variants/TSHIRT-RED-M" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/variants/TSHIRT-RED-M"); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/variants/TSHIRT-RED-M", headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` `GET /variants/{identifier}` accepts an id, a SKU **or a barcode**, so a barcode scanner's output can be looked up directly. > **Note — Variants are the commercial unit** > > Price, SKU and stock hang off variants, not products. A product is the thing > a shopper browses; a variant is the thing they buy. Every order line > references a variant. ## 3. Resolve the customer Two options, and you must pick exactly one: - **`customer_id`** — an existing customer in this store. - **`customer`** — `{ phone, full_name?, email? }`, which finds or creates. Find-or-create matches on the **canonical Bangladeshi phone number**, so `+8801712345678` and `01712345678` are the same person. `full_name` is required only when the phone matches nobody. > **Warning — A match never updates the existing record** > > If the phone is already known, that customer is used exactly as they are and > the `full_name`/`email` you sent are ignored. A sale is not a CRM edit. Use > `PATCH /customers/{id}` when you actually mean to change someone's details. ## 4. Place the order **cURL** ```bash # The key identifies the ORDER you are placing, not this HTTP attempt. # Generate it once, store it with your own record, and reuse it on retries. IDEMPOTENCY_KEY=$(uuidgen) curl -sS -X POST "https://api.shopdeskbd.com/ext/v1/orders" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: $IDEMPOTENCY_KEY" \ -H "Content-Type: application/json" \ -d '{ "channel": "facebook", "customer": { "phone": "01712345678", "full_name": "Rafiqul Islam" }, "items": [ { "sku": "TSHIRT-RED-M", "quantity": 2 } ], "shipping_charge": 120, "note": "Deliver after 6pm." }' ``` **JavaScript** ```javascript import { randomUUID } from "node:crypto"; // One key per logical order. Persist it next to your own order record BEFORE // the call, so a retry after a process restart still sends the same one. const idempotencyKey = randomUUID(); const response = await fetch("https://api.shopdeskbd.com/ext/v1/orders", { method: "POST", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": idempotencyKey, "Content-Type": "application/json", }, body: JSON.stringify({ channel: "facebook", customer: { phone: "01712345678", full_name: "Rafiqul Islam" }, items: [{ sku: "TSHIRT-RED-M", quantity: 2 }], shipping_charge: 120, note: "Deliver after 6pm.", }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } // 201 with the full order: items and customer expanded, totals computed, // stock already reserved. const order = await response.json(); console.log(order.number, order.total_amount, order.payment_status); // true when this was a replay of an earlier identical call. console.log(response.headers.get("Idempotent-Replayed") === "true"); ``` **Python** ```python import os import uuid import requests # One key per logical order — generated where the order is DECIDED, not # inside the retry loop, and stored with your own record. idempotency_key = str(uuid.uuid4()) response = requests.post( "https://api.shopdeskbd.com/ext/v1/orders", json={ "channel": "facebook", "customer": {"phone": "01712345678", "full_name": "Rafiqul Islam"}, "items": [{"sku": "TSHIRT-RED-M", "quantity": 2}], "shipping_charge": 120, "note": "Deliver after 6pm.", }, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": idempotency_key, }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") order = response.json() print(order["number"], order["total_amount"], order["payment_status"]) print(response.headers.get("Idempotent-Replayed") == "true") ``` **PHP** ```php 'facebook', 'customer' => ['phone' => '01712345678', 'full_name' => 'Rafiqul Islam'], 'items' => [ ['sku' => 'TSHIRT-RED-M', 'quantity' => 2], ], 'shipping_charge' => 120, 'note' => 'Deliver after 6pm.', ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/orders'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('SHOPDESK_API_KEY'), 'Idempotency-Key: ' . $idempotencyKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } echo $response['number'] . ' ' . $response['total_amount'] . PHP_EOL; ``` The fields that matter, and their defaults: | Field | Required | Notes | | ---------------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `items[]` | Yes | At least one line. `variant_id` **or** `sku`, plus `quantity`. | | `customer_id` / `customer` | Yes, one of | Never both. | | `store_id` | Only for company keys | A store key may repeat its own store, and may not name another. | | `channel` | No | A channel **slug**. Defaults to the store's `manual` channel. | | `location_id` | No | Which warehouse reserves the stock. Defaults to the company default. | | `shipping_charge` | No | Default `0`. **Nothing calculates this for you.** | | `tax_amount` | No | Default `0`. | | `coupon_code` | No | Priced and redeemed by the same engine as every other surface. An unusable code fails the order rather than mispricing it. | | `payment` | No | An upfront settled payment — `{ amount, payment_method_id?, transaction_id? }`. | | `unit_price` / `discount_price` per line | No | Your prices are trusted here. Omitted, the catalogue price is used. | ## 5. Read the response `201` with the **full order** — items and customer expanded, totals computed, stock already reserved: ```json { "id": "c9d8e7f6-1a2b-4c3d-8e9f-0a1b2c3d4e5f", "number": "ORD-20260811-1042", "source": "api", "channel": { "slug": "facebook", "name": "Facebook", "type": "facebook" }, "customer_id": "5b1e7c33-2a94-4f18-8d60-7e3b9c40a2f1", "status": "pending", "payment_status": "unpaid", "fulfillment_status": "unfulfilled", "subtotal": "2500.00", "shipping_charge": "120.00", "total_amount": "2620.00", "paid_amount": "0.00", "due_amount": "2620.00", "items": ["…one object per line…"], "customer": { "full_name": "Rafiqul Islam", "phone": "01712345678" } } ``` **Store `id` and `number` against your own record.** `id` is what every later call takes; `number` is what the merchant and the customer will say out loud. Two things to note in that body: `source` is `api` and cannot be set — it is how the merchant tells API orders from POS ones — and money is a **string** with two decimals, because JSON numbers cannot hold every `Decimal(12,2)` exactly. ## 6. Handle the failures that matter | Code | HTTP | What to do | | ----------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------------------- | | `IDEMPOTENCY_IN_FLIGHT` | 409 | Wait a couple of seconds and retry **with the same key**. Never re-key. | | `IDEMPOTENCY_CONFLICT` | 422 | Your code reused a key for a different body. A bug; do not retry. | | `INSUFFICIENT_STOCK` | 400 | `fields.items` names the line. Surface it to whoever is taking the order — this is a real business answer, not a transient failure. | | `VALIDATION_ERROR` | 422 | Unknown SKU, bad phone, unknown channel slug, missing `store_id` on a company key. `fields` says which. | | `FORBIDDEN_SCOPE` | 403 | The key lacks `orders:write`. Scopes are fixed at creation — make a new key. | | `RATE_LIMITED` | 429 | Writes are 30/min per key by default. Honour `Retry-After`. | ## What happens next Placing the order reserves stock but does not finish it. The rest of its life happens through: - **`POST /orders/{id}/payments`** — record money as it arrives. The amount is capped at the net due, and `transaction_id` is unique per store. - **`PATCH /orders/{id}/status`** — move it along, validated against the same strict transition map staff use. - **`POST /orders/{id}/cancel`** — release the reservation. - **[Webhooks](https://www.shopdeskbd.com/developers/webhooks.md)** — `order.updated`, `order.paid` and `order.fulfilled` tell you when the merchant's staff move it, so you do not have to poll for it. ## The whole thing, in order 1. Generate an idempotency key and persist it with your record. 2. Resolve variants (or use your SKUs directly). 3. Build the body — customer, items, shipping charge, channel. 4. `POST /orders` with the key. Retry the same key on `409` and on network failure. 5. Store `id` and `number`. 6. Subscribe to `order.*` webhooks, or poll `updated_after`, for the rest. --- # Sync inventory to an ERP Cursors, watermarks and checkpoints that survive a crash. An ERP, an accounting package or a warehouse system needs its own copy of the store's catalogue, stock and orders — and needs it to stay correct while the store keeps trading. This is the shape that gets built, every time. **You need** a key with `products:read`, `inventory:read`, `locations:read` and usually `orders:read`. A store key unless head office genuinely reads several stores at once. ## The shape ```text once full load → page everything, record a watermark then incremental poll → updated_after=, upsert on id alongside slow full re-read → the small lists that have no updated_after ``` ## 1. Full load Page through with a cursor until the end. Ask for `limit=100` — the rate limit counts *requests*, not rows, so a bigger page is free throughput. **cURL** ```bash # Page 1 — no cursor. curl -sS "https://api.shopdeskbd.com/ext/v1/orders?limit=100" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" # The response ends with: # "meta": { "has_more": true, "next_cursor": "0f0b…c31" } # Feed that cursor back as starting_after, and repeat until # has_more is false. curl -sS "https://api.shopdeskbd.com/ext/v1/orders?limit=100&starting_after=0f0b…c31" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript async function* everyOrder(params = {}) { let cursor = null; do { const url = new URL("https://api.shopdeskbd.com/ext/v1/orders"); url.searchParams.set("limit", "100"); for (const [key, value] of Object.entries(params)) { url.searchParams.set(key, value); } if (cursor) { url.searchParams.set("starting_after", cursor); } const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}` }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); yield* data; // next_cursor is null on the last page, which ends the loop. cursor = meta.has_more ? meta.next_cursor : null; } while (cursor); } for await (const order of everyOrder({ status: "confirmed" })) { console.log(order.number, order.total_amount); } ``` **Python** ```python import os import requests def every_order(**params): cursor = None headers = {"Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}"} while True: query = {"limit": 100, **params} if cursor: query["starting_after"] = cursor response = requests.get( "https://api.shopdeskbd.com/ext/v1/orders", params=query, headers=headers, timeout=30, ) response.raise_for_status() page = response.json() yield from page["data"] if not page["meta"]["has_more"]: return cursor = page["meta"]["next_cursor"] for order in every_order(status="confirmed"): print(order["number"], order["total_amount"]) ``` **PHP** ```php 100], $params); if ($cursor !== null) { $query['starting_after'] = $cursor; } $ch = curl_init('https://api.shopdeskbd.com/ext/v1/orders?' . http_build_query($query)); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $page = json_decode(curl_exec($ch), true); curl_close($ch); yield from $page['data']; $cursor = $page['meta']['has_more'] ? $page['meta']['next_cursor'] : null; } while ($cursor !== null); } foreach (every_order(['status' => 'confirmed']) as $order) { echo $order['number'] . ' ' . $order['total_amount'] . PHP_EOL; } ``` Do this for `/products` and `/orders`, then for `/inventory/levels`. **Record the timestamp you started at**, not the one you finished at. Anything that changed while the load was running is then picked up by the first incremental poll rather than falling into the gap between them. ## 2. Incremental polling `updated_after` is the workhorse. It returns everything modified since an instant — including rows created long before it, which is precisely what a `created_after` poll misses. **cURL** ```bash # Everything that changed since the last successful sync. # Store the timestamp you STARTED the run at, not the one you finished at. SINCE="2026-08-10T00:00:00Z" curl -sS "https://api.shopdeskbd.com/ext/v1/orders?updated_after=$SINCE&limit=100&expand=items,customer" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript // Read the watermark BEFORE the run: anything modified while the run is // in flight is then picked up next time instead of being skipped. const runStartedAt = new Date().toISOString(); const since = await loadWatermark(); // your storage let cursor = null; do { const url = new URL("https://api.shopdeskbd.com/ext/v1/orders"); url.searchParams.set("updated_after", since); url.searchParams.set("limit", "100"); url.searchParams.set("expand", "items,customer"); if (cursor) { url.searchParams.set("starting_after", cursor); } const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}` }, }); const { data, meta } = await response.json(); for (const order of data) { // Upsert on id — an order can appear in several runs as it moves // through its statuses, and the newest snapshot always wins. await upsertOrder(order); } cursor = meta.has_more ? meta.next_cursor : null; } while (cursor); await saveWatermark(runStartedAt); ``` **Python** ```python import os from datetime import datetime, timezone import requests # Read the watermark BEFORE the run, so rows changed mid-run are caught # by the next one rather than skipped by this one. run_started_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") since = load_watermark() # your storage headers = {"Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}"} cursor = None while True: query = { "updated_after": since, "limit": 100, "expand": "items,customer", } if cursor: query["starting_after"] = cursor page = requests.get( "https://api.shopdeskbd.com/ext/v1/orders", params=query, headers=headers, timeout=30, ).json() for order in page["data"]: # Upsert on id: the same order reappears as its status changes. upsert_order(order) if not page["meta"]["has_more"]: break cursor = page["meta"]["next_cursor"] save_watermark(run_started_at) ``` **PHP** ```php $since, 'limit' => 100, 'expand' => 'items,customer', ]; if ($cursor !== null) { $query['starting_after'] = $cursor; } $ch = curl_init('https://api.shopdeskbd.com/ext/v1/orders?' . http_build_query($query)); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $page = json_decode(curl_exec($ch), true); curl_close($ch); foreach ($page['data'] as $order) { upsert_order($order); // upsert on id } $cursor = $page['meta']['has_more'] ? $page['meta']['next_cursor'] : null; } while ($cursor !== null); save_watermark($runStartedAt); ``` Three rules make it correct: 1. **Watermark before the run**, as above. 2. **Upsert on `id`.** The same order legitimately appears in many runs as it moves through its statuses; the newest snapshot wins. 3. **Overlap slightly.** Subtracting a minute from your stored watermark costs a few duplicate rows — which rule 2 already absorbs — and protects you from clock skew between your machine and ours. > **Warning — updated\_after exists on orders and products only** > > Customers, categories, brands, inventory levels, movements, locations and > channels do not have it yet. > > For those, re-read the whole list on a slower schedule. They are small and > change rarely — a nightly full pass is entirely reasonable, and cheaper than > the machinery you would build to avoid it. ## 3. Stock levels Levels are one row per variant per location, so this list is the one that grows fastest. It has no `updated_after`, which makes a full paged pass the sync. **cURL** ```bash # Levels are one row per variant per location. Page with the cursor; # there is no updated_after on this resource, so a full pass is the sync. curl -sS "https://api.shopdeskbd.com/ext/v1/inventory/levels?limit=100" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" # Then follow meta.next_cursor until has_more is false: curl -sS "https://api.shopdeskbd.com/ext/v1/inventory/levels?limit=100&starting_after=0f0b…c31" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" # Only what is running out, for a reorder report: curl -sS "https://api.shopdeskbd.com/ext/v1/inventory/levels?low_stock=true&limit=100" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript // Checkpoint the CURSOR, not a row count: a crash at page 40 resumes at // page 40 rather than re-reading everything from the top. async function syncLevels(checkpoint) { let cursor = await checkpoint.load(); // null on a fresh run do { const url = new URL("https://api.shopdeskbd.com/ext/v1/inventory/levels"); url.searchParams.set("limit", "100"); if (cursor) { url.searchParams.set("starting_after", cursor); } const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}` }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); // available = on_hand − reserved. Sell against available; report on // on_hand. Writing the wrong one into an ERP oversells the store. await erp.upsertStock( data.map((level) => ({ sku: level.sku, warehouse: level.location.code, onHand: level.on_hand, available: level.available, })), ); cursor = meta.has_more ? meta.next_cursor : null; await checkpoint.save(cursor); } while (cursor); } ``` **Python** ```python import os import requests HEADERS = {"Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}"} def sync_levels(checkpoint): """Checkpoint the cursor so a crash resumes mid-walk, not from zero.""" cursor = checkpoint.load() while True: query = {"limit": 100} if cursor: query["starting_after"] = cursor response = requests.get( "https://api.shopdeskbd.com/ext/v1/inventory/levels", params=query, headers=HEADERS, timeout=30, ) response.raise_for_status() page = response.json() # available = on_hand - reserved. Sell against available. erp.upsert_stock( [ { "sku": level["sku"], "warehouse": level["location"]["code"], "on_hand": level["on_hand"], "available": level["available"], } for level in page["data"] ] ) if not page["meta"]["has_more"]: checkpoint.save(None) return cursor = page["meta"]["next_cursor"] checkpoint.save(cursor) ``` **PHP** ```php load(); do { $query = ['limit' => 100]; if ($cursor !== null) { $query['starting_after'] = $cursor; } $ch = curl_init('https://api.shopdeskbd.com/ext/v1/inventory/levels?' . http_build_query($query)); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $page = json_decode(curl_exec($ch), true); curl_close($ch); foreach ($page['data'] as $level) { // available = on_hand - reserved. erp_upsert_stock( $level['sku'], $level['location']['code'], $level['on_hand'], $level['available'], ); } $cursor = $page['meta']['has_more'] ? $page['meta']['next_cursor'] : null; $checkpoint->save($cursor); } while ($cursor !== null); } ``` The three numbers, and which one to write into your ERP: | Field | Meaning | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `on_hand` | Physically present. What a stock count would find. | | `reserved` | Held for orders that have not shipped yet, and for returned orders whose parcel is still on its way back (until the merchant receives it). | | `available` | `on_hand − reserved`. **Sell against this one.** | > **Danger — Writing on\_hand where you meant available oversells** > > A variant with 10 on hand and 8 reserved has 2 available. An ERP that > publishes 10 as sellable will take eight orders it cannot fill, and the > merchant finds out from customers. `low_stock=true` narrows the list to variants at or below their threshold — that is the reorder report, and it is far cheaper than fetching everything and filtering client-side. ## 4. Checkpoint properly The difference between a sync that survives an outage and one that starts over: - **Checkpoint the cursor**, not a page number. `meta.next_cursor` is a position anchored to a row, so resuming from it is exact — even if the store took 200 orders while you were down. - **Checkpoint after the page is committed** to your side, not after it is fetched. Crashing between fetch and commit should re-fetch, and it does. - **Clear the cursor when `has_more` is false**, so the next run starts a fresh pass. ## Why there are no page numbers Because the store keeps trading while your sync runs. With offset pages, one order placed between page 1 and page 2 shifts every row down by one. Page 2 then re-serves a row you already had and **skips one entirely** — silently. The merchant discovers it weeks later as a missing order in their accounts. A cursor is anchored to a row rather than to a count, so new rows arriving at the head do not move the rows behind them. Deep pages are cheap for the same reason: there is no `OFFSET 40000` for the database to count through. Full reasoning on the [Pagination](https://www.shopdeskbd.com/developers/pagination.md#why-cursors-instead-of-page-numbers) page. ## Adding webhooks later Polling is the reconciliation path and should stay, but you do not have to poll *often* once [webhooks](https://www.shopdeskbd.com/developers/webhooks.md) are wired up: | Instead of polling for | Subscribe to | | ---------------------- | -------------------------------------------------------------------------------------------------- | | New and changed orders | `order.created`, `order.updated`, `order.paid`, `order.cancelled` | | Stock movement | `inventory.updated` (coalesced per variant per 10s), `inventory.adjusted`, `inventory.transferred` | | Reorder alerts | `inventory.low_stock` (edge-triggered, once per crossing) | | Catalogue changes | `product.created`, `product.updated`, `product.archived` | Keep the poll as a daily or hourly safety net. Webhooks are at-least-once and your receiver can be down; a periodic `updated_after` pass closes any gap without anybody having to notice there was one. ## Scheduling and limits - **120 reads per minute per key** by default. A full pass of 10,000 products at `limit=100` is 100 requests — under a minute of quota. - **Do not poll tightly.** Once a minute is plenty for almost every integration; once every few minutes is plenty for most. - **Split heavy work onto its own key.** Buckets are per key, so a nightly bulk load on one key cannot starve a real-time listener on another — and you can revoke one without touching the other. - **Watch `X-RateLimit-Remaining`** and slow down before it reaches zero. Backing off at 10 remaining is cheaper than a `429` and a retry. ## Alerting Log the `request_id` of every failure — it is the only handle that finds your exact request in our logs. Alert on `KEY_REVOKED` **specifically and loudly**: it means a human at the merchant deliberately turned your integration off. Retrying forever, quietly, is the wrong response to that. --- # Handle webhooks idempotently Verify, dedupe, upsert, and answer fast. A correct webhook receiver is four steps in a fixed order. Get the order wrong and you have a receiver that works in testing and corrupts data in production. ```text 1. verify the signature ─ before parsing, against the raw bytes 2. de-duplicate on event id ─ delivery is at-least-once 3. answer 2xx ─ within 10 seconds, before doing the work 4. upsert from data.object ─ off the request, in any order ``` ## The whole receiver **Node.js** ```javascript app.post( "/webhooks/shopdesk", express.raw({ type: "application/json" }), async (req, res) => { // 1. Verify BEFORE parsing or trusting anything. const ok = verifyShopdeskSignature( req.body, req.get("X-Shopdesk-Signature"), process.env.SHOPDESK_WEBHOOK_SECRET, ); if (!ok) { return res.sendStatus(400); // we will not retry a 4xx into working } const event = JSON.parse(req.body.toString("utf8")); const eventId = req.get("X-Shopdesk-Event-Id"); // === event.id // 2. Dedupe. Delivery is at-least-once: the same event WILL arrive // twice eventually. A unique index on the event id is the whole trick. const inserted = await db.query( `INSERT INTO shopdesk_events (id, type, payload) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING`, [eventId, event.type, event], ); // 3. Answer FAST. Anything slower than a few hundred ms risks our 10s // timeout, and a timeout means a retry you did not need. res.sendStatus(200); if (inserted.rowCount === 0) { return; // already handled — nothing more to do } // 4. Do the work off the request. Order is NOT guaranteed, so write // upserts: order.updated may land before order.created. void queue.add("shopdesk-event", { eventId }); }, ); async function handleEvent(event) { switch (event.type) { case "order.created": case "order.updated": case "order.paid": // data.object is the full snapshot as it was inside the transaction // that produced it — upsert it, do not patch field by field. return upsertOrder(event.data.object); case "inventory.updated": return setLevels(event.data.object.variant_id, event.data.object.levels); default: return; // unknown types are normal: the catalog only ever grows } } ``` **Python** ```python import json from django.db import IntegrityError from django.http import HttpResponse @csrf_exempt @require_POST def shopdesk_webhook(request): # 1. Verify before trusting the body. if not verify_shopdesk_signature( request.body, request.headers.get("X-Shopdesk-Signature"), settings.SHOPDESK_WEBHOOK_SECRET, ): return HttpResponse(status=400) event = json.loads(request.body) event_id = request.headers["X-Shopdesk-Event-Id"] # same as event["id"] # 2. Dedupe on the event id — delivery is at-least-once. try: ShopdeskEvent.objects.create( id=event_id, type=event["type"], payload=event ) except IntegrityError: return HttpResponse(status=200) # already seen; still a success # 3. Queue the work and answer immediately: our timeout is 10 seconds, # and a slow 200 costs you a duplicate you did not need. process_shopdesk_event.delay(event_id) return HttpResponse(status=200) def handle_event(event): kind = event["type"] obj = event["data"]["object"] # 4. Upsert, never patch: events can arrive out of order, and every # payload carries the complete object as of its own transaction. if kind in ("order.created", "order.updated", "order.paid"): upsert_order(obj) elif kind == "inventory.updated": set_levels(obj["variant_id"], obj["levels"]) # Unknown types are expected — the catalog only ever grows. ``` **PHP** ```php getContent(), $request->header('X-Shopdesk-Signature'), config('services.shopdesk.webhook_secret'), )) { return response()->noContent(400); } $event = json_decode($request->getContent(), true); $eventId = $request->header('X-Shopdesk-Event-Id'); // same as $event['id'] // 2. Dedupe on the event id (unique column). At-least-once delivery // means duplicates are routine, not exceptional. $isNew = ShopdeskEvent::query()->insertOrIgnore([ 'id' => $eventId, 'type' => $event['type'], 'payload' => json_encode($event), ]) === 1; // 3. Queue and answer — the timeout is 10 seconds. if ($isNew) { ShopdeskEventJob::dispatch($eventId); } return response()->noContent(200); } public function process(array $event): void { $object = $event['data']['object']; // 4. Upsert: order.updated can arrive before order.created. match ($event['type']) { 'order.created', 'order.updated', 'order.paid' => $this->upsertOrder($object), 'inventory.updated' => $this->setLevels($object['variant_id'], $object['levels']), default => null, // unknown types are normal }; } ``` The rest of this page is why each of those four lines is where it is. ## 1. Verify first Anyone can `POST` JSON at a public URL. Until the signature checks out, the body is a stranger's input — do not parse it, log it, or let it near a database. Three rules, all of which the samples on [Verifying signatures](https://www.shopdeskbd.com/developers/webhook-signatures.md) implement: - Hash the **raw bytes**, never a re-serialised object. - Compare in **constant time** (`timingSafeEqual`, `hmac.compare_digest`, `hash_equals`). - Reject a timestamp more than **five minutes** from your clock. Answer `400` when verification fails. We will retry, and every one of those retries lands in the merchant's delivery log where a human can see something is wrong — which is better than silently discarding requests that might have been ours. ## 2. De-duplicate on the event id **Duplicates are routine, not exceptional.** A timeout on our side is indistinguishable from a slow success on yours: you may have processed the event perfectly and lost the acknowledgement. So we retry, and you receive it again. The defence is one unique column: ```sql CREATE TABLE shopdesk_events ( id TEXT PRIMARY KEY, -- the evt_… id, verbatim type TEXT NOT NULL, payload JSONB NOT NULL, received_at TIMESTAMPTZ NOT NULL DEFAULT now(), handled_at TIMESTAMPTZ ); ``` Insert with `ON CONFLICT DO NOTHING`. If the insert did nothing, you have seen this event: answer `200` and stop. If it inserted, you own it. > **Warning — De-duplicate on the EVENT id, not the delivery id** > > `X-Shopdesk-Event-Id` (`evt_…`) identifies the fact and is **identical on every > retry**. `X-Shopdesk-Delivery` (`whd_…`) identifies the attempt and is > **different every time**. > > A receiver keyed on the delivery id de-duplicates nothing — every retry looks > new, and every retry runs again. Log the delivery id; key on the event id. > **Note — Storing the raw event pays for itself** > > Keeping the payload as you received it turns "why is this order wrong?" into > a query rather than an investigation, and lets you re-run a handler after > fixing a bug without asking the merchant to re-send anything. Our own copy is > kept for 30 days; yours can be kept for as long as you like. ## 3. Answer fast, work later The delivery times out after **10 seconds**. That is generous for "I have it" and deliberately hostile to "let me do the work first". Answer as soon as the event is durably recorded, then process it from a queue. A handler that talks to a payment provider, rebuilds a cache or sends an email inline will eventually exceed the timeout — and a timeout is not just slow, it produces a retry, so the slow work runs twice. If you have no queue, the minimum viable version is: insert the event row, answer `200`, and let a worker poll the table for `handled_at IS NULL`. That is a queue, and it is ten lines. ## 4. Upsert, in any order **Ordering is not guaranteed.** Deliveries run in parallel and retries reshuffle everything, so `order.updated` can arrive before `order.created`. This is much less painful than it sounds, because every payload carries the **whole object** as of the transaction that produced it. So: - **Write the object wholesale.** `INSERT … ON CONFLICT … DO UPDATE`, or your ORM's upsert. Never patch field-by-field from an event. - **Handle the create you never saw.** An update for an unknown id should create the record from `data.object`, not drop the event. - **Guard with a timestamp if you keep your own state machine.** Ignore a payload whose `data.object.updated_at` is older than what you already stored, and out-of-order delivery stops mattering entirely. - **`GET` the resource when in doubt.** The REST API is authoritative at the moment you call it. ```js // Last-write-wins, safe against out-of-order delivery. await db.query( `INSERT INTO orders (id, number, status, payload, updated_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, payload = EXCLUDED.payload, updated_at = EXCLUDED.updated_at WHERE orders.updated_at < EXCLUDED.updated_at`, [order.id, order.number, order.status, order, order.updated_at], ); ``` ## Ignore what you do not know The catalog only ever grows, and a merchant subscribed to `*` receives new event types the day they ship. A handler that throws on an unrecognised `type` turns our additive change into your outage. `default: return`. Log it at debug level if you like. The same applies to fields: never validate a payload against a closed schema that rejects unknown keys. ## Failure modes worth handling | Situation | What to do | | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Signature fails | `400`, log, alert if it is more than a stray. Check the [test vectors](https://www.shopdeskbd.com/developers/webhook-signatures.md#verify-your-implementation) before blaming the network. | | Your database is down | Return `5xx` **without** answering `2xx`. We retry on the ladder — this is exactly what it is for. | | A handler bug corrupts a record | Fix, then replay from your own stored events. No need to involve the merchant. | | You were down for days | Deliveries go `dead` after \~45 hours, and events are purged after 30 days. Reconcile with [`updated_after`](https://www.shopdeskbd.com/developers/pagination.md#syncing-changes-with-updated_after) rather than asking for a re-send. | | A merchant reports missing data | Ask for the `X-Shopdesk-Delivery` id from their delivery log; it pins the exact attempt and the exact payload we sent. | > **Danger — Never answer 2xx for an event you failed to record** > > A `200` means "I have this, do not send it again". If your insert failed and > you answer `200` anyway, the event is gone — we will not retry it, and no > amount of looking at our delivery log will show anything wrong. > > Answer `5xx` when you could not record it. That is what the retry ladder is > for, and it costs you nothing. ## Ready to go live Work through the [pre-launch checklist](https://www.shopdeskbd.com/developers/webhook-testing.md#a-checklist-before-you-go-live), then send a [`shopdesk.ping`](https://www.shopdeskbd.com/developers/webhook-testing.md) and watch it arrive. --- # Build a custom storefront The read path, checkout, and what the API does not give you. You want to build your own shopping experience — a mobile app, a headless frontend, a Facebook shop bot — on top of a Shopdesk store's catalogue and checkout. The read path and the order path both exist. This page is honest about where they stop. **You need** `products:read`, `inventory:read`, `channels:read`, `locations:read`, `orders:write`, `customers:write` and `payments:write`. > **Danger — The API key never goes near a browser** > > A `shopdesk_sk_` key grants everything its scopes allow across a whole store, > and there is no such thing as a public or restricted key. It belongs on your > server, and only on your server. > > Your storefront calls **your** backend; your backend calls Shopdesk. Any design > where a shopper's device holds the key is a design where any shopper can read > every customer record and place orders at any price. ## What the API gives you | You need | Endpoint | Notes | | -------------------- | -------------------------------- | ---------------------------------------------------- | | Catalogue listing | `GET /products` | Variants are nested. Filter `status=active`. | | One product page | `GET /products/{id}` | | | Navigation | `GET /categories`, `GET /brands` | Flat lists; `parent_id` gives you the category tree. | | SKU / barcode lookup | `GET /variants/{identifier}` | Accepts id, SKU or barcode. | | Availability | `GET /inventory/levels` | Filter by `variant_id`. | | Attribution | `GET /channels` | Slugs for tagging the order's origin. | | Checkout | `POST /orders` | Find-or-create customer, stock reserved. | | Payment | `POST /orders/{id}/payments` | Record what your gateway settled. | | Order status | `GET /orders/{id}`, webhooks | For an order-tracking page. | ## 1. Render the catalogue **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/products?status=active&limit=50" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/products"); Object.entries({ status: "active", limit: "50", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/products", params={ "status": "active", "limit": "50", }, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` A product carries its `variants[]` inline, so one list request gives you cards *and* prices. Two conventions to build around: - **Money is a string** with two decimals — `"1250.00"`. Parse it as a decimal, never as a float, and never do arithmetic on it in JavaScript numbers if you can avoid it. - **`discount_price` is the sale price** when it is set and genuinely below `price`. Show both; sell at the lower one. Cache aggressively. A catalogue changes far less often than it is browsed, and your rate limit is 120 reads a minute per key — which is plenty for a server serving from cache and nowhere near enough to proxy every page view. ## 2. Show availability honestly **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/inventory/levels?variant_id=REPLACE_WITH_VARIANT_ID" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/inventory/levels"); Object.entries({ variant_id: "REPLACE_WITH_VARIANT_ID", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/inventory/levels", params={ "variant_id": "REPLACE_WITH_VARIANT_ID", }, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` You get one row per location. For a single-warehouse store that is one row; for a multi-location store, sum `available` across the locations you actually ship from. | Field | Use it for | | -------------- | ---------------------------------------------------- | | `available` | **Whether a shopper may buy.** `on_hand − reserved`. | | `on_hand` | Internal reporting. Not a sellable number. | | `is_low_stock` | An "only a few left" badge. | > **Warning — Availability is a hint, not a lock** > > Nothing reserves stock until the order is created. Between your page render > and the shopper's checkout, a POS sale can take the last unit — so > `POST /orders` may still answer `INSUFFICIENT_STOCK`, and your checkout has > to handle that gracefully rather than treating it as a server error. > > That is the correct design: the reservation happens once, atomically, at > order creation, in the same code the till uses. ## 3. Take the order Checkout is one call. It finds or creates the customer by phone, reserves stock, prices any coupon, and returns the whole order. **cURL** ```bash # The key identifies the ORDER you are placing, not this HTTP attempt. # Generate it once, store it with your own record, and reuse it on retries. IDEMPOTENCY_KEY=$(uuidgen) curl -sS -X POST "https://api.shopdeskbd.com/ext/v1/orders" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: $IDEMPOTENCY_KEY" \ -H "Content-Type: application/json" \ -d '{ "channel": "facebook", "customer": { "phone": "01712345678", "full_name": "Rafiqul Islam" }, "items": [ { "sku": "TSHIRT-RED-M", "quantity": 2 } ], "shipping_charge": 120, "note": "Deliver after 6pm." }' ``` **JavaScript** ```javascript import { randomUUID } from "node:crypto"; // One key per logical order. Persist it next to your own order record BEFORE // the call, so a retry after a process restart still sends the same one. const idempotencyKey = randomUUID(); const response = await fetch("https://api.shopdeskbd.com/ext/v1/orders", { method: "POST", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": idempotencyKey, "Content-Type": "application/json", }, body: JSON.stringify({ channel: "facebook", customer: { phone: "01712345678", full_name: "Rafiqul Islam" }, items: [{ sku: "TSHIRT-RED-M", quantity: 2 }], shipping_charge: 120, note: "Deliver after 6pm.", }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } // 201 with the full order: items and customer expanded, totals computed, // stock already reserved. const order = await response.json(); console.log(order.number, order.total_amount, order.payment_status); // true when this was a replay of an earlier identical call. console.log(response.headers.get("Idempotent-Replayed") === "true"); ``` **Python** ```python import os import uuid import requests # One key per logical order — generated where the order is DECIDED, not # inside the retry loop, and stored with your own record. idempotency_key = str(uuid.uuid4()) response = requests.post( "https://api.shopdeskbd.com/ext/v1/orders", json={ "channel": "facebook", "customer": {"phone": "01712345678", "full_name": "Rafiqul Islam"}, "items": [{"sku": "TSHIRT-RED-M", "quantity": 2}], "shipping_charge": 120, "note": "Deliver after 6pm.", }, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": idempotency_key, }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") order = response.json() print(order["number"], order["total_amount"], order["payment_status"]) print(response.headers.get("Idempotent-Replayed") == "true") ``` **PHP** ```php 'facebook', 'customer' => ['phone' => '01712345678', 'full_name' => 'Rafiqul Islam'], 'items' => [ ['sku' => 'TSHIRT-RED-M', 'quantity' => 2], ], 'shipping_charge' => 120, 'note' => 'Deliver after 6pm.', ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/orders'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('SHOPDESK_API_KEY'), 'Idempotency-Key: ' . $idempotencyKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } echo $response['number'] . ' ' . $response['total_amount'] . PHP_EOL; ``` For a storefront specifically: - **Send a `channel` slug** so the merchant can see where their sales come from. `GET /channels` lists what the store has; omitted, orders land on `manual`. - **Compute `shipping_charge` yourself.** Nothing calculates it on this path, and omitting it means free delivery. - **Use an idempotency key per checkout attempt**, stored with your own cart or session record, so a double-tapped Pay button cannot produce two orders. See [Writes & idempotency](https://www.shopdeskbd.com/developers/idempotency.md). - **Do not send `unit_price`** unless you have a real reason. The endpoint trusts you, which means a bug in your pricing becomes the merchant's revenue problem. Omit it and the catalogue price is used. ## 4. Record payment If you take money through your own gateway, tell Shopdesk what settled: **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X POST "https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ORDER_ID/payments" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: REPLACE_WITH_A_UUID" \ -H "Content-Type: application/json" \ -d '{ "amount": 500, "transaction_id": "TRX9F3K2" }' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ORDER_ID/payments"); const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "REPLACE_WITH_A_UUID", "Content-Type": "application/json", }, body: JSON.stringify({ "amount": 500, "transaction_id": "TRX9F3K2" }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "amount": 500, "transaction_id": "TRX9F3K2", } response = requests.post( "https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ORDER_ID/payments", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "REPLACE_WITH_A_UUID", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 500, 'transaction_id' => 'TRX9F3K2', ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ORDER_ID/payments'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: REPLACE_WITH_A_UUID', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` `transaction_id` is unique per store, so replaying the same provider reference is a `409` rather than a double credit. The amount is capped at the order's net due. Recording payment in full moves the order to `payment_status: "paid"` and fires `order.paid`. For cash on delivery — the common case in Bangladesh — record nothing at checkout. The merchant's staff record it when the courier settles. ## 5. Keep the shopper informed An order-tracking page needs the order's current state. Either read it — `GET /orders/{id}` — or, better, subscribe to [webhooks](https://www.shopdeskbd.com/developers/webhooks.md) and push a notification when it changes: | Event | Tell the shopper | | ----------------- | ------------------------------------------------------ | | `order.updated` | "Confirmed", "Out for delivery" — `data.object.status` | | `order.paid` | Payment received | | `order.fulfilled` | Delivered | | `order.cancelled` | Cancelled, with the reason if you captured one | ## What the API does not give you Being blunt, so you can design around it rather than discover it halfway: | Not available | What to do | | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | **No shopper authentication.** There are no customer logins, sessions or password endpoints. | Own the identity in your app. Map your user to a Shopdesk `customer_id` and store it yourself. | | **No cart.** Carts are not a Shopdesk concept on this surface. | Keep the cart in your own system; the order is created once, at checkout. | | **No shipping-rate calculation.** | Compute it and send `shipping_charge`. | | **No coupon validation endpoint.** | You can only find out by placing the order — an unusable code fails it. Validate the shape client-side and handle the failure at checkout. | | **No payment-gateway integration for you.** Shopdesk records payments; it does not take them on this surface. | Use your own gateway, then `POST /orders/{id}/payments`. | | **No customer address book over the API.** Addresses are returned on a customer read, but there is no endpoint to add one. | Send `shipping_address` on the order; it is stored verbatim with it. | | **No product search beyond `search=`.** Name and SKU only. | Index the catalogue yourself if you need facets or fuzzy matching. | | **No sandbox.** Every key acts on its store's real data. | Build against a free developer test store — email `support@shopdeskbd.com` — or a store the merchant treats as a test store, with small amounts. | > **Note — There is already a storefront, if you want one** > > Shopdesk ships a hosted, themeable storefront — this guide is for teams who > specifically want their own. If you are weighing it up: the hosted one > already has shopper sessions, carts, coupon validation and payment gateways > wired in, and none of those are things this API hands you. ## Rate limits and caching 120 reads and 30 writes per minute, per key. A storefront is read-heavy and bursty, which means: - **Cache the catalogue** at your edge or in your backend. Re-read it on a schedule, or on the `product.*` webhooks. - **Do not cache availability for long.** Seconds, not minutes. - **Use a separate key for checkout writes**, so a traffic spike on the read path cannot exhaust the bucket your orders depend on. --- # Build a COD confirmation tool Find the order by phone, confirm or cancel it, schedule the call-back. Most orders in Bangladesh are cash on delivery, and most merchants call the customer before shipping to confirm. This guide builds that loop over the API: pull the queue, find an order by the number that is calling, check the customer's history, and record what they said. **You need** a key with `orders:write` (which implies `orders:read`) and `customers:read`. The customer history in step 3 needs `orders:read` as well, which `orders:write` already covers. ## 1. Pull the queue Two lists make up a call agent's day: new orders nobody has confirmed yet, and call-backs that are now due. New orders are `status=pending`. Due call-backs are orders in `follow_up` whose follow-up time has passed — `status=follow_up` with the current time as `follow_up_before`. That is the same list the dashboard's overdue follow-up count and the MCP `follow_up_due` queue use: **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/orders?status=follow_up&follow_up_before=2026-10-02T12%3A00%3A00.000Z&expand=customer" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/orders"); Object.entries({ status: "follow_up", follow_up_before: "2026-10-02T12:00:00.000Z", expand: "customer", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/orders", params={ "status": "follow_up", "follow_up_before": "2026-10-02T12:00:00.000Z", "expand": "customer", }, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` `follow_up_before` and `follow_up_after` only ever match orders that have a follow-up scheduled, so combine them for a window ("today") or use one alone for "overdue". Keep `status=follow_up` in the query: an order that has since been confirmed or cancelled keeps its old `follow_up_at`, and the status is what takes it off the list. Every order carries its `follow_up_at`, `follow_up_note` and `assignee` (`{ id, name }`, or `null`), and `assignee_id` narrows the list to one agent's orders. Assigning orders is done in the dashboard; the API reads the assignment. ## 2. Find the order by phone When a customer calls in, all you have is their number, in whatever form your phone system shows it. `search` matches order numbers, customer names and customer phones, and a phone matches in **any** Bangladeshi form: `+8801712345678`, `8801712345678`, `01712345678` and `+880 1712-345678` all find the same orders, because phones are stored as `01XXXXXXXXX` and the search also tries the term rewritten that way. Order numbers and names are matched as typed, so `20260929-0001` still finds `ORD-20260929-0001`. **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/orders?search=%2B8801712345678&status=pending&expand=customer" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/orders"); Object.entries({ search: "+8801712345678", status: "pending", expand: "customer", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const { data, meta } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/orders", params={ "search": "+8801712345678", "status": "pending", "expand": "customer", }, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") page = response.json() rows, meta = page["data"], page["meta"] ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } $rows = $response['data']; $meta = $response['meta']; ``` The same rule applies to `GET /customers?search=`, which also matches name and email. To look up exactly one number, use `GET /customers?phone=` instead: it matches the phone only, in any Bangladeshi form, so an email such as `01712345678@gmail.com` never gets in the way. ## 3. Check the customer's history Before you confirm a ৳5,000 COD order, it helps to know whether this customer usually keeps what they order. Add `expand=stats` to the customer read: **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/customers/REPLACE_WITH_CUSTOMER_ID?expand=stats" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/customers/REPLACE_WITH_CUSTOMER_ID"); Object.entries({ expand: "stats", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/customers/REPLACE_WITH_CUSTOMER_ID", params={ "expand": "stats", }, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` | Field | What it counts | | ---------------- | ------------------------------------------------------------------------------------------------ | | `orders_count` | Orders that are not cancelled, returned or spam — the count the dashboard's customer page shows. | | `total_spent` | The total of their **delivered** orders, delivery and tax included. | | `last_order_at` | When their latest order was placed (spam excluded). | | `returned_count` | Orders that came back whole. | > **Note — stats needs orders:read too** > > The stats summarise orders, so a key with `customers:read` alone gets the > customer **without** `stats` — never a `403`. That keeps a CRM key that > cannot read orders working unchanged. ## 4. Record the outcome **The customer confirmed.** Move the order to `confirmed` with the status route. The same transition rules as the dashboard apply: **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X PATCH "https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ORDER_ID/status" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: REPLACE_WITH_A_UUID" \ -H "Content-Type: application/json" \ -d '{ "status": "confirmed", "note": "Confirmed on the phone." }' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ORDER_ID/status"); const response = await fetch(url, { method: "PATCH", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "REPLACE_WITH_A_UUID", "Content-Type": "application/json", }, body: JSON.stringify({ "status": "confirmed", "note": "Confirmed on the phone." }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "status": "confirmed", "note": "Confirmed on the phone.", } response = requests.patch( "https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ORDER_ID/status", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "REPLACE_WITH_A_UUID", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php 'confirmed', 'note' => 'Confirmed on the phone.', ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ORDER_ID/status'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: REPLACE_WITH_A_UUID', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` **The customer cancelled.** Use `POST /orders/{id}/cancel`, which releases the reserved stock. **No answer, or "call me after 4pm".** Move the order to `follow_up` with the status route (as for a confirmation, with `"status": "follow_up"`), then set the call-back time with `PATCH /orders/{id}`: **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS -X PATCH "https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ORDER_ID" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: REPLACE_WITH_A_UUID" \ -H "Content-Type: application/json" \ -d '{ "follow_up_at": "2026-10-02T10:00:00.000Z", "follow_up_note": "No answer at 11am — try after 4pm." }' ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ORDER_ID"); const response = await fetch(url, { method: "PATCH", headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, "Idempotency-Key": "REPLACE_WITH_A_UUID", "Content-Type": "application/json", }, body: JSON.stringify({ "follow_up_at": "2026-10-02T10:00:00.000Z", "follow_up_note": "No answer at 11am — try after 4pm." }), }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests payload = { "follow_up_at": "2026-10-02T10:00:00.000Z", "follow_up_note": "No answer at 11am — try after 4pm.", } response = requests.patch( "https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ORDER_ID", json=payload, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", "Idempotency-Key": "REPLACE_WITH_A_UUID", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php '2026-10-02T10:00:00.000Z', 'follow_up_note' => 'No answer at 11am — try after 4pm.', ]); $ch = curl_init('https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ORDER_ID'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: REPLACE_WITH_A_UUID', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` `PATCH /orders/{id}` changes the order `note`, `follow_up_at` and `follow_up_note`, and nothing else. It works on an order in any status, runs the same code as the dashboard's order-details edit, and cannot touch prices, lines or stock. Send `null` to clear a field and leave out a field to keep it. Send at least one field. Both `pending` and `confirmed` orders can move to `follow_up`. Once the order is confirmed or cancelled, its status takes it off the call list; the old `follow_up_at` stays on the order, as it does in the dashboard. Send `{"follow_up_at": null, "follow_up_note": null}` if you want it cleared too. > **Note — Send an Idempotency-Key on every outcome** > > Both routes accept an `Idempotency-Key`. A call agent's double-click, or a > retry after a timeout, then records the outcome once. See > [Writes & idempotency](https://www.shopdeskbd.com/developers/idempotency.md). ## 5. Stay in sync If other staff work the same orders in the dashboard, subscribe to `order.updated`. It fires when the status moves (`data.previous.status`) and when the note, follow-up or delivery address changes. On those changes `data.previous` holds the old `note`, `follow_up_at`, `follow_up_note` or `shipping_address`, named as in `data.object`. See the [event catalog](https://www.shopdeskbd.com/developers/webhook-events.md). ## With the store MCP server The same loop runs from an AI assistant connected to the [store MCP server](https://www.shopdeskbd.com/developers/ai.md#the-store-mcp-server) with a key holding `orders:write` and `customers:read`. Each step above is one tool call: | Step | Tool | | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Pull the queue | `list_orders_needing_attention` with `kind: "unconfirmed"` (pending orders) or `"follow_up_due"` (orders in `follow_up` whose call-back time has passed), oldest first | | Find the order by phone | `find_orders` with the number in any form, an order number or a name | | Check the history | `get_customer_summary` with the `phone` or `customer_id`: the stats and the latest five orders | | Record the outcome | `record_call_outcome` | `record_call_outcome` takes the order id, an `outcome` and an optional `note`: | `outcome` | What it does | | ----------- | ------------------------------------------------------------------------------------------------------------ | | `confirmed` | Moves the order to `confirmed`. | | `cancelled` | Cancels it through the cancel route; reserved stock is released. | | `call_back` | Needs `call_back_at`. Moves the order to `follow_up` and sets the call-back time, with `note` as the reason. | | `no_answer` | Moves the order to `follow_up`; sets the call-back time if you send `call_back_at`. | The note also goes on the order timeline with the status change. The status rules are the dashboard's: an order that cannot move to the new status is refused before anything changes. Repeating the same outcome changes nothing more, and an `idempotency_key` makes a retried call return the first answer. The result lists the `steps` taken and the order as it now stands. `update_order_details` edits the note and call-back directly, like `PATCH /orders/{id}`. The order note it sets replaces the whole note, and notes these tools show are shortened to 500 characters — so an assistant should never rebuild a note from a value it read. Notes, addresses and names come back in labelled fields with links and markup removed; see [MCP tools](https://www.shopdeskbd.com/developers/mcp-tools.md). --- # Sales and reports The sales summary, comparisons and top products, and what each figure counts. "How were sales this week?" is the first thing a merchant asks an assistant, and the first number a BI tool wants. This guide covers the two report endpoints, what each figure counts, and which plan each needs. **You need** a key with `reports:read`. It is flagged sensitive when you create the key, because it reveals the store's sales. | Endpoint | Plan | | ---------------------------------------------------- | -------------------------- | | `GET /reports/sales-summary` | Every plan with API access | | `GET /reports/sales-summary?compare=previous_period` | Advanced Reports | | `GET /reports/top-products` | Advanced Reports | A plan without Advanced Reports gets `403` with the code `PLAN_FEATURE_REQUIRED` on the last two, and the summary still works. That code means the plan, not the key, is missing something: a key without `reports:read` gets `FORBIDDEN_SCOPE` instead. See [Errors](https://www.shopdeskbd.com/developers/errors.md). Each call to either endpoint counts as **5 reads** against the key's read limit, because a report is several queries. See [Rate limits](https://www.shopdeskbd.com/developers/rate-limits.md). ## Windows Both endpoints take `from` and `to`: the first and last day of the window, inclusive, as the **store's** calendar days (`2026-09-01`). With neither, the window is the current month so far. A window spans at most **366 days**. The store's timezone is echoed as `time_zone`, and `GET /store` returns it as `timezone`. Instead of `from` and `to`, you can name the window with `period`: `today`, `yesterday`, `last_7_days` (today and the six days before), `last_30_days`, `this_month` (the 1st to today) or `last_month`. It is read on the store's calendar, so "yesterday" is the store's yesterday, and the response echoes the concrete `from` and `to`. Sending `period` with `from` or `to` is a `422`. A company-scoped key must name the store with `store_id`. A report answers for one store. Answers are cached for **5 minutes** per store and window, so polling faster than that returns the same figures. ## The sales summary **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/reports/sales-summary?from=2026-09-01&to=2026-09-30" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/reports/sales-summary"); Object.entries({ from: "2026-09-01", to: "2026-09-30", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/reports/sales-summary", params={ "from": "2026-09-01", "to": "2026-09-30", }, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` The figures are the same numbers the dashboard's Reports pages show for the same window. They run on three different clocks, and `basis` says which: | Field | Counts | `basis` | | --------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------ | | `orders` | Orders **delivered** in the window | `sales: delivered` | | `gross_sales` | Their line value after markdowns, before coupons and delivery | `sales: delivered` | | `net_sales` | What those customers were billed for goods and delivery, excluding tax | `sales: delivered` | | `net_revenue` | `net_sales` less returned goods, refunds, whole-order returns and courier concessions | `sales: delivered` | | `average_order_value` | `net_sales ÷ orders` | `sales: delivered` | | `money_received` | Payments **settled** in the window, whichever order they belong to | `money_received: settled` | | `cod_outstanding` | What open orders still owe **as of today**: not shipped, with the courier, delivered but unpaid | `cod_outstanding: as_of_today` | An order placed on the 30th and delivered on the 2nd counts in the next month's sales, which is when the sale became final. Don't add `money_received` to `net_sales`: they measure different things. `money_received` leaves out payments on orders that are cancelled or returned **now**. If an order paid in a past window is cancelled or returned later, that window's `money_received` goes down, exactly as the dashboard's Payments report does. Store a closed month's figure yourself if you need it frozen. **By channel.** Pass `channel` (a slug from `GET /channels`) to count only that channel's sales. `money_received` and `cod_outstanding` are then `null`, because payments are not split by channel. ## Comparing with the previous period **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/reports/sales-summary?from=2026-09-22&to=2026-09-28&compare=previous_period" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/reports/sales-summary"); Object.entries({ from: "2026-09-22", to: "2026-09-28", compare: "previous_period", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/reports/sales-summary", params={ "from": "2026-09-22", "to": "2026-09-28", "compare": "previous_period", }, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` `compare=previous_period` adds `previous_period`: the same figures for the window of equal length that ends the day before `from`. A week compares with the week before it. A 30-day month compares with the 30 days before it, not with the previous calendar month. ## Top products **cURL** ```bash export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY" curl -sS "https://api.shopdeskbd.com/ext/v1/reports/top-products?from=2026-09-01&to=2026-09-30&limit=10" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` **JavaScript** ```javascript const url = new URL("https://api.shopdeskbd.com/ext/v1/reports/top-products"); Object.entries({ from: "2026-09-01", to: "2026-09-30", limit: "10", }).forEach(([key, value]) => url.searchParams.set(key, value)); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, }, }); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message} [${error.request_id}]`); } const object = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.shopdeskbd.com/ext/v1/reports/top-products", params={ "from": "2026-09-01", "to": "2026-09-30", "limit": "10", }, headers={ "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}", }, timeout=30, ) if not response.ok: error = response.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']}") obj = response.json() ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $response = json_decode($body, true); if ($status >= 400) { throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}"); } ``` Product variants ranked by the revenue of the units delivered in the window that customers **kept**. Returned units are netted out and shown as `units_returned`. `limit` is 1–50, default 10. > **Note — Revenue and units only** > > The ranking never includes cost, profit or margin, at any scope. The API > does not return cost data anywhere. ## With the store MCP server An assistant connected to the [store MCP server](https://www.shopdeskbd.com/developers/ai.md#the-store-mcp-server) answers "how were sales this week?" with one tool call: `get_sales_summary` takes `period` (or `from`/`to`), `compare_to: "previous_period"` and `channel`, and returns the same figures as `GET /reports/sales-summary` with the same `basis`. It answers concisely by default — the headline figures and the COD total — and with `response_format: "detailed"` it returns the full REST body, stage split included. The same plan rules apply: comparisons need Advanced Reports. A call counts as 5 reads against the key's read limit, the same as the REST endpoint. A comparison on a plan without Advanced Reports comes back as an error with the code `PLAN_FEATURE_REQUIRED`. The ranking has no tool: a BI sync reads `GET /reports/top-products` over REST. --- # Build a catalog through the API Attributes, a category and a brand, then a product with variants and photos — in four calls. An importer moving a shop onto Shopdesk, or an assistant setting up a catalogue from a supplier's sheet, needs the whole product — options, a category, a brand, photos — without the dashboard. This guide builds one in four calls: an attribute, a category, a brand, then the product with its variants and photos. **You need** a key with `products:write` (products and their photos) and `catalog:write` (attributes, categories and brands). `catalog:write` is only granted together with `products:read` or `products:write`. Send an `Idempotency-Key` on every call below, so a retry never creates a second anything — see [Idempotency](https://www.shopdeskbd.com/developers/idempotency.md#idempotency-key). ## The order of things ```text 1. POST /attributes Colour: Red, Blue (the options a variant can have) 2. POST /categories Tees, with its image (photos by link) 3. POST /brands Nukta, logo + banner (photos by link) 4. POST /products 2 variants, 5 photos (options and photos by name and position) ``` Attributes come first because a variant's options must already exist: the product write looks them up by name and **never creates one**. Categories and brands come before the product because it links them by id. ## 1. An attribute with its values ```bash curl -sS https://api.shopdeskbd.com/ext/v1/attributes \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 0a4f7c2e-1b3d-4e5f-8a9b-0c1d2e3f4a01" \ -H "Content-Type: application/json" \ -d '{ "name": "Colour", "type": "variant", "values": [{ "value": "Red" }, { "value": "Blue" }] }' ``` `type: "variant"` makes it an option that defines variants; `specification` attributes only describe a product. If the attribute already exists, add the missing values with `POST /attributes/{id}/values` instead — a repeated value is a `409`. ## 2. A category and 3. a brand, with images by link ```bash curl -sS https://api.shopdeskbd.com/ext/v1/categories \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 0a4f7c2e-1b3d-4e5f-8a9b-0c1d2e3f4a02" \ -H "Content-Type: application/json" \ -d '{ "name": "Tees", "image_url": "https://cdn.example.com/cats/tees.jpg" }' curl -sS https://api.shopdeskbd.com/ext/v1/brands \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 0a4f7c2e-1b3d-4e5f-8a9b-0c1d2e3f4a03" \ -H "Content-Type: application/json" \ -d '{ "name": "Nukta", "logo_url": "https://cdn.example.com/brands/nukta-logo.png", "banner_url": "https://cdn.example.com/brands/nukta-banner.jpg" }' ``` Shopdesk downloads each link, checks it is a real image and stores its own copy; the answer carries the stored URLs, never yours. Keep the `id` of each. The rules for links are the same everywhere — see [Photos by link](https://www.shopdeskbd.com/developers/guide-photos-by-link.md). ## 4. The product: variants, options and photos in one call ```bash curl -sS https://api.shopdeskbd.com/ext/v1/products \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 0a4f7c2e-1b3d-4e5f-8a9b-0c1d2e3f4a04" \ -H "Content-Type: application/json" \ -d '{ "name": "Classic Tee", "status": "active", "brand_id": "BRAND_ID", "categories": [{ "id": "CATEGORY_ID", "is_primary": true }], "images": [ { "source_url": "https://cdn.example.com/tee/red-front.jpg", "alt_text": "Red tee, front" }, { "source_url": "https://cdn.example.com/tee/red-back.jpg" }, { "source_url": "https://cdn.example.com/tee/blue-front.jpg", "alt_text": "Blue tee, front" }, { "source_url": "https://cdn.example.com/tee/blue-back.jpg" }, { "source_url": "https://cdn.example.com/tee/detail.jpg" } ], "variants": [ { "sku": "TEE-RED", "name": "Red", "price": 1250, "options": [{ "attribute": "Colour", "value": "Red" }], "image_index": 0 }, { "sku": "TEE-BLUE", "name": "Blue", "price": 1250, "options": [{ "attribute": "Colour", "value": "Blue" }], "image_index": 2 } ] }' ``` What each part does: - **`images[]`** is the gallery in display order — up to 10 links per call. The call answers once every photo is stored, so the response already has each photo's `id` and stored `url`. The first photo is the thumbnail unless one entry says `"is_thumbnail": true`. - **`options[]`** names each option by attribute and value — the name (any case), the attribute's slug, or the ids from `GET /attributes`. An unknown name is a `422` on that exact field, such as `variants[1].options[0].value`, telling you which call creates it. Two variants of one product cannot have the same options (`409`). - **`image_index`** points a variant at a photo by its position in this request's `images[]` — the only way to reference a photo that did not exist a moment ago. One photo that cannot be used fails the whole call with a `422` naming `images[i].source_url`, and nothing is written. The response is the product exactly as `GET /products/{id}` returns it. Each variant carries its photo as `image` and its options as `options[]`: ```json { "sku": "TEE-RED", "image": { "id": "…", "url": "https://…/gallery/…webp" }, "options": [ { "attribute_id": "…", "attribute": "Colour", "value_id": "…", "value": "Red" } ] } ``` ## Later changes | To | Call | | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | Add photos without touching the rest | `POST /products/{id}/images` with `{ "images": [{ "source_url": … }] }` (up to 10) | | Put the photos in a new order | `POST /products/{id}/images/reorder` with every image id, in order | | Change one variant's price, sale price, SKU, barcode, weight or photo | `PATCH /variants/{identifier}` — by id, SKU or barcode | | Add a variant | `PATCH /products/{id}` with the full `variants` list, the new one without an `id` and with its `options` | | Restate the whole gallery | `PATCH /products/{id}` with `images[]`: `image_id` keeps a photo, `source_url` adds one, and photos left out are **deleted** | Two rules to know before a `PATCH /products/{id}`: - **`variants` and `images` are full sets.** Anything left out is removed — a variant (unless it has stock or order history) or a photo. - **Options are set when a variant is created.** An existing variant keeps its options whatever you send; `options` on an entry with an `id` is refused. To sell a new combination, add a new variant. Stock is not set on any of these calls: it enters through `POST /inventory/adjustments` — see the [Inventory reference](https://www.shopdeskbd.com/developers/reference/inventory.md). ## With the store MCP server The same build runs from an AI assistant connected to the [store MCP server](https://www.shopdeskbd.com/developers/ai.md#the-store-mcp-server) with a key holding `products:write` and `catalog:write` (which comes with `products:read`). Each step above is one tool call: | Step | Tool | Scopes | | ----------------------------------- | ------------------ | --------------------------------- | | 1. The attribute and its values | `create_attribute` | `catalog:write` + `products:read` | | 2. The category, with its image | `create_category` | `catalog:write` + `products:read` | | 3. The brand, with logo and banner | `create_brand` | `catalog:write` | | 4. The product, variants and photos | `create_product` | `products:write` | The tools take the same fields as the REST calls, with three differences made for a conversation: - **`create_attribute` is safe to repeat.** It looks the attribute up by name (or `slug`) and `type` first. If the store already has it, only the values it lacks are added — compared ignoring case, placed after the existing ones — and nothing is renamed or removed, so the second identical call adds nothing. `type` defaults to `variant`. The answer is `{ attribute, created, added_values }`. - **A name that matches nothing comes back with candidates.** When `create_product` names an option that does not exist, the call fails with nothing created and nothing downloaded, and the error object adds `candidates` beside `fields` — the store's variant attributes for an unknown attribute, that attribute's values for an unknown value: ```json { "error": { "code": "VALIDATION_ERROR", "message": "An option name matches nothing in this store, so nothing was created. …", "fields": { "variants[0].options[0].attribute": [ "No attribute \"Color\" in this store. Create it first with `create_attribute` (nothing is created here)." ] }, "candidates": { "variants[0].options[0].attribute": [ { "attribute_id": "…", "attribute": "Colour", "slug": "colour", "type": "variant", "store_id": "…", "values": ["Red", "Blue"] } ] } } } ``` The assistant then uses the right name, or adds what is missing with `create_attribute`. Nothing is ever created from a name. - **`create_category` takes `parent` by name,** slug or id instead of `parent_id`. A name shared by several categories (two "Shirts" under Men and Women) fails with those categories as `candidates`; send the slug or id. Later changes have their tools too: `add_product_images_from_urls` adds up to 10 photos from links, and `update_variant` changes one variant by id, SKU or barcode. A new variant on `update_product` takes `options` like `create_product`. Reordering photos, editing or deleting a photo, and updating categories, brands and attributes stay on REST. `create_product`, `create_category`, `create_brand` and `add_product_images_from_urls` declare `openWorldHint: true` — they download the links they are given — so a client that confirms such tools will ask first. Links follow the same rules as on REST: see [Photos by link](https://www.shopdeskbd.com/developers/guide-photos-by-link.md#from-the-store-mcp-server). Every tool is on [MCP tools](https://www.shopdeskbd.com/developers/mcp-tools.md). --- # Photos by link Where links are taken, what is checked, all-or-nothing calls, limits and safe retries. Most catalogues already live somewhere with photo URLs: a supplier feed, an old shop, a spreadsheet. Instead of downloading and re-uploading every file, send the links — Shopdesk downloads them, checks them and stores its own copy. This guide covers where links are taken, the limits, and how to retry safely. ## Where a link is taken | Call | Field | Scope | | ------------------------------------------------------------ | ------------------------------------------------------------------ | ---------------- | | `POST /products` | `images[].source_url` (up to 10) | `products:write` | | `PATCH /products/{id}` | `images[].source_url` (up to 10 new; the list is the full gallery) | `products:write` | | `POST /products/{id}/images` | `images[].source_url` in a JSON body (up to 10) | `products:write` | | `POST` / `PATCH /categories` | `image_url`, `banner_url` | `catalog:write` | | `POST` / `PATCH /brands` | `logo_url`, `banner_url` | `catalog:write` | | `POST /attributes`, `POST` / `PATCH /attributes/{id}/values` | `swatch_image_url` | `catalog:write` | If you have the bytes instead of a link, three routes also take the file itself as a `multipart/form-data` part: - `POST /products/{id}/images`: up to 10 `image` parts, with each photo's options in an `images` JSON text field; - `POST` / `PATCH /categories`: `image` and `banner` parts; - `POST` / `PATCH /brands`: `logo` and `banner` parts. Product create and update, and attribute swatches, take links only — upload product photos as files through `POST /products/{id}/images`. See the [reference](https://www.shopdeskbd.com/developers/reference.md) for both encodings of each route. ## What happens to a link 1. **The link is checked before any connection:** `https` only, no user name or password in it, a public host name. It is resolved once, and every address it resolves to must be public — never a private, loopback or link-local address, whether IPv4 or IPv6. 2. **It is downloaded** from the address that was checked, following at most 3 redirects, each checked the same way. Each link gets 10 seconds, and all the links of one call get 30 seconds together. Up to 4 download at once. 3. **The bytes are checked:** PNG, JPEG, WebP or GIF, read from the bytes — the file name and the server's `Content-Type` are ignored, and SVG is never accepted. At most 10 MB, 8000 pixels on the longest side and 40 megapixels in all (16 for a GIF), and the whole image must decode. 4. **A copy is stored,** re-encoded as WebP with metadata removed. Your link is not kept anywhere; responses carry the stored URL. ## All or nothing A call either stores every photo it was given or none of them. - **One link fails, the call fails.** The answer is `422 VALIDATION_ERROR` with `fields` naming the link — `images[3].source_url`, `logo_url` — and a message saying why (not found, not an image, too large, not a public host, too slow). Nothing is written: no product, no variant, no photo. - **Checked before downloaded.** Everything that can be checked without downloading is checked first, so a mistake there costs no download: the shape of `images[]` (including at most one `is_thumbnail`), option names, a full gallery, the 30-photo cap, the plan's product limit, the brand and categories, variant SKUs, and a category's parent or a slug. - **Nothing is left behind.** If the save fails after photos were stored, the stored copies are deleted again. The call answers once every photo is stored, with the photos' final `id`s — that is what lets a variant point at a photo in the same call (`image_index`). ## Retrying Send an `Idempotency-Key` with every call that carries links. On a JSON body the key's fingerprint covers the links, so: - **A retry with the same key and body** answers with the stored response — **nothing is downloaded again**. - **The same key with different links** is `422 IDEMPOTENCY_CONFLICT`: a key names one request. - **A call that failed** releases its key, so after fixing the link you can retry under the same key. On a multipart upload the key covers the route only, not the files — reuse a key only to retry the same upload. See [Idempotency](https://www.shopdeskbd.com/developers/idempotency.md). ## Limits | Limit | Value | | -------------------------------------- | ----------------------------------------------------------------- | | New photos per call | 10 (send more with further calls) | | Photos per product | 30 | | Size per image | 10 MB, 8000 px on the longest side, 40 megapixels (16 for a GIF) | | Bytes per call | 50 MB downloaded, or 50 MB of uploaded files | | Time | 10 s per link, 30 s per call | | Writes | each image counts as one write against the per-minute write limit | | Calls handling images at once, per key | 2, downloading or uploading (a third is `429 RATE_LIMITED`) | | Links per company per UTC day | 2,000 (then `429 RATE_LIMITED`) | A call that would pass a limit is refused before anything is downloaded, and every `429` here carries `Retry-After`. Ten 9 MB photos pass the per-image limit but not the per-call one: send them over two calls. The daily budget counts links only; uploaded files count against the others. See [Rate limits](https://www.shopdeskbd.com/developers/rate-limits.md#image-downloads). ## Adding photos to a product later ```bash curl -sS https://api.shopdeskbd.com/ext/v1/products/PRODUCT_ID/images \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 6c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f" \ -H "Content-Type: application/json" \ -d '{ "images": [ { "source_url": "https://cdn.example.com/tee/side.jpg", "alt_text": "Side view" }, { "source_url": "https://cdn.example.com/tee/model.jpg", "position": 0 } ] }' ``` The batch is one change to the gallery and answers `{ "data": [...] }` — the new photos in the order you sent them. `position` places a photo (applied in list order); without it a photo is appended. ## From the store MCP server An AI assistant connected to the [store MCP server](https://www.shopdeskbd.com/developers/ai.md#the-store-mcp-server) sends links through four tools. Each calls the same service as its REST twin, so everything on this page applies unchanged — the checks, the all-or-nothing rule and the limits, which a key shares between REST and MCP. | Tool | Takes links in | Scope | | ------------------------------ | -------------------------------- | --------------------------------- | | `create_product` | `images[].source_url` (up to 10) | `products:write` | | `add_product_images_from_urls` | `images[].source_url` (up to 10) | `products:write` | | `create_category` | `image_url`, `banner_url` | `catalog:write` + `products:read` | | `create_brand` | `logo_url`, `banner_url` | `catalog:write` | These four declare `openWorldHint: true`, because they reach outside the store to download; a client that confirms such tools will ask before running them. Swatch images by link, a full gallery restated on update, and multipart uploads stay on REST. - **A failed link** comes back as a tool result with `isError: true`, and its text is the REST error object: `VALIDATION_ERROR`, with `fields` naming the link (`images[1].source_url`, `logo_url`). Nothing was written. - **Retrying:** pass an `idempotency_key` — the same value on every retry of the same call. A retry with the same key and arguments answers with the first result and downloads nothing; the same key with different links is `IDEMPOTENCY_CONFLICT`; a call that failed releases its key. - **Metering:** each link counts as one write, as on REST, plus the image limits above (two image calls at once per key, 2,000 links per company per UTC day). --- # Upload a product video Raw calls from start to finish, covers, a video for one option, imports by link, reject codes and limits. A short clip on a product page sells what a photo cannot: how a saree drapes or how a bag opens. This guide uploads a video with plain HTTP calls from start to finish. It then sets the video's cover and shows it on a product for one option, such as Colour: Red. It also covers importing a video from a link, the reject codes and the limits. **You need** a key with `videos:write`, and a plan that includes product videos (otherwise every call below answers `403 PLAN_FEATURE_REQUIRED`). Step 6 reads the product to find the option, which needs `products:read` as well. `videos:write` is its own scope because a video uses the plan's video storage and can be up to 200 MB. No existing key has it: create a new key that includes it ([Authentication](https://www.shopdeskbd.com/developers/authentication.md#scopes)). > **Warning — From a server, not a web page** > > The file goes straight from your code to storage, on URLs Shopdesk signs for > each part. Storage accepts uploads from browsers on the Shopdesk dashboard > only. Run the upload from a server, a script or a desktop tool. A web page on > your own domain cannot send the parts, and your API key must never be in a > browser anyway. ## How an upload works ```text 1. POST /videos start: the video id, its part plan, the size reserved 2. POST /videos/{id}/part-urls a signed URL for each part (up to 10 per call) 3. PUT each part's bytes, straight to storage 4. POST /videos/{id}/complete 200 ready, 202 still checking (poll GET /videos/{id}), or 422 5. POST /videos/{id}/poster optional: the cover shown before it plays 6. POST /products/{id}/videos show it on a product, for every variant or for one option ``` The bytes never pass through the API. Shopdesk signs one URL per part, and your code sends each part to its URL. Shopdesk then puts the file together and reads it itself to check it before any shopper sees it. ## What a file must be | Rule | Accepted | | --------- | --------------------------------------------------------------------------------------- | | Container | MP4, not fragmented (streaming) MP4 and not QuickTime `.mov` | | Video | H.264, 8-bit (SDR). Not HEVC (H.265), AV1, VP9, 10-bit or HDR | | Sound | AAC, MP3, Opus, or none | | Size | At most 200 MB (200,000,000 bytes) | | Length | 3 seconds to 5 minutes | | Picture | Shorter side at least 480 px, longer side at most 4096 px; turned only in quarter turns | Phones and editing apps can export this. On an iPhone, choose **Settings → Camera → Formats → Most Compatible**; in CapCut or InShot, choose H.264. About 5 Mbps at 1080p is plenty for a phone screen. A file that passes can still come back with `warnings`, such as `NOT_FASTSTART` ("starts a little slower") or `HIGH_BITRATE`. They are advice for the next export and never block the video. ## 1. Start the upload ```bash curl -sS https://api.shopdeskbd.com/ext/v1/videos \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 7d0c4a52-3b1e-4f6a-9c2d-8e5f1a0b3c01" \ -H "Content-Type: application/json" \ -d '{ "file_name": "red-saree-drape.mp4", "size_bytes": 48000000, "part_size": 8388608 }' ``` The answer (abridged): ```json { "id": "6b1f3c2d-9e8a-4b7c-a6d5-1e2f3a4b5c6d", "status": "uploading", "source": "upload", "file_name": "red-saree-drape.mp4", "size_bytes": 48000000, "part_size": 8388608, "part_count": 6, "checksum_algorithm": "SHA256", "uploaded_parts": [], "expires_at": "2026-09-30T09:12:00.000Z", "resumed": false } ``` - **`size_bytes`** is the exact size of the file. It is reserved against the plan's video storage from now on, so a plan that has no room answers `403 PLAN_LIMIT_REACHED` before you send a byte. - **`part_size`** is 5, 8 or 16 MiB (`5242880`, `8388608` or `16777216`). Every part except the last is exactly this long. The answer's `part_count` says how many parts there are. - **`Idempotency-Key` is required**, and it is also how you resume. The same key always names the same upload. Within 24 hours a retry replays the first answer exactly as it was (`Idempotent-Replayed: true`, `resumed: false`, and `uploaded_parts` as they were then). After that, the same key returns the upload with `resumed: true`. Either way, read `GET /videos/{id}` for the parts storage holds now. Keep the key with your own record of the file, and reuse it only for the same file. - **`product_id`** (optional) checks early that the product still has room for a video on its plan. It does not place the video; step 6 does that. - **`expires_at`** is when an unfinished upload lapses: a day after your last request for part URLs, and never more than three days after the start. A lapsed upload becomes `aborted` and its reserved storage is released. ## 2. Sign the parts, and 3. send them Part `n` is the bytes from `(n − 1) × part_size` up to `n × part_size`. The last part is whatever is left. For each part, send its number and the **base64 SHA-256 of its bytes**: ```bash curl -sS https://api.shopdeskbd.com/ext/v1/videos/$VIDEO_ID/part-urls \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "parts": [{ "part_number": 1, "sha256": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=" }] }' ``` ```json { "data": [ { "part_number": 1, "url": "https://…/video.mp4?partNumber=1&uploadId=…&X-Amz-Signature=…", "headers": { "x-amz-checksum-sha256": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=" }, "content_length": 8388608, "expires_at": "2026-09-29T10:12:00.000Z" } ] } ``` Then `PUT` each part's bytes to its `url`: - **Send exactly the `headers` given.** Do not send your `Authorization` header, which is only for the API, and do not send a `Content-Type`. - **Send exactly `content_length` bytes.** The length and the checksum are part of the signature, so storage refuses any other bytes. That refusal comes from storage (a `400` or `403`), not from Shopdesk. - **Ask for up to 10 parts per call.** URLs are valid for 60 minutes. Ask again for new ones whenever they expire. Send **no** `Idempotency-Key` on `part-urls`: asking again is always safe, and a replayed answer would hand back the first URLs, which may have expired. - **A part's checksum is fixed the first time you ask.** Asking again for the same part with a different checksum is `409 FILE_CHANGED`: it is not the same file. Start a new upload for it. A shell loop that sends every part (it uses `openssl`, `base64` and `jq`): ```bash # VIDEO_ID and PART_COUNT come from step 1's answer (id, part_count). FILE=red-saree-drape.mp4 PART_SIZE=8388608 for N in $(seq 1 "$PART_COUNT"); do dd if="$FILE" of=part.bin bs=$PART_SIZE skip=$((N - 1)) count=1 2>/dev/null SHA=$(openssl dgst -sha256 -binary part.bin | base64) URL=$(curl -sS "https://api.shopdeskbd.com/ext/v1/videos/$VIDEO_ID/part-urls" \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"parts\":[{\"part_number\":$N,\"sha256\":\"$SHA\"}]}" | jq -r '.data[0].url') curl -sS --fail -T part.bin -H "x-amz-checksum-sha256: $SHA" "$URL" done ``` `curl -T` sends a `PUT` with no `Content-Type`. Today the only header in `headers` is the checksum. A script that sends whatever `headers` holds, as the Node script below does, keeps working if that changes. ## 4. Complete, then poll ```bash curl -sS -X POST https://api.shopdeskbd.com/ext/v1/videos/$VIDEO_ID/complete \ -H "Authorization: Bearer $SHOPDESK_API_KEY" ``` Shopdesk puts the file together from the parts storage holds, so there are no part receipts to send. Then it reads the file and checks it against the rules above: | Answer | Means | | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `200`, `status: "ready"` | The video is checked and playable. `url` is the MP4 and `facts` holds what the file contains: `duration_ms`, `width`, `height`, `codecs`, `has_audio`. | | `202`, `status: "verifying"` | The check is still running. Poll `GET /videos/{id}` every few seconds until `status` is `ready` or `rejected`. | | `422` with a `VIDEO_*` code | The file breaks a rule. `error.fields.video` says how to fix it, where there is a fix. The file is deleted and its storage released. See [reject codes](#reject-codes). | | `409 PARTS_INCOMPLETE` | Some parts are not in storage yet. `error.fields.parts` names them. Send them and complete again. | Completing again is safe: a ready video answers `200` again, and a rejected one `422` again. `GET /videos/{id}` gives the same object, with a `rejected` video's reason in `failure` (`code`, `message`, `fix`). ## The whole upload as one script The same steps in Node.js 18 or later, with no libraries. It signs ten parts per call and reads each part from disk only when it needs it: ```js // SHOPDESK_API_KEY=… node upload-video.mjs red-saree.mp4 PRODUCT_ID Colour Red import { createHash, randomUUID } from 'node:crypto'; import { open } from 'node:fs/promises'; import { basename } from 'node:path'; const API = 'https://api.shopdeskbd.com/ext/v1'; const PART_SIZE = 8 * 1024 * 1024; // 5, 8 or 16 MiB const [file, productId, attribute, value] = process.argv.slice(2); async function call(method, path, body, idempotencyKey) { const response = await fetch(API + path, { method, headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`, ...(body && { 'Content-Type': 'application/json' }), ...(idempotencyKey && { 'Idempotency-Key': idempotencyKey }), }, body: body && JSON.stringify(body), }); const json = await response.json(); if (!response.ok) { throw new Error(`${response.status} ${json.error.code}: ${json.error.message}`); } return json; } const handle = await open(file); const { size } = await handle.stat(); const sha256 = (bytes) => createHash('sha256').update(bytes).digest('base64'); async function readPart(video, n) { const start = (n - 1) * video.part_size; const bytes = Buffer.alloc(Math.min(video.part_size, video.size_bytes - start)); await handle.read(bytes, 0, bytes.length, start); return bytes; } // 1. Start. Keep this key with your record of the file: it resumes the upload. const uploadKey = randomUUID(); const video = await call( 'POST', '/videos', { file_name: basename(file), size_bytes: size, part_size: PART_SIZE, product_id: productId }, uploadKey, ); // 2 + 3. Ten parts per call: sign them, then PUT each straight to storage. for (let first = 1; first <= video.part_count; first += 10) { const parts = []; for (let n = first; n <= Math.min(first + 9, video.part_count); n++) { parts.push({ part_number: n, sha256: sha256(await readPart(video, n)) }); } const signed = await call('POST', `/videos/${video.id}/part-urls`, { parts }); for (const part of signed.data) { const put = await fetch(part.url, { method: 'PUT', headers: part.headers, // exactly these: no Authorization, no Content-Type body: await readPart(video, part.part_number), }); if (!put.ok) throw new Error(`part ${part.part_number}: storage answered ${put.status}`); } } // 4. Complete: ready now, or poll while it is checked. A reject throws (422). let done = await call('POST', `/videos/${video.id}/complete`); while (done.status === 'verifying') { await new Promise((resolve) => setTimeout(resolve, 5000)); done = await call('GET', `/videos/${video.id}`); } if (done.status !== 'ready') throw new Error(`${done.failure.code}: ${done.failure.message}`); // 5. A cover: the product's main photo, copied beside the video. const product = await call('GET', `/products/${productId}`); const main = product.images.find((image) => image.is_thumbnail); if (main) { await call('POST', `/videos/${video.id}/poster`, { source: 'product_image', image_id: main.id }); } // 6. Show it for one option value (Colour: Red), by the id a variant uses. const option = product.variants .flatMap((variant) => variant.options) .find((o) => o.attribute === attribute && o.value === value); if (attribute && !option) throw new Error(`No variant of this product is ${attribute}: ${value}`); const placement = await call('POST', `/products/${productId}/videos`, { video_id: video.id, option_value_id: option ? option.value_id : null, }); console.log(placement); // status "ready": GET /products/{id} lists it in videos[] ``` ## Resuming an interrupted upload If the script stops halfway, nothing is lost as long as the upload has not expired: 1. `GET /videos/{id}` lists the parts storage already holds in `uploaded_parts`. 2. Ask for URLs for the missing parts only, with the same checksums as before, and send them. 3. Complete as usual. If you lost the video id, start again with the **same** `Idempotency-Key`. You get the same upload back: within 24 hours as a replay of the first answer, after that with `resumed: true`. Its `id` is what matters; read `GET /videos/{id}` for the parts storage holds, because a replayed answer shows the parts as they were then. The optional `fingerprint` on `POST /videos` finds the upload for a new key too (see the [reference](https://www.shopdeskbd.com/developers/reference/videos.md)). Once an upload is `aborted` (it expired, or you deleted it), start over with a new key. If you rotate the key meanwhile, the new key carries on: it reads, completes and places the uploads the old key started, and the same `Idempotency-Key` still names the same upload. ## 5. Set a cover The cover is the picture shown before the video plays. Without one, product pages show the product's main photo. There are three sources, and a cover can be set or replaced at any time, even while the video is still uploading: ```bash # One of the product's photos, copied beside the video (deleting the photo later keeps the cover) curl -sS https://api.shopdeskbd.com/ext/v1/videos/$VIDEO_ID/poster \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "source": "product_image", "image_id": "IMAGE_ID" }' # A link, which Shopdesk downloads and stores (the link is not kept) curl -sS https://api.shopdeskbd.com/ext/v1/videos/$VIDEO_ID/poster \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "source": "url", "url": "https://cdn.example.com/covers/red-saree.jpg" }' # A file you upload, in a multipart "file" part curl -sS https://api.shopdeskbd.com/ext/v1/videos/$VIDEO_ID/poster \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -F source=upload \ -F file=@cover.jpg ``` A cover must be a JPEG, PNG or WebP of at most 2 MB. The type is read from the file's bytes, and a cover link is checked like any [photo link](https://www.shopdeskbd.com/developers/guide-photos-by-link.md#what-happens-to-a-link). A cover by link or upload counts as one image under the [image limits](https://www.shopdeskbd.com/developers/rate-limits.md#image-downloads). The answer is the video with its new `poster_url`. Covers cannot be cut from a frame of the video through the API; the dashboard offers that. Send each **new** cover with a new `Idempotency-Key`, or none. A key is matched on the form fields, not on the file's bytes, so reusing one with a different file replays the first answer and the new cover is not stored. A cover by link on a video that cannot take one (rejected, cancelled or expired), or while uploads are switched off, is refused before the link is downloaded. ## 6. Place it on a product, for the Red option A product can show a video for every variant, or only for one option value, such as Colour: Red. The option value's id is on the product's variants. Read the product (`products:read`) and take the `value_id` from `options[]`: ```json { "sku": "SAREE-RED", "options": [ { "attribute_id": "…", "attribute": "Colour", "value_id": "2c7e9f41-5b3a-4d8e-9f10-6a2b3c4d5e6f", "value": "Red" } ] } ``` Then place the video: ```bash curl -sS https://api.shopdeskbd.com/ext/v1/products/$PRODUCT_ID/videos \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "video_id": "6b1f3c2d-9e8a-4b7c-a6d5-1e2f3a4b5c6d", "option_value_id": "2c7e9f41-5b3a-4d8e-9f10-6a2b3c4d5e6f", "gallery_slot": 1, "title": "Red saree drape" }' ``` ```json { "product_id": "…", "video_id": "6b1f3c2d-9e8a-4b7c-a6d5-1e2f3a4b5c6d", "status": "ready", "gallery_slot": 1, "sort_order": 0, "option_value_id": "2c7e9f41-5b3a-4d8e-9f10-6a2b3c4d5e6f", "title": "Red saree drape" } ``` - **`gallery_slot`** is how many photos come before the video: `0` puts it first, and `1` (the default) right after the main photo. - **`sort_order`** orders the product's videos among themselves (default `0`). - **`option_value_id`** shows it only for that value. The value must be one that the product's active variants use. Leave it out, or send `null`, to show it for every variant. - **`title`** is the video's accessible name. Without one, the product name is used. - **You can place a video before it is ready.** The answer's `status` says where it stands. The product shows it once it is `ready`: in `videos[]` on `GET /products/{id}` and on the store's product page, and a `product.updated` webhook goes out for each product that shows it at that moment (so a consumer of `product.*` events sees it arrive; changing a ready video's cover sends one too). A video that is still checking, or was rejected, never appears there. - **Place a finished video within 48 hours.** A ready video that no product shows 48 hours after it was checked is released: it stops counting against the plan's video storage, and it is removed 7 days later (after that, `GET /videos/{id}` answers `404`). Placing it on a product before then keeps it. - **Placing it again moves it.** Nothing else on the product changes. The plan sets how many videos one product may show (3 unless the plan says otherwise). One more is `403 PLAN_LIMIT_REACHED`. A new product can take its videos in the same call: `videos[]` on `POST /products` takes the same fields, one entry per video. That needs `videos:write` beside `products:write`. To take a video off a product, call `DELETE /products/{id}/videos/{videoId}`. The video stays yours, to place again or to delete with `DELETE /videos/{id}`. ## Import from a link instead When the MP4 is already online, let Shopdesk fetch it: ```bash curl -sS https://api.shopdeskbd.com/ext/v1/videos/import \ -H "Authorization: Bearer $SHOPDESK_API_KEY" \ -H "Idempotency-Key: 7d0c4a52-3b1e-4f6a-9c2d-8e5f1a0b3c02" \ -H "Content-Type: application/json" \ -d '{ "source_url": "https://cdn.example.com/videos/red-saree-drape.mp4" }' ``` The answer is always `202`, with the video in `status: "uploading"` and `source: "import"`. The copy runs in the background, and the file is checked exactly like an upload. Poll `GET /videos/{id}` until it is `ready`, `rejected` or `aborted`. You can set its cover and place it on a product straight away. - **The link must be the file itself:** https, a public address (and so must every one of the up to 3 redirects it takes), and not a player page. YouTube, Vimeo, Facebook, Instagram, TikTok and similar links are refused with a `422` on `source_url` before anything is fetched. A link that returns a web page instead of an MP4 is caught by the file's first bytes. - **The link must state the file's size** in `Content-Length`. Shopdesk reads the response headers first and closes the connection before the body when the size is missing, over 200 MB, or more than the plan's video storage has left. It also stops when the download runs longer or shorter than that size. - **Storage is reserved from the start.** Until the link answers, the reservation (and `size_bytes`) is 200 MB, or less if the plan has less room left. It then shrinks to the file's size. - **Time limits:** 10 seconds to get the response headers, 30 seconds without new bytes, 15 minutes in all. After its first minute a download must average at least 64 KB a second, or it stops (`import_download_failed`). - **Imports wait their turn.** Each company's imports are copied one at a time. An import whose copy has not started within an hour ends `aborted` with `expired`, and its reservation is released. - **The key must still be valid when the copy starts.** If the key is revoked or expires (a rotated key's grace period ends), or the plan loses product videos, or uploads are switched off before then, the import ends `aborted` with `cancelled` and nothing is downloaded. - **`Idempotency-Key` is required** and names the import: the same key gives back the same video (within 24 hours as a replay of the first answer, then with `resumed: true`) and never imports the file twice. A retry is never refused by the daily import cap. - **The file's name.** Send `file_name` to name it. Without one, the video is named after the link's last path segment when that reads as a file name (such as `red-saree-drape.mp4`: at most 80 characters of letters `A`–`Z`, digits, spaces and `._-()`, ending in `.mp4` or `.m4v`, with no long run of letters and digits that looks like a token), and `video.mp4` otherwise. Nothing else of the link, including its query string, is stored. - **A failed import leaves nothing behind.** Its reservation is released and no file stays in storage. The video ends `aborted` with one of these codes in `failure.code`: | `failure.code` | Means | | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | | `import_link_refused` | The link, or a redirect, is not a public https address, or it redirected more than 3 times. | | `import_download_failed` | The download failed: not found, an error answer, too slow, or cut off. | | `import_length_unknown` | The link did not state the file's size. Upload the file instead. | | `import_length_mismatch` | The download was not the size the link stated. | | `import_over_quota` | The file is bigger than the plan's remaining video storage. | | `import_storage_error` | Video storage failed during the copy. Import it again in a moment. | | `expired` | The copy did not start within an hour of the import. Import it again. | | `cancelled` | You deleted it, or before the copy started the key was revoked or expired, the plan lost product videos, or uploads were switched off. | A file that is over 200 MB, or is not an MP4 at all, ends `rejected` with `VIDEO_TOO_LARGE` or `VIDEO_NOT_MP4`, like an upload. Shopdesk sends an import's parts itself, so asking for part URLs or completing an import is `409 UPLOAD_IS_IMPORT`. ## Reject codes Uploads and imports share these codes. `complete` answers `422` with one of them, and `GET /videos/{id}` shows the same code in `failure.code` on a `rejected` video: | Code | The file | Fix | | ---------------------------- | --------------------------------------------------- | ------------------------------------------------- | | `VIDEO_NOT_MP4` | is not an MP4 (for example a `.mov`, or a web page) | Save or export as MP4 (H.264). | | `VIDEO_CORRUPT` | could not be read: damaged or incomplete | Export it again. | | `VIDEO_FRAGMENTED` | is a fragmented (streaming) MP4 | Export as a standard MP4. | | `VIDEO_NO_VIDEO_TRACK` | has no picture (sound only) | Use a file with a video track. | | `VIDEO_HEVC` | uses HEVC (H.265) | Export as H.264 ("Most Compatible" on an iPhone). | | `VIDEO_CODEC_UNSUPPORTED` | is not H.264 (AV1, VP9, MPEG-4…) | Export as MP4 with H.264 video. | | `VIDEO_PROFILE_UNSUPPORTED` | is 10-bit, HDR or 4:2:2 H.264 | Export 8-bit (SDR) H.264. | | `VIDEO_AUDIO_UNSUPPORTED` | has sound that phones cannot play (AC-3, PCM…) | Export with AAC sound, or none. | | `VIDEO_ROTATION_UNSUPPORTED` | is turned at an angle that is not a quarter turn | Export it upright. | | `VIDEO_TOO_LARGE` | is over 200 MB | Export at 1080p, about 5 Mbps. | | `VIDEO_TOO_LONG` | is longer than 5 minutes | Trim it. | | `VIDEO_TOO_SHORT` | is shorter than 3 seconds | Use a longer clip. | | `VIDEO_LOW_RES` | has a shorter side under 480 px | Export at a higher resolution. | | `VIDEO_FRAME_TOO_BIG` | has a longer side over 4096 px | Export at 1080p. | `error.message` and `error.fields.video` carry the same wording the dashboard shows, so you can show them to the merchant as they are. ## Other errors | HTTP | Code | Means | | ---- | ------------------------ | ------------------------------------------------------------------------------------------- | | 403 | `PLAN_FEATURE_REQUIRED` | The store's plan does not include product videos. | | 403 | `VIDEO_UPLOADS_DISABLED` | Video uploads are switched off for now. Try again later. | | 403 | `PLAN_LIMIT_REACHED` | The plan's video storage is full, or the product already shows its plan's number of videos. | | 404 | `NOT_FOUND` | No video with this id was started by **this key**. | | 409 | `TOO_MANY_OPEN_UPLOADS` | This key has 4 unfinished uploads or imports, or the store has 6. Complete or delete one. | | 409 | `FILE_CHANGED` | A part was signed, or stored, with a different checksum. This is not the same file. | | 409 | `PARTS_INCOMPLETE` | Parts are missing at `complete`. `error.fields.parts` names them. | | 409 | `UPLOAD_IS_IMPORT` | The video is an import, and Shopdesk sends its parts. | | 409 | `UPLOAD_VERIFYING` | The video is still being checked. Wait for `ready`. | | 409 | `VIDEO_IN_USE` | `DELETE /videos/{id}` on a video a live product shows. Take it off the product first. | | 410 | `UPLOAD_NOT_ACTIVE` | The upload expired, was cancelled or already ended. Start a new one. | | 429 | `RATE_LIMITED` | The key's rate limit, or a daily cap below. `Retry-After` says when to try again. | | 502 | `STORAGE_UNAVAILABLE` | Video storage did not answer. Retry in a moment. | ## Quotas and limits | Limit | Value | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | | File size | 200 MB | | Video storage | The plan's `video_storage_mb`. Every unfinished upload's size is reserved, and a finished video counts until it is deleted | | Videos per product | The plan's, 3 unless the plan says otherwise (10 at most) | | Unfinished uploads and imports | 4 per key, 6 per store (uploading or still being checked) | | Uploads started | 50 per store per UTC day (`429 RATE_LIMITED`, `Retry-After` to midnight UTC) | | Imports started | 20 per key per UTC day, within the store's 50 | | Part URLs | Up to 10 per call, each valid for 60 minutes | | Unfinished upload | Lapses a day after the last part-URL request, three days after the start at most | | Import waiting to start | One hour; each company's imports are copied one at a time | | Import download speed | At least 64 KB a second on average, after the first minute | | Finished video on no product | Released 48 hours after it is ready (it stops counting against storage), removed 7 days later. Place it before then to keep it | | Covers | JPEG, PNG or WebP, at most 2 MB | `DELETE /videos/{id}` cancels an unfinished upload and releases its storage at once. On a finished video that no live product shows, it stops counting against the plan straight away, and the file is removed from storage 7 days later. Deleting again changes nothing. ## Who sees what - **Each key sees only its own uploads.** `GET /videos` lists the videos this key started. Another key's video, or one uploaded in the dashboard, answers `404` on every `/videos` route and cannot be placed with this key (`422` on `video_id`). A key made by rotating another also sees, and can complete, place and delete, the videos the key it replaced started. - **Staff see everything.** The merchant's dashboard shows videos uploaded through the API beside their own. - **Products show ready videos, whoever uploaded them.** `videos[]` on `GET /products/{id}` (under `products:read`) lists every ready video the product shows, with `url`, `poster_url`, size, length, `gallery_slot` and `option_value_id`. A store whose plan does not include product videos shows an empty list, as its product pages do. Any key with `videos:write` can take a video off a product, even while uploads are switched off. - **Webhooks follow the product.** `product.*` payloads carry the same `videos[]`. A product save sends `product.updated` as usual; a video turning ready, a ready video's cover changing, or a video being released from archived products sends `product.updated` for the products concerned. ## With the store MCP server An AI assistant connected to the [store MCP server](https://www.shopdeskbd.com/developers/ai.md#the-store-mcp-server) with a `videos:write` key has one video tool, `attach_product_video`. It calls the same services as `POST /videos/import` and `POST /products/{id}/videos`, so everything above applies to it. - **With `video_url`**, it imports the file and places it on the product in one call. The answer's `video.status` and `placement.status` say where the import is. `get_product` lists the video once it is ready. `idempotency_key` is required with a link, so a retried call never imports twice. - **With `video_id`**, a video this key uploaded or imported, it only places it. - **With neither**, it changes nothing and answers `upload_steps`: the REST calls above, for a script. A model cannot send file bytes, so uploading a file stays a job for code. - **`option`** names the option value in words, as `{ "attribute": "Colour", "value": "Red" }`. This reads the product, so the key also needs `products:read`. A name that the product's active variants do not use fails with the product's options as `candidates`, and nothing is placed. `option_value_id` takes the id instead; with a link and `products:read`, a wrong id is refused 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. Call the tool again with that `video_id` (or the same `idempotency_key` and link) rather than importing again. The tool declares `openWorldHint: true`, because it can download from outside the store, so a client that confirms such tools will ask first. The upload session, covers and deletes stay on REST. See [MCP tools](https://www.shopdeskbd.com/developers/mcp-tools.md). --- # 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).