SHOP DESKDevelopers
shopdeskbd.com

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).

How an upload works

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

RuleAccepted
ContainerMP4, not fragmented (streaming) MP4 and not QuickTime .mov
VideoH.264, 8-bit (SDR). Not HEVC (H.265), AV1, VP9, 10-bit or HDR
SoundAAC, MP3, Opus, or none
SizeAt most 200 MB (200,000,000 bytes)
Length3 seconds to 5 minutes
PictureShorter 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

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):

{
  "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:

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=" }] }'
{
  "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):

# 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

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:

AnswerMeans
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_* codeThe 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.
409 PARTS_INCOMPLETESome 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:

// 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). 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:

# 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. A cover by link or upload counts as one image under the image limits. 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[]:

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

Then place the video:

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"
  }'
{
  "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}.

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

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.codeMeans
import_link_refusedThe link, or a redirect, is not a public https address, or it redirected more than 3 times.
import_download_failedThe download failed: not found, an error answer, too slow, or cut off.
import_length_unknownThe link did not state the file's size. Upload the file instead.
import_length_mismatchThe download was not the size the link stated.
import_over_quotaThe file is bigger than the plan's remaining video storage.
import_storage_errorVideo storage failed during the copy. Import it again in a moment.
expiredThe copy did not start within an hour of the import. Import it again.
cancelledYou 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:

CodeThe fileFix
VIDEO_NOT_MP4is not an MP4 (for example a .mov, or a web page)Save or export as MP4 (H.264).
VIDEO_CORRUPTcould not be read: damaged or incompleteExport it again.
VIDEO_FRAGMENTEDis a fragmented (streaming) MP4Export as a standard MP4.
VIDEO_NO_VIDEO_TRACKhas no picture (sound only)Use a file with a video track.
VIDEO_HEVCuses HEVC (H.265)Export as H.264 ("Most Compatible" on an iPhone).
VIDEO_CODEC_UNSUPPORTEDis not H.264 (AV1, VP9, MPEG-4…)Export as MP4 with H.264 video.
VIDEO_PROFILE_UNSUPPORTEDis 10-bit, HDR or 4:2:2 H.264Export 8-bit (SDR) H.264.
VIDEO_AUDIO_UNSUPPORTEDhas sound that phones cannot play (AC-3, PCM…)Export with AAC sound, or none.
VIDEO_ROTATION_UNSUPPORTEDis turned at an angle that is not a quarter turnExport it upright.
VIDEO_TOO_LARGEis over 200 MBExport at 1080p, about 5 Mbps.
VIDEO_TOO_LONGis longer than 5 minutesTrim it.
VIDEO_TOO_SHORTis shorter than 3 secondsUse a longer clip.
VIDEO_LOW_REShas a shorter side under 480 pxExport at a higher resolution.
VIDEO_FRAME_TOO_BIGhas a longer side over 4096 pxExport 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

HTTPCodeMeans
403PLAN_FEATURE_REQUIREDThe store's plan does not include product videos.
403VIDEO_UPLOADS_DISABLEDVideo uploads are switched off for now. Try again later.
403PLAN_LIMIT_REACHEDThe plan's video storage is full, or the product already shows its plan's number of videos.
404NOT_FOUNDNo video with this id was started by this key.
409TOO_MANY_OPEN_UPLOADSThis key has 4 unfinished uploads or imports, or the store has 6. Complete or delete one.
409FILE_CHANGEDA part was signed, or stored, with a different checksum. This is not the same file.
409PARTS_INCOMPLETEParts are missing at complete. error.fields.parts names them.
409UPLOAD_IS_IMPORTThe video is an import, and Shopdesk sends its parts.
409UPLOAD_VERIFYINGThe video is still being checked. Wait for ready.
409VIDEO_IN_USEDELETE /videos/{id} on a video a live product shows. Take it off the product first.
410UPLOAD_NOT_ACTIVEThe upload expired, was cancelled or already ended. Start a new one.
429RATE_LIMITEDThe key's rate limit, or a daily cap below. Retry-After says when to try again.
502STORAGE_UNAVAILABLEVideo storage did not answer. Retry in a moment.

Quotas and limits

LimitValue
File size200 MB
Video storageThe plan's video_storage_mb. Every unfinished upload's size is reserved, and a finished video counts until it is deleted
Videos per productThe plan's, 3 unless the plan says otherwise (10 at most)
Unfinished uploads and imports4 per key, 6 per store (uploading or still being checked)
Uploads started50 per store per UTC day (429 RATE_LIMITED, Retry-After to midnight UTC)
Imports started20 per key per UTC day, within the store's 50
Part URLsUp to 10 per call, each valid for 60 minutes
Unfinished uploadLapses a day after the last part-URL request, three days after the start at most
Import waiting to startOne hour; each company's imports are copied one at a time
Import download speedAt least 64 KB a second on average, after the first minute
Finished video on no productReleased 48 hours after it is ready (it stops counting against storage), removed 7 days later. Place it before then to keep it
CoversJPEG, 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 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.