# 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.
- URL: https://socialcutter.theboomer.dev/en/guides/shopify/
- Idioma: en
- Familia: cms
- Actualizado: 2026-09-24
- Palabras clave: 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

```bash
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

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

```js
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.

```js
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`:

```js
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.

```js
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`.

```js
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](/en/guides/wordpress/)
- ERPNext: [Normalize catalogue images in ERPNext](/en/guides/erpnext/)
- MCP: [Use SocialCutter from your LLM or editor with MCP](/en/guides/mcp/)
- Python: [Automate SocialCutter with Python](/en/guides/python/)
- Automation: [Automate image resizing with n8n](/en/guides/n8n/)