# Upload a product video

Raw calls from start to finish, covers, a video for one option, imports by link, reject codes and limits.

A short clip on a product page sells what a photo cannot: how a saree drapes
or how a bag opens. This guide uploads a video with plain HTTP calls from
start to finish. It then sets the video's cover and shows it on a product for
one option, such as Colour: Red. It also covers importing a video from a link,
the reject codes and the limits.

**You need** a key with `videos:write`, and a plan that includes product
videos (otherwise every call below answers `403 PLAN_FEATURE_REQUIRED`).
Step 6 reads the product to find the option, which needs `products:read` as
well. `videos:write` is its own scope because a video uses the plan's video
storage and can be up to 200 MB. No existing key has it: create a new key
that includes it ([Authentication](https://www.shopdeskbd.com/developers/authentication.md#scopes)).

> **Warning — From a server, not a web page**
>
> The file goes straight from your code to storage, on URLs Shopdesk signs for
> each part. Storage accepts uploads from browsers on the Shopdesk dashboard
> only. Run the upload from a server, a script or a desktop tool. A web page on
> your own domain cannot send the parts, and your API key must never be in a
> browser anyway.

## How an upload works

```text
1. POST /videos                  start: the video id, its part plan, the size reserved
2. POST /videos/{id}/part-urls   a signed URL for each part (up to 10 per call)
3. PUT <part url>                each part's bytes, straight to storage
4. POST /videos/{id}/complete    200 ready, 202 still checking (poll GET /videos/{id}), or 422
5. POST /videos/{id}/poster      optional: the cover shown before it plays
6. POST /products/{id}/videos    show it on a product, for every variant or for one option
```

The bytes never pass through the API. Shopdesk signs one URL per part, and
your code sends each part to its URL. Shopdesk then puts the file together
and reads it itself to check it before any shopper sees it.

## What a file must be

| Rule      | Accepted                                                                                |
| --------- | --------------------------------------------------------------------------------------- |
| Container | MP4, not fragmented (streaming) MP4 and not QuickTime `.mov`                            |
| Video     | H.264, 8-bit (SDR). Not HEVC (H.265), AV1, VP9, 10-bit or HDR                           |
| Sound     | AAC, MP3, Opus, or none                                                                 |
| Size      | At most 200 MB (200,000,000 bytes)                                                      |
| Length    | 3 seconds to 5 minutes                                                                  |
| Picture   | Shorter side at least 480 px, longer side at most 4096 px; turned only in quarter turns |

Phones and editing apps can export this. On an iPhone, choose
**Settings → Camera → Formats → Most Compatible**; in CapCut or InShot, choose
H.264. About 5 Mbps at 1080p is plenty for a phone screen.

A file that passes can still come back with `warnings`, such as
`NOT_FASTSTART` ("starts a little slower") or `HIGH_BITRATE`. They are advice
for the next export and never block the video.

## 1. Start the upload

```bash
curl -sS https://api.shopdeskbd.com/ext/v1/videos \
  -H "Authorization: Bearer $SHOPDESK_API_KEY" \
  -H "Idempotency-Key: 7d0c4a52-3b1e-4f6a-9c2d-8e5f1a0b3c01" \
  -H "Content-Type: application/json" \
  -d '{
    "file_name": "red-saree-drape.mp4",
    "size_bytes": 48000000,
    "part_size": 8388608
  }'
```

The answer (abridged):

```json
{
  "id": "6b1f3c2d-9e8a-4b7c-a6d5-1e2f3a4b5c6d",
  "status": "uploading",
  "source": "upload",
  "file_name": "red-saree-drape.mp4",
  "size_bytes": 48000000,
  "part_size": 8388608,
  "part_count": 6,
  "checksum_algorithm": "SHA256",
  "uploaded_parts": [],
  "expires_at": "2026-09-30T09:12:00.000Z",
  "resumed": false
}
```

- **`size_bytes`** is the exact size of the file. It is reserved against the
  plan's video storage from now on, so a plan that has no room answers
  `403 PLAN_LIMIT_REACHED` before you send a byte.
- **`part_size`** is 5, 8 or 16 MiB (`5242880`, `8388608` or `16777216`).
  Every part except the last is exactly this long. The answer's `part_count`
  says how many parts there are.
- **`Idempotency-Key` is required**, and it is also how you resume. The same
  key always names the same upload. Within 24 hours a retry replays the first
  answer exactly as it was (`Idempotent-Replayed: true`, `resumed: false`, and
  `uploaded_parts` as they were then). After that, the same key returns the
  upload with `resumed: true`. Either way, read `GET /videos/{id}` for the
  parts storage holds now. Keep the key with your own record of the file, and
  reuse it only for the same file.
- **`product_id`** (optional) checks early that the product still has room
  for a video on its plan. It does not place the video; step 6 does that.
- **`expires_at`** is when an unfinished upload lapses: a day after your last
  request for part URLs, and never more than three days after the start. A
  lapsed upload becomes `aborted` and its reserved storage is released.

## 2. Sign the parts, and 3. send them

Part `n` is the bytes from `(n − 1) × part_size` up to `n × part_size`. The
last part is whatever is left. For each part, send its number and the
**base64 SHA-256 of its bytes**:

```bash
curl -sS https://api.shopdeskbd.com/ext/v1/videos/$VIDEO_ID/part-urls \
  -H "Authorization: Bearer $SHOPDESK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "parts": [{ "part_number": 1, "sha256": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=" }] }'
```

```json
{
  "data": [
    {
      "part_number": 1,
      "url": "https://…/video.mp4?partNumber=1&uploadId=…&X-Amz-Signature=…",
      "headers": { "x-amz-checksum-sha256": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=" },
      "content_length": 8388608,
      "expires_at": "2026-09-29T10:12:00.000Z"
    }
  ]
}
```

Then `PUT` each part's bytes to its `url`:

- **Send exactly the `headers` given.** Do not send your `Authorization`
  header, which is only for the API, and do not send a `Content-Type`.
- **Send exactly `content_length` bytes.** The length and the checksum are
  part of the signature, so storage refuses any other bytes. That refusal
  comes from storage (a `400` or `403`), not from Shopdesk.
- **Ask for up to 10 parts per call.** URLs are valid for 60 minutes. Ask
  again for new ones whenever they expire. Send **no** `Idempotency-Key` on
  `part-urls`: asking again is always safe, and a replayed answer would hand
  back the first URLs, which may have expired.
- **A part's checksum is fixed the first time you ask.** Asking again for the
  same part with a different checksum is `409 FILE_CHANGED`: it is not the
  same file. Start a new upload for it.

A shell loop that sends every part (it uses `openssl`, `base64` and `jq`):

```bash
# VIDEO_ID and PART_COUNT come from step 1's answer (id, part_count).
FILE=red-saree-drape.mp4
PART_SIZE=8388608
for N in $(seq 1 "$PART_COUNT"); do
  dd if="$FILE" of=part.bin bs=$PART_SIZE skip=$((N - 1)) count=1 2>/dev/null
  SHA=$(openssl dgst -sha256 -binary part.bin | base64)
  URL=$(curl -sS "https://api.shopdeskbd.com/ext/v1/videos/$VIDEO_ID/part-urls" \
    -H "Authorization: Bearer $SHOPDESK_API_KEY" \
    -H "Content-Type: application/json" \
    -d "{\"parts\":[{\"part_number\":$N,\"sha256\":\"$SHA\"}]}" | jq -r '.data[0].url')
  curl -sS --fail -T part.bin -H "x-amz-checksum-sha256: $SHA" "$URL"
done
```

`curl -T` sends a `PUT` with no `Content-Type`. Today the only header in
`headers` is the checksum. A script that sends whatever `headers` holds, as
the Node script below does, keeps working if that changes.

## 4. Complete, then poll

```bash
curl -sS -X POST https://api.shopdeskbd.com/ext/v1/videos/$VIDEO_ID/complete \
  -H "Authorization: Bearer $SHOPDESK_API_KEY"
```

Shopdesk puts the file together from the parts storage holds, so there are no
part receipts to send. Then it reads the file and checks it against the rules
above:

| Answer                       | Means                                                                                                                                                                   |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `200`, `status: "ready"`     | The video is checked and playable. `url` is the MP4 and `facts` holds what the file contains: `duration_ms`, `width`, `height`, `codecs`, `has_audio`.                  |
| `202`, `status: "verifying"` | The check is still running. Poll `GET /videos/{id}` every few seconds until `status` is `ready` or `rejected`.                                                          |
| `422` with a `VIDEO_*` code  | The file breaks a rule. `error.fields.video` says how to fix it, where there is a fix. The file is deleted and its storage released. See [reject codes](#reject-codes). |
| `409 PARTS_INCOMPLETE`       | Some parts are not in storage yet. `error.fields.parts` names them. Send them and complete again.                                                                       |

Completing again is safe: a ready video answers `200` again, and a rejected
one `422` again. `GET /videos/{id}` gives the same object, with a
`rejected` video's reason in `failure` (`code`, `message`, `fix`).

## The whole upload as one script

The same steps in Node.js 18 or later, with no libraries. It signs ten parts
per call and reads each part from disk only when it needs it:

```js
// SHOPDESK_API_KEY=… node upload-video.mjs red-saree.mp4 PRODUCT_ID Colour Red
import { createHash, randomUUID } from 'node:crypto';
import { open } from 'node:fs/promises';
import { basename } from 'node:path';

const API = 'https://api.shopdeskbd.com/ext/v1';
const PART_SIZE = 8 * 1024 * 1024; // 5, 8 or 16 MiB
const [file, productId, attribute, value] = process.argv.slice(2);

async function call(method, path, body, idempotencyKey) {
  const response = await fetch(API + path, {
    method,
    headers: {
      Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`,
      ...(body && { 'Content-Type': 'application/json' }),
      ...(idempotencyKey && { 'Idempotency-Key': idempotencyKey }),
    },
    body: body && JSON.stringify(body),
  });
  const json = await response.json();
  if (!response.ok) {
    throw new Error(`${response.status} ${json.error.code}: ${json.error.message}`);
  }
  return json;
}

