# Videos

Part of the [Shopdesk Commerce API reference](https://www.shopdeskbd.com/developers/reference.md). Paths below are shown exactly as the server routes them — prefix them with `https://api.shopdeskbd.com`. Authenticate every request with `Authorization: Bearer shopdesk_sk_YOUR_API_KEY`; each operation names the scope its key must hold. Every failure uses the one error envelope — see [Errors](https://www.shopdeskbd.com/developers/errors.md).

## Place a video on a product

`POST /ext/v1/products/{id}/videos` · Requires scope `videos:write`

Puts one of your videos (from `POST /videos` or `POST /videos/import`) on the product: `gallery_slot` says how many photos come before it (default 1, right after the main photo), `sort_order` orders it among the product’s videos, `option_value_id` shows it only for one option value such as Colour: Red (a value one of the product’s active variants uses), and `title` names it. A video may be placed while it is still uploading or verifying; the product shows it (`GET /products/{id}` → `videos[]`, the storefront) once it is `ready`, and a `product.updated` webhook is sent then. Placing a video the product already shows moves it instead. Nothing else on the product changes. The plan decides how many videos one product may show. A key made by rotating another may place that key’s videos too. Company-scoped keys must send `store_id`.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `uuid` | Yes | The product id. |

### Headers

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. |

### Body parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `video_id` | `uuid` | Yes | A video THIS API key started (`POST /videos` or `POST /videos/import`). It may still be uploading or verifying: the product shows it once it is `ready`. |
| `gallery_slot` | `number` (default `1`, max 30) | No | How many of the product's photos come before the video: 0 = before every photo, 1 (default) = right after the main photo. Clamped to the number of photos. |
| `sort_order` | `number` (default `0`, max 99) | No | Order among the product's videos; breaks ties within one slot. |
| `option_value_id` | `uuid` | No | Show the video for one option value (e.g. Colour: Red — the value id from `GET /attributes`, or a variant’s `options[].value_id`). A value one of the product's active variants uses; null or absent = every variant. |
| `title` | `string` | No | Its accessible name. Empty or null falls back to the product name. |
| `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. |

### Request

**cURL**

```bash
export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY"

curl -sS -X POST "https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/videos" \
  -H "Authorization: Bearer $SHOPDESK_API_KEY" \
  -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \
  -H "Content-Type: application/json" \
  -d '{
  "video_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
  "gallery_slot": 1,
  "option_value_id": "2c7e9f41-5b3a-4d8e-9f10-6a2b3c4d5e6f",
  "title": "Red saree drape"
}'
```

**JavaScript**

```javascript
const url = new URL("https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/videos");

