# Shopify Product Images: Find, Scrape and Optimise Manufacturer Images With AI (+ Free n8n Template)

> Find manufacturer photos for your Shopify product images with n8n, check them with a vision model, approve in Slack and fix weak alt text weekly.

*Last tested on n8n 2.46, November 2026.*

Resellers who import supplier feeds end up with **Shopify product images** that are small, mismatched in shape, stamped with distributor watermarks, or missing altogether, while their alt text sits empty or repeats the product title word for word. Installing a resize app, running an AI alt text generator, or editing photos by hand only tidies up the files already on your store, because none of those tools goes out to find better photos when the supplier feed only gave you a small thumbnail. By the end of this guide, you'll have an n8n workflow that locates the manufacturer's own high-resolution gallery, filters out banners and watermarks with a vision model, pads every approved shot onto a clean 2048 x 2048 square with descriptive alt text, and repairs weak alt text across the images you already have.

<TemplateCallout
  repo="https://github.com/reganmcgregor/n8n-ecommerce-templates/tree/main/product-images"
  title="Shopify product images template for n8n"
>

- Finds each product's page on the manufacturer's site, scrapes its gallery, checks the best shots with a vision model, and writes unique alt text per image.
- Sends one numbered Slack review card per product, then pads approved photos to 2048 x 2048 white squares and uploads them to Shopify with readable file names.
- Runs a weekly audit that rewrites empty, duplicate, or filename-style alt text across your existing catalogue.

</TemplateCallout>

## What you'll build

When you tag a product `needs-images`, the workflow finds its page on the manufacturer's domain, measures the real pixel dimensions of every photo on the page, and asks a vision model to check the top candidates and write alt text for each shot. A single review card arrives in Slack showing a numbered contact sheet of the surviving photos, tick boxes for which ones to keep, and a radio button for the main image.

![The Slack review card for the TP-Link Tapo C232: numbered contact sheet, Images to add tick boxes, Main image choice, Approve and Reject](./_attachments/shopify-product-images/review-card.png "The Slack review card for the TP-Link Tapo C232: numbered contact sheet, Images to add tick boxes, Main image choice, Approve and Reject")

Clicking **Approve** squares every chosen photo onto a white 2048 x 2048 canvas, names the file after the product, uploads it to Shopify with its alt text, waits until Shopify finishes processing the file, and updates the main image. On a store I run, about half the products this method looked at got better manufacturer images (1,307 pushed against 1,271 where nothing better than the existing image turned up), while the rest already had the best image available. A separate weekly lane fixes alt text on the rest of your catalogue.

![The full canvas: Setup, Find, Review, Publish (with the contact sheet helper row) and Alt text lanes, overview note top left](./_attachments/shopify-product-images/template-canvas.png "The full canvas: Setup, Find, Review, Publish (with the contact sheet helper row) and Alt text lanes, overview note top left")

## What you need