const handle = await open(file);
const { size } = await handle.stat();
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('base64');
async function readPart(video, n) {
  const start = (n - 1) * video.part_size;
  const bytes = Buffer.alloc(Math.min(video.part_size, video.size_bytes - start));
  await handle.read(bytes, 0, bytes.length, start);
  return bytes;
}

// 1. Start. Keep this key with your record of the file: it resumes the upload.
const uploadKey = randomUUID();
const video = await call(
  'POST',
  '/videos',
  { file_name: basename(file), size_bytes: size, part_size: PART_SIZE, product_id: productId },
  uploadKey,
);

// 2 + 3. Ten parts per call: sign them, then PUT each straight to storage.
for (let first = 1; first <= video.part_count; first += 10) {
  const parts = [];
  for (let n = first; n <= Math.min(first + 9, video.part_count); n++) {
    parts.push({ part_number: n, sha256: sha256(await readPart(video, n)) });
  }
  const signed = await call('POST', `/videos/${video.id}/part-urls`, { parts });
  for (const part of signed.data) {
    const put = await fetch(part.url, {
      method: 'PUT',
      headers: part.headers, // exactly these: no Authorization, no Content-Type
      body: await readPart(video, part.part_number),
    });
    if (!put.ok) throw new Error(`part ${part.part_number}: storage answered ${put.status}`);
  }
}