const response = await fetch(url, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`,
    "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "video_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
    "gallery_slot": 1,
    "option_value_id": "2c7e9f41-5b3a-4d8e-9f10-6a2b3c4d5e6f",
    "title": "Red saree drape"
  }),
});

if (!response.ok) {
  const { error } = await response.json();
  throw new Error(`${error.code}: ${error.message} [${error.request_id}]`);
}

const object = await response.json();
```

**Python**

```python
import os

import requests

payload = {
    "video_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
    "gallery_slot": 1,
    "option_value_id": "2c7e9f41-5b3a-4d8e-9f10-6a2b3c4d5e6f",
    "title": "Red saree drape",
}

response = requests.post(
    "https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/videos",
    json=payload,
    headers={
        "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}",
        "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47",
    },
    timeout=30,
)

if not response.ok:
    error = response.json()["error"]
    raise RuntimeError(f"{error['code']}: {error['message']}")

obj = response.json()
```

**PHP**

```php
<?php

$key = getenv('SHOPDESK_API_KEY');

$payload = json_encode([
    'video_id' => '5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001',
    'gallery_slot' => 1,
    'option_value_id' => '2c7e9f41-5b3a-4d8e-9f10-6a2b3c4d5e6f',
    'title' => 'Red saree drape',
]);

$ch = curl_init('https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/videos');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'],
    CURLOPT_POSTFIELDS => $payload,
    CURLOPT_TIMEOUT => 30,
]);

$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$response = json_decode($body, true);

if ($status >= 400) {
    throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}");
}
```

### Responses

- `200` — The placement, as saved.
- `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header).
- `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store.
- `403` — `FORBIDDEN_SCOPE`: the key's scopes do not cover this endpoint. `PLAN_FEATURE_REQUIRED`: the plan does not include product videos. `PLAN_LIMIT_REACHED`: the product already shows its plan's number of videos (or a released video would not fit the plan’s video storage again). `VIDEO_UPLOADS_DISABLED`: video uploads are switched off on this platform — no video can be placed (taking one off still works).
- `404` — Product not found.
- `409` — A request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished.
- `422` — Validation failed — `error.fields` names the field: `video_id` names no video this key started in this store, or one that was rejected or whose upload ended; `option_value_id` is used by none of the product’s active variants.
- `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers.

**Example 200 response**

```json
{
  "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
  "video_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
  "status": "verifying",
  "gallery_slot": 1,
  "sort_order": 0,
  "option_value_id": null,
  "title": "Red saree drape"
}
```

## Take a video off a product

`DELETE /ext/v1/products/{id}/videos/{videoId}` · Requires scope `videos:write`

The product stops showing the video; nothing else on it changes. The video itself stays — delete your own upload with `DELETE /videos/{id}` once no product shows it. Any video the product shows may be taken off, whoever uploaded it — even while video uploads are switched off. A company-scoped key names the store with the `store_id` query parameter, since a DELETE carries no body.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `uuid` | Yes | The product id. |
| `videoId` | `uuid` | Yes | The video id, as listed in the product’s `videos[]`. |

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `store_id` | `uuid` | No | Restrict the lookup to one store. Breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. |

### Headers

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. |

### Request

**cURL**

```bash
export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY"

curl -sS -X DELETE "https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/videos/REPLACE_WITH_ID" \
  -H "Authorization: Bearer $SHOPDESK_API_KEY" \
  -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47"
```

**JavaScript**

```javascript
const url = new URL("https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/videos/REPLACE_WITH_ID");

const response = await fetch(url, {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`,
    "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47",
  },
});

if (!response.ok) {
  const { error } = await response.json();
  throw new Error(`${error.code}: ${error.message} [${error.request_id}]`);
}

const object = await response.json();
```

**Python**

```python
import os

import requests

response = requests.delete(
    "https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/videos/REPLACE_WITH_ID",
    headers={
        "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}",
        "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47",
    },
    timeout=30,
)

if not response.ok:
    error = response.json()["error"]
    raise RuntimeError(f"{error['code']}: {error['message']}")

obj = response.json()
```

**PHP**

```php
<?php

$key = getenv('SHOPDESK_API_KEY');

$ch = curl_init('https://api.shopdeskbd.com/ext/v1/products/REPLACE_WITH_ID/videos/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` — The video is off the product.
- `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header).
- `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store.
- `403` — `FORBIDDEN_SCOPE`: the key's scopes do not cover this endpoint. `PLAN_FEATURE_REQUIRED`: the plan does not include product videos.
- `404` — Product not found, or the product does not show the video.
- `409` — A request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished.
- `422` — Validation failed — `error.fields` maps each offending field to its messages.
- `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers.

**Example 200 response**

```json
{
  "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
  "video_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
  "removed": true
}
```

## List your video uploads

`GET /ext/v1/videos` · Requires scope `videos:write`

The uploads THIS API key started — and, for a key made by rotating another, the uploads of the keys it replaced — newest first, in every status unless `status` narrows it. Uploads made in the dashboard or by another key are not listed. An unfinished upload past its expiry is left out until it is recorded as `aborted`. `uploaded_parts` is empty in a list — read one upload for its parts.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `limit` | `number` (default `25`, max 100) | No | Page size. |
| `starting_after` | `string` | No | Return the page AFTER this object id (the previous page's `next_cursor`). |
| `ending_before` | `string` | No | Return the page BEFORE this object id. |
| `sort` | `string` (default `-created_at`) | No | Newest first by default. Only `created_at` is sortable — a cursor over a mutable key (like `updated_at`) cannot page reliably. Use `updated_after` to sync changes. One of: `created_at`, `-created_at`. |
| `store_id` | `string` | No | Restrict to one store. Required breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. |
| `status` | `string` | No | Only uploads in this status. Default: every status. One of: `uploading`, `verifying`, `ready`, `rejected`, `aborted`. |

### Request

**cURL**

```bash
export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY"

curl -sS "https://api.shopdeskbd.com/ext/v1/videos?limit=25" \
  -H "Authorization: Bearer $SHOPDESK_API_KEY"
```

**JavaScript**

```javascript
const url = new URL("https://api.shopdeskbd.com/ext/v1/videos");
Object.entries({
  limit: "25",
}).forEach(([key, value]) => url.searchParams.set(key, value));

const response = await fetch(url, {
  headers: {
    Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`,
  },
});

if (!response.ok) {
  const { error } = await response.json();
  throw new Error(`${error.code}: ${error.message} [${error.request_id}]`);
}

const { data, meta } = await response.json();
```

**Python**

```python
import os

import requests

response = requests.get(
    "https://api.shopdeskbd.com/ext/v1/videos",
    params={
        "limit": "25",
    },
    headers={
        "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}",
    },
    timeout=30,
)

if not response.ok:
    error = response.json()["error"]
    raise RuntimeError(f"{error['code']}: {error['message']}")

page = response.json()
rows, meta = page["data"], page["meta"]
```

**PHP**

```php
<?php

$key = getenv('SHOPDESK_API_KEY');

$ch = curl_init('https://api.shopdeskbd.com/ext/v1/videos?limit=25');
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 page of uploads.
- `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store.
- `403` — `FORBIDDEN_SCOPE`: the key's scopes do not cover this endpoint. `PLAN_FEATURE_REQUIRED`: the plan does not include product videos. `VIDEO_UPLOADS_DISABLED`: video uploads are switched off for now.
- `422` — Validation failed — `error.fields` maps each offending field to its messages.
- `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers.

**Example 200 response**

```json
{
  "data": [
    {
      "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
      "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
      "status": "uploading",
      "source": "upload",
      "file_name": "red-saree-drape.mp4",
      "size_bytes": 48000000,
      "part_size": 8388608,
      "part_count": 6,
      "checksum_algorithm": "SHA256",
      "uploaded_parts": [
        {
          "part_number": 1,
          "size": 8388608,
          "checksum_sha256": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg="
        }
      ],
      "uploaded_bytes": 16777216,
      "expires_at": "2026-08-11T10:00:00.000Z",
      "product_ids": [],
      "url": "https://cdn.example.com/companies/c/videos/v/video.mp4",
      "poster_url": null,
      "facts": {
        "duration_ms": 24000,
        "width": 1080,
        "height": 1920,
        "rotation": 0,
        "codecs": "avc1.640028, mp4a.40.2",
        "has_audio": true,
        "frame_rate": 29.97,
        "bitrate_kbps": 4200,
        "faststart": true
      },
      "warnings": [
        "NOT_FASTSTART"
      ],
      "failure": {
        "code": "VIDEO_HEVC",
        "message": "This video is HEVC (H.265).",
        "fix": "Export as H.264."
      },
      "created_at": "2026-08-11T10:00:00.000Z",
      "completed_at": "2026-08-11T10:00:00.000Z",
      "verified_at": "2026-08-11T10:00:00.000Z"
    }
  ],
  "meta": {
    "has_more": true,
    "next_cursor": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001"
  }
}
```

## Start a video upload

`POST /ext/v1/videos` · Requires scope `videos:write`

Opens an upload session for one H.264 MP4 of at most 200 MB (3 s to 5 min; AAC, MP3, Opus or no audio) and reserves its size against the plan's video storage. Then: ask `POST /videos/{id}/part-urls` for part URLs, `PUT` each part (exactly `part_size` bytes, the last one the rest) to its URL with the headers given, and call `POST /videos/{id}/complete`. `Idempotency-Key` is REQUIRED and is also the resume handle. A retry with the same key within 24 hours replays the first answer as it was (`Idempotent-Replayed: true`, `resumed: false`, its `uploaded_parts` as they were then); after that, the same key returns the same session with `resumed: true`. Either way the session is the same — read `GET /videos/{id}` for the parts storage holds now. Reuse a key only to retry the same file; once that session has ended (`aborted`), start over with a new key. A key made by rotating another keeps its uploads, and the same `Idempotency-Key` still names the same session. A finished video no product places within 48 hours is released (it stops counting against video storage) and removed 7 days later. At most 50 uploads start per store per day. Company-scoped keys must send `store_id`.

### Headers

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | Yes | REQUIRED. A unique value per logical request (a UUID is ideal); reuse it verbatim when retrying. A retry replays the original response with `Idempotent-Replayed: true` instead of running twice. |

### Body parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. |
| `file_name` | `string` | Yes | For display only; never part of a storage key. |
| `size_bytes` | `number` (max 200000000) | Yes | The exact size of the file in bytes — at most 200,000,000 (200 MB). It is reserved against the plan's video storage until the upload finishes or is cancelled. |
| `part_size` | `number` | Yes | Bytes per part: 5, 8 or 16 MiB. Every part but the last is exactly this long; the answer says how many parts there are. One of: `5242880`, `8388608`, `16777216`. |
| `fingerprint` | `string` | No | Optional. Hex sha256 of `<size_bytes>:` followed by the sha256 of the file's first MiB and of its last MiB. When sent, starting again with the same file (under a new `Idempotency-Key`) resumes the unfinished upload instead of opening another. |
| `product_id` | `uuid` | No | The product the video is meant for, for an early check of its video limit. It does not place the video. |
| `client_probe` | `object` | No | Optional. A client-side check of the file in the dashboard's `ClientProbeReport` shape (at most 32 KB as JSON); a file it already rejects is refused before any byte is sent. The server reads and verifies every file itself either way. |

### Request

**cURL**

```bash
export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY"

curl -sS -X POST "https://api.shopdeskbd.com/ext/v1/videos" \
  -H "Authorization: Bearer $SHOPDESK_API_KEY" \
  -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \
  -H "Content-Type: application/json" \
  -d '{
  "file_name": "red-saree-drape.mp4",
  "size_bytes": 48000000,
  "part_size": 8388608
}'
```

**JavaScript**

```javascript
const url = new URL("https://api.shopdeskbd.com/ext/v1/videos");

const response = await fetch(url, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`,
    "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "file_name": "red-saree-drape.mp4",
    "size_bytes": 48000000,
    "part_size": 8388608
  }),
});

if (!response.ok) {
  const { error } = await response.json();
  throw new Error(`${error.code}: ${error.message} [${error.request_id}]`);
}

const object = await response.json();
```

**Python**

```python
import os

import requests

payload = {
    "file_name": "red-saree-drape.mp4",
    "size_bytes": 48000000,
    "part_size": 8388608,
}

response = requests.post(
    "https://api.shopdeskbd.com/ext/v1/videos",
    json=payload,
    headers={
        "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}",
        "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47",
    },
    timeout=30,
)

if not response.ok:
    error = response.json()["error"]
    raise RuntimeError(f"{error['code']}: {error['message']}")

obj = response.json()
```

**PHP**

```php
<?php

$key = getenv('SHOPDESK_API_KEY');

$payload = json_encode([
    'file_name' => 'red-saree-drape.mp4',
    'size_bytes' => 48000000,
    'part_size' => 8388608,
]);

$ch = curl_init('https://api.shopdeskbd.com/ext/v1/videos');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'],
    CURLOPT_POSTFIELDS => $payload,
    CURLOPT_TIMEOUT => 30,
]);

$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$response = json_decode($body, true);

if ($status >= 400) {
    throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}");
}
```

### Responses

- `201` — The upload session, with its part plan.
- `400` — The required `Idempotency-Key` header is missing, empty, or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header).
- `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store.
- `403` — `FORBIDDEN_SCOPE`: the key's scopes do not cover this endpoint. `PLAN_FEATURE_REQUIRED`: the plan does not include product videos. `VIDEO_UPLOADS_DISABLED`: video uploads are switched off for now. `PLAN_LIMIT_REACHED`: the plan's video storage is full (or `product_id`’s product already has its plan’s number of videos).
- `404` — `product_id` names no product of this store.
- `409` — `TOO_MANY_OPEN_UPLOADS`: this key already has 4 unfinished uploads, or the store 6 — complete or delete one first; or a request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished.
- `422` — Validation failed — `error.fields` names the field. With `client_probe`: A file that breaks a rule gets its own code — `VIDEO_NOT_MP4`, `VIDEO_HEVC`, `VIDEO_CODEC_UNSUPPORTED`, `VIDEO_PROFILE_UNSUPPORTED`, `VIDEO_AUDIO_UNSUPPORTED`, `VIDEO_FRAGMENTED`, `VIDEO_NO_VIDEO_TRACK`, `VIDEO_TOO_LARGE`, `VIDEO_TOO_LONG`, `VIDEO_TOO_SHORT`, `VIDEO_LOW_RES`, `VIDEO_FRAME_TOO_BIG`, `VIDEO_ROTATION_UNSUPPORTED` or `VIDEO_CORRUPT` — with how to fix it in `error.fields.video`.
- `429` — Rate limit exceeded for this key, or the store started 50 uploads today (`RATE_LIMITED`, `Retry-After` to the next UTC midnight).
- `502` — `STORAGE_UNAVAILABLE`: video storage refused or was unreachable — retry in a moment.

**Example 200 response**

```json
{
  "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
  "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
  "status": "uploading",
  "source": "upload",
  "file_name": "red-saree-drape.mp4",
  "size_bytes": 48000000,
  "part_size": 8388608,
  "part_count": 6,
  "checksum_algorithm": "SHA256",
  "uploaded_parts": [
    {
      "part_number": 1,
      "size": 8388608,
      "checksum_sha256": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg="
    }
  ],
  "uploaded_bytes": 16777216,
  "expires_at": "2026-08-11T10:00:00.000Z",
  "product_ids": [],
  "url": "https://cdn.example.com/companies/c/videos/v/video.mp4",
  "poster_url": null,
  "facts": {
    "duration_ms": 24000,
    "width": 1080,
    "height": 1920,
    "rotation": 0,
    "codecs": "avc1.640028, mp4a.40.2",
    "has_audio": true,
    "frame_rate": 29.97,
    "bitrate_kbps": 4200,
    "faststart": true
  },
  "warnings": [
    "NOT_FASTSTART"
  ],
  "failure": {
    "code": "VIDEO_HEVC",
    "message": "This video is HEVC (H.265).",
    "fix": "Export as H.264."
  },
  "created_at": "2026-08-11T10:00:00.000Z",
  "completed_at": "2026-08-11T10:00:00.000Z",
  "verified_at": "2026-08-11T10:00:00.000Z",
  "resumed": false
}
```

## Get a video upload

`GET /ext/v1/videos/{id}` · Requires scope `videos:write`

The upload and its status. While `uploading`, `uploaded_parts` lists the parts storage already holds — resume by sending the rest. Poll this after a `202` from complete until `ready` or `rejected`. A finished video no product places within 48 hours is released and, 7 days later, removed — then this answers `404`.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `uuid` | Yes | The video id `POST /videos` returned. |

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `store_id` | `uuid` | No | Restrict the lookup to one store. Breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. |

### Request

**cURL**

```bash
export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY"

curl -sS "https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID" \
  -H "Authorization: Bearer $SHOPDESK_API_KEY"
```

**JavaScript**

```javascript
const url = new URL("https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID");

const response = await fetch(url, {
  headers: {
    Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`,
  },
});

if (!response.ok) {
  const { error } = await response.json();
  throw new Error(`${error.code}: ${error.message} [${error.request_id}]`);
}

const object = await response.json();
```

**Python**

```python
import os

import requests

response = requests.get(
    "https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID",
    headers={
        "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}",
    },
    timeout=30,
)

if not response.ok:
    error = response.json()["error"]
    raise RuntimeError(f"{error['code']}: {error['message']}")

obj = response.json()
```

**PHP**

```php
<?php

$key = getenv('SHOPDESK_API_KEY');

$ch = curl_init('https://api.shopdeskbd.com/ext/v1/videos/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 upload.
- `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store.
- `403` — `FORBIDDEN_SCOPE`: the key's scopes do not cover this endpoint. `PLAN_FEATURE_REQUIRED`: the plan does not include product videos. `VIDEO_UPLOADS_DISABLED`: video uploads are switched off for now.
- `404` — No video with this id was started by this API key (another key’s or a dashboard upload is not visible here).
- `422` — Validation failed — `error.fields` maps each offending field to its messages.
- `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers.
- `502` — `STORAGE_UNAVAILABLE`: video storage refused or was unreachable — retry in a moment.

**Example 200 response**

```json
{
  "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
  "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
  "status": "uploading",
  "source": "upload",
  "file_name": "red-saree-drape.mp4",
  "size_bytes": 48000000,
  "part_size": 8388608,
  "part_count": 6,
  "checksum_algorithm": "SHA256",
  "uploaded_parts": [
    {
      "part_number": 1,
      "size": 8388608,
      "checksum_sha256": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg="
    }
  ],
  "uploaded_bytes": 16777216,
  "expires_at": "2026-08-11T10:00:00.000Z",
  "product_ids": [],
  "url": "https://cdn.example.com/companies/c/videos/v/video.mp4",
  "poster_url": null,
  "facts": {
    "duration_ms": 24000,
    "width": 1080,
    "height": 1920,
    "rotation": 0,
    "codecs": "avc1.640028, mp4a.40.2",
    "has_audio": true,
    "frame_rate": 29.97,
    "bitrate_kbps": 4200,
    "faststart": true
  },
  "warnings": [
    "NOT_FASTSTART"
  ],
  "failure": {
    "code": "VIDEO_HEVC",
    "message": "This video is HEVC (H.265).",
    "fix": "Export as H.264."
  },
  "created_at": "2026-08-11T10:00:00.000Z",
  "completed_at": "2026-08-11T10:00:00.000Z",
  "verified_at": "2026-08-11T10:00:00.000Z"
}
```

## Delete a video upload

`DELETE /ext/v1/videos/{id}` · Requires scope `videos:write`

Cancels an unfinished upload (its reserved storage is released at once), or releases a finished video no live product shows: it stops counting against the plan's video storage now and is removed from storage after 7 days. A video only archived products show is taken off them. Deleting again changes nothing. A company-scoped key may narrow with the `store_id` query parameter.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `uuid` | Yes | The video id `POST /videos` returned. |

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `store_id` | `uuid` | No | Restrict the lookup to one store. Breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. |

### Headers

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. |

### Request

**cURL**

```bash
export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY"

curl -sS -X DELETE "https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID" \
  -H "Authorization: Bearer $SHOPDESK_API_KEY" \
  -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47"
```

**JavaScript**

```javascript
const url = new URL("https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID");

const response = await fetch(url, {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`,
    "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47",
  },
});

if (!response.ok) {
  const { error } = await response.json();
  throw new Error(`${error.code}: ${error.message} [${error.request_id}]`);
}

const object = await response.json();
```

**Python**

```python
import os

import requests

response = requests.delete(
    "https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID",
    headers={
        "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}",
        "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47",
    },
    timeout=30,
)

if not response.ok:
    error = response.json()["error"]
    raise RuntimeError(f"{error['code']}: {error['message']}")

obj = response.json()
```

**PHP**

```php
<?php

$key = getenv('SHOPDESK_API_KEY');

$ch = curl_init('https://api.shopdeskbd.com/ext/v1/videos/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` — `FORBIDDEN_SCOPE`: the key's scopes do not cover this endpoint. `PLAN_FEATURE_REQUIRED`: the plan does not include product videos. `VIDEO_UPLOADS_DISABLED`: video uploads are switched off for now.
- `404` — No video with this id was started by this API key (another key’s or a dashboard upload is not visible here).
- `409` — `VIDEO_IN_USE`: a live product shows the video — take it off the product first; `UPLOAD_VERIFYING`: the video is still being checked; or a request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished.
- `422` — Validation failed — `error.fields` maps each offending field to its messages.
- `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers.
- `502` — `STORAGE_UNAVAILABLE`: video storage refused or was unreachable — retry in a moment.

**Example 200 response**

```json
{
  "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
  "deleted": true
}
```

## Complete a video upload

`POST /ext/v1/videos/{id}/complete` · Requires scope `videos:write`

Completes the file from the parts storage holds, then the server reads and checks it. `200` with `status: ready` when the verdict is in; `202` with `status: verifying` while the check is still running — poll `GET /videos/{id}`. Calling it again is safe: a `ready` video answers `200` again, a rejected one `422` again. Send no `Idempotency-Key` on a repeat meant to re-check a video that stayed `verifying`: a replay answers the first result.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `uuid` | Yes | The video id `POST /videos` returned. |

### Headers

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. |

### Request

**cURL**

```bash
export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY"

curl -sS -X POST "https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID/complete" \
  -H "Authorization: Bearer $SHOPDESK_API_KEY" \
  -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47"
```

**JavaScript**

```javascript
const url = new URL("https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID/complete");

const response = await fetch(url, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`,
    "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47",
  },
});

if (!response.ok) {
  const { error } = await response.json();
  throw new Error(`${error.code}: ${error.message} [${error.request_id}]`);
}

const object = await response.json();
```

**Python**

```python
import os

import requests

response = requests.post(
    "https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID/complete",
    headers={
        "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}",
        "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47",
    },
    timeout=30,
)

if not response.ok:
    error = response.json()["error"]
    raise RuntimeError(f"{error['code']}: {error['message']}")

obj = response.json()
```

**PHP**

```php
<?php

$key = getenv('SHOPDESK_API_KEY');

$ch = curl_init('https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID/complete');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47'],
    CURLOPT_TIMEOUT => 30,
]);

$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$response = json_decode($body, true);

if ($status >= 400) {
    throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}");
}
```

### Responses

- `200` — The video, `ready`.
- `202` — The video, still `verifying`.
- `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header).
- `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store.
- `403` — `FORBIDDEN_SCOPE`: the key's scopes do not cover this endpoint. `PLAN_FEATURE_REQUIRED`: the plan does not include product videos. `VIDEO_UPLOADS_DISABLED`: video uploads are switched off for now.
- `404` — No video with this id was started by this API key (another key’s or a dashboard upload is not visible here).
- `409` — `PARTS_INCOMPLETE`: some parts are not in storage yet (`error.fields` names them) — upload them and complete again; `FILE_CHANGED`: a stored part does not match its checksum; or a request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished.
- `410` — `UPLOAD_NOT_ACTIVE`: the upload expired or was cancelled — start a new one.
- `422` — A file that breaks a rule gets its own code — `VIDEO_NOT_MP4`, `VIDEO_HEVC`, `VIDEO_CODEC_UNSUPPORTED`, `VIDEO_PROFILE_UNSUPPORTED`, `VIDEO_AUDIO_UNSUPPORTED`, `VIDEO_FRAGMENTED`, `VIDEO_NO_VIDEO_TRACK`, `VIDEO_TOO_LARGE`, `VIDEO_TOO_LONG`, `VIDEO_TOO_SHORT`, `VIDEO_LOW_RES`, `VIDEO_FRAME_TOO_BIG`, `VIDEO_ROTATION_UNSUPPORTED` or `VIDEO_CORRUPT` — with how to fix it in `error.fields.video`. The file is deleted and its storage released.
- `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers.
- `502` — `STORAGE_UNAVAILABLE`: video storage refused or was unreachable — retry in a moment.

**Example 200 response**

```json
{
  "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
  "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
  "status": "uploading",
  "source": "upload",
  "file_name": "red-saree-drape.mp4",
  "size_bytes": 48000000,
  "part_size": 8388608,
  "part_count": 6,
  "checksum_algorithm": "SHA256",
  "uploaded_parts": [
    {
      "part_number": 1,
      "size": 8388608,
      "checksum_sha256": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg="
    }
  ],
  "uploaded_bytes": 16777216,
  "expires_at": "2026-08-11T10:00:00.000Z",
  "product_ids": [],
  "url": "https://cdn.example.com/companies/c/videos/v/video.mp4",
  "poster_url": null,
  "facts": {
    "duration_ms": 24000,
    "width": 1080,
    "height": 1920,
    "rotation": 0,
    "codecs": "avc1.640028, mp4a.40.2",
    "has_audio": true,
    "frame_rate": 29.97,
    "bitrate_kbps": 4200,
    "faststart": true
  },
  "warnings": [
    "NOT_FASTSTART"
  ],
  "failure": {
    "code": "VIDEO_HEVC",
    "message": "This video is HEVC (H.265).",
    "fix": "Export as H.264."
  },
  "created_at": "2026-08-11T10:00:00.000Z",
  "completed_at": "2026-08-11T10:00:00.000Z",
  "verified_at": "2026-08-11T10:00:00.000Z"
}
```

## Get upload URLs for video parts

`POST /ext/v1/videos/{id}/part-urls` · Requires scope `videos:write`

Presigned `PUT` URLs for up to 10 parts, each valid for 60 minutes. Send each part’s base64 SHA-256: it is signed into the URL (storage refuses other bytes) and pinned — the same part asked for again with a different checksum is `409 FILE_CHANGED`. Ask again for fresh URLs whenever they expire — and send no `Idempotency-Key` here: asking again is always safe, and a replayed answer would hand back the first URLs, which may have expired.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `uuid` | Yes | The video id `POST /videos` returned. |

### Headers

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. |

### Body parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. |
| `parts` | `VideoPartUrlRequestDto[]` | Yes | — |

### `parts[]` fields

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `part_number` | `number` | Yes | — |
| `sha256` | `string` | Yes | Base64 SHA-256 of the part's bytes. It is signed into the URL, and the first checksum sent for a part is pinned: asking again with a different one is `409 FILE_CHANGED`. |

### Request

**cURL**

```bash
export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY"

curl -sS -X POST "https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID/part-urls" \
  -H "Authorization: Bearer $SHOPDESK_API_KEY" \
  -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \
  -H "Content-Type: application/json" \
  -d '{
  "parts": [
    {
      "part_number": 1,
      "sha256": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg="
    }
  ]
}'
```

**JavaScript**

```javascript
const url = new URL("https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID/part-urls");

const response = await fetch(url, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`,
    "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "parts": [
      {
        "part_number": 1,
        "sha256": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg="
      }
    ]
  }),
});

if (!response.ok) {
  const { error } = await response.json();
  throw new Error(`${error.code}: ${error.message} [${error.request_id}]`);
}

const { data } = await response.json();
```

**Python**

```python
import os

import requests

payload = {
    "parts": [
        {
            "part_number": 1,
            "sha256": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=",
        },
    ],
}

response = requests.post(
    "https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID/part-urls",
    json=payload,
    headers={
        "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}",
        "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47",
    },
    timeout=30,
)

if not response.ok:
    error = response.json()["error"]
    raise RuntimeError(f"{error['code']}: {error['message']}")

page = response.json()
rows = page["data"]
```

**PHP**

```php
<?php

$key = getenv('SHOPDESK_API_KEY');

$payload = json_encode([
    'parts' => [
        [
            'part_number' => 1,
            'sha256' => 'n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=',
        ],
    ],
]);

$ch = curl_init('https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID/part-urls');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'],
    CURLOPT_POSTFIELDS => $payload,
    CURLOPT_TIMEOUT => 30,
]);

$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$response = json_decode($body, true);

if ($status >= 400) {
    throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}");
}

$rows = $response['data'];
```

### Responses

- `200` — One URL per part asked for.
- `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header).
- `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store.
- `403` — `FORBIDDEN_SCOPE`: the key's scopes do not cover this endpoint. `PLAN_FEATURE_REQUIRED`: the plan does not include product videos. `VIDEO_UPLOADS_DISABLED`: video uploads are switched off for now.
- `404` — No video with this id was started by this API key (another key’s or a dashboard upload is not visible here).
- `409` — `FILE_CHANGED`: a part was already signed with a different checksum — this is not the same file; start a new upload; or a request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished.
- `410` — `UPLOAD_NOT_ACTIVE`: the upload expired, was cancelled or already completed — start a new one.
- `422` — Validation failed — `error.fields` maps each offending field to its messages.
- `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers.
- `502` — `STORAGE_UNAVAILABLE`: video storage refused or was unreachable — retry in a moment.

**Example 200 response**

```json
{
  "data": [
    {
      "part_number": 1,
      "url": "https://media.example.com/companies/c/videos/v/video.mp4?partNumber=1&uploadId=2a7f&X-Amz-Signature=9c1e",
      "headers": {
        "x-amz-checksum-sha256": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg="
      },
      "content_length": 8388608,
      "expires_at": "2026-08-11T10:00:00.000Z"
    }
  ]
}
```

## Set a video cover

`POST /ext/v1/videos/{id}/poster` · Requires scope `videos:write`

Sets the image shown before the video plays; with none, product pages show the product thumbnail. Three sources: **`upload`** — `multipart/form-data` with the image in a `file` part and `source` as a text field; **`product_image`** — JSON `{ "source": "product_image", "image_id": "…" }` with one of a product’s photos (copied beside the video, so deleting the photo later keeps the cover); **`url`** — JSON `{ "source": "url", "url": "https://…" }`, which Shopdesk downloads and stores (the link is not kept). The image must be a JPEG, PNG or WebP of at most 2 MB, read from its bytes. A cover may be set while the video is still uploading, and replaced any time; a video a product shows gets its new cover on the product page at once. Send each NEW cover with a new `Idempotency-Key`, or none: a key is matched on the form fields, not the file’s bytes, so reusing one with a different file replays the first answer. The answer is the video with its `poster_url`.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `uuid` | Yes | The video id `POST /videos` returned. |

### Headers

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. |

### Body parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. |
| `source` | `string` | Yes | `upload`: a JPEG, PNG or WebP of at most 2 MB in a multipart `file` part. `product_image`: one of the product photos (`image_id`), copied beside the video. `url`: an https link to the image, which Shopdesk downloads and stores (the link is not kept). One of: `upload`, `product_image`, `url`. |
| `image_id` | `uuid` | No | `product_image` only: a photo of a product in this store, as listed in its `images[]`. |
| `url` | `string` | No | `url` only: an https link to a JPEG, PNG or WebP image. |

### Request

**cURL**

```bash
export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY"

curl -sS -X POST "https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID/poster" \
  -H "Authorization: Bearer $SHOPDESK_API_KEY" \
  -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \
  -H "Content-Type: application/json" \
  -d '{
  "source": "product_image",
  "image_id": "9c1e4b2a-7d3f-4e58-b0a1-2f6d8c9e1a45"
}'
```

**JavaScript**

```javascript
const url = new URL("https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID/poster");

const response = await fetch(url, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`,
    "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "source": "product_image",
    "image_id": "9c1e4b2a-7d3f-4e58-b0a1-2f6d8c9e1a45"
  }),
});

if (!response.ok) {
  const { error } = await response.json();
  throw new Error(`${error.code}: ${error.message} [${error.request_id}]`);
}

const object = await response.json();
```

**Python**

```python
import os

import requests

payload = {
    "source": "product_image",
    "image_id": "9c1e4b2a-7d3f-4e58-b0a1-2f6d8c9e1a45",
}

response = requests.post(
    "https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID/poster",
    json=payload,
    headers={
        "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}",
        "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47",
    },
    timeout=30,
)

if not response.ok:
    error = response.json()["error"]
    raise RuntimeError(f"{error['code']}: {error['message']}")

obj = response.json()
```

**PHP**

```php
<?php

$key = getenv('SHOPDESK_API_KEY');

$payload = json_encode([
    'source' => 'product_image',
    'image_id' => '9c1e4b2a-7d3f-4e58-b0a1-2f6d8c9e1a45',
]);

$ch = curl_init('https://api.shopdeskbd.com/ext/v1/videos/REPLACE_WITH_ID/poster');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'],
    CURLOPT_POSTFIELDS => $payload,
    CURLOPT_TIMEOUT => 30,
]);

$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$response = json_decode($body, true);

if ($status >= 400) {
    throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}");
}
```

### Responses

- `200` — The video, with its new `poster_url`.
- `400` — An `Idempotency-Key` header was sent but is empty or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header).
- `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store.
- `403` — `FORBIDDEN_SCOPE`: the key's scopes do not cover this endpoint. `PLAN_FEATURE_REQUIRED`: the plan does not include product videos. `VIDEO_UPLOADS_DISABLED`: video uploads are switched off for now.
- `404` — No video with this id was started by this API key (another key’s or a dashboard upload is not visible here).
- `409` — A request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished.
- `410` — `UPLOAD_NOT_ACTIVE`: the upload was rejected, cancelled or expired — it takes no cover.
- `413` — The `file` part is larger than 8 MB.
- `422` — No usable image — `error.fields` names `file`, `url` or `image_id`: not a JPEG/PNG/WebP, over 2 MB, a link that cannot be downloaded, a photo id of no product in this store, or a missing or extra `file` part for the source.
- `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers.
- `502` — `STORAGE_UNAVAILABLE`: video storage refused or was unreachable — retry in a moment.

**Example 200 response**

```json
{
  "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
  "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
  "status": "uploading",
  "source": "upload",
  "file_name": "red-saree-drape.mp4",
  "size_bytes": 48000000,
  "part_size": 8388608,
  "part_count": 6,
  "checksum_algorithm": "SHA256",
  "uploaded_parts": [
    {
      "part_number": 1,
      "size": 8388608,
      "checksum_sha256": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg="
    }
  ],
  "uploaded_bytes": 16777216,
  "expires_at": "2026-08-11T10:00:00.000Z",
  "product_ids": [],
  "url": "https://cdn.example.com/companies/c/videos/v/video.mp4",
  "poster_url": null,
  "facts": {
    "duration_ms": 24000,
    "width": 1080,
    "height": 1920,
    "rotation": 0,
    "codecs": "avc1.640028, mp4a.40.2",
    "has_audio": true,
    "frame_rate": 29.97,
    "bitrate_kbps": 4200,
    "faststart": true
  },
  "warnings": [
    "NOT_FASTSTART"
  ],
  "failure": {
    "code": "VIDEO_HEVC",
    "message": "This video is HEVC (H.265).",
    "fix": "Export as H.264."
  },
  "created_at": "2026-08-11T10:00:00.000Z",
  "completed_at": "2026-08-11T10:00:00.000Z",
  "verified_at": "2026-08-11T10:00:00.000Z"
}
```

## Import a video from a link

`POST /ext/v1/videos/import` · Requires scope `videos:write`

Shopdesk downloads an H.264 MP4 from an https link to the file itself (not a player page such as YouTube) and stores its own copy — the link is not kept. Always answered `202` at once: the copy runs in the background, so poll `GET /videos/{id}` until `ready` or `rejected` (or `aborted` with an `import_*` failure code). The link must be public (so must any of the up to 3 redirects it takes) and must declare its size (`Content-Length`): an import is refused before its body is downloaded when that is over 200 MB or over the plan's remaining video storage, which is reserved from the start and released if the import fails; a download longer or shorter than declared is refused too. Nothing stays in storage from a failed import. The file is then verified exactly like an upload. Imports run one at a time per company; one whose copy has not started within an hour ends `aborted` (`expired`), and one whose link sends under 64 KB a second on average after its first minute ends `import_download_failed`. If the key is revoked or expires, or the plan loses product videos, before the copy starts, the import ends `aborted` (`cancelled`). Without `file_name` the video is named after the link’s last path segment when that reads as a file name (`red-saree.mp4`), else `video.mp4`. `Idempotency-Key` is REQUIRED and names the import: within 24 hours a retry replays the first answer; after that the same key returns the same video (`resumed: true`) — and a retry is never refused by the daily cap. At most 20 imports start per key per day, within the store’s 50 uploads a day. Company-scoped keys must send `store_id`.

### Headers

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | Yes | REQUIRED. A unique value per logical request (a UUID is ideal); reuse it verbatim when retrying. A retry replays the original response with `Idempotent-Replayed: true` instead of running twice. |

### Body parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. |
| `source_url` | `string` | Yes | An https link to the MP4 file itself (not a player page such as YouTube). Shopdesk downloads it and stores its own copy; the link is not kept. |
| `file_name` | `string` | No | For display only. Defaults to the link's last path segment. |
| `product_id` | `uuid` | No | The product the video is meant for, for an early check of its video limit. It does not place the video — `POST /products/{id}/videos` does. |

### Request

**cURL**

```bash
export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY"

curl -sS -X POST "https://api.shopdeskbd.com/ext/v1/videos/import" \
  -H "Authorization: Bearer $SHOPDESK_API_KEY" \
  -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \
  -H "Content-Type: application/json" \
  -d '{
  "source_url": "https://cdn.example.com/videos/red-saree-drape.mp4"
}'
```

**JavaScript**

```javascript
const url = new URL("https://api.shopdeskbd.com/ext/v1/videos/import");

const response = await fetch(url, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`,
    "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "source_url": "https://cdn.example.com/videos/red-saree-drape.mp4"
  }),
});

if (!response.ok) {
  const { error } = await response.json();
  throw new Error(`${error.code}: ${error.message} [${error.request_id}]`);
}

const object = await response.json();
```

**Python**

```python
import os

import requests

payload = {
    "source_url": "https://cdn.example.com/videos/red-saree-drape.mp4",
}

response = requests.post(
    "https://api.shopdeskbd.com/ext/v1/videos/import",
    json=payload,
    headers={
        "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}",
        "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47",
    },
    timeout=30,
)

if not response.ok:
    error = response.json()["error"]
    raise RuntimeError(f"{error['code']}: {error['message']}")

obj = response.json()
```

**PHP**

```php
<?php

$key = getenv('SHOPDESK_API_KEY');

$payload = json_encode([
    'source_url' => 'https://cdn.example.com/videos/red-saree-drape.mp4',
]);

$ch = curl_init('https://api.shopdeskbd.com/ext/v1/videos/import');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'],
    CURLOPT_POSTFIELDS => $payload,
    CURLOPT_TIMEOUT => 30,
]);

$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$response = json_decode($body, true);

if ($status >= 400) {
    throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}");
}
```

### Responses

- `202` — The video, `source: import`, still `uploading` while it is copied.
- `400` — The required `Idempotency-Key` header is missing, empty, or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header).
- `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store.
- `403` — `FORBIDDEN_SCOPE`: the key's scopes do not cover this endpoint. `PLAN_FEATURE_REQUIRED`: the plan does not include product videos. `VIDEO_UPLOADS_DISABLED`: video uploads are switched off for now. `PLAN_LIMIT_REACHED`: the plan's video storage is full (or `product_id`’s product already has its plan’s number of videos).
- `404` — `product_id` names no product of this store.
- `409` — `TOO_MANY_OPEN_UPLOADS`: this key already has 4 unfinished uploads or imports, or the store 6 — wait for one to finish or delete one; or a request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished.
- `422` — Validation failed — `error.fields` names the field. `source_url` must be an https link without a username or password, on port 443 or from 1024 up, and not a video player page.
- `429` — Rate limit exceeded for this key, or this key started 20 imports (or the store 50 uploads) today (`RATE_LIMITED`, `Retry-After` to the next UTC midnight).
- `502` — `STORAGE_UNAVAILABLE`: video storage refused or was unreachable — retry in a moment.

**Example 200 response**

```json
{
  "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
  "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
  "status": "uploading",
  "source": "upload",
  "file_name": "red-saree-drape.mp4",
  "size_bytes": 48000000,
  "part_size": 8388608,
  "part_count": 6,
  "checksum_algorithm": "SHA256",
  "uploaded_parts": [
    {
      "part_number": 1,
      "size": 8388608,
      "checksum_sha256": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg="
    }
  ],
  "uploaded_bytes": 16777216,
  "expires_at": "2026-08-11T10:00:00.000Z",
  "product_ids": [],
  "url": "https://cdn.example.com/companies/c/videos/v/video.mp4",
  "poster_url": null,
  "facts": {
    "duration_ms": 24000,
    "width": 1080,
    "height": 1920,
    "rotation": 0,
    "codecs": "avc1.640028, mp4a.40.2",
    "has_audio": true,
    "frame_rate": 29.97,
    "bitrate_kbps": 4200,
    "faststart": true
  },
  "warnings": [
    "NOT_FASTSTART"
  ],
  "failure": {
    "code": "VIDEO_HEVC",
    "message": "This video is HEVC (H.265).",
    "fix": "Export as H.264."
  },
  "created_at": "2026-08-11T10:00:00.000Z",
  "completed_at": "2026-08-11T10:00:00.000Z",
  "verified_at": "2026-08-11T10:00:00.000Z",
  "resumed": false
}
```
