Skip to main content
SocialCutter

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 storeSocialCutter destinationSize
Main product imageinstagram post1080x1080 (1:1)
Second product imageinstagram story1080x1920 (9:16)
Collection bannerfacebook post1200x630 (1.91:1)
Store headertwitter header1500x500 (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:

ScopeWhy you need it here
write_productsproductUpdate with media and productVariantsBulkUpdate
write_filesfileCreate, to create files on the Files page
read_productsOnly 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:

TypeUpload method
ImagesPUT to url, with the parameters as headers
Videos and 3D modelsPOST 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: productCreateMedia and productUpdateMedia still exist, but recent GraphQL Admin API versions mark them as deprecated. The product media documentation points to productUpdate, productSet or productCreate with the media argument 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

SymptomCauseFix
userErrors saying “Access denied”The scope was never granted at installAdd write_products or write_files and reinstall the app
THROTTLED in errorsYou exhausted the API bucket’s costApply backoff and space out the mutations
The file exists but is not visiblefileStatus is not READY yetIt is asynchronous: read it again after a few seconds
The main image is not the one you wantedOrder of the media listReorder with productReorderMedia
originalSource rejectedThe URL is not public or is not an imageCheck that it points at a SocialCutter output
401 from SocialCutterMissing, malformed or revoked keySend X-API-Key with an active sc_ key
429 from SocialCutterWallet quota exhaustedCheck GET /api/v1/credits or upgrade the plan

Next steps

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.