CMS and websites
Integrate SocialCutter with the Shopify Admin API
Attach SocialCutter sizes to Shopify products and variants with the Admin API. write_files and write_products scopes, a Node fetch snippet and version notes.
- Shopify
- Admin API
- GraphQL
- write_products
- write_files
- product images
- Node
Why a single master image
A product page lives in several places at once: the catalogue grid, the product page, the mobile view, the ad and the collection. Each slot wants a different ratio. Crop one copy per slot and you end up with duplicated files and an off-centre photo.
The flow is: one master goes in, SocialCutter returns every size, and Shopify gets the right one in each slot. The cover crop (the default mode) is centred: it scales and trims the excess evenly on both sides.
| Where it goes in the store | SocialCutter destination | Size |
|---|---|---|
| Main product image | instagram post | 1080x1080 (1:1) |
| Second product image | instagram story | 1080x1920 (9:16) |
| Collection banner | facebook post | 1200x630 (1.91:1) |
| Store header | twitter header | 1500x500 (3:1) |
Formats and sizes come from GET /api/v1/platforms, which is public.
A note on 4:5: SocialCutter’s destination catalogue does not include a 4:5 format today. The portrait options are 9:16 (
instagram story,tiktok cover, 1080x1920) and the square one is 1:1 (instagram post, 1080x1080). For a product page use 1:1 as the main image and 9:16 as the second; if you need exactly 4:5 you will have to crop outside SocialCutter.
Version and scopes
API version. The Admin API is versioned in the URL and each version lives for a year. Pin a specific version in every call:
https://your-store.myshopify.com/admin/api/2026-07/graphql.json
Shopify ships a new version every quarter and retires the old ones. Read the notes at https://shopify.dev/docs/api/versioning before upgrading and check that the argument names you rely on have not changed.
Scopes. A public or custom app declares its permissions in its configuration and receives them at install time:
| Scope | Why you need it here |
|---|---|
write_products | productUpdate with media and productVariantsBulkUpdate |
write_files | fileCreate, to create files on the Files page |
read_products | Only if you just read products |
Scopes are granted at install time, not per request. To see what an installation actually holds, query currentAppInstallation and its accessScopes field. Docs: https://shopify.dev/docs/api/usage/access-scopes
Authentication. The app token goes in the X-Shopify-Access-Token header. Reference: https://shopify.dev/docs/api/usage/authentication
export SHOP="your-store.myshopify.com"
export SHOPIFY_TOKEN="shpat_..."
export API_VERSION="2026-07"
export SC_KEY="sc_your_key"
1. Process the master with SocialCutter
curl -s -X POST "https://api.socialcutter.theboomer.dev/api/v1/images/process" \
-H "X-API-Key: *** \
-H "Content-Type: application/json" \
-d '{
"source": { "type": "url", "value": "https://your-cdn.com/master.jpg" },
"destinations": [
{ "platform": "instagram", "format": "post" },
{ "platform": "instagram", "format": "story" }
]
}' > sc.json
jq '.image_id, (.outputs[] | {url, platform, format})' sc.json
Every output is a public URL. fileCreate accepts URLs, so nothing has to be downloaded.
2. A GraphQL client in Node
Every mutation in this guide belongs to the GraphQL Admin API. A minimal fetch client (Node 18 or newer):
const SHOP = 'your-store.myshopify.com'
const VERSION = '2026-07'
const TOKEN = process.env.SHOPIFY_TOKEN
async function gql(query, variables = {}) {
const res = await fetch(`https://${SHOP}/admin/api/${VERSION}/graphql.json`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Access-Token': TOKEN
},
body: JSON.stringify({ query, variables })
})
const json = await res.json()
if (json.errors) throw new Error(JSON.stringify(json.errors))
return json.data
}
json.errors are GraphQL errors (malformed query, unknown field, throttling). Business failures arrive separately, in each mutation’s userErrors: you have to check both.
3. Create the files on the store
fileCreate takes several entries per call and returns one id per file. Processing is asynchronous: read fileStatus to know whether it finished.
const FILE_CREATE = `
mutation CreateFiles($files: [FileCreateInput!]!) {
fileCreate(files: $files) {
files { id fileStatus alt }
userErrors { field message }
}
}`
const { fileCreate } = await gql(FILE_CREATE, {
files: [
{ originalSource: square, contentType: 'IMAGE', alt: 'T-shirt, front view' },
{ originalSource: portrait, contentType: 'IMAGE', alt: 'T-shirt, detail' }
]
})
console.log(fileCreate.files, fileCreate.userErrors)
Requires write_files. Docs: https://shopify.dev/docs/api/admin-graphql/latest/mutations/fileCreate
Images by URL, without uploading the binary
Every SocialCutter output is already a public URL. In that case fileCreate downloads, processes and stores it for you: you do not need stagedUploadsCreate and never touch the binary. Just point originalSource at the SocialCutter URL.
When stagedUploadsCreate is needed
stagedUploadsCreate is the two-step flow for when the file is not at an accessible URL: it lives on your disk, on an unreliable network, or it is large and you want to upload it directly. It returns stagedTargets, each with url, resourceUrl and parameters:
const STAGED = `
mutation StagedUploads($input: [StagedUploadInput!]!) {
stagedUploadsCreate(input: $input) {
stagedTargets { url resourceUrl parameters { name value } }
userErrors { field message }
}
}`
const { stagedUploadsCreate } = await gql(STAGED, {
input: [{ filename: 'square.jpg', mimeType: 'image/jpeg', httpMethod: 'PUT', resource: 'IMAGE' }]
})
const target = stagedUploadsCreate.stagedTargets[0]
The upload to url differs by file type:
| Type | Upload method |
|---|---|
| Images | PUT to url, with the parameters as headers |
| Videos and 3D models | POST multipart to url |
After uploading the binary the file still does not exist for Shopify: you have to register it with fileCreate using resourceUrl as originalSource, which is the step above. For videos and 3D models the fileSize is required in the stagedUploadsCreate input; for images it is not.
4. Attach the media to the product
productUpdate accepts a media argument with the list of files added to the product. Order matters: Shopify uses the first entry as the main image. To reorder afterwards, productReorderMedia exists for that.
const PRODUCT_UPDATE = `
mutation AttachMedia($product: ProductUpdateInput!, $media: [CreateMediaInput!]) {
productUpdate(product: $product, media: $media) {
product { id media(first: 10) { nodes { id alt } } }
userErrors { field message }
}
}`
const PRODUCT_ID = 'gid://shopify/Product/108828309'
await gql(PRODUCT_UPDATE, {
product: { id: PRODUCT_ID },
media: [
{ originalSource: square, contentType: 'IMAGE', alt: 'T-shirt, front view' },
{ originalSource: portrait, contentType: 'IMAGE', alt: 'T-shirt, detail' }
]
})
Requires write_products. Docs: https://shopify.dev/docs/api/admin-graphql/latest/mutations/productUpdate
Version warning:
productCreateMediaandproductUpdateMediastill exist, but recent GraphQL Admin API versions mark them as deprecated. The product media documentation points toproductUpdate,productSetorproductCreatewith themediaargument instead. If your integration uses the old ones, plan the migration.
5. Associate the media with the variants
So the variant selector shows the right image, each variant is tied to a specific media through mediaId (or mediaSrc). The mutation is productVariantsBulkUpdate and it also requires write_products.
const VARIANT_MEDIA = `
mutation AttachVariantMedia($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
productVariantsBulkUpdate(productId: $productId, variants: $variants) {
productVariants { id }
userErrors { field message }
}
}`
await gql(VARIANT_MEDIA, {
productId: PRODUCT_ID,
variants: [
{ id: 'gid://shopify/ProductVariant/43729076', mediaId: 'gid://shopify/MediaImage/1234' }
]
})
Docs: https://shopify.dev/docs/api/admin-graphql/latest/mutations/productVariantsBulkUpdate and https://shopify.dev/docs/api/admin-graphql/latest/input-objects/ProductVariantsBulkInput
Cost
- 1 use per destination (platform and format) per request.
- Repeated destinations in the same request are not charged twice.
- Failed processings are refunded.
- Plans include API and MCP: Free 3 uses/day, Basic 10, Pro 30, Agency 100.
Common errors
| Symptom | Cause | Fix |
|---|---|---|
userErrors saying “Access denied” | The scope was never granted at install | Add write_products or write_files and reinstall the app |
THROTTLED in errors | You exhausted the API bucket’s cost | Apply backoff and space out the mutations |
| The file exists but is not visible | fileStatus is not READY yet | It is asynchronous: read it again after a few seconds |
| The main image is not the one you wanted | Order of the media list | Reorder with productReorderMedia |
originalSource rejected | The URL is not public or is not an image | Check that it points at a SocialCutter output |
401 from SocialCutter | Missing, malformed or revoked key | Send X-API-Key with an active sc_ key |
429 from SocialCutter | Wallet quota exhausted | Check GET /api/v1/credits or upgrade the plan |
Next steps
- WordPress and WooCommerce: Integrate SocialCutter with WordPress and WooCommerce
- ERPNext: Normalize catalogue images in ERPNext
- MCP: Use SocialCutter from your LLM or editor with MCP
- Python: Automate SocialCutter with Python
- Automation: Automate image resizing with n8n
Frequently asked questions
Which Admin API version should I use?
The one you pin in the URL, for example /admin/api/2026-07/graphql.json. Shopify ships a new version every quarter and retires old ones, so pin a specific version and upgrade it on purpose after reading the release notes.
Which scopes does the app need?
write_products to attach media to a product and update variants, and write_files to create files on the Files page. If you only read, read_products is enough. Scopes are granted at install time: check what an installation holds with the currentAppInstallation query.
How do I authenticate?
With the Admin API access token of a custom or public app, sent in the X-Shopify-Access-Token header. Every request goes to the store's .myshopify.com domain.
Does fileCreate accept a URL, or must I upload the binary?
It accepts a public URL in originalSource, so you can pass a SocialCutter output straight through. If the file only exists on your disk, use stagedUploadsCreate: it returns a url with its parameters plus a resourceUrl, you upload the binary to that url (a PUT for images) and pass the resourceUrl as fileCreate's originalSource.
Should I still use productCreateMedia?
Better not. Recent GraphQL Admin API versions mark productCreateMedia and productUpdateMedia as deprecated; the product media documentation points to productUpdate, productSet or productCreate with the media argument instead.
What does it cost to process one product image?
1 use per destination, meaning per platform and format pair. Asking for Instagram post and Instagram story from the same master spends 2 uses.