Use stagedUploadsCreate when the file lives on your disk, not at a public URL. The mutation hands back a signed url, a resourceUrl and a list of parameters. You PUT an image to the url with the parameters as headers, or POST a video as multipart form data with the file field last, then pass the resourceUrl to fileCreate as originalSource and poll fileStatus until it reads READY.
Everything below was written against Admin GraphQL API version 2026-10, released on 1 October 2026 according to Shopify’s versioning schedule. Re-check the enum values at each quarterly bump; one of them is already deprecated.
Why not pass a URL straight to fileCreate?
You can. fileCreate accepts any public originalSource URL and Shopify downloads it for you. For a one-off image on a CDN that is the whole job.
It falls apart in three situations. The file is on a laptop or a build server with no public URL. The file is large (the stagedUploadsCreate reference says anything over “a few MB” benefits from staging) and a flaky download turns into an IMAGE_DOWNLOAD_FAILURE on Shopify’s side, which you only learn about after the fact. Or you are importing a thousand images from a Magento export and the old host throttles Shopify’s fetcher. In each case the staged upload moves the transfer into your own process, where you can retry it and watch it fail in real time.
The same mechanism also backs two things that look unrelated: the JSONL file for bulkOperationRunMutation (resource: BULK_MUTATION_VARIABLES) and the CSV for urlRedirectImportCreate (resource: URL_REDIRECT_IMPORT). If you have read our post on exporting without throttling via bulk operations you have already met half of this flow.
What does stagedUploadsCreate actually return?
One target per input. The mutation takes a list of StagedUploadInput and returns a matching list of StagedMediaUploadTarget:
const STAGED_UPLOADS_CREATE = `#graphql
mutation StagedUploadsCreate($input: [StagedUploadInput!]!) {
stagedUploadsCreate(input: $input) {
stagedTargets {
url
resourceUrl
parameters { name value }
}
userErrors { field message }
}
}`;
async function admin<T>(query: string, variables: Record<string, unknown>): Promise<T> {
const res = await fetch(`https://${process.env.SHOP}/admin/api/2026-10/graphql.json`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Access-Token': process.env.ADMIN_TOKEN!,
},
body: JSON.stringify({ query, variables }),
});
const json = await res.json();
if (json.errors) throw new Error(JSON.stringify(json.errors));
return json.data as T;
}
Each input needs four things, and sometimes a fifth. filename and mimeType are strings. resource is a StagedUploadTargetGenerateUploadResource enum: IMAGE, VIDEO, MODEL_3D, FILE, COLLECTION_IMAGE, SHOP_IMAGE, BULK_MUTATION_VARIABLES, URL_REDIRECT_IMPORT, DISPUTE_FILE_UPLOAD or RETURN_LABEL. PRODUCT_IMAGE is still in the schema but marked deprecated, so use IMAGE. httpMethod is PUT or POST and defaults to PUT if you leave it out. fileSize is an UnsignedInt64, sent as a string, and the StagedUploadInput reference says it is required when resource is VIDEO or MODEL_3D.
Call it like this for one image and one video:
const { stagedUploadsCreate } = await admin<{
stagedUploadsCreate: { stagedTargets: Target[]; userErrors: { field: string[]; message: string }[] };
}>(STAGED_UPLOADS_CREATE, {
input: [
{ filename: 'hero.jpg', mimeType: 'image/jpeg', resource: 'IMAGE', httpMethod: 'PUT' },
{
filename: 'demo.mp4',
mimeType: 'video/mp4',
resource: 'VIDEO',
httpMethod: 'POST',
fileSize: String((await stat('demo.mp4')).size),
},
],
});
A target that fails validation comes back with url: null and an entry in userErrors whose field points at the offending index, for example ["input", "1", "mimeType"]. Check that list before you touch the targets.
How do you upload to the staged URL?
This is where most first attempts die, because the request shape depends on the media type and the docs show it as two curl commands without stating the rule. Shopify’s media guide shows images uploaded with a PUT, raw bytes in the body and the parameters sent as request headers. Videos and 3D models go up with a POST as multipart/form-data, every parameter as its own form field, and the file as the final field.
Order matters on the POST. Keep the file as the last form field, matching the guide’s curl example: the parameters authenticate the request, and the bucket needs to read them before the file bytes arrive.
import { readFile, stat } from 'node:fs/promises';
import { basename } from 'node:path';
type Target = {
url: string;
resourceUrl: string;
parameters: { name: string; value: string }[];
};
async function uploadToTarget(target: Target, path: string, method: 'PUT' | 'POST') {
const bytes = await readFile(path);
if (method === 'PUT') {
const headers = Object.fromEntries(target.parameters.map((p) => [p.name, p.value]));
const res = await fetch(target.url, { method: 'PUT', headers, body: bytes });
if (!res.ok) throw new Error(`Staged PUT failed: ${res.status} ${await res.text()}`);
return;
}
const form = new FormData();
for (const { name, value } of target.parameters) form.append(name, value);
form.append('file', new Blob([bytes]), basename(path));
const res = await fetch(target.url, { method: 'POST', body: form });
if (!res.ok) throw new Error(`Staged POST failed: ${res.status} ${await res.text()}`);
}
Do not add your own Content-Type header on the PUT. The parameters already carry one (content_type in the guide’s example, alongside acl). A second value that disagrees with the signed request is rejected by the storage bucket, not by Shopify, so the error body is XML from the bucket and there is no userErrors array to read.
Nothing has been created in Shopify yet. The bytes are sitting in storage behind the signed URL, and the resourceUrl is the handle to them.
How do you register the file and know it is ready?
fileCreate turns a staged resourceUrl into a File. Set contentType to IMAGE, VIDEO, MODEL_3D or FILE to match what you uploaded, and decide what happens on a filename clash with duplicateResolutionMode: APPEND_UUID, REPLACE or RAISE_ERROR. The fileCreate reference caps a single call at 250 files and requires the write_files scope (write_themes or write_images also unlock it).
const FILE_CREATE = `#graphql
mutation FileCreate($files: [FileCreateInput!]!) {
fileCreate(files: $files) {
files { id fileStatus }
userErrors { field message code }
}
}`;
const FILE_STATUS = `#graphql
query FileStatus($id: ID!) {
node(id: $id) {
... on File {
fileStatus
fileErrors { code message details }
preview { image { url } }
}
}
}`;
type FileNode = {
fileStatus: 'UPLOADED' | 'PROCESSING' | 'READY' | 'FAILED';
fileErrors: { code: string; message: string; details: string | null }[];
preview: { image: { url: string } | null } | null;
};
async function waitUntilReady(id: string, attempts = 20): Promise<FileNode> {
for (let i = 0; i < attempts; i++) {
const { node } = await admin<{ node: FileNode }>(FILE_STATUS, { id });
if (node.fileStatus === 'READY') return node;
if (node.fileStatus === 'FAILED') {
throw new Error(node.fileErrors.map((e) => `${e.code}: ${e.message}`).join('; '));
}
await new Promise((r) => setTimeout(r, 1500 * (i + 1)));
}
throw new Error(`File ${id} not READY after ${attempts} polls`);
}
The response to fileCreate usually reports PROCESSING. The guide’s examples do; the reference page’s example happens to show READY, so treat any of the four as possible on the first read. Processing is asynchronous, images are resized and videos transcoded, and the FileStatus enum walks UPLOADED, PROCESSING, READY, FAILED. The guide is explicit: do not attach a file to a product until it is READY.
When it does fail, look at fileErrors, not userErrors. The second only covers input validation on the mutation itself. The codes are specific enough to act on: INVALID_IMAGE_RESOLUTION, INVALID_IMAGE_FILE_SIZE, UNSUPPORTED_IMAGE_FILE_TYPE, VIDEO_MAX_DURATION_ERROR, FILE_STORAGE_LIMIT_EXCEEDED, DUPLICATE_FILENAME_ERROR. Log the code with the filename and most import failures become a one-line fix in the source data.
How do you attach the file to a product?
Not with productCreateMedia. That mutation is marked deprecated in 2026-10 and its reference page points you at productUpdate or productSet instead. Both take a files list, and for a file that already exists you reference it by id:
const ATTACH_MEDIA = `#graphql
mutation AttachMedia($input: ProductSetInput!) {
productSet(input: $input) {
product {
id
media(first: 10) { nodes { id alt mediaContentType } }
}
userErrors { field message }
}
}`;
await admin(ATTACH_MEDIA, {
input: {
id: 'gid://shopify/Product/1234567890',
files: [{ id: fileId, alt: 'Front view of the hero product' }],
},
});
If you are already upserting the catalogue with productSet, as in our productSet by custom ID post, the file step slots into the same payload. Variant images are a second hop: a variant can only point at media that is already on its product, so set mediaId through productVariantsBulkUpdate after the product has the file. Collections are different again. collectionUpdate wants an image src URL, not a file ID, so read preview.image.url from the ready file and pass that.
Which limits will you hit first?
The media guide publishes the ceilings, and they are lower than people expect. Images: 20 MB, 4472 by 4472 pixels (20 megapixels), aspect ratio between 100:1 and 1:100, in PNG, GIF, JPEG, WEBP or HEIC. Videos: 1 GB, ten minutes, 3840 by 2160, and an app may create 1,000 videos per store per week. 3D models: 500 MB as GLB or USDZ. A 6000 by 4000 export straight from a camera fails on resolution, not size, and the code you get back is INVALID_IMAGE_RESOLUTION. Resize before you stage, not after Shopify tells you.
Each stagedUploadsCreate and fileCreate call also spends query cost from the same budget as the rest of your Admin API traffic (see our GraphQL rate limits post). Batch the inputs (up to 250 per fileCreate) instead of looping one file per mutation.
What I would do
For anything a human uploads through your app, or any import over a few dozen files, stage the upload. Generate targets in batches, upload them concurrently with a small pool, register with one fileCreate per batch and poll each id until READY or FAILED. Keep fileErrors in your import log. Skip the whole thing for a single image that already lives on a CDN you trust: fileCreate with a URL is one call and Shopify does the fetch.
Whoooop builds Shopify apps and runs catalogue migrations where several thousand product images have to arrive intact, in order, with their alt text. If that is the job in front of you, our Shopify development service covers the import tooling as well as the storefront.