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

$key = getenv('SHOPDESK_API_KEY');

$ch = curl_init('https://api.shopdeskbd.com/ext/v1/products?limit=25&updated_after=2026-08-01T00%3A00%3A00Z');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => 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 `<img>` 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
<?php

$key = getenv('SHOPDESK_API_KEY');

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

$key = getenv('SHOPDESK_API_KEY');

$ch = curl_init('https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => 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
<?php

$key = getenv('SHOPDESK_API_KEY');

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

$key = getenv('SHOPDESK_API_KEY');

$payload = json_encode((object) []);

$ch = curl_init('https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/archive');
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` — 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
<?php

$key = getenv('SHOPDESK_API_KEY');

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

$key = getenv('SHOPDESK_API_KEY');

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

$key = getenv('SHOPDESK_API_KEY');

$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 => '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
<?php

$key = getenv('SHOPDESK_API_KEY');

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

$key = getenv('SHOPDESK_API_KEY');

$ch = curl_init('https://api.shopdeskbd.com/ext/v1/variants/SKU-001');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => 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
<?php

$key = getenv('SHOPDESK_API_KEY');

$payload = json_encode([
    'price' => 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"
}
```
