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 optionThe 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
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_bytesis 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 answers403 PLAN_LIMIT_REACHEDbefore you send a byte.part_sizeis 5, 8 or 16 MiB (5242880,8388608or16777216). Every part except the last is exactly this long. The answer'spart_countsays how many parts there are.Idempotency-Keyis 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, anduploaded_partsas they were then). After that, the same key returns the upload withresumed: true. Either way, readGET /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_atis 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 becomesabortedand 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
headersgiven. Do not send yourAuthorizationheader, which is only for the API, and do not send aContent-Type. - Send exactly
content_lengthbytes. The length and the checksum are part of the signature, so storage refuses any other bytes. That refusal comes from storage (a400or403), 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-Keyonpart-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"
donecurl -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:
| 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. |
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:
// 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:
GET /videos/{id}lists the parts storage already holds inuploaded_parts.- Ask for URLs for the missing parts only, with the same checksums as before, and send them.
- 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.jpgA 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_slotis how many photos come before the video:0puts it first, and1(the default) right after the main photo.sort_orderorders the product's videos among themselves (default0).option_value_idshows it only for that value. The value must be one that the product's active variants use. Leave it out, or sendnull, to show it for every variant.titleis the video's accessible name. Without one, the product name is used.- You can place a video before it is ready. The answer's
statussays where it stands. The product shows it once it isready: invideos[]onGET /products/{id}and on the store's product page, and aproduct.updatedwebhook goes out for each product that shows it at that moment (so a consumer ofproduct.*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}answers404). 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:
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
422onsource_urlbefore 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
abortedwithexpired, 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
abortedwithcancelledand nothing is downloaded. Idempotency-Keyis required and names the import: the same key gives back the same video (within 24 hours as a replay of the first answer, then withresumed: true) and never imports the file twice. A retry is never refused by the daily import cap.- The file's name. Send
file_nameto name it. Without one, the video is named after the link's last path segment when that reads as a file name (such asred-saree-drape.mp4: at most 80 characters of lettersA–Z, digits, spaces and._-(), ending in.mp4or.m4v, with no long run of letters and digits that looks like a token), andvideo.mp4otherwise. 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
abortedwith one of these codes infailure.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 /videoslists the videos this key started. Another key's video, or one uploaded in the dashboard, answers404on every/videosroute and cannot be placed with this key (422onvideo_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[]onGET /products/{id}(underproducts:read) lists every ready video the product shows, withurl,poster_url, size, length,gallery_slotandoption_value_id. A store whose plan does not include product videos shows an empty list, as its product pages do. Any key withvideos:writecan take a video off a product, even while uploads are switched off. - Webhooks follow the product.
product.*payloads carry the samevideos[]. A product save sendsproduct.updatedas usual; a video turning ready, a ready video's cover changing, or a video being released from archived products sendsproduct.updatedfor 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'svideo.statusandplacement.statussay where the import is.get_productlists the video once it is ready.idempotency_keyis 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. optionnames the option value in words, as{ "attribute": "Colour", "value": "Red" }. This reads the product, so the key also needsproducts:read. A name that the product's active variants do not use fails with the product's options ascandidates, and nothing is placed.option_value_idtakes the id instead; with a link andproducts: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 sameidempotency_keyand 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.