- **n8n** (self-hosted Community Edition or n8n Cloud) with [Data tables](https://docs.n8n.io/build/work-with-data/data-tables/) enabled.
- **A custom Shopify app** with `read_products`, `write_products`, `read_files`, and `write_files` scopes (the last two handle alt text updates and detaching old images). The repository's `SETUP.md` covers creating the app and configuring the `Get Shopify token` node.
- **An Ollama Cloud API key** running [`gemma4:31b`](https://ollama.com/library/gemma4/tags), or any vision-capable chat model sub-node.
- **A Slack app** with interactivity switched on and the `chat:write` and `files:write` scopes (the second one uploads the contact sheets).
- **Optional: DataForSEO** credentials if you want the workflow to search Google for manufacturer pages by model number when no URL is stored on the product.
- **[wsrv.nl](https://wsrv.nl/docs/)**, which is free and requires no account.
- **Running cost:** about US$0.70 per 500 products for the vision model across both lanes at list price, plus DataForSEO if you turn it on (measured November 2026).

## How it works

1. **Setup** (Manual Trigger, run once): creates four Data Tables and seeds your initial list of brand domains.
2. **Find** (Schedule Trigger every hour, plus a Manual Trigger): picks up to 10 products tagged `needs-images`, locates each product's page on the manufacturer's site, scrapes and ranks the gallery images by real pixel dimensions, checks every ranked shot with the vision model while writing its alt text, and posts one Slack card per product for a person to review.
3. **Review** (Webhook from Slack interactivity): verifies the Slack signature and records your tick-box choices and main-image selection when you click **Approve** or **Reject**, or processes the weekly alt text digest when you click **Approve all** or **Skip**, then triggers the Publish lane immediately on approval.
4. **Publish** (When Executed by Another Workflow, plus an hourly Schedule Trigger as a backstop): pads each approved image onto a white 2048 x 2048 square, names the file after the product, adds it to Shopify with its alt text, waits until Shopify marks each image `READY`, sets the main image, detaches the old image if you unticked it (or deletes it if Shopify could never load it), and replies in the Slack thread. The same trigger also runs the contact sheet helper for the Find lane.
5. **Alt text** (Schedule Trigger weekly): scans the products in your `product_query` (active products by default) for images whose alt text is empty, matches the product title, looks like a file name, or repeats across the same product, generates new alt text with the vision model, and applies the changes via `fileUpdate` after you approve the weekly Slack digest (or straight away if you turn on `auto_apply`).

The workflow splits finding from publishing because scraping manufacturer pages and running vision checks is slow and cheap to repeat, whereas writing to your Shopify store should only happen after a person has seen the contact sheet and approved the images.

## Step 1: Create the tables and settings

The Setup lane builds the four Data Tables that track queued products, uploaded files, brand websites, and alt text batches. Storing a hash of each uploaded source URL in `image_log` is what makes retries safe, because the Publish lane can check which manufacturer photos are already attached to a product without hiding tracking codes inside the alt text where screen readers would read them out.

In the Setup lane, add a **Manual Trigger**, connect a **Set** node named `Setup settings` (JSON mode), and connect four **Data table** nodes set to **Table > Create** with **Reuse Existing Tables** toggled on so running Setup a second time does no harm:

| Table | Columns (String unless noted) |
| --- | --- |
| `image_queue` | `product_id`, `title`, `vendor`, `mpn`, `page_url`, `status` (`review`, `skipped`, `approved`, `rejected`, `pushing`, `pushed`, `failed`, `dry_run`), `candidates` (JSON as text), `current` (JSON as text), `remove_current` (Boolean), `card_channel`, `card_ts`, `error`, `report`, `updated_at` (Date) |
| `image_log` | `product_id`, `source_hash`, `media_id`, `filename`, `alt`, `status`, `pushed_at` (Date) |
| `brand_domains` | `brand` (the vendor as Shopify has it), `domain`, `url_pattern` (optional, with `{model}` where the model slug goes) |
| `alt_queue` | `media_id`, `product_id`, `title`, `image_url`, `old_alt`, `new_alt`, `problems`, `batch_id`, `status` (`pending`, `applied`, `skipped`, `failed`), `updated_at` (Date) |

![Setup lane, a Data table node set to Table > Create with the image_queue columns and Reuse Existing Tables on](./_attachments/shopify-product-images/setup-data-tables.png "Setup lane, a Data table node set to Table > Create with the image_queue columns and Reuse Existing Tables on")

A single brand can use more than one website, such as TP-Link hosting its commercial VIGI cameras on `vigi.com` while serving the image files from `static.tp-link.com`, so `brand_domains` allows multiple rows per vendor. Paste this JSON into `Setup settings`:

```json
{
  "brand_domains": [
    {
      "brand": "TP-Link",
      "domain": "tp-link.com",
      "url_pattern": ""
    },
    {
      "brand": "TP-Link",
      "domain": "vigi.com",
      "url_pattern": ""
    },
    {
      "brand": "Samsung",
      "domain": "samsung.com",
      "url_pattern": ""
    }
  ]
}
```

Connect a **Code** node named `Seed brand domains` after the table creation nodes, followed by a **Data table** node set to **Row > Upsert** into `brand_domains` matching on `brand` and `domain`:

```javascript
return $('Setup settings').first().json.brand_domains.map(b => ({ json: {
  brand: b.brand, domain: String(b.domain).toLowerCase().replace(/^www\./, ''), url_pattern: b.url_pattern || ''
} }));
```

Run the Setup lane once and check that all four tables appear under **Data tables** in your n8n sidebar, with three rows inside `brand_domains`.

Next, place a **Set** node named `Find settings` (JSON mode) at the start of the Find lane so every threshold, metafield key, and Slack channel lives in one place:

```json
{
  "store_domain": "your-store.myshopify.com",
  "admin_store_handle": "your-store",
  "pick_tag": "needs-images",
  "batch_size": 10,
  "min_short_side": 500,
  "fallback_min_side": 250,
  "max_candidates": 8,
  "vision_top_n": 8,
  "vision_model": "gemma4:31b",
  "max_alt_length": 125,
  "mpn_namespace": "custom",
  "mpn_key": "mpn",
  "page_namespace": "custom",
  "page_key": "manufacturer_url",
  "use_dataforseo": false,
  "search_location": "United States",
  "language_code": "en",
  "user_agent": "YourStoreImageBot/1.0 (+https://your-store.com)",
  "strip_query_params": "content_token",
  "slack_channel": "#review-images"
}
```

At the head of the Publish lane, add a **Set** node named `Publish settings` in manual mode with `dry_run` enabled so you can inspect the generated URLs before writing anything to Shopify:

| Setting | Value |
| --- | --- |
| `store_domain` | `your-store.myshopify.com` |
| `admin_store_handle` | `your-store` (the part after `admin.shopify.com/store/`) |
| `image_size` | `2048` |
| `pad_colour` | `white` |
| `max_wait_seconds` | `60` |
| `dry_run` | `true` (until the first dry-run report looks right) |
| `product_id` | `{{ $json.product_id \|\| '' }}` |

## Step 2: Pick the products that need images

Every lane that talks to Shopify starts with the `Get Shopify token` **HTTP Request** node described in the repo's `SETUP.md`, and every Shopify GraphQL call in this workflow uses an **HTTP Request** node configured with `POST https://{{ $('<Lane> settings').first().json.store_domain }}/admin/api/2026-10/graphql.json`, a header of `X-Shopify-Access-Token` set to `{{ $('Get Shopify token').first().json.access_token }}`, and a JSON body containing `query` and `variables`.

In the Find lane, wire a **Schedule Trigger** (every hour) and a **Manual Trigger** into `Find settings`, connect `Get Shopify token`, and add an **HTTP Request** node named `Get products to check` that pulls up to 50 products carrying your `needs-images` tag along with their MPN and manufacturer URL metafields:

```graphql
query ProductsNeedingImages($q: String!, $n: Int!, $ns: String!, $key: String!, $pns: String!, $pkey: String!) {
  products(first: $n, query: $q) {
    nodes {
      id
      title
      vendor
      mpn: metafield(namespace: $ns, key: $key) { value }
      page: metafield(namespace: $pns, key: $pkey) { value }
      variants(first: 1) { nodes { sku barcode mpn: metafield(namespace: $ns, key: $key) { value } } }
      media(first: 20) {
        nodes { id status alt mediaContentType preview { image { url width height } } }
      }
    }
  }
}
```

Set the `variables` expression on `Get products to check` to read from `Find settings`:

```javascript
{{ (s => ({ q: 'tag:' + s.pick_tag, n: 50, ns: s.mpn_namespace, key: s.mpn_key, pns: s.page_namespace, pkey: s.page_key }))($('Find settings').first().json) }}
```

Connect a **Data table** node named `Brand domains` (**Row > Get** on `brand_domains` with **Return All**, **Always Output Data**, and **Execute Once** on), which reads every brand website once per run, then a **Data table** node named `Open rows` (set to **Row > Get** on `image_queue`, **Any Condition** where `status` Equals `review`, `status` Equals `approved`, or `status` Equals `pushing`, with **Return All**, **Always Output Data**, and **Execute Once** switched on), followed by a **Code** node named `Pick the batch`. Capping the batch at 10 products an hour keeps vision model calls and Slack review cards arriving at a pace a person can comfortably check:

```javascript
const s = $('Find settings').first().json;
const products = $('Get products to check').first().json.data.products.nodes;
// A product that already has a card waiting, or images on their way, is left alone.
const open = new Set($input.all().map(i => i.json.product_id).filter(Boolean));
const out = [];
for (const p of products) {
  if (open.has(p.id)) continue;
  const v = p.variants.nodes[0] || {};
  const images = p.media.nodes.filter(m => m.mediaContentType === 'IMAGE');
  out.push({ json: {
    product_id: p.id, title: p.title, vendor: p.vendor,
    mpn: (p.mpn || v.mpn || {}).value || '',
    gtin: v.barcode || '', sku: v.sku || '',
    page_url: (p.page || {}).value || '',
    current: images.map(m => ({ id: m.id, status: m.status, alt: m.alt || '', url: m.preview && m.preview.image ? m.preview.image.url : '' })),
    // A product whose only image FAILED (a dead supplier link) counts as having none.
    no_image: images.filter(m => m.status !== 'FAILED').length === 0
  } });
  if (out.length >= s.batch_size) break;
}
return out;
```

Connect a **Loop Over Items** node named `Loop over products` with **Batch Size** set to `1`, because Steps 3 to 7 run per product so each vision check and Slack card handles one product's gallery at a time. Tag three test products `needs-images` in Shopify, run the Find lane by hand, and verify that `Pick the batch` outputs three items.

## Step 3: Find the manufacturer's product page

The manufacturer's own product page is the best place to get product photos because it carries every angle the brand shot at full resolution without the compression or watermarks added by other retailers. Inside the loop (on its **loop** output), add a **Code** node named `Pick the page` that picks this vendor's rows out of `Brand domains` (ignoring case, because suppliers write `TP-Link` and `TP-LINK` alike), checks the product's `manufacturer_url` metafield first, falls back to the brand's `url_pattern` second, and prepares an optional DataForSEO search third:

```javascript
const s = $('Find settings').first().json;
const p = $('Loop over products').first().json;
const vendor = String(p.vendor || '').trim().toLowerCase();
const brands = $('Brand domains').all().map(i => i.json).filter(b => b.domain && String(b.brand || '').trim().toLowerCase() === vendor);
const model = String(p.mpn || '').trim();
const slug = model.toLowerCase().replace(/\(.*?\)/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
// 1. a manufacturer_url metafield, 2. the brand's URL pattern, 3. a search (optional)
let page_url = p.page_url;
if (!page_url && slug) {
  const b = brands.find(x => x.url_pattern);
  if (b) page_url = b.url_pattern.replace('{model}', slug);
}
return [{ json: { ...p, page_url,
  brand_domains: brands.map(b => b.domain.toLowerCase()),
  search: !page_url && s.use_dataforseo === true && !!model && brands.length > 0,
  // Brand and model, no site: operator: DataForSEO bills operator queries at five times the price, and Match the page keeps brand domains only.
  query: (code => code.toLowerCase().startsWith(vendor) ? code : p.vendor + ' ' + code)(model.replace(/\(.*?\)/g, '').trim()) } }];
```

Connect an **If** node named `Search for the page?` checking whether `{{ $json.search }}` is true, and on its **true** branch add a DataForSEO **HTTP Request** node:

| Setting | Value |
| --- | --- |
| Method | POST |
| URL | `https://api.dataforseo.com/v3/serp/google/organic/live/advanced` |
| Authentication | Generic Credential Type > Basic Auth (your DataForSEO API login and password) |
| Send Body | JSON |
| Options > Response > Never Error | On |
| Settings > On Error | Continue |

The query is just the brand and model, without a `site:` filter: in testing, Google ignored `site:` filters joined with `OR`, and DataForSEO billed a query with an operator at US$0.01 against US$0.002 for a plain one. Set the JSON body on the DataForSEO node (named `Search Google`):

```javascript
{{ JSON.stringify([{
  keyword: $json.query,
  location_name: $('Find settings').first().json.search_location,
  language_code: $('Find settings').first().json.language_code,
  depth: 10
}]) }}
```

Connect a **Code** node named `Match the page` after the DataForSEO request so it checks that the whole model token appears in the URL path or title on one of the brand's registered domains, which stops a base model like `C440` from matching the wireless `C440-W` variant on `vigi.com`. DataForSEO occasionally answers with a task error (`40101`) inside an HTTP 200, so the node records it and the product keeps its tag for the next run:

```javascript
const p = $('Pick the page').first().json;
const task = ($json.tasks || [])[0] || {};
const items = ((task.result || [])[0] || {}).items || [];
// DataForSEO sometimes answers a task with an error over HTTP 200 (40101 "Internal SE Server Error"): try again next run.
const search_error = task.status_code === 20000 ? '' : String(task.status_message || $json.status_message || ($json.error || {}).message || 'search failed');
const norm = t => String(t || '').toUpperCase().replace(/\+/g, 'PLUS');
const compact = t => norm(t).replace(/[^A-Z0-9]/g, '');
const code = norm(p.mpn).replace(/\(.*?\)/g, '').trim();
const esc = t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const inTitle = t => new RegExp('(^|[^A-Z0-9])' + esc(code) + '($|[^A-Z0-9])').test(norm(t));
const inPath = u => u.replace(/^https?:\/\/[^\/]+/i, '').split(/[\/?#.]/).map(compact).includes(compact(code));
const host = u => ((u.match(/^https?:\/\/([^\/?#]+)/i) || [])[1] || '').toLowerCase();
const onBrand = u => p.brand_domains.some(d => host(u) === d || host(u).endsWith('.' + d));
const hit = items.find(i => i.type === 'organic' && i.url && onBrand(i.url) && (inPath(i.url) || inTitle(i.title)));
return [{ json: { ...p, page_url: hit ? hit.url : '', search_error, search_cost: $json.cost || 0 } }];
```

Check the output to confirm `page_url` is populated for each test product; if one comes back blank, either add a `url_pattern` for that vendor or check that all of its regional or sub-brand domains are listed in `brand_domains`. Expect the search to miss more often than it used to, because Google now wraps many result links in encrypted `google.com/goto` redirects that can't be matched to a brand domain; in testing, a search for the VIGI C440-W returned mostly redirects and retailers, so a `manufacturer_url` metafield or a brand `url_pattern` is the reliable route.

## Step 4: Scrape the images from the manufacturer's page

Wire both the **false** branch of `Search for the page?` and the output of `Match the page` into a **Code** node named `Read the manufacturer page`. This node respects [RFC 9309 `robots.txt`](https://www.rfc-editor.org/rfc/rfc9309) rules before fetching the HTML, then extracts `og:image`, any JSON-LD `Product.image` entries, and the largest `srcset` URL from every `<img>` tag while keeping only images hosted on the brand's own domains or subdomains:

```javascript
const s = $('Find settings').first().json;
const p = $input.first().json;
const out = { ...p, page_images: [], page_note: '' };
if (!p.page_url) { out.page_note = p.search_error ? 'search failed: ' + p.search_error + ', trying again next run' : 'no manufacturer page'; return [{ json: out }]; }
const m = /^(https?:\/\/([^\/?#]+))([^?#]*)/i.exec(p.page_url);
if (!m) { out.page_note = 'bad page url'; return [{ json: out }]; }
const get = (url, timeout = 30000) => this.helpers.httpRequest({ url, timeout, json: false,
  headers: { 'User-Agent': s.user_agent }, returnFullResponse: true, ignoreHttpStatusErrors: true });

// 1. robots.txt (RFC 9309): 4xx means no rules, 5xx or unreachable means stay away, longest match wins
let rules = [];
try {
  const r = await get(m[1] + '/robots.txt', 10000);
  if (r.statusCode >= 500) rules = [{ allow: false, path: '/' }];
  else if (r.statusCode < 400) {
    let mine = false, inRules = false;
    for (const raw of String(r.body || '').split(/\r?\n/)) {
      const line = raw.replace(/#.*/, '').trim();
      const i = line.indexOf(':'); if (i < 0) continue;
      const k = line.slice(0, i).trim().toLowerCase(), v = line.slice(i + 1).trim();
      if (k === 'user-agent') { if (inRules) { mine = false; inRules = false; } mine = mine || v === '*'; }
      else if (k === 'allow' || k === 'disallow') { inRules = true; if (mine && v) rules.push({ allow: k === 'allow', path: v }); }
    }
  }
} catch (e) { rules = [{ allow: false, path: '/' }]; }
const test = (rule, path) => { const end = rule.endsWith('$'); const body = end ? rule.slice(0, -1) : rule;
  return new RegExp('^' + body.split('*').map(x => x.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*') + (end ? '$' : '')).test(path); };
let best = null;
for (const r of rules) if (test(r.path, m[3] || '/') && (!best || r.path.length > best.path.length || (r.path.length === best.path.length && r.allow))) best = r;
if (best && !best.allow) { out.page_note = 'robots.txt disallows ' + best.path; return [{ json: out }]; }

// 2. the page
let html = '';
try { const r = await get(p.page_url); if (r.statusCode < 400) html = String(r.body || ''); else out.page_note = 'page returned ' + r.statusCode; }
catch (e) { out.page_note = 'page fetch failed'; }

// 3. og:image, JSON-LD Product.image, then <img> (largest srcset entry first)
const abs = u => { u = String(u || '').trim().replace(/&amp;/g, '&'); if (u.startsWith('//')) return 'https:' + u; if (u.startsWith('/')) return m[1] + u; return u; };
const found = [];
const add = (u, alt, how) => { u = abs(u); if (/^https?:\/\//i.test(u)) found.push({ url: u, alt: String(alt || '').slice(0, 200), how }); };
for (const x of html.matchAll(/<meta[^>]+(?:property|name)=["']og:image(?::secure_url)?["'][^>]*>/gi)) add((x[0].match(/content=["']([^"']+)/i) || [])[1], 'og:image', 'og');
for (const x of html.matchAll(/<script[^>]+application\/ld\+json[^>]*>([\s\S]*?)<\/script>/gi)) {
  let data; try { data = JSON.parse(x[1]); } catch (e) { continue; }
  for (const n of [].concat(data['@graph'] || data)) {
    if (!n || !String(n['@type'] || '').includes('Product')) continue;
    for (const im of [].concat(n.image || [])) add(typeof im === 'string' ? im : (im.contentUrl || im.url), n.name, 'jsonld');
  }
}
for (const x of html.matchAll(/<img\b[^>]*>/gi)) {
  const tag = x[0];
  const alt = (tag.match(/\balt=["']([^"']*)/i) || [])[1] || '';
  const srcset = (tag.match(/\b(?:data-)?srcset=["']([^"']+)/i) || [])[1];
  if (srcset) {
    const big = srcset.split(',').map(e => e.trim().split(/\s+/)).map(([u, w]) => ({ u, w: parseInt(w, 10) || 0 })).sort((a, b) => b.w - a.w)[0];
    if (big && big.u) { add(big.u, alt, 'img'); continue; }
  }
  add((tag.match(/\b(?:data-src|src)=["']([^"']+)/i) || [])[1], alt, 'img');
}

// 4. the brand's own domains and their CDNs only, never a retailer
const host = u => ((u.match(/^https?:\/\/([^\/?#]+)/i) || [])[1] || '').toLowerCase();
const pageDomain = m[2].toLowerCase().replace(/^www\./, '');
const own = u => p.brand_domains.concat(pageDomain).some(d => host(u) === d || host(u).endsWith('.' + d));
const seen = new Set();
out.page_images = found.filter(f => own(f.url) && !seen.has(f.url) && seen.add(f.url)).slice(0, 40);
if (!out.page_note && !out.page_images.length) out.page_note = 'no images on the brand page';
return [{ json: out }];
```

Check the output on the **TP-Link Tapo C232** (`https://www.tp-link.com/au/home-networking/cloud-camera/tapo-c232/`): `page_images` should contain its `og:image` along with its gallery shots hosted on `static.tp-link.com` in `_large`, `_normal`, and `_thumb` sizes. Even when the **TP-Link VIGI C440-W (4mm)** redirects from `tp-link.com/au/business-networking/vigi-network-camera/vigi-c440-w/` to `vigi.com`, its images on `static.tp-link.com` and `static-product.tp-link.com` pass the domain filter because both `tp-link.com` and `vigi.com` are in `brand_domains`. If a brand's site answers `page returned 403`, it is refusing your `user_agent`: some sites, such as navman.com.au, only answer a plain one like `curl/8.7.1`.

## Step 5: Rank the candidates

Manufacturer pages often include three sizes of every gallery angle alongside icons, social buttons, and wide feature banners. Connect a **Code** node named `Rank candidates` to strip access tokens from URLs, drop UI icons, collapse duplicate sizes of the same photo so `_large` beats `_normal` and `_thumb` (and Magento's `/cache/<hash>/` copies collapse into the original), and read the real pixel width and height from the first 64 KB of each file header via a ranged HTTP GET.

Scoring gives a base of 65 points to `og:image` and JSON-LD images and 50 to standard `<img>` tags, adds 20 points when the model code appears in the file name or alt attribute, adds up to 40 points for the short side (1 point per 40 px up to 1600 px), subtracts 30 points when one side is more than 2.5 times the other, and subtracts 10 points if the dimensions cannot be read. Any image whose short side is under `min_short_side` (500 px, which matches [Google Merchant Center's minimum image size](https://support.google.com/merchants/answer/6324350) for all products from 31 January 2027) is dropped unless the product currently has no working image at all, in which case anything from `fallback_min_side` (250 px) up stays, so a 60 px menu icon never makes the card:

```javascript
const s = $('Find settings').first().json;
const p = $input.first().json;
const norm = t => String(t || '').toLowerCase().replace(/[^a-z0-9]/g, '');
// Access tokens (such as content_token) come off every URL before it's stored or shown.
// Plain string replace: the Code node sandbox has no URL class.
const keys = String(s.strip_query_params || '').split(',').map(k => k.trim()).filter(Boolean);
const strip = u => { let x = String(u); for (const k of keys) x = x.replace(new RegExp('([?&])' + k.replace(/[^\w-]/g, '') + '=[^&#]*&?', 'g'), '$1'); return x.replace(/[?&]$/, ''); };
const JUNK = /(logo|favicon|sprite|icon|banner|header|footer|nav[-_]|megamenu|social|facebook|twitter|instagram|youtube|linkedin|badge|award|flag|payment|cookie|placeholder|spacer|loading|spinner|arrow|chevron|avatar|swatch|watermark|newsletter|qr[-_]?code|app[-_]?store|google[-_]?play|\.svg(\?|$)|data:image)/i;
const ICON = /[-_](16|24|32|48|64|96|128)\.(png|jpe?g|webp|gif)($|[?])/i;

// Real dimensions from the file header (a ranged GET), never from a size in the URL.
// 404 or 410: gone, drop it. Anything else that fails (a 403 hotlink guard, a timeout) keeps the image with size unknown.
const dims = async (url) => {
  try {
    const raw = await this.helpers.httpRequest({ url, headers: { Range: 'bytes=0-65535', 'User-Agent': s.user_agent }, encoding: 'arraybuffer', timeout: 15000 });
    const b = Buffer.from(raw);
    if (b.length > 24 && b[0] === 0x89 && b[1] === 0x50) return { w: b.readUInt32BE(16), h: b.readUInt32BE(20) };
    if (b.length > 10 && b.toString('ascii', 0, 4) === 'GIF8') return { w: b.readUInt16LE(6), h: b.readUInt16LE(8) };
    if (b.length > 30 && b.toString('ascii', 0, 4) === 'RIFF' && b.toString('ascii', 8, 12) === 'WEBP') {
      const c = b.toString('ascii', 12, 16);
      if (c === 'VP8 ') return { w: b.readUInt16LE(26) & 0x3fff, h: b.readUInt16LE(28) & 0x3fff };
      if (c === 'VP8L') return { w: 1 + (((b[22] & 0x3f) << 8) | b[21]), h: 1 + (((b[24] & 0x0f) << 10) | (b[23] << 2) | ((b[22] & 0xc0) >> 6)) };
      if (c === 'VP8X') return { w: 1 + b.readUIntLE(24, 3), h: 1 + b.readUIntLE(27, 3) };
    }
    if (b[0] === 0xff && b[1] === 0xd8) {
      let i = 2;
      while (i + 9 < b.length) {
        if (b[i] !== 0xff) { i++; continue; }
        const mk = b[i + 1];
        if (mk >= 0xc0 && mk <= 0xcf && mk !== 0xc4 && mk !== 0xc8 && mk !== 0xcc) return { w: b.readUInt16BE(i + 7), h: b.readUInt16BE(i + 5) };
        const len = b.readUInt16BE(i + 2); if (len < 2) break;
        i += 2 + len;
      }
    }
    return {};
  } catch (e) {
    const code = Number(e && (e.httpCode || e.statusCode || (e.response && e.response.status))) || 0;
    return { dead: code === 404 || code === 410 };
  }
};

const cands = [];
for (const c of p.page_images || []) {
  if (JUNK.test(c.url) || ICON.test(c.url) || JUNK.test(c.alt)) continue;
  cands.push({ url: strip(c.url), source: c.how === 'img' ? 'page' : 'page_' + c.how, alt: c.alt, base: c.how === 'img' ? 50 : 65 });
}
// The same shot in several sizes or formats collapses to one (the large copy wins); numbered angles (-1, -2) stay.
const hint = u => /(large|zoom|big|original|full)/i.test(u) ? 0 : /(thumb|small|mini|tiny)/i.test(u) ? 2 : 1;
cands.sort((a, b) => hint(a.url) - hint(b.url));
const key = u => u.split('?')[0].toLowerCase()
  .replace(/\/cache\/[0-9a-f]{32}\//, '/')
  .replace(/[-_]?\d{2,4}[x×]\d{2,4}/g, '')
  .replace(/[-_](large|medium|normal|small|thumb|thumbnail|zoom|big)(?=[-_.])/g, '')
  .replace(/[-_]\d{8,}[a-z]?(?=\.[a-z0-9]+$)/, '')
  .replace(/\.[a-z0-9]+$/, '');
const seen = new Set();
const uniq = cands.filter(c => { const k = key(c.url); if (seen.has(k)) return false; seen.add(k); return true; }).slice(0, 25);

const tokens = [norm(String(p.mpn).replace(/\(.*?\)/g, ''))]
  .concat(String(p.title).split(/[\s,\/]+/).map(norm).filter(t => /\d/.test(t) && /[a-z]/.test(t)))
  .filter(t => t.length >= 4);
let dropped_small = 0, dropped_dead = 0;
for (const c of uniq) {
  const d = await dims(c.url);
  Object.assign(c, { width: d.w || null, height: d.h || null, dead: !!d.dead });
  const reasons = [c.source];
  let score = c.base;
  if (tokens.some(t => norm(c.url + ' ' + c.alt).includes(t))) { score += 20; reasons.push('model in name'); }
  if (c.width && c.height) {
    const short = Math.min(c.width, c.height), long = Math.max(c.width, c.height);
    score += Math.min(short, 1600) / 40;
    reasons.push(c.width + 'x' + c.height);
    if (long / short > 2.5) { score -= 30; reasons.push('very wide or tall'); }
    c.too_small = short < s.min_short_side;
    c.tiny = short < s.fallback_min_side;
  } else { score -= 10; reasons.push('size unknown'); c.too_small = false; c.tiny = false; }
  c.score = Math.round(score);
  c.reasons = reasons;
  if (c.dead) dropped_dead++;
  else if (c.tiny || (c.too_small && !p.no_image)) dropped_small++;
}
// Any manufacturer image beats none, so a product with no working image gets anything above fallback_min_side.
const ranked = uniq.filter(c => !c.dead && !c.tiny && (p.no_image || !c.too_small))
  .sort((a, b) => b.score - a.score).slice(0, s.max_candidates);
return [{ json: { ...p, ranked, considered: uniq.length, dropped_small, dropped_dead } }];
```

Check the output on the **TP-Link VIGI C440-W (4mm)** and **TP-Link Tapo C232**: the `_normal` and `_thumb` duplicates are gone, and each entry in `ranked` lists its `source`, pixel dimensions, and scoring `reasons`.

![Rank candidates output for the TP-Link VIGI C440-W: each kept image with its source, size and reasons](./_attachments/shopify-product-images/rank-candidates-output.png "Rank candidates output for the TP-Link VIGI C440-W: each kept image with its source, size and reasons")

## Step 6: Check each image and write its alt text

Dimensions alone won't catch a lifestyle banner or an image showing the wrong product, so the top `vision_top_n` candidates (all eight by default, because a check costs about US$0.0001) go through a vision model that grades each photo and writes its alt text in the same call, which halves your model requests. Connect an **If** node named `Any candidates?` checking `{{ $json.ranked.length > 0 }}`. Wire its **false** branch straight to `Score and write alt`, and on its **true** branch connect a **Code** node named `Vision queue`, which removes curly braces from the title and vendor because n8n's [Basic LLM Chain](https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.chainllm/) treats `{` and `}` as prompt template variables:

```javascript
const s = $('Find settings').first().json;
const p = $input.first().json;
// Basic LLM Chain reads { and } as template variables, so they come out of the title and vendor.
return p.ranked.slice(0, s.vision_top_n).map((c, idx) => ({ json: {
  idx, url: c.url,
  title: String(p.title).replace(/[{}]/g, ''), vendor: String(p.vendor).replace(/[{}]/g, ''),
  vision_url: 'https://wsrv.nl/?url=' + encodeURIComponent(c.url) + '&w=768&h=768&fit=contain&cbg=white&output=jpg'
} }));
```

Connect an **HTTP Request** node named `Fetch a 768 px copy` (GET `{{ $json.vision_url }}`, **Response Format** set to `File`, **Put Output in Field** set to `data`, **Timeout** `30000`, and **Settings > On Error** set to `Continue`). Passing a 768 px JPEG from wsrv.nl converts WebP and PNG files into a single format and keeps image token usage low.

Next, add a **Basic LLM Chain** node named `Check the image` with an **Ollama Chat Model** sub-node (using Base URL `https://ollama.com` and your API key):

| Setting | Value |
| --- | --- |
| Source for Prompt (User Message) | Define below |
| Prompt (User Message) | see below |
| Chat Messages > System | see below |
| Chat Messages > User, Message Type | Image (Binary) |
| Image Data Field Name | `data` |
| Image Details | Auto |
| Settings > Retry On Fail | On, 2 tries |
| Settings > On Error | Continue (so every image still lines up with its answer) |
| Ollama Chat Model > Model | `{{ $('Find settings').first().json.vision_model }}` |
| Ollama Chat Model > Sampling Temperature | 0 |
| Ollama Chat Model > Output Format | JSON |

The image does reach the model as binary through the Basic LLM Chain: on Ollama Cloud, `gemma4:31b` described each photo correctly and answered in 0.5 to 1.2 seconds. Output Format JSON is only partly honoured, though. Sometimes the model answers with bare JSON, which the chain hands on as an object, and sometimes with JSON inside a code fence, which arrives as `text`, so `Score and write alt` reads both rather than relying on a Structured Output Parser.

Set the **Prompt (User Message)** to pull the product details from `Vision queue`, because the HTTP Request node that downloads the binary file only passes the image forward:

```text
Product: {{ $('Vision queue').item.json.title }}
Brand: {{ $('Vision queue').item.json.vendor }}
```

If you ask a vision model "is there text or a watermark?", it will flag clean studio shots whenever the brand's logo is printed on the casing, so the system prompt asks specifically about `added_text` laid over the photo after it was taken (and you should test this checklist on 20 of your own product images before trusting it):

```text
You check product photos for an online store and write their alt text. Look only at the photo you are given. Reply with a JSON object with exactly these keys: white_background, single_product, packaging, lifestyle, added_text and matches (each true or false), view (a string) and alt_text (a string).

white_background: a plain white or very light, empty background.
single_product: the product itself is the main subject.
packaging: a retail box or packaging is visible.
lifestyle: shown in a room, on a building, outdoors or in use.
added_text: text, logos or watermarks laid over the photo after it was taken. Ignore printing, logos and labels on the product itself.
matches: it plausibly shows the product named in the message.
view: one of front, side, back, top, detail, in the box, in use, accessories.
alt_text: one sentence under 125 characters that says what this shot shows: the angle, the colour, and what is attached or included. Name the product once. Don't start with "Image of" or "Picture of". No keyword lists, prices or marketing words.
```

![The Check the image Basic LLM Chain: System message, User message set to Image (Binary) with field data, Ollama Chat Model sub-node at temperature 0](./_attachments/shopify-product-images/vision-check-chain.png "The Check the image Basic LLM Chain: System message, User message set to Image (Binary) with field data, Ollama Chat Model sub-node at temperature 0")

Connect `Score and write alt` (**Code** node, **Run Once for All Items**), which adjusts each candidate's score based on the vision model's JSON, unticks any shot that has overlaid text or fails `matches`, and checks that every image on the product gets a distinct alt text that meets [Google's image SEO guidelines](https://developers.google.com/search/docs/appearance/google-images) and the [W3C alt decision tree](https://www.w3.org/WAI/tutorials/images/decision-tree/):

```javascript
const s = $('Find settings').first().json;
const p = $('Rank candidates').first().json;
const queue = p.ranked.length ? $('Vision queue').all().map(i => i.json) : [];
const answers = p.ranked.length ? $input.all().map(i => i.json) : [];
// The chain hands back the parsed object when the model replies with bare JSON, and { text } when it
// wraps the JSON in a code fence (Ollama Cloud does both, Output Format JSON or not), so read either.
const parse = r => { if (!r || r.error) return null; if (r.text === undefined) return r.alt_text !== undefined ? r : null;
  const t = String(r.text); try { return JSON.parse(t.slice(t.indexOf('{'), t.lastIndexOf('}') + 1)); } catch (e) { return null; } };
const vis = {};
answers.forEach((r, i) => { if (queue[i]) vis[queue[i].idx] = parse(r); });

// The answers move the score. Added text or the wrong product unticks the image; packaging and lifestyle stay ticked but sort lower.
const ranked = p.ranked.map((c, idx) => {
  const v = vis[idx] || null;
  const c2 = { ...c, reasons: [...c.reasons], vision: v, selected: true, primary: false };
  if (v) {
    if (v.white_background === true) { c2.score += 25; c2.reasons.push('white background'); }
    if (v.single_product === true) c2.score += 10;
    if (v.packaging === true) { c2.score -= 15; c2.reasons.push('packaging'); }
    if (v.lifestyle === true) { c2.score -= 10; c2.reasons.push('lifestyle'); }
    if (v.added_text === true) { c2.score -= 40; c2.reasons.push('added text'); c2.selected = false; }
    if (v.matches === false) { c2.score -= 50; c2.reasons.push('may not match'); c2.selected = false; }
  } else if (idx < s.vision_top_n) c2.reasons.push('not checked');
  return c2;
}).sort((a, b) => b.score - a.score);

// Alt text checks. A failed alt falls back to "<title>, <view>" (or "photo N"); a repeat gets its view added so no two images share one.
const title = String(p.title || '').trim();
const name = String(p.mpn || p.vendor || '').replace(/\(.*?\)/g, '').trim();
const esc = t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const used = new Set();
ranked.forEach((c, k) => {
  const v = c.vision || {};
  const view = String(v.view || '').trim();
  let a = String(v.alt_text || '').replace(/\s+/g, ' ').trim().replace(/^["']|["']$/g, '');
  const problems = [];
  if (!a) problems.push('empty');
  if (a.length > s.max_alt_length) problems.push('too long');
  if (/^(an? )?(image|picture|photo) of\b/i.test(a)) problems.push('starts with image of');
  if (a.toLowerCase() === title.toLowerCase()) problems.push('same as the title');
  if (name && (a.match(new RegExp(esc(name), 'gi')) || []).length > 1) problems.push('names the product twice');
  if (problems.length) a = title + ', ' + (view || 'photo ' + (k + 1));
  if (used.has(a.toLowerCase())) a = a.replace(/[.\s]+$/, '') + ', ' + (view || 'view ' + (k + 1));
  used.add(a.toLowerCase());
  c.alt = a;
  c.alt_problems = problems.join('; ');
});

const selected = ranked.filter(c => c.selected);
return [{ json: {
  product_id: p.product_id, title: p.title, vendor: p.vendor, mpn: p.mpn, page_url: p.page_url,
  status: selected.length ? 'review' : 'skipped',
  // a failed search keeps the tag, so the next run tries again
  retry: !selected.length && !!p.search_error,
  error: selected.length ? '' : (p.considered ? 'no usable image' : 'no manufacturer image found') + (p.page_note ? ' (' + p.page_note + ')' : ''),
  candidates: JSON.stringify(ranked.map(c => ({ url: c.url, source: c.source, width: c.width, height: c.height, score: c.score,
    reasons: c.reasons, selected: c.selected, primary: false, alt: c.alt, alt_problems: c.alt_problems, view: (c.vision || {}).view || '' }))),
  current: JSON.stringify(p.current),
  updated_at: new Date().toISOString()
} }];
```

Connect a **Data table** Upsert node named `Save for review` into `image_queue` (matching on `product_id` and mapping every field output by `Score and write alt`), followed by an **HTTP Request** node named `Remove the tag` so the product leaves the queue:

```graphql
mutation Done($id: ID!, $tags: [String!]!) {
  tagsRemove(id: $id, tags: $tags) { userErrors { field message } }
}
```

Set the `variables` on `Remove the tag` to `{{ (r => ({ id: r.product_id, tags: r.retry ? [] : [$('Find settings').first().json.pick_tag] }))($('Score and write alt').first().json) }}`, so a product whose search failed keeps its tag for the next run. Check the output for the **TP-Link Tapo C232**: of its eight gallery shots, the three white-background studio shots stay ticked with their own alt text, while the feature infographic and the pan-and-tilt diagram come back with `added_text: true` and the sample-view photos of children with `lifestyle: true` or `matches: false`, so all five are unticked.

![Check the image output for the Tapo C232: the vision answers per photo, studio shots on the left, an infographic and a lifestyle shot with added_text true on the right, a different alt text on each](./_attachments/shopify-product-images/vision-check-output.png "Check the image output for the Tapo C232: the vision answers per photo, studio shots on the left, an infographic and a lifestyle shot with added_text true on the right, a different alt text on each")

## Step 7: Post one card per product

A reviewer can't approve photos they haven't seen, and Slack only renders one image per block, so the workflow stitches the current main image and up to nine numbered candidates into a single contact sheet whose tile numbers match the card's tick boxes. Connect an **If** node named `Card to post?` checking `{{ $('Score and write alt').first().json.status === 'review' }}`. Wire its **false** branch back to `Loop over products`, and on its **true** branch add a **Code** node named `Build the card` (Slack caps a checkbox group at ten options, so the card offers up to nine new images plus `Current`):

```javascript
const s = $('Find settings').first().json;
const r = $('Score and write alt').first().json;
const cands = JSON.parse(r.candidates);
const cur = JSON.parse(r.current).filter(m => m.url && m.status !== 'FAILED')[0];
// Slack caps a checkbox group at ten options: up to nine new images plus Current.
const pick = cands.map((c, i) => ({ c, i })).filter(x => x.c.selected).slice(0, 9);
const n = pick.length;
const id = r.product_id.split('/').pop();
const opt = (x, k) => ({ text: { type: 'plain_text', text: String(k + 1) }, value: 'i' + x.i });
const curOpt = cur ? [{ text: { type: 'plain_text', text: 'Current (untick to remove)' }, value: 'current' }] : [];
const links = [
  { type: 'button', text: { type: 'plain_text', text: 'Shopify' }, url: 'https://admin.shopify.com/store/' + s.admin_store_handle + '/products/' + id },
  ...(r.page_url ? [{ type: 'button', text: { type: 'plain_text', text: 'Manufacturer' }, url: r.page_url }] : []),
  { type: 'button', text: { type: 'plain_text', text: 'Google' }, url: 'https://www.google.com/search?q=' + encodeURIComponent(r.title + ' ' + (r.mpn || '')) }
];
const blocks = [
  { type: 'header', text: { type: 'plain_text', text: r.title.slice(0, 150) } },
  { type: 'context', elements: [{ type: 'mrkdwn', text: (r.mpn ? '`' + r.mpn + '` · ' : '') + '*' + n + '* image' + (n === 1 ? '' : 's') + ' to add'
    + (cur ? ' (*Current* is the main image today)' : ' (the product has no working image)') + '. Untick any you don\'t want, pick a main image if it should change, then Approve.' }] },
  { type: 'actions', elements: links },
  // Placeholder: Add sheet to card swaps in the contact sheet, or keeps this top pick if the sheet fails.
  { type: 'image', image_url: cands[pick[0].i].url, alt_text: n + ' images for ' + r.title.slice(0, 100), title: { type: 'plain_text', text: n + ' image' + (n === 1 ? '' : 's') + ', numbered' } },
  { type: 'input', block_id: 'keep', optional: true, label: { type: 'plain_text', text: 'Images to add' },
    element: { type: 'checkboxes', action_id: 'keep', options: curOpt.concat(pick.map(opt)), initial_options: curOpt.concat(pick.map(opt)) } },
  { type: 'input', block_id: 'main', optional: true, label: { type: 'plain_text', text: 'Main image' },
    element: { type: 'radio_buttons', action_id: 'main', initial_option: { text: { type: 'plain_text', text: 'Keep current' }, value: 'keep' },
      options: [{ text: { type: 'plain_text', text: 'Keep current' }, value: 'keep' }].concat(pick.map(opt)) } },
  { type: 'actions', block_id: 'decide', elements: [
    { type: 'button', style: 'primary', text: { type: 'plain_text', text: 'Approve' }, action_id: 'img_approve', value: r.product_id },
    { type: 'button', style: 'danger', text: { type: 'plain_text', text: 'Reject' }, action_id: 'img_reject', value: r.product_id }
  ] }
];
return [{ json: { product_id: r.product_id, channel: s.slack_channel, text: 'Images to review: ' + r.title, blocks,
  images: (cur ? [{ url: cur.url, label: 'Current' }] : []).concat(pick.map((x, k) => ({ url: cands[x.i].url, label: String(k + 1) }))) } }];
```

Connect an **Execute Workflow** node named `Build the contact sheet` (set **Source** to `Database`, **Workflow** to **By ID** `{{ $workflow.id }}`, **Mode** to `Run once for each item`, **Wait For Sub-Workflow Completion** toggled on, inputs `job` = `contact_sheet`, `title` = `{{ $json.text }}`, and `images` = `{{ $json.images }}`, with **Settings > On Error** set to `Continue`). Next, connect a **Code** node named `Add sheet to card` that swaps the uploaded contact sheet into the image block. If the helper failed, which happens when the brand's server refuses wsrv.nl (navman.com.au did in testing), it puts links to the numbered photos in place of the image, because Slack can't show an image it can't fetch either:

```javascript
const card = $('Build the card').first().json;
const r = $input.first().json || {};
const fileId = r.ok === true && r.file_id;
// No sheet (every tile failed): links to the photos instead, because Slack can't show an image it can't fetch either.
const links = { type: 'section', text: { type: 'mrkdwn', text: 'Contact sheet unavailable. ' + card.images.map(x => '<' + x.url + '|' + x.label + '>').join(' · ') } };
const blocks = card.blocks.map(b => b.type !== 'image' ? b : fileId ? { type: 'image', slack_file: { id: fileId }, alt_text: b.alt_text, title: b.title } : links);
return [{ json: { channel: card.channel, text: card.text, blocks, product_id: card.product_id } }];
```

Connect an **HTTP Request** node named `Post the card` (`POST https://slack.com/api/chat.postMessage`, **Authentication** set to **Predefined Credential Type > Slack API**, and JSON body `{{ JSON.stringify({ channel: $json.channel, text: $json.text, blocks: $json.blocks, unfurl_links: false, unfurl_media: false }) }}`), then a **Data table** Update node named `Save the card` on `image_queue` (matching `product_id` = `{{ $('Add sheet to card').first().json.product_id }}`, setting `card_channel` = `{{ $json.channel }}` and `card_ts` = `{{ $json.ts }}`), and wire it back to `Loop over products`.

## Step 8: Turn the click into an approval

[Slack requires interactive webhooks to respond within 3 seconds](https://docs.slack.dev/interactivity/handling-user-interaction), so the Review lane starts with a **Webhook** node (`POST`, path `product-images`, **Raw Body** toggled on, **Respond** set to `Using 'Respond to Webhook' Node`), wired immediately to a **Respond to Webhook** node (**Respond With** set to `No Data`). Connect a **Code** node named `Read the click` to unpack the URL-encoded payload and prepare the signature base string:

```javascript
const raw = (await this.helpers.getBinaryDataBuffer(0, 'data')).toString('utf8');
const h = $input.first().json.headers;
if (Math.abs(Date.now() / 1000 - Number(h['x-slack-request-timestamp'])) > 300) throw new Error('Stale request');
const field = raw.split('&').find(kv => kv.startsWith('payload=')) || ''; // no URLSearchParams in the Code node
const b = JSON.parse(decodeURIComponent(field.slice(8).replace(/\+/g, ' ')));
const a = (b.actions || [])[0] || {};
const v = (b.state && b.state.values) || {};
const keep = ((v.keep || {}).keep || {}).selected_options || [];
const main = (((v.main || {}).main || {}).selected_option || {}).value || 'keep';
return [{ json: {
  base: `v0:${h['x-slack-request-timestamp']}:${raw}`, expected: h['x-slack-signature'],
  action: a.action_id || '', value: a.value || '', keep: keep.map(o => o.value), main,
  user: b.user.username || b.user.name || b.user.id, user_id: b.user.id,
  channel: (b.channel || {}).id || '', ts: (b.message || {}).ts || '', blocks: (b.message || {}).blocks || []
} }];
```

Connect a **Crypto** node (**Action** `Hmac`, **Type** `SHA256`, **Encoding** `HEX`, **Secret** set to your Slack signing secret, **Value** `{{ $json.base }}`, **Property Name** `sig`), followed by an **If** node named `From Slack?` that only continues when `{{ 'v0=' + $json.sig }}` equals `{{ $json.expected }}`. Connect a **Switch** node named `Which button?` reading `{{ $json.action }}` to route `img_approve` and `img_reject` along the product image branch and `alt_apply` and `alt_skip` to the digest branch described in Step 11.

On the product image branch, add a **Data table** Get node named `Card row` on `image_queue` (`product_id` Equals `{{ $json.value }}`, **Always Output Data** on) and an **If** node named `Still waiting?` (`{{ $json.status === 'review' }}`) so clicking an old card twice does nothing. Connect a **Code** node named `Apply the choices`:

```javascript
const c = $('Read the click').first().json;
const row = $input.first().json;
const cands = JSON.parse(row.candidates || '[]');
const keep = new Set(c.keep);
cands.forEach((x, i) => { x.selected = keep.has('i' + i); x.primary = x.selected && c.main === 'i' + i; });
const chosen = cands.filter(x => x.selected).length;
// Approve with nothing ticked counts as Reject.
const approved = c.action === 'img_approve' && chosen > 0;
const hadCurrent = JSON.parse(row.current || '[]').some(m => m.url && m.status !== 'FAILED');
return [{ json: {
  product_id: row.product_id, status: approved ? 'approved' : 'rejected', candidates: JSON.stringify(cands),
  remove_current: approved && hadCurrent && !keep.has('current'), chosen,
  updated_at: new Date().toISOString()
} }];
```

Connect a **Data table** Update node on `image_queue` (matching `product_id` = `{{ $json.product_id }}` and updating `status`, `candidates`, `remove_current`, and `updated_at`), then a **Code** node named `Close the card` followed by an **HTTP Request** node named `Update the card` (`POST https://slack.com/api/chat.update` with your Slack API credential, JSON body `{{ JSON.stringify({ channel: $json.channel, ts: $json.ts, text: $json.text, blocks: $json.blocks }) }}`, **On Error** `Continue`) to replace the interactive controls with a read-only audit line. It uses `chat.update` with the card's channel and timestamp rather than Slack's `response_url`, which expires 30 minutes after the tap:

```javascript
const c = $('Read the click').first().json;
const a = $('Apply the choices').first().json;
// The same card without the tick boxes and buttons, plus what was decided and by whom.
const blocks = c.blocks.filter(b => b.type !== 'actions' && b.type !== 'input');
blocks.push({ type: 'context', elements: [{ type: 'mrkdwn', text: a.status === 'approved'
  ? ':white_check_mark: ' + a.chosen + ' image' + (a.chosen === 1 ? '' : 's') + ' approved by <@' + c.user_id + '>' + (a.remove_current ? ', current image will be removed' : '') + '. Adding them now.'
  : ':x: Rejected by <@' + c.user_id + '>' }] });
return [{ json: { channel: c.channel, ts: c.ts, text: 'Images ' + a.status, blocks } }];
```

Finish the Review lane with an **If** node named `Approved?` (true when `Apply the choices` set the status to `approved`) wired to an **Execute Workflow** node named `Start Publish` (**Source** `Database`, **By ID** `{{ $workflow.id }}`, inputs `job` = `publish` and `product_id` = `{{ $('Apply the choices').first().json.product_id }}`, with **Wait For Sub-Workflow Completion** switched off). Check that clicking **Approve** makes the Slack card read-only and starts a Publish execution immediately.

## Step 9: Clean, name and add the images

The Publish lane handles both uploading approved product photos and building contact sheets. Start the lane with a **When Executed by Another Workflow** trigger (configured with input fields `job`, `product_id`, `title`, and `images` as Array) wired into a **Switch** node named `Which job?` that routes `publish` to `Publish settings` and `contact_sheet` to the helper row below, plus an hourly **Schedule Trigger** wired straight into `Publish settings` as a backstop.

After `Publish settings` and `Get Shopify token`, add a **Data table** Get node named `Approved rows` on `image_queue` (**Any Condition**: `status` Equals `approved` or `status` Equals `pushing`, **Order By** `updated_at` ascending, **Limit** `20`, **Always Output Data** on) and connect a **Code** node named `Pick one`:

```javascript
const s = $('Publish settings').first().json;
const rows = $input.all().map(i => i.json).filter(r => r.product_id);
const hourAgo = Date.now() - 3600000;
const row = (s.product_id && rows.find(r => r.product_id === s.product_id && r.status === 'approved'))
  || rows.find(r => r.status === 'approved')
  // a push that died part way gets another go after an hour
  || rows.find(r => r.status === 'pushing' && new Date(r.updated_at).getTime() < hourAgo);
return row ? [{ json: row }] : [];
```

Connect a **Data table** Update node setting that row's `status` to `pushing` and `updated_at` to `{{ $now.toISO() }}`, a **Data table** Get node named `Already pushed` on `image_log` (`product_id` Equals `{{ $('Pick one').first().json.product_id }}`, **Always Output Data** on), and an **HTTP Request** node named `Get current media`:

```graphql
query CurrentMedia($id: ID!) {
  product(id: $id) {
    onlineStoreUrl
    media(first: 100) { nodes { id status alt mediaContentType } }
  }
}
```

Set `variables` on `Get current media` to `{{ ({ id: $('Pick one').first().json.product_id }) }}`.

I pad images onto a square canvas rather than cropping them because a single gallery often mixes aspect ratios, such as the **Navman MiVue M820D** pairing a 1798 x 1200 landscape shot of the camera with a 781 x 820 app screenshot, next to TP-Link's 1000 x 1000 squares; using [wsrv.nl's `fit=contain`](https://wsrv.nl/docs/fit) letterboxes both shapes onto the 2048 x 2048 square [Shopify recommends for product images](https://help.shopify.com/en/manual/products/product-media/product-media-types) (well above the 800 x 800 threshold [needed for zoom](https://www.shopify.com/blog/image-sizes)) without cutting off the product, and [converts WebP or PNG sources to JPEG](https://wsrv.nl/docs/format). Connect a **Code** node named `Prepare media`, which places the product's slug in the wsrv.nl path so Shopify saves a clean, descriptive file name:

```javascript
const s = $('Publish settings').first().json;
const row = $('Pick one').first().json;
const product = ($json.data || {}).product || {};
const nodes = (product.media || {}).nodes || [];
const live = new Map(nodes.map(n => [n.id, n.status]));
// A source is already on the product when the log has it and its media still exists and hasn't FAILED.
const logs = $('Already pushed').all().map(i => i.json).filter(l => l.source_hash && live.has(l.media_id) && live.get(l.media_id) !== 'FAILED');
const present = new Map(logs.map(l => [l.source_hash, l.media_id]));
const hash = t => { let h = 0; for (const ch of String(t)) h = (Math.imul(31, h) + ch.charCodeAt(0)) | 0; return (h >>> 0).toString(16); };
const slug = String(row.title || 'product').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'product';
const chosen = JSON.parse(row.candidates || '[]').filter(c => c.selected)
  .sort((a, b) => (b.primary === true) - (a.primary === true))
  .map(c => ({ ...c, source_hash: hash(c.url) }));
// wsrv.nl ignores the path, so the file name goes there and Shopify names the file after it.
const media = chosen.filter(c => !present.has(c.source_hash)).map((c, k) => {
  const name = slug + (k ? '-' + (k + 1) : '');
  return { source_url: c.url, source_hash: c.source_hash, primary: !!c.primary, alt: c.alt, filename: name + '.jpg',
    originalSource: 'https://wsrv.nl/' + name + '.jpg?url=' + encodeURIComponent(c.url) + '&w=' + s.image_size + '&h=' + s.image_size + '&fit=contain&cbg=' + s.pad_colour + '&output=jpg' };
});
const images = nodes.filter(n => n.mediaContentType === 'IMAGE');
const main = images[0];
const presentReady = chosen.filter(c => present.has(c.source_hash)).every(c => live.get(present.get(c.source_hash)) === 'READY');
return [{ json: {
  product_id: row.product_id, title: row.title, card_channel: row.card_channel, card_ts: row.card_ts, store_url: product.onlineStoreUrl || '',
  before_ids: nodes.map(n => n.id), media, has_new: media.length > 0, present_ready: presentReady,
  // the reviewer unticked Current, or the current main image is a FAILED (dead) link
  remove_media_id: main && (row.remove_current === true || row.remove_current === 'true' || main.status === 'FAILED') ? main.id : '',
  // Shopify won't detach a FAILED file (NON_READY_STATE), so a dead one is deleted instead.
  remove_failed: !!main && main.status === 'FAILED'
} }];
```

Connect an **If** node named `Anything new?` (`{{ $json.has_new }}`). On its **false** branch, update `image_queue` with `status` set to `{{ $json.present_ready ? 'pushed' : 'approved' }}` and stop. On its **true** branch, connect a **Code** node named `Check each source` to warm the wsrv.nl cache and prepare a Shopify staged upload if a manufacturer's CDN blocks wsrv.nl:

```javascript
const s = $('Publish settings').first().json;
const tok = $('Get Shopify token for Publish').first().json;
const p = $input.first().json;
const gql = (query, variables) => this.helpers.httpRequest({ method: 'POST', url: 'https://' + s.store_domain + '/admin/api/2026-10/graphql.json',
  headers: { 'X-Shopify-Access-Token': tok.access_token }, body: { query, variables }, json: true, timeout: 60000 });
const out = [];
for (const m of p.media) {
  // Fetching the wsrv.nl copy proves wsrv.nl can read the source, and warms its cache before Shopify asks.
  let ok = false;
  try { await this.helpers.httpRequest({ url: m.originalSource, encoding: 'arraybuffer', timeout: 45000 }); ok = true; } catch (e) {}
  if (ok) { out.push({ json: { ...m, blocked: false } }); continue; }
  // Blocked: the brand's server refuses wsrv.nl. n8n downloads and pads the original, then stages it on Shopify.
  try {
    const r = await gql('mutation Stage($input: [StagedUploadInput!]!) { stagedUploadsCreate(input: $input) { stagedTargets { url resourceUrl } userErrors { field message } } }',
      { input: [{ resource: 'IMAGE', filename: m.filename, mimeType: 'image/jpeg', httpMethod: 'PUT' }] });
    const t = (((r.data || {}).stagedUploadsCreate || {}).stagedTargets || [])[0];
    if (!t || !t.url) throw new Error('no staged target');
    out.push({ json: { ...m, blocked: true, upload_url: t.url, resource_url: t.resourceUrl } });
  } catch (e) { out.push({ json: { ...m, blocked: false, stage_error: String(e.message || e).slice(0, 120) } }); }
}
return out;
```

Connect an **If** node named `Blocked?` (`{{ $json.blocked }}`). On **true**, add `Download the source` (**HTTP Request** GET `{{ $json.source_url }}`, headers `User-Agent: curl/8.7.1` and `Accept: image/*`, **Response Format** `File` into `data`, **Timeout** `45000`, **On Error** `Continue`). A blocked source still has to end up as a white square like the rest, so before it goes to Shopify, n8n pads it itself in three nodes. First a **Code** node named `Plan the padding` reads the downloaded file's real size and works out where the photo will sit once it fits inside the square:

```javascript
const s = $('Publish settings').first().json;
const S = Number(s.image_size);
const out = [];
const items = $input.all();
for (let i = 0; i < items.length; i++) {
  const it = items[i];
  if (!it.binary || !it.binary.data) { out.push({ json: { ...it.json, pad_error: 'download failed' } }); continue; }
  // Real size from the file header, then the size it will have once it fits inside the square.
  const b = await this.helpers.getBinaryDataBuffer(i, 'data');
  let w = 0, h = 0;
  if (b[0] === 0x89 && b[1] === 0x50) { w = b.readUInt32BE(16); h = b.readUInt32BE(20); }
  else if (b.toString('ascii', 0, 4) === 'GIF8') { w = b.readUInt16LE(6); h = b.readUInt16LE(8); }
  else if (b.toString('ascii', 0, 4) === 'RIFF' && b.toString('ascii', 8, 12) === 'WEBP') {
    const c = b.toString('ascii', 12, 16);
    if (c === 'VP8 ') { w = b.readUInt16LE(26) & 0x3fff; h = b.readUInt16LE(28) & 0x3fff; }
    if (c === 'VP8L') { w = 1 + (((b[22] & 0x3f) << 8) | b[21]); h = 1 + (((b[24] & 0x0f) << 10) | (b[23] << 2) | ((b[22] & 0xc0) >> 6)); }
    if (c === 'VP8X') { w = 1 + b.readUIntLE(24, 3); h = 1 + b.readUIntLE(27, 3); }
  } else if (b[0] === 0xff && b[1] === 0xd8) {
    let k = 2;
    while (k + 9 < b.length) {
      if (b[k] !== 0xff) { k++; continue; }
      const mk = b[k + 1];
      if (mk >= 0xc0 && mk <= 0xcf && mk !== 0xc4 && mk !== 0xc8 && mk !== 0xcc) { h = b.readUInt16BE(k + 5); w = b.readUInt16BE(k + 7); break; }
      k += 2 + b.readUInt16BE(k + 2);
    }
  }
  if (!w || !h) { w = S; h = S; }
  const scale = Math.min(S / w, S / h);
  const fw = Math.round(w * scale), fh = Math.round(h * scale);
  out.push({ json: { ...it.json, src_w: w, src_h: h, x: Math.floor((S - fw) / 2), y: Math.floor((S - fh) / 2) }, binary: it.binary });
}
return out;
```

Then two **Edit Image** nodes (both with **On Error** `Continue`):

| Node | Settings |
| --- | --- |
| `Fit in the square` | Operation `Multi Step`: Resize to `image_size` x `image_size` with **Only if Larger**, then Resize to the same with **Only if Smaller**. Options: Format `png` (keeps a transparent background), Destination Key `photo` |
| `Pad to a white square` | Operation `Multi Step`: Create, Background Color `#ffffff`, Width and Height `image_size`; then Composite, Composite Image Property `photo`, Operator `Over`, Position X `{{ $json.x }}`, Position Y `{{ $json.y }}`. Options: Format `jpeg`, Quality `90`, File Name `{{ $json.filename }}` |

Don't use the Resize option **Maximum Area** to fit the photo: it caps the number of pixels rather than the width and height, so a 1798 x 1200 shot grew to about 2507 x 1673 and overflowed the square in testing. Shrinking only if larger and then enlarging only if smaller always fits the photo inside the square, like wsrv.nl's `fit=contain`.

Finish the branch with `Upload to Shopify` (**HTTP Request** PUT `{{ $('Check each source').item.json.upload_url }}`, header `Content-Type: image/jpeg`, **Body Content Type** `Binary File` from `data`, **On Error** `Continue`). Merge both the **false** branch of `Blocked?` and `Upload to Shopify` into a **Merge** node named `Join` (`Append`), then connect `Collect the media` (**Code**), where a failed download or upload keeps the wsrv.nl URL so the `READY` check records the failure:

```javascript
const p = $('Prepare media').first().json;
const plan = $('Check each source').all().map(x => x.json);
const uploads = $('Upload to Shopify').isExecuted ? $('Upload to Shopify').all().map(x => x.json) : [];
const pads = $('Plan the padding').isExecuted ? $('Plan the padding').all().map(x => x.json) : [];
let k = 0;
const media = [], notes = [];
for (const m of plan) {
  if (m.blocked) {
    const u = uploads[k] || {}, pad = pads[k] || {};
    k++;
    // A failed download or upload keeps the wsrv.nl URL; the READY check then records the failure.
    if (u.error || pad.pad_error) { notes.push('staging failed for ' + m.source_url); media.push(m); }
    else media.push({ ...m, originalSource: m.resource_url, staged: true });
  } else { if (m.stage_error) notes.push(m.stage_error); media.push(m); }
}
return [{ json: { ...p, media, notes } }];
```

Connect an **If** node named `Dry run?` (`{{ $('Publish settings').first().json.dry_run }}`). On **true**, update `image_queue` with `status` = `dry_run` and `report` = `{{ JSON.stringify($json.media) }}`. Because [`productCreateMedia` is deprecated](https://shopify.dev/docs/api/admin-graphql/2026-10/mutations/productCreateMedia), wire the **false** branch to an **HTTP Request** node named `Add the images` (mark it with a red sticky note on your canvas as the node that writes to your store) using [`productUpdate`'s `media` argument](https://shopify.dev/docs/api/admin-graphql/2026-10/mutations/productUpdate) to bulk upload images to Shopify:

```graphql
mutation AddImages($product: ProductUpdateInput!, $media: [CreateMediaInput!]) {
  productUpdate(product: $product, media: $media) {
    product { id media(first: 100) { nodes { id status alt } } }
    userErrors { field message }
  }
}
```

Set the `variables` on `Add the images` to:

```javascript
{{ ({ product: { id: $json.product_id }, media: $json.media.map(m => ({ originalSource: m.originalSource, mediaContentType: 'IMAGE', alt: m.alt })) }) }}
```

Check your first approval with `dry_run` switched on: the `report` column in `image_queue` should list 2048 x 2048 wsrv.nl URLs with file names like `tp-link-tapo-c232-....jpg` and an alt text per photo, after which you can set `dry_run` to `false` and flip the row's status back to `approved`.

**The contact sheet helper.** On the `contact_sheet` output of `Which job?`, build a helper row under the Publish lane starting with a **Code** node named `Plan tiles`:

```javascript
const c = $input.first().json;
const imgs = (Array.isArray(c.images) ? c.images : []).filter(x => x && x.url).slice(0, 10);
const T = 300, G = 12, C = Math.min(3, Math.max(imgs.length, 1));
const rows = Math.ceil(imgs.length / C);
const W = C * T + (C + 1) * G, H = rows * T + (rows + 1) * G;
return imgs.map((x, k) => ({ json: {
  n: String(x.label || (k + 1)),
  tile_url: 'https://wsrv.nl/?url=' + encodeURIComponent(x.url) + '&w=' + T + '&h=' + T + '&fit=contain&cbg=white&output=jpg&q=85',
  x: G + (k % C) * (T + G), y: G + Math.floor(k / C) * (T + G),
  sheet_w: W, sheet_h: H, title: c.title || 'Images'
} }));
```

Connect `Fetch tile` (**HTTP Request** GET `{{ $json.tile_url }}`, **Response Format** `File` into `data`), then `Number tile` (**Edit Image**, **Operation** `Multi Step`, output JPEG quality `85`), both with **On Error** set to `Continue` so a tile wsrv.nl can't fetch leaves a gap instead of stopping the sheet:

| Step | Settings |
| --- | --- |
| Draw | Primitive Rectangle, Color `#222222`, Start 0,0, End X `{{ $('Plan tiles').item.json.n.length > 2 ? 24 + $('Plan tiles').item.json.n.length * 16 : 44 }}`, End Y 40 |
| Text | Text `{{ $('Plan tiles').item.json.n }}`, Font Size 28, Font Color `#ffffff`, Position X 12, Position Y 30 |
| Border | Width 1, Height 1, Color `#dddddd` |

Connect `Gather tiles` (**Code**) to combine all tiles onto a single item as binary properties `t1` through `t10`:

```javascript
const items = $input.all();
const plan = $('Plan tiles').all().map(i => i.json);
// A tile that couldn't be fetched leaves a gap; its number stays with its place on the card.
const ok = items.map((it, k) => ({ it, k })).filter(x => x.it.binary && x.it.binary.data);
if (!ok.length) throw new Error('no tiles could be fetched');
const binary = { data: ok[0].it.binary.data };
const pos = [];
for (let k = 0; k < 10; k++) {
  const x = ok[k];
  if (x) { binary['t' + (k + 1)] = x.it.binary.data; pos.push({ x: plan[x.k].x, y: plan[x.k].y, p: 't' + (k + 1) }); }
  else pos.push({ x: 20000, y: 20000, p: 't1' }); // unused slots land off the canvas
}
return [{ json: { sheet_w: plan[0].sheet_w, sheet_h: plan[0].sheet_h, title: plan[0].title, pos }, binary }];
```

Connect `Build sheet` (**Edit Image**, `Multi Step`, JPEG quality `85`, file name `contact-sheet.jpg`):

| Step | Settings |
| --- | --- |
| Create | Background Color `#ffffff`, Width `{{ $json.sheet_w }}`, Height `{{ $json.sheet_h }}` |
| Composite (x10) | Composite Image Property `{{ $json.pos[0].p }}`, Position X `{{ $json.pos[0].x }}`, Position Y `{{ $json.pos[0].y }}`, then the same with `pos[1]` to `pos[9]` |

Finish the helper row with Slack's three-step external file upload: connect `Measure sheet` (**Code**):

```javascript
const buf = await this.helpers.getBinaryDataBuffer(0, 'data');
return [{ json: { title: $('Gather tiles').first().json.title, length: buf.length }, binary: { data: $input.first().binary.data } }];
```

Then connect `Get upload URL` (**HTTP Request** GET `https://slack.com/api/files.getUploadURLExternal` with your Slack API credential and query parameters `filename` = `contact-sheet.jpg` and `length` = `{{ $json.length }}`), `Attach sheet` (**Code** with `return [{ json: $input.first().json, binary: { data: $('Measure sheet').first().binary.data } }];` because the GET request drops binary data), `Upload sheet` (**HTTP Request** POST `{{ $json.upload_url }}`, **Body** `Binary File` from `data`), `Complete upload` (**HTTP Request** POST `https://slack.com/api/files.completeUploadExternal`, Slack API credential, JSON body `{{ JSON.stringify({ files: [{ id: $('Get upload URL').first().json.file_id, title: $('Measure sheet').first().json.title }] }) }}`), a **Wait** node set to `5` seconds so Slack finishes processing the file, and a **Set** node named `Return file` setting `ok` = `{{ $json.ok === true }}` and `file_id` = `{{ $('Get upload URL').first().json.file_id }}`.

## Step 10: Wait for Shopify, then set the main image

Because [Shopify fetches and processes new media asynchronously](https://shopify.dev/docs/apps/build/online-store/product-media) after `productUpdate` returns, counting an image as live straight away hides `FAILED` downloads (see Common mistakes below). Connect a **Code** node named `Wait until ready` after `Add the images` to poll the new media IDs every 5 seconds until each one reaches `READY` or `FAILED`, up to `max_wait_seconds`:

```javascript
const s = $('Publish settings').first().json;
const tok = $('Get Shopify token for Publish').first().json;
const p = $('Collect the media').first().json;
const before = new Set($('Prepare media').first().json.before_ids);
const r = $input.first().json;
const pu = ((r.data || {}).productUpdate) || {};
const errors = (pu.userErrors || []).concat(r.errors || []);
const created = (((pu.product || {}).media || {}).nodes || []).filter(n => !before.has(n.id));
const ids = created.map(n => n.id);
const Q = 'query M($ids: [ID!]!) { nodes(ids: $ids) { id ... on MediaImage { status alt mediaErrors { code message } } } }';
const sleep = ms => new Promise(res => setTimeout(res, ms));
const deadline = Date.now() + s.max_wait_seconds * 1000;
let byId = {};
while (ids.length) {
  const q = await this.helpers.httpRequest({ method: 'POST', url: 'https://' + s.store_domain + '/admin/api/2026-10/graphql.json',
    headers: { 'X-Shopify-Access-Token': tok.access_token }, body: { query: Q, variables: { ids } }, json: true, timeout: 60000 });
  byId = {};
  (((q.data || {}).nodes) || []).forEach(n => { if (n) byId[n.id] = n; });
  if (ids.every(id => ['READY', 'FAILED'].includes((byId[id] || {}).status)) || Date.now() > deadline) break;
  await sleep(5000);
}
// Shopify adds media in the order given; the alt text (unique per product) is the cross-check.
const results = created.map((n, i) => {
  const m = p.media.find(x => x.alt === n.alt) || p.media[i] || {};
  const st = byId[n.id] || {};
  return { media_id: n.id, status: st.status || 'MISSING', source_hash: m.source_hash, source_url: m.source_url, filename: m.filename, alt: m.alt,
    primary: !!m.primary, error: (st.mediaErrors || []).map(e => e.code + ': ' + e.message).join('; ') };
});
const ready = results.filter(x => x.status === 'READY');
const failed = results.filter(x => x.status === 'FAILED' || x.status === 'MISSING');
const waiting = results.length - ready.length - failed.length;
const problems = errors.map(e => e.message).concat(p.notes || [],
  failed.map(x => 'Shopify could not fetch ' + x.source_url + (x.error ? ' (' + x.error + ')' : '')));
if (!created.length && !errors.length) problems.push('no media created');
const allReady = results.length > 0 && ready.length === results.length;
return [{ json: {
  product_id: p.product_id, title: p.title, card_channel: p.card_channel, card_ts: p.card_ts, store_url: p.store_url,
  remove_media_id: p.remove_media_id, remove_failed: p.remove_failed, results, all_ready: allReady,
  primary_id: (ready.find(x => x.primary) || {}).media_id || '',
  status: allReady ? 'pushed' : (failed.length || errors.length || !results.length ? 'failed' : 'approved'),
  error: (waiting ? waiting + ' image(s) still processing; the next run checks again. ' : '') + problems.join('; ')
} }];
```

Wire two branches out of `Wait until ready`. On the first branch, connect a **Split Out** node on `results`, a **Filter** node keeping rows where `status` is not `FAILED` and not `MISSING`, and a **Data table** Insert node into `image_log` (`product_id` = `{{ $('Wait until ready').first().json.product_id }}`, plus `source_hash`, `media_id`, `filename`, `alt`, `status`, and `pushed_at` = `{{ $now.toISO() }}`), which prevents the hourly backstop from adding a still-processing image a second time.

On the second branch, connect an **If** node named `Main image chosen?` (`{{ !!$json.primary_id }}`). On **true**, add an **HTTP Request** node named `Move main image first` calling [`productReorderMedia`](https://shopify.dev/docs/api/admin-graphql/2026-10/mutations/productReorderMedia):

```graphql
mutation MoveFirst($id: ID!, $moves: [MoveInput!]!) {
  productReorderMedia(id: $id, moves: $moves) {
    job { id done }
    mediaUserErrors { field message }
  }
}
```

Set `variables` on `Move main image first` to `{{ ({ id: $('Wait until ready').first().json.product_id, moves: [{ id: $('Wait until ready').first().json.primary_id, newPosition: '0' }] }) }}`.

Wire both outputs of `Main image chosen?` into an **If** node named `Remove the old main image?` (`{{ !!$('Wait until ready').first().json.remove_media_id && $('Wait until ready').first().json.all_ready }}`). Because [`productDeleteMedia` is deprecated](https://shopify.dev/docs/api/admin-graphql/2026-10/mutations/productDeleteMedia) and `fileDelete` would break any other product sharing that image, wire **true** to an **HTTP Request** node named `Take it off the product` that uses [`fileUpdate`](https://shopify.dev/docs/api/admin-graphql/2026-10/mutations/fileUpdate) with `referencesToRemove` from [`FileUpdateInput`](https://shopify.dev/docs/api/admin-graphql/2026-10/input-objects/FileUpdateInput) to detach the image while leaving the file intact in Shopify Files. There's one exception: Shopify refuses to update a file that never loaded (`Non-ready files cannot be updated`, code `NON_READY_STATE`), so an old main image that `FAILED` is removed with `fileDelete` instead, which is safe because no product can show a file Shopify never fetched. Send the request with this JSON body (**Send Body** `JSON`, the same URL and token header as the other GraphQL nodes):

```javascript
{{ (w => JSON.stringify(w.remove_failed
  ? { query: 'mutation Remove($ids: [ID!]!) { fileDelete(fileIds: $ids) { deletedFileIds userErrors { field message } } }', variables: { ids: [w.remove_media_id] } }
  : { query: 'mutation Detach($files: [FileUpdateInput!]!) { fileUpdate(files: $files) { files { id } userErrors { field message } } }',
      variables: { files: [{ id: w.remove_media_id, referencesToRemove: [w.product_id] }] } }))($('Wait until ready').first().json) }}
```

Connect a **Data table** Update node on `image_queue` (setting `status` and `error` from `Wait until ready` and `updated_at` = `{{ $now.toISO() }}`), followed by an **HTTP Request** node named `Reply in the thread` (`POST https://slack.com/api/chat.postMessage` with your Slack API credential) using this JSON body:

```javascript
{{ (w => JSON.stringify({ channel: w.card_channel, thread_ts: w.card_ts, unfurl_links: false,
  text: w.status === 'pushed'
    ? ':white_check_mark: Live on the store' + (w.store_url ? ': <' + w.store_url + '|view the product>' : '') + ' · <https://admin.shopify.com/store/' + $('Publish settings').first().json.admin_store_handle + '/products/' + w.product_id.split('/').pop() + '|Shopify admin>'
    : (w.status === 'approved' ? ':hourglass: Still processing, checking again within the hour. ' : ':warning: Not added: ') + w.error
}))($('Wait until ready').first().json) }}
```

Check that the Slack thread receives the "Live on the store" reply, the Shopify product page shows your new padded square main image with readable file names in **Content > Files**, and running Publish a second time on the same product adds nothing.

![The card after Approve (TP-Link Tapo C210, one studio shot kept): read-only, who approved it, and the "Live on the store" reply in its thread](./_attachments/shopify-product-images/approved-card.png "The card after Approve (TP-Link Tapo C210, one studio shot kept): read-only, who approved it, and the 'Live on the store' reply in its thread")

![Files as Shopify stored them after Publish, each 2048 x 2048 with a readable name: the Navman M820D landscape shot and app screenshot (blocked source, padded in n8n) and the Tapo C232 main image](./_attachments/shopify-product-images/published-files.png "Files as Shopify stored them after Publish, each 2048 x 2048 with a readable name: the Navman M820D landscape shot and app screenshot (blocked source, padded in n8n) and the Tapo C232 main image")

## Step 11: Fix alt text on the images you already have

Most stores have hundreds of existing product photos whose alt text is blank or copied from the product title, and fixing that **alt text for product images** doesn't require scraping manufacturer sites. In the Alt text lane, wire a weekly **Schedule Trigger** (Monday 9am) and a **Manual Trigger** into a **Set** node named `Alt text settings`:

| Setting | Value |
| --- | --- |
| `store_domain` | `your-store.myshopify.com` |
| `product_query` | `status:active` (the Shopify product search the lane scans) |
| `batch_size` | `40` (a Slack message holds 50 blocks) |
| `max_pages` | `20` |
| `auto_apply` | `false` |
| `vision_model` | `gemma4:31b` |
| `slack_channel` | `#review-images` |

Connect `Get Shopify token`, a **Data table** Get node named `Already queued` on `alt_queue` (**Any Condition**: `status` Equals `pending` or `status` Equals `skipped`, with **Return All**, **Always Output Data**, and **Execute Once** on), and a **Code** node named `Find weak alt text` that pages through the products matching `product_query` using workflow static data to remember its cursor between scheduled runs:

```javascript
const s = $('Alt text settings').first().json;
const tok = $('Get Shopify token for Alt text').first().json;
const queued = new Set($input.all().map(i => i.json.media_id).filter(Boolean));
const Q = 'query Media($after: String, $q: String) { products(first: 50, after: $after, query: $q) { pageInfo { hasNextPage endCursor } nodes { id title vendor media(first: 20) { nodes { id alt status mediaContentType preview { image { url } } } } } } }';
const weak = (alt, title, all) => {
  const a = String(alt || '').trim();
  if (!a) return 'empty';
  if (a.toLowerCase() === String(title).trim().toLowerCase()) return 'same as the title';
  if (/\.(jpe?g|png|webp|gif)\b|^(img|dsc|image)[-_ ]?\d+/i.test(a)) return 'a file name';
  if (/\b[a-z]{2,8}:[0-9a-f]{6,}\b/i.test(a)) return 'a code';
  if (all.filter(x => String(x || '').trim().toLowerCase() === a.toLowerCase()).length > 1) return 'repeated on the product';
  return '';
};
// Static data keeps the cursor between scheduled runs (it isn't saved on manual runs).
const sd = $getWorkflowStaticData('global');
let after = sd.alt_after || null;
const found = [];
for (let page = 0; page < s.max_pages && found.length < s.batch_size; page++) {
  const r = await this.helpers.httpRequest({ method: 'POST', url: 'https://' + s.store_domain + '/admin/api/2026-10/graphql.json',
    headers: { 'X-Shopify-Access-Token': tok.access_token }, body: { query: Q, variables: { after, q: s.product_query } }, json: true, timeout: 60000 });
  const d = (r.data || {}).products || { nodes: [], pageInfo: {} };
  for (const p of d.nodes) {
    const imgs = p.media.nodes.filter(m => m.mediaContentType === 'IMAGE' && m.status === 'READY' && m.preview && m.preview.image);
    const alts = imgs.map(m => m.alt);
    imgs.forEach((m, k) => {
      const why = weak(m.alt, p.title, alts);
      if (why && !queued.has(m.id) && found.length < s.batch_size) found.push({ json: {
        media_id: m.id, product_id: p.id, title: p.title.replace(/[{}]/g, ''), vendor: String(p.vendor || '').replace(/[{}]/g, ''),
        position: k + 1, old_alt: m.alt || '', why, image_url: m.preview.image.url,
        vision_url: 'https://wsrv.nl/?url=' + encodeURIComponent(m.preview.image.url) + '&w=768&h=768&fit=contain&cbg=white&output=jpg' } });
    });
  }
  if (!d.pageInfo.hasNextPage) { after = null; break; }
  after = d.pageInfo.endCursor;
}
sd.alt_after = after;
return found;
```

Connect `Fetch a 768 px copy` (using the same settings as Step 6) and a **Basic LLM Chain** node named `Write the alt text` with its own **Ollama Chat Model** sub-node (using the same settings table as Step 6 and model `{{ $('Alt text settings').first().json.vision_model }}`). Set the **Prompt (User Message)** to:

```text
Product: {{ $('Find weak alt text').item.json.title }}
Brand: {{ $('Find weak alt text').item.json.vendor }}
This is photo {{ $('Find weak alt text').item.json.position }} on the product page.
```

Set the **System** message to:

```text
You write alt text for product photos in an online store. Reply with a JSON object with two keys: view (one of front, side, back, top, detail, in the box, in use, accessories) and alt_text (a string).

alt_text: one sentence under 125 characters that says what this shot shows: the angle, the colour, and what is attached or included. Name the product once. Don't start with "Image of" or "Picture of". No keyword lists, prices or marketing words. If the photo doesn't show the product named in the message, set alt_text to an empty string.
```

Connect a **Code** node named `Check the alt text` (**Run Once for All Items**) to validate length, phrasing, and per-product uniqueness:

```javascript
const imgs = $('Find weak alt text').all().map(i => i.json);
const batch = 'alt-' + new Date().toISOString().slice(0, 16);
const used = new Set();
return $input.all().map((it, i) => {
  const m = imgs[i];
  // the parsed object (bare JSON reply) or { text } (JSON in a code fence)
  let v = it.json.text === undefined && it.json.alt_text !== undefined ? it.json : null;
  if (!v && it.json.text !== undefined) { const t = String(it.json.text); try { v = JSON.parse(t.slice(t.indexOf('{'), t.lastIndexOf('}') + 1)); } catch (e) {} }
  let a = String((v && v.alt_text) || '').replace(/\s+/g, ' ').trim().replace(/^["']|["']$/g, '');
  const view = String((v && v.view) || '').trim();
  const problems = [];
  if (!a) problems.push('no alt text (the model may not recognise the product)');
  if (a.length > 125) problems.push('too long');
  if (/^(an? )?(image|picture|photo) of\b/i.test(a)) problems.push('starts with image of');
  if (a.toLowerCase() === m.title.toLowerCase()) problems.push('same as the title');
  if (a && used.has(m.product_id + '|' + a.toLowerCase())) a = a.replace(/[.\s]+$/, '') + ', ' + (view || 'photo ' + m.position);
  used.add(m.product_id + '|' + a.toLowerCase());
  return { json: { ...m, new_alt: problems.length ? '' : a, problems: problems.join('; '), batch_id: batch,
    status: problems.length ? 'failed' : 'pending', updated_at: new Date().toISOString() } };
});
```

Connect a **Data table** Insert node into `alt_queue` mapping every field above, followed by an **If** node named `Auto apply?` (`{{ $('Alt text settings').first().json.auto_apply }}`). On **true**, connect an **HTTP Request** node named `Apply alt text` (**Settings > Execute Once** switched on):

```graphql
mutation SetAlt($files: [FileUpdateInput!]!) {
  fileUpdate(files: $files) {
    files { id alt }
    userErrors { field message }
  }
}
```

Set `variables` on `Apply alt text` to `{{ ({ files: $('Check the alt text').all().map(i => i.json).filter(r => r.status === 'pending').map(r => ({ id: r.media_id, alt: r.new_alt })) }) }}`, then connect a **Data table** Update node on `alt_queue` (where `batch_id` Equals the batch and `status` Equals `pending`) setting `status` to `applied`.

On the **false** branch of `Auto apply?`, connect a **Code** node named `Build the digest` and an **HTTP Request** node named `Post the digest` (`chat.postMessage`, same settings as `Post the card`):

```javascript
const s = $('Alt text settings').first().json;
const rows = $('Check the alt text').all().map(i => i.json);
const ok = rows.filter(r => r.status === 'pending');
const bad = rows.filter(r => r.status === 'failed');
const line = r => '*' + r.title.slice(0, 60) + '* (photo ' + r.position + ', ' + r.why + ')\n~' + (r.old_alt || 'empty').slice(0, 80) + '~ → ' + r.new_alt;
const blocks = [
  { type: 'header', text: { type: 'plain_text', text: 'Alt text to review: ' + ok.length + ' images' } },
  ...ok.slice(0, 45).map(r => ({ type: 'section', text: { type: 'mrkdwn', text: line(r) }, accessory: { type: 'image', image_url: r.image_url, alt_text: r.new_alt } })),
  ...(bad.length ? [{ type: 'context', elements: [{ type: 'mrkdwn', text: bad.length + ' image(s) need a person: ' + bad.slice(0, 5).map(r => r.title + ' (' + r.problems + ')').join('; ') }] }] : []),
  ...(ok.length ? [{ type: 'actions', elements: [
    { type: 'button', style: 'primary', text: { type: 'plain_text', text: 'Approve all' }, action_id: 'alt_apply', value: rows[0].batch_id },
    { type: 'button', text: { type: 'plain_text', text: 'Skip' }, action_id: 'alt_skip', value: rows[0].batch_id }
  ] }] : [])
];
return [{ json: { channel: s.slack_channel, text: 'Alt text to review', blocks } }];
```

In the Review lane from Step 8, wire the `Apply alt text` output of `Which button?` to a **Set** node named `Review settings` (just `store_domain`), its own `Get Shopify token for Review`, a **Data table** Get node named `Pending alt rows` on `alt_queue` (`batch_id` Equals `{{ $('Read the click').first().json.value }}`, `status` Equals `pending`, **Always Output Data** on), an **If** node named `Anything to apply?` (`{{ $('Pending alt rows').all().some(i => i.json.media_id) }}`) so a second tap does nothing, an `Apply approved alt text` HTTP Request with the same `fileUpdate` mutation (`variables` built from `$('Pending alt rows').all()`, **Execute Once** on), and a **Data table** Update named `Mark applied` setting those rows to `applied`. Wire the `Skip alt text` output to a **Data table** Update named `Mark skipped` that sets the batch's `pending` rows to `skipped` so they aren't proposed again. Both paths end in a **Code** node named `Close the digest` and a `chat.update` HTTP Request named `Update the digest` that strip the buttons and record who decided, or say nothing was waiting when the batch was already handled:

```javascript
const c = $('Read the click').first().json;
const applied = c.action === 'alt_apply' && $('Anything to apply?').isExecuted && $('Pending alt rows').all().some(i => i.json.media_id);
const skipped = c.action === 'alt_skip' && $('Mark skipped').all().some(i => i.json.media_id);
const blocks = c.blocks.filter(b => b.type !== 'actions');
blocks.push({ type: 'context', elements: [{ type: 'mrkdwn', text: applied ? ':white_check_mark: Applied by <@' + c.user_id + '>'
  : skipped ? ':heavy_minus_sign: Skipped by <@' + c.user_id + '>. These images won\'t be proposed again.'
  : 'Already decided: nothing was waiting in this batch.' }] });
return [{ json: { channel: c.channel, ts: c.ts, text: 'Alt text ' + (c.action === 'alt_apply' ? 'applied' : 'skipped'), blocks } }];
```

Check that your first few weekly digests read well in Slack, then switch `auto_apply` to `true`.

![The weekly alt text digest in Slack: before and after per image, with Approve all and Skip](./_attachments/shopify-product-images/alt-text-digest.png "The weekly alt text digest in Slack: before and after per image, with Approve all and Skip")

## What it costs

All prices below were checked November 2026, with [n8n self-hosted free](https://docs.n8n.io/hosting/) or [n8n Cloud from €20 a month](https://n8n.io/pricing/), [DataForSEO at about US$0.002 a search](https://dataforseo.com/pricing/serp/google-organic-serp-api) ([US$50 minimum payment](https://dataforseo.com/help-center/minimum-payment)), and [AltText.ai](https://alttext.ai/pricing) shown for comparison (alt text only, as it does not find or clean images). The [Ollama Cloud](https://ollama.com/pricing) model cost is measured: Ollama's API reported 557 input tokens and about 90 output tokens for every 768 px image in testing (the image counts the same whatever its content), which at list price (US$0.14 per million input tokens and US$0.40 per million output tokens) is about US$0.0001 an image. DataForSEO billed US$0.002 for each plain search, and five times that for a query with a `site:` operator. For a one-off backfill with `vision_top_n` at its default of 8 in Find and about four existing images per product in the Alt text lane:

| Piece | Price | 500 products | 2,000 products | 10,000 products |
| --- | --- | --- | --- | --- |
| n8n | self-hosted free, or Cloud from €20 a month | US$0 | US$0 | US$0 |
| wsrv.nl | free | US$0 | US$0 | US$0 |
| `gemma4:31b`, Find (8 images a product) | about US$0.0001 an image (measured) | US$0.46 | US$1.82 | US$9.12 |
| `gemma4:31b`, Alt text (4 images a product) | about US$0.0001 an image (measured) | US$0.23 | US$0.91 | US$4.56 |
| DataForSEO (optional, one search a product) | US$0.002 a search (measured) | US$1.00 | US$4.00 | US$20.00 |
| AltText.ai, alt text only, for comparison (4 images a product) | US$19 to US$229 a month | US$59 (Gold, 2,000 images) | US$229 (Platinum, up to 10,000 images) | about US$916 (four months of Platinum for 40,000 images) |

On n8n Cloud, the two hourly schedules use about 1,440 executions a month even when nothing is tagged, which fits inside Starter's 2,500; self-hosting has no execution limit. The main cost levers are `vision_top_n`, sending the 768 px copy to the model, filling `manufacturer_url` or a brand `url_pattern` so fewer products need a DataForSEO search, and adjusting the Alt text lane's `batch_size`.

## Common mistakes with Shopify product images

### Taking images from retailer pages

Scraping photos from other stores brings along their watermarks, heavy re-compression, and copyright problems. Restrict `Read the manufacturer page` to the domains listed for that vendor in `brand_domains` so you only ever pull from the manufacturer's own site and CDN.

### Cropping to square, or upscaling a thumbnail

Centre-cropping tall or wide shots cuts off the product, while stretching a small thumbnail leaves it blurry. Pad every image onto a white canvas with `fit=contain` instead, and keep a `min_short_side` size floor that only lifts when a product has no working image at all.

### Asking the vision model "is there text or a watermark?"

When asked a broad question about text or watermarks, vision models flag the logo printed on the product itself, which caught the Tapo C232's main shot and several SmartTag2 shots during testing. Ask specifically about text laid over the photo after it was taken, and test the checklist on 20 images before trusting it.

### Alt text that repeats the product title on every image, starts with "Image of", lists keywords or carries a tracking code

Screen readers already announce an image as an image and will read out any tracking code placed in the alt attribute, while repeating the title or stuffing keywords hurts both accessibility and Shopify image optimisation. Describe what is in each shot instead, and keep your deduplication hashes in the `image_log` table, never in the alt text.

### Counting an image as added before Shopify has fetched it

`productUpdate` returns straight away while Shopify fetches the image afterwards in the background, so a source that blocks Shopify's or wsrv.nl's fetcher ends up `FAILED` while the run reports success. Poll each new media's `status` until `READY` or `FAILED`, record only `READY` images as live, and when the wsrv.nl copy can't be fetched, download the original from n8n and hand it to Shopify as a staged upload instead.

### Leaving access tokens in image URLs

Some image hosts put an access token in the URL, such as a paid Icecat account's `content_token`, and it ends up in Slack and Shopify. List those parameters in `strip_query_params` so they come off before anything is stored or shown.

## Make it your own

### Your own photos instead of the manufacturer's

If you shoot your own products or receive studio folders from suppliers, replace the Find lane's page lookup and scraper with a Google Drive or Dropbox node that lists files in a folder named after each SKU, while leaving the dimension check, vision model, Slack card, and Publish lane untouched.

### Add Open Icecat for electronics and IT brands

Many electronics and IT manufacturers syndicate their product photos through [Open Icecat](https://iceclog.com/open-catalog-interface-oci-open-icecat-xml-and-full-icecat-xml-repositories/) with the main shot already flagged, and the open catalogue is free for brands that sponsor it. You can add an HTTP Request node after `Read the manufacturer page` that queries the [Icecat JSON API](https://www.iceclog.com/manual-for-icecat-json-product-requests/) by GTIN and then by brand plus model code, feeding its gallery into `Rank candidates` alongside the scraped page images. The Icecat guide (coming soon) covers building that lookup along with product specs and manuals.

### Remove backgrounds

When a manufacturer only supplies photos on grey or textured backdrops, insert an HTTP Request node calling remove.bg or Photoroom right before the wsrv.nl padding step, triggered only on candidates where the vision model returned `white_background: false`.

### No third-party image service

If you prefer not to route image URLs through wsrv.nl, run its open-source engine ([weserv/images](https://github.com/weserv/images)) in a Docker container on your own server and swap `https://wsrv.nl/` in the Code nodes for your own hostname.

### When Data Tables aren't enough

Because [n8n Data tables share a 200 MiB default limit](https://docs.n8n.io/build/work-with-data/data-tables/) and storing candidate JSON for thousands of products adds up quickly, large catalogues can move `image_queue` and `image_log` into Postgres (such as [Supabase's free 500 MB plan](https://supabase.com/pricing)) while keeping all five workflow lanes identical.

If you're also automating product creation, pair this workflow with the guide [How to Add Products to Shopify Automatically](/add-products-to-shopify-automatically/), so newly created drafts get their first clean manufacturer image through the same pipeline.

## FAQ

### Can I use the manufacturer's product images on my store?

Usually, yes. Brands publish product images so the stores selling their products can use them, which is why they hand them to catalogues like Icecat in the first place. If you sell a brand's products, using the photos from its own site is standard practice. Just don't take images from another retailer's store, and check the brand's terms if you're unsure.

### What size should Shopify product images be?

Shopify notes that square product images display best at [2048 x 2048 pixels](https://help.shopify.com/en/manual/products/product-media/product-media-types) (supporting up to 5000 x 5000 pixels or 25 megapixels under 20 MB), and its [Shopify product image size guide](https://www.shopify.com/blog/image-sizes) points out that photos must be over 800 x 800 pixels for zoom to work. [Google Merchant Center](https://support.google.com/merchants/answer/6324350) requires at least 500 x 500 pixels for every product from 31 January 2027 and recommends 1500 x 1500 pixels or higher.

### Is AI alt text good enough?

For product photos, a vision model writes strong alt text when you prompt it to describe the specific angle, colour, and included accessories in the shot and review a few batches before turning on `auto_apply`. That matches [Google's image guidelines](https://developers.google.com/search/docs/appearance/google-images), which ask for useful, information-rich descriptions rather than keyword lists.

### Will it work on WooCommerce or another platform?

Finding, ranking, checking and cleaning the images don't depend on the platform, because they work on standard URLs and JSON in Data Tables. To use this on WooCommerce, BigCommerce, or Magento, swap the Shopify GraphQL calls (the product query and tag removal in Find, plus the Publish and Alt text lanes) for your platform's equivalents.

## Next steps

Tag ten products `needs-images` in your Shopify admin, run the Find lane once by hand, and approve your first review card in Slack with `dry_run` switched on to see the padded URLs and alt text before pushing them live.

<TemplateCallout
  repo="https://github.com/reganmcgregor/n8n-ecommerce-templates/tree/main/product-images"
>

- Finds each product's page on the manufacturer's site, scrapes its gallery, checks the best shots with a vision model, and writes unique alt text per image.
- Sends one numbered Slack review card per product, then pads approved photos to 2048 x 2048 white squares and uploads them to Shopify with readable file names.
- Runs a weekly audit that rewrites empty, duplicate, or filename-style alt text across your existing catalogue.

</TemplateCallout>
