# 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
<?php

// One key per logical order. Store it with your own record before calling.
$idempotencyKey = bin2hex(random_bytes(16));

$payload = json_encode([
    'channel' => '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
<?php

function create_order_once(array $order, string $idempotencyKey, int $attempts = 5): array
{
    for ($attempt = 0; $attempt < $attempts; $attempt++) {
        $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 => 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).