// 4. Complete: ready now, or poll while it is checked. A reject throws (422).
let done = await call('POST', `/videos/${video.id}/complete`);
while (done.status === 'verifying') {
  await new Promise((resolve) => setTimeout(resolve, 5000));
  done = await call('GET', `/videos/${video.id}`);
}
if (done.status !== 'ready') throw new Error(`${done.failure.code}: ${done.failure.message}`);

// 5. A cover: the product's main photo, copied beside the video.
const product = await call('GET', `/products/${productId}`);
const main = product.images.find((image) => image.is_thumbnail);
if (main) {
  await call('POST', `/videos/${video.id}/poster`, { source: 'product_image', image_id: main.id });
}

// 6. Show it for one option value (Colour: Red), by the id a variant uses.
const option = product.variants
  .flatMap((variant) => variant.options)
  .find((o) => o.attribute === attribute && o.value === value);
if (attribute && !option) throw new Error(`No variant of this product is ${attribute}: ${value}`);
const placement = await call('POST', `/products/${productId}/videos`, {
  video_id: video.id,
  option_value_id: option ? option.value_id : null,
});
console.log(placement); // status "ready": GET /products/{id} lists it in videos[]
```

## Resuming an interrupted upload

If the script stops halfway, nothing is lost as long as the upload has not
expired:

1. `GET /videos/{id}` lists the parts storage already holds in
   `uploaded_parts`.
2. Ask for URLs for the missing parts only, with the same checksums as before,
   and send them.
3. Complete as usual.

If you lost the video id, start again with the **same** `Idempotency-Key`. You
get the same upload back: within 24 hours as a replay of the first answer,
after that with `resumed: true`. Its `id` is what matters; read
`GET /videos/{id}` for the parts storage holds, because a replayed answer
shows the parts as they were then. The optional `fingerprint` on
`POST /videos` finds the upload for a new key too (see the
[reference](https://www.shopdeskbd.com/developers/reference/videos.md)). Once an upload is `aborted`
(it expired, or you deleted it), start over with a new key.

If you rotate the key meanwhile, the new key carries on: it reads, completes
and places the uploads the old key started, and the same `Idempotency-Key`
still names the same upload.

## 5. Set a cover

The cover is the picture shown before the video plays. Without one, product
pages show the product's main photo. There are three sources, and a cover can
be set or replaced at any time, even while the video is still uploading:

```bash
# One of the product's photos, copied beside the video (deleting the photo later keeps the cover)
curl -sS https://api.shopdeskbd.com/ext/v1/videos/$VIDEO_ID/poster \
  -H "Authorization: Bearer $SHOPDESK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "source": "product_image", "image_id": "IMAGE_ID" }'

