Most small Shopify stores that resell or dropship have the same weekly chore. The supplier publishes a stock and price file, someone downloads it, and someone types the numbers into Shopify. Or they don't, and the store sells something the supplier ran out of on Tuesday.
I build Shopify apps and n8n automations, and this is the workflow I wish I'd had the first time a client asked for it. It's a full template, not a demo: 15 nodes plus setup notes, a dry-run mode that's on by default, and the safety checks I ended up needing. You can import it below for free.
What it does
Every 6 hours (or when you click Execute workflow):
- Config holds the shop URL, location, feed URL, column names and pricing rules.
- Get Shopify token swaps your app's client ID and secret for a 24-hour Admin API token. It's the same approach as in my low-stock alert post, so the secret stays in an n8n credential.
- Download supplier feed fetches a CSV or JSON file over HTTP.
- Fetch Shopify variants reads every variant with its SKU, price and available stock at your location, page by page.
- Build sync plan matches feed rows to variants by SKU, calculates the selling price from cost, and works out exactly what differs.
- If
dryRunis on, it only reports. If it's off, Send updates to Shopify writes the changes in batches, and Check results counts what Shopify actually accepted. - A Slack summary shows the counts: changed, unchanged, unmatched, skipped and errors.
- A built-in error branch posts to Slack when a production run fails.
It only ever touches stock and price. It never creates, edits or deletes a product, and titles, descriptions and images are never sent at all.
Setup (about 10 minutes)
- In Shopify's Dev Dashboard, create an app with the
read_products,write_products,read_inventoryandwrite_inventoryscopes. Install it on your store and copy the client ID and secret. - In n8n, create a Custom Auth credential with
{"body": {"client_id": "…", "client_secret": "…"}}. Select it in Get Shopify token. - Open Config and fill in your values. To get
locationId, go to Settings → Locations, open the location and take the number at the end of the URL:gid://shopify/Location/<number>. SetskuColumn,stockColumnandcostColumnto the header names exactly as they appear in the supplier's file. - Paste your Slack incoming-webhook URL into Config and into the Slack error alert node. The error branch runs as a separate execution, so it can't read Config.
- In Workflow settings → Error workflow, pick this same workflow. The Error Trigger lives inside it.
- Run it with
dryRun = trueand read the report. When the numbers look right, setdryRun = falseand activate it.
If your feed needs a login, add a Header Auth, Basic Auth or Query Auth credential to Download supplier feed rather than putting a password in Config.
Older store apps with a permanent shpat_ token: delete Get Shopify token. Set the two Shopify HTTP nodes to Header Auth with a credential X-Shopify-Access-Token: shpat_…, and remove the token header row.
How matching works
SKU is the only key. Real feeds are messy. Mine had sup-1005 with a trailing space and lower case, which doesn't match SUP-1005. The plan node trims and upper-cases both sides before comparing.
Then it sorts every variant into a bucket:
- Matched: the SKU is in the feed. Its stock and price are compared with Shopify.
- Not in feed: the SKU is in Shopify but not in the feed. If
zeroMissingSkusis on, its stock is set to 0, because the supplier dropped it. If it's off, it's left alone. - In feed but not in Shopify: products you haven't listed. They're reported only. A sync should never create products behind your back.
- Skipped: no SKU, a SKU outside your
skuPrefix, "don't track quantity", not stocked at the chosen location, or a duplicate SKU in Shopify. Duplicates are never guessed. If two variants share a SKU, neither gets touched, and the report tells you.
skuPrefix matters more than it looks. If your store mixes supplier items (SUP-…) with your own products, set it to SUP-. Then "not in feed → 0" can never hit your own stock.
How the price rule works
price = cost × markup price = max(price, cost / (1 − minMarginPercent / 100)) price = round UP to the next .99 (priceEnding = 0.99, or -1 to just round to cents) Some examples with markup = 2.2:
- Cost
8,40→ 18.48 → 18.99 - Cost
11,00→ 24.20 → 24.99. That's what the store already had, so nothing is written.
The minimum margin kicks in when you use a low markup. With markup 1.3 and cost 10.00 you get 13.00, which is only a 23% margin. With minMarginPercent = 35 it becomes 15.38, rounded up to 15.99.
Rounding goes up on purpose. Rounding 18.48 down to 17.99 would quietly eat your margin.
There's also maxPriceChangePercent (default 50). Suppliers make typos. In one of my test feeds, a cost of 18.20 became 180.0, which would have priced a 38.99 vase at 396.99. Any change bigger than the limit is held for review in the Slack report and not written. stockBuffer subtracts a safety margin from the supplier's stock, so you're not selling their last unit.
Dry run
dryRun is true in the template. In dry-run mode the plan is built in full, but the IF node sends it straight to Slack. A real report from one of my runs looks like this:
*Supplier → Shopify sync* — DRY RUN, nothing was written Feed: 8 rows · Shopify: 10 variants · managed: 9 · matched: 7 Would change: 5 stock, 6 price · unchanged: 1 Not in feed: 2 (SUP-1007, OWN-001), 1 set to 0 In feed but not in Shopify: 1 (SUP-2000) Skipped: no SKU 1, not tracked 1, not stocked at location 1 *Changes:* • SUP-1001 Linen Napkin Set - Natural: stock 10 → 25, price 19.99 → 18.99 • SUP-1003 Ceramic Vase - Small: stock 0 → 6 • SUP-1007 Candle Trio - Default Title: not in feed, stock 12 → 0 Run the same feed again in live mode and nothing is written. If nothing changed, no Slack message goes out either, unless you turn on notifyWhenNothingChanged.
The gotchas (the reason this took longer than an afternoon)
I built and ran it against a test Shopify API on n8n 2.44.5. These are the things that bit me, plus one API change you need to know about.
1. "Throttled" arrives as HTTP 200
Shopify's GraphQL API limits you by query cost. When you're over, you can get HTTP 200 with errors[].extensions.code = "THROTTLED". n8n's Retry on fail never fires, because nothing technically failed.
I use the HTTP node's pagination as a retry loop. For reads, the pagination expression keeps the same cursor when a page was throttled. It also refuses to "complete" on a throttled response:
// Pagination parameter (body → variables) {{ { after: $response.body.data ? $response.body.data.productVariants.pageInfo.endCursor : $request.body.variables.after, loc: $('Config').first().json.locationId } }} // Complete when (Other) {{ JSON.stringify($response.body.errors || '').includes('THROTTLED') ? false : !($response.body.data && $response.body.data.productVariants.pageInfo.hasNextPage) }} For writes, the same trick works by updating a dummy X-Attempt header. Pagination finishes when the response is not throttled, with at most 6 tries and 2 seconds between them.
That cursor detail matters more here than in an alert workflow. If a throttled page silently ended the loop, you'd get half your variants. Everything on the missing half would then look "not in feed" and be set to 0. So the plan node also checks that the last page has hasNextPage: false. If it doesn't, it stops before writing anything.
2. API 2026-04 changed inventorySetQuantities
From API version 2026-04, inventorySetQuantities requires two things. Every quantity needs a changeFromQuantity, and the mutation needs an @idempotent(key: …) directive. Without them, old snippets from blog posts fail.
Both are actually useful:
mutation SetStock($input: InventorySetQuantitiesInput!, $key: String!) { b0: inventorySetQuantities(input: $input) @idempotent(key: $key) { inventoryAdjustmentGroup { changes { name delta } } userErrors { code field message } } } changeFromQuantity is the stock the workflow read a moment ago. If an order comes in between the read and the write, Shopify answers CHANGE_FROM_QUANTITY_STALE instead of overwriting the sale. The report lists it as an error, and the next run fixes it with fresh numbers. I tested exactly that case.
The idempotency key is built from the execution ID and the batch number. A throttled retry sends the identical body, so it can't apply twice.
Prices go through productVariantsBulkUpdate, which takes one product at a time. The workflow puts up to 10 products in one request using GraphQL aliases. Stock goes in batches of 100 quantities.
3. Don't trust item order after an HTTP node with retries
With pagination as a retry loop, one batch can produce three output items: two throttled and one real. I first used pairedItem to map responses back to batches. In my runs, inside the Code node, it pointed at response positions instead, so the report said "0 updated" when everything had worked.
The fix is to give every batch its own GraphQL alias (b0, b1_u0, b1_u1…). The alias comes back in the response, so Check results knows which batch each result belongs to. It counts what Shopify says changed, not what was requested.
4. European CSVs
Supplier files from EU wholesalers are often semicolon-separated, use decimal commas (8,40, "1.234,50"), and start with an Excel BOM. The parser auto-detects ;, ,, tab and |. It strips the BOM, handles quoted fields, and reads both 1.234,50 and 1,234.50. A stock value of >100 becomes 100.
If a column name is wrong, the run stops with a message listing the real headers, for example Column "Item No" not found in feed. Columns are: SKU, Name, Stock, Price. That beats a silent "0 matched".
5. A broken feed must not empty your store
Feeds break: an empty file, a login page instead of a CSV, a half-uploaded export. With "missing → 0" turned on, that could zero your whole catalog.
maxZeroPercent (default 30) is a hard stop. When I fed the workflow a file containing only unknown SKUs, it refused: Safety stop: 7 of 7 stock-tracked variants are missing from the feed and would be set to 0 (limit 30%). It wrote nothing, and the error branch sent the alert.
A few n8n habits that helped
- Keep decisions in one Code node and keep the HTTP nodes dumb. Then the dry run and the live run share 100% of the logic.
- Pin the feed and variant outputs while you tweak pricing rules, so you're not downloading the feed on every click. Unpin before you activate.
- The 24-hour access token appears in saved execution data. If that bothers you, turn off saving of successful executions in the workflow settings.
Limits
It syncs one location per workflow. For two warehouses, duplicate the workflow and change locationId. It doesn't do compare-at prices, currency conversion or variant creation. Always start with a development store and dry-run mode.
The template
In n8n, open a new workflow and paste this with Ctrl/Cmd+V, or save it as a .json file and use Import from file.
{ "name": "Supplier feed → Shopify stock & price sync (free, by Tikkivo)", "nodes": [ {"id": "5f0c1e2a-0002-4000-8000-000000000101", "name": "Sticky: setup", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [-460, -420], "parameters": {"content": "## Supplier feed → Shopify stock & price sync\n**Free template by Arturas (Tikkivo)**\n\nEvery 6 hours: downloads your supplier's CSV/JSON feed, matches rows to Shopify variants by **SKU**, and updates **only the stock and prices that changed**. It never creates, edits or deletes products, titles, images or descriptions.\n\n### Setup (10 min)\n1. Shopify **Dev Dashboard** → create an app with scopes `read_products, write_products, read_inventory, write_inventory`, install it on your store, copy client ID + secret.\n2. n8n → Credentials → **Custom Auth**:\n`{\"body\": {\"client_id\": \"…\", \"client_secret\": \"…\"}}`\nSelect it in **Get Shopify token**.\n3. Fill in **Config** (shop URL, location ID, feed URL, column names, markup, Slack webhook).\n4. Paste the Slack webhook URL in **Slack error alert** too.\n5. Workflow settings → **Error workflow** → choose *this workflow*.\n6. Run with `dryRun = true`, read the Slack report, then set `dryRun = false` and activate.", "width": 420, "height": 760, "color": 4}}, {"id": "5f0c1e2a-0002-4000-8000-000000000102", "name": "Sticky: config", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [-20, -420], "parameters": {"content": "### Config cheat sheet\n- **locationId**: Settings → Locations → open one → the number in the URL → `gid://shopify/Location/<number>`\n- **dryRun** (default ON): only reports, writes nothing\n- **skuColumn / stockColumn / costColumn**: header names exactly as in the feed\n- **csvDelimiter**: empty = auto (`;` `,` tab `|`). Decimal commas like `8,40` are handled\n- **skuPrefix**: only manage SKUs starting with this (e.g. `SUP-`). Empty = all\n- **price** = cost × markup, then at least `minMarginPercent` margin, then rounded *up* to `priceEnding` (.99). `-1` = round to cents\n- **maxPriceChangePercent**: bigger jumps are held for review, not written\n- **stockBuffer**: subtract this from supplier stock (safety stock)\n- **zeroMissingSkus**: SKUs not in the feed → stock 0\n- **maxZeroPercent**: safety stop if a broken feed would zero more than this share of variants\n\nSecrets live in n8n **credentials**, not here.", "width": 420, "height": 600, "color": 7}}, {"id": "5f0c1e2a-0002-4000-8000-000000000103", "name": "Sticky: legacy token", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [420, -420], "parameters": {"content": "### Old store app with a `shpat_` token?\nDelete **Get Shopify token**. In **Fetch Shopify variants** and **Send updates to Shopify** switch Authentication to *Generic → Header Auth* with a credential `X-Shopify-Access-Token: shpat_…`, and remove the token header row.\n\n### Feed needs a login?\nAdd a Header Auth / Basic Auth / Query Auth credential to **Download supplier feed**. Don't put passwords in Config.", "width": 400, "height": 300, "color": 5}}, {"id": "5f0c1e2a-0002-4000-8000-000000000104", "name": "Sticky: safety", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [1080, -420], "parameters": {"content": "### Safety built in\n- Writes only what differs from Shopify right now\n- Stock writes use `changeFromQuantity` (compare-and-swap) + an idempotency key, so a sale in between is not overwritten and a retry can't double-apply\n- Throttled responses (HTTP 200 + `THROTTLED`) are retried by the pagination settings, up to 6 tries\n- Incomplete variant list, missing columns, empty feed or too many zeroes → the run stops **before** writing\n- Duplicate SKUs in Shopify are skipped, never guessed", "width": 460, "height": 300, "color": 6}}, {"id": "5f0c1e2a-0002-4000-8000-000000000105", "name": "Sticky: errors", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [-20, 460], "parameters": {"content": "### Error alerts\nRuns when any production execution fails (set it under Workflow settings → Error workflow). Paste your Slack webhook URL in the node.", "width": 420, "height": 200, "color": 3}}, {"id": "5f0c1e2a-0002-4000-8000-000000000001", "name": "Every 6 hours", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.2, "position": [0, 200], "parameters": {"rule": {"interval": [{"field": "hours", "hoursInterval": 6}]}}}, {"id": "5f0c1e2a-0002-4000-8000-000000000002", "name": "Run manually", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [0, 360], "parameters": {}}, {"id": "5f0c1e2a-0002-4000-8000-000000000003", "name": "Config", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [220, 280], "parameters": {"assignments": {"assignments": [{"id": "cfg1", "name": "shopUrl", "value": "https://your-store.myshopify.com", "type": "string"}, {"id": "cfg2", "name": "apiVersion", "value": "2026-10", "type": "string"}, {"id": "cfg3", "name": "locationId", "value": "gid://shopify/Location/REPLACE_ME", "type": "string"}, {"id": "cfg4", "name": "dryRun", "value": true, "type": "boolean"}, {"id": "cfg5", "name": "feedUrl", "value": "https://supplier.example.com/stock.csv", "type": "string"}, {"id": "cfg6", "name": "feedFormat", "value": "csv", "type": "string"}, {"id": "cfg7", "name": "csvDelimiter", "value": "", "type": "string"}, {"id": "cfg8", "name": "jsonItemsKey", "value": "items", "type": "string"}, {"id": "cfg9", "name": "skuColumn", "value": "Item No", "type": "string"}, {"id": "cfg10", "name": "stockColumn", "value": "Qty available", "type": "string"}, {"id": "cfg11", "name": "costColumn", "value": "Net price EUR", "type": "string"}, {"id": "cfg12", "name": "skuPrefix", "value": "", "type": "string"}, {"id": "cfg13", "name": "updateStock", "value": true, "type": "boolean"}, {"id": "cfg14", "name": "updatePrices", "value": true, "type": "boolean"}, {"id": "cfg15", "name": "markup", "value": 2.2, "type": "number"}, {"id": "cfg16", "name": "priceEnding", "value": 0.99, "type": "number"}, {"id": "cfg17", "name": "minMarginPercent", "value": 35, "type": "number"}, {"id": "cfg18", "name": "maxPriceChangePercent", "value": 50, "type": "number"}, {"id": "cfg19", "name": "stockBuffer", "value": 0, "type": "number"}, {"id": "cfg20", "name": "zeroMissingSkus", "value": true, "type": "boolean"}, {"id": "cfg21", "name": "maxZeroPercent", "value": 30, "type": "number"}, {"id": "cfg22", "name": "inventoryReason", "value": "correction", "type": "string"}, {"id": "cfg23", "name": "slackWebhookUrl"<...