# A link, which Shopdesk downloads and stores (the link is not kept)
curl -sS https://api.shopdeskbd.com/ext/v1/videos/$VIDEO_ID/poster \
  -H "Authorization: Bearer $SHOPDESK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "source": "url", "url": "https://cdn.example.com/covers/red-saree.jpg" }'

# A file you upload, in a multipart "file" part
curl -sS https://api.shopdeskbd.com/ext/v1/videos/$VIDEO_ID/poster \
  -H "Authorization: Bearer $SHOPDESK_API_KEY" \
  -F source=upload \
  -F file=@cover.jpg
```

A cover must be a JPEG, PNG or WebP of at most 2 MB. The type is read from
the file's bytes, and a cover link is checked like any
[photo link](https://www.shopdeskbd.com/developers/guide-photos-by-link.md#what-happens-to-a-link). A cover
by link or upload counts as one image under the
[image limits](https://www.shopdeskbd.com/developers/rate-limits.md#image-downloads). The answer is the
video with its new `poster_url`. Covers cannot be cut from a frame of the
video through the API; the dashboard offers that.

Send each **new** cover with a new `Idempotency-Key`, or none. A key is
matched on the form fields, not on the file's bytes, so reusing one with a
different file replays the first answer and the new cover is not stored. A
cover by link on a video that cannot take one (rejected, cancelled or
expired), or while uploads are switched off, is refused before the link is
downloaded.

## 6. Place it on a product, for the Red option

A product can show a video for every variant, or only for one option value,
such as Colour: Red. The option value's id is on the product's variants. Read
the product (`products:read`) and take the `value_id` from `options[]`:

```json
{
  "sku": "SAREE-RED",
  "options": [
    { "attribute_id": "…", "attribute": "Colour", "value_id": "2c7e9f41-5b3a-4d8e-9f10-6a2b3c4d5e6f", "value": "Red" }
  ]
}
```

Then place the video:

```bash
curl -sS https://api.shopdeskbd.com/ext/v1/products/$PRODUCT_ID/videos \
  -H "Authorization: Bearer $SHOPDESK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "video_id": "6b1f3c2d-9e8a-4b7c-a6d5-1e2f3a4b5c6d",
    "option_value_id": "2c7e9f41-5b3a-4d8e-9f10-6a2b3c4d5e6f",
    "gallery_slot": 1,
    "title": "Red saree drape"
  }'
```

```json
{
  "product_id": "…",
  "video_id": "6b1f3c2d-9e8a-4b7c-a6d5-1e2f3a4b5c6d",
  "status": "ready",
  "gallery_slot": 1,
  "sort_order": 0,
  "option_value_id": "2c7e9f41-5b3a-4d8e-9f10-6a2b3c4d5e6f",
  "title": "Red saree drape"
}
```

- **`gallery_slot`** is how many photos come before the video: `0` puts it
  first, and `1` (the default) right after the main photo.
- **`sort_order`** orders the product's videos among themselves (default `0`).
- **`option_value_id`** shows it only for that value. The value must be one
  that the product's active variants use. Leave it out, or send `null`, to show
  it for every variant.
- **`title`** is the video's accessible name. Without one, the product name is
  used.
- **You can place a video before it is ready.** The answer's `status` says
  where it stands. The product shows it once it is `ready`: in `videos[]` on
  `GET /products/{id}` and on the store's product page, and a
  `product.updated` webhook goes out for each product that shows it at that
  moment (so a consumer of `product.*` events sees it arrive; changing a
  ready video's cover sends one too). A video that is still checking, or was
  rejected, never appears there.
- **Place a finished video within 48 hours.** A ready video that no product
  shows 48 hours after it was checked is released: it stops counting against
  the plan's video storage, and it is removed 7 days later (after that,
  `GET /videos/{id}` answers `404`). Placing it on a product before then keeps
  it.
- **Placing it again moves it.** Nothing else on the product changes. The
  plan sets how many videos one product may show (3 unless the plan says
  otherwise). One more is `403 PLAN_LIMIT_REACHED`.

A new product can take its videos in the same call: `videos[]` on
`POST /products` takes the same fields, one entry per video. That needs
`videos:write` beside `products:write`. To take a video off a product, call
`DELETE /products/{id}/videos/{videoId}`. The video stays yours, to place
again or to delete with `DELETE /videos/{id}`.

## Import from a link instead

When the MP4 is already online, let Shopdesk fetch it:

```bash
curl -sS https://api.shopdeskbd.com/ext/v1/videos/import \
  -H "Authorization: Bearer $SHOPDESK_API_KEY" \
  -H "Idempotency-Key: 7d0c4a52-3b1e-4f6a-9c2d-8e5f1a0b3c02" \
  -H "Content-Type: application/json" \
  -d '{ "source_url": "https://cdn.example.com/videos/red-saree-drape.mp4" }'
```

The answer is always `202`, with the video in `status: "uploading"` and
`source: "import"`. The copy runs in the background, and the file is checked
exactly like an upload. Poll `GET /videos/{id}` until it is `ready`,
`rejected` or `aborted`. You can set its cover and place it on a product
straight away.

- **The link must be the file itself:** https, a public address (and so must
  every one of the up to 3 redirects it takes), and not a player page. YouTube,
  Vimeo, Facebook, Instagram, TikTok and similar links are refused with a
  `422` on `source_url` before anything is fetched. A link that returns a web
  page instead of an MP4 is caught by the file's first bytes.
- **The link must state the file's size** in `Content-Length`. Shopdesk
  reads the response headers first and closes the connection before the body
  when the size is missing, over 200 MB, or more than the plan's video storage
  has left. It also stops when the download runs longer or shorter than that
  size.
- **Storage is reserved from the start.** Until the link answers, the
  reservation (and `size_bytes`) is 200 MB, or less if the plan has less
  room left. It then shrinks to the file's size.
- **Time limits:** 10 seconds to get the response headers, 30 seconds without
  new bytes, 15 minutes in all. After its first minute a download must
  average at least 64 KB a second, or it stops (`import_download_failed`).
- **Imports wait their turn.** Each company's imports are copied one at a
  time. An import whose copy has not started within an hour ends `aborted`
  with `expired`, and its reservation is released.
- **The key must still be valid when the copy starts.** If the key is
  revoked or expires (a rotated key's grace period ends), or the plan loses
  product videos, or uploads are switched off before then, the import ends
  `aborted` with `cancelled` and nothing is downloaded.
- **`Idempotency-Key` is required** and names the import: the same key gives
  back the same video (within 24 hours as a replay of the first answer, then
  with `resumed: true`) and never imports the file twice. A retry is never
  refused by the daily import cap.
- **The file's name.** Send `file_name` to name it. Without one, the video is
  named after the link's last path segment when that reads as a file name
  (such as `red-saree-drape.mp4`: at most 80 characters of letters `A`–`Z`,
  digits, spaces and `._-()`, ending in `.mp4` or `.m4v`, with no long run of
  letters and digits that looks like a token), and `video.mp4` otherwise.
  Nothing else of the link, including its query string, is stored.
- **A failed import leaves nothing behind.** Its reservation is released and
  no file stays in storage. The video ends `aborted` with one of these codes
  in `failure.code`:

| `failure.code`           | Means                                                                                                                                  |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
| `import_link_refused`    | The link, or a redirect, is not a public https address, or it redirected more than 3 times.                                            |
| `import_download_failed` | The download failed: not found, an error answer, too slow, or cut off.                                                                 |
| `import_length_unknown`  | The link did not state the file's size. Upload the file instead.                                                                       |
| `import_length_mismatch` | The download was not the size the link stated.                                                                                         |
| `import_over_quota`      | The file is bigger than the plan's remaining video storage.                                                                            |
| `import_storage_error`   | Video storage failed during the copy. Import it again in a moment.                                                                     |
| `expired`                | The copy did not start within an hour of the import. Import it again.                                                                  |
| `cancelled`              | You deleted it, or before the copy started the key was revoked or expired, the plan lost product videos, or uploads were switched off. |

A file that is over 200 MB, or is not an MP4 at all, ends `rejected` with
`VIDEO_TOO_LARGE` or `VIDEO_NOT_MP4`, like an upload. Shopdesk sends an
import's parts itself, so asking for part URLs or completing an import is
`409 UPLOAD_IS_IMPORT`.

## Reject codes

Uploads and imports share these codes. `complete` answers `422` with one of
them, and `GET /videos/{id}` shows the same code in `failure.code` on a
`rejected` video:

| Code                         | The file                                            | Fix                                               |
| ---------------------------- | --------------------------------------------------- | ------------------------------------------------- |
| `VIDEO_NOT_MP4`              | is not an MP4 (for example a `.mov`, or a web page) | Save or export as MP4 (H.264).                    |
| `VIDEO_CORRUPT`              | could not be read: damaged or incomplete            | Export it again.                                  |
| `VIDEO_FRAGMENTED`           | is a fragmented (streaming) MP4                     | Export as a standard MP4.                         |
| `VIDEO_NO_VIDEO_TRACK`       | has no picture (sound only)                         | Use a file with a video track.                    |
| `VIDEO_HEVC`                 | uses HEVC (H.265)                                   | Export as H.264 ("Most Compatible" on an iPhone). |
| `VIDEO_CODEC_UNSUPPORTED`    | is not H.264 (AV1, VP9, MPEG-4…)                    | Export as MP4 with H.264 video.                   |
| `VIDEO_PROFILE_UNSUPPORTED`  | is 10-bit, HDR or 4:2:2 H.264                       | Export 8-bit (SDR) H.264.                         |
| `VIDEO_AUDIO_UNSUPPORTED`    | has sound that phones cannot play (AC-3, PCM…)      | Export with AAC sound, or none.                   |
| `VIDEO_ROTATION_UNSUPPORTED` | is turned at an angle that is not a quarter turn    | Export it upright.                                |
| `VIDEO_TOO_LARGE`            | is over 200 MB                                      | Export at 1080p, about 5 Mbps.                    |
| `VIDEO_TOO_LONG`             | is longer than 5 minutes                            | Trim it.                                          |
| `VIDEO_TOO_SHORT`            | is shorter than 3 seconds                           | Use a longer clip.                                |
| `VIDEO_LOW_RES`              | has a shorter side under 480 px                     | Export at a higher resolution.                    |
| `VIDEO_FRAME_TOO_BIG`        | has a longer side over 4096 px                      | Export at 1080p.                                  |

`error.message` and `error.fields.video` carry the same wording the dashboard
shows, so you can show them to the merchant as they are.

## Other errors

| HTTP | Code                     | Means                                                                                       |
| ---- | ------------------------ | ------------------------------------------------------------------------------------------- |
| 403  | `PLAN_FEATURE_REQUIRED`  | The store's plan does not include product videos.                                           |
| 403  | `VIDEO_UPLOADS_DISABLED` | Video uploads are switched off for now. Try again later.                                    |
| 403  | `PLAN_LIMIT_REACHED`     | The plan's video storage is full, or the product already shows its plan's number of videos. |
| 404  | `NOT_FOUND`              | No video with this id was started by **this key**.                                          |
| 409  | `TOO_MANY_OPEN_UPLOADS`  | This key has 4 unfinished uploads or imports, or the store has 6. Complete or delete one.   |
| 409  | `FILE_CHANGED`           | A part was signed, or stored, with a different checksum. This is not the same file.         |
| 409  | `PARTS_INCOMPLETE`       | Parts are missing at `complete`. `error.fields.parts` names them.                           |
| 409  | `UPLOAD_IS_IMPORT`       | The video is an import, and Shopdesk sends its parts.                                       |
| 409  | `UPLOAD_VERIFYING`       | The video is still being checked. Wait for `ready`.                                         |
| 409  | `VIDEO_IN_USE`           | `DELETE /videos/{id}` on a video a live product shows. Take it off the product first.       |
| 410  | `UPLOAD_NOT_ACTIVE`      | The upload expired, was cancelled or already ended. Start a new one.                        |
| 429  | `RATE_LIMITED`           | The key's rate limit, or a daily cap below. `Retry-After` says when to try again.           |
| 502  | `STORAGE_UNAVAILABLE`    | Video storage did not answer. Retry in a moment.                                            |

## Quotas and limits

| Limit                          | Value                                                                                                                          |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| File size                      | 200 MB                                                                                                                         |
| Video storage                  | The plan's `video_storage_mb`. Every unfinished upload's size is reserved, and a finished video counts until it is deleted     |
| Videos per product             | The plan's, 3 unless the plan says otherwise (10 at most)                                                                      |
| Unfinished uploads and imports | 4 per key, 6 per store (uploading or still being checked)                                                                      |
| Uploads started                | 50 per store per UTC day (`429 RATE_LIMITED`, `Retry-After` to midnight UTC)                                                   |
| Imports started                | 20 per key per UTC day, within the store's 50                                                                                  |
| Part URLs                      | Up to 10 per call, each valid for 60 minutes                                                                                   |
| Unfinished upload              | Lapses a day after the last part-URL request, three days after the start at most                                               |
| Import waiting to start        | One hour; each company's imports are copied one at a time                                                                      |
| Import download speed          | At least 64 KB a second on average, after the first minute                                                                     |
| Finished video on no product   | Released 48 hours after it is ready (it stops counting against storage), removed 7 days later. Place it before then to keep it |
| Covers                         | JPEG, PNG or WebP, at most 2 MB                                                                                                |

`DELETE /videos/{id}` cancels an unfinished upload and releases its storage at
once. On a finished video that no live product shows, it stops counting
against the plan straight away, and the file is removed from storage 7 days
later. Deleting again changes nothing.

## Who sees what

- **Each key sees only its own uploads.** `GET /videos` lists the videos this
  key started. Another key's video, or one uploaded in the dashboard, answers
  `404` on every `/videos` route and cannot be placed with this key (`422` on
  `video_id`). A key made by rotating another also sees, and can complete,
  place and delete, the videos the key it replaced started.
- **Staff see everything.** The merchant's dashboard shows videos uploaded
  through the API beside their own.
- **Products show ready videos, whoever uploaded them.** `videos[]` on
  `GET /products/{id}` (under `products:read`) lists every ready video the
  product shows, with `url`, `poster_url`, size, length, `gallery_slot` and
  `option_value_id`. A store whose plan does not include product videos
  shows an empty list, as its product pages do. Any key with `videos:write` can
  take a video off a product, even while uploads are switched off.
- **Webhooks follow the product.** `product.*` payloads carry the same
  `videos[]`. A product save sends `product.updated` as usual; a video
  turning ready, a ready video's cover changing, or a video being released
  from archived products sends `product.updated` for the products concerned.

## With the store MCP server

An AI assistant connected to the
[store MCP server](https://www.shopdeskbd.com/developers/ai.md#the-store-mcp-server) with a `videos:write`
key has one video tool, `attach_product_video`. It calls the same services as
`POST /videos/import` and `POST /products/{id}/videos`, so everything above
applies to it.

- **With `video_url`**, it imports the file and places it on the product in
  one call. The answer's `video.status` and `placement.status` say where the
  import is. `get_product` lists the video once it is ready. `idempotency_key`
  is required with a link, so a retried call never imports twice.
- **With `video_id`**, a video this key uploaded or imported, it only places
  it.
- **With neither**, it changes nothing and answers `upload_steps`: the REST
  calls above, for a script. A model cannot send file bytes, so uploading a
  file stays a job for code.
- **`option`** names the option value in words, as
  `{ "attribute": "Colour", "value": "Red" }`. This reads the product, so the
  key also needs `products:read`. A name that the product's active variants
  do not use fails with the product's options as `candidates`, and nothing is
  placed. `option_value_id` takes the id instead; with a link and
  `products:read`, a wrong id is refused the same way before anything is
  imported.
- **If the import started but the video could not be placed**, the error
  names the imported video's id. Call the tool again with that `video_id`
  (or the same `idempotency_key` and link) rather than importing again.

The tool declares `openWorldHint: true`, because it can download from outside
the store, so a client that confirms such tools will ask first. The upload
session, covers and deletes stay on REST. See
[MCP tools](https://www.shopdeskbd.com/developers/mcp-tools.md).
