Order tags are the cheapest automation a Shopify store has. Once an order says high-value or risk-high, you can filter the order list by it, build a saved view for the packing team, trigger Shopify Flow, or exclude it from an auto-fulfilment app. The hard part is getting the tags on in the first place, consistently, without someone clicking through every order.
This is the third free n8n workflow I'm sharing, after the low-stock alerts and the supplier stock and price sync. It has 15 working nodes plus setup notes, dry-run mode on by default, and the full JSON is at the end.
What it does
Every 15 minutes:
- Config holds the shop URL, the rules and the tag names. Leave a tag empty and that rule is off.
- Get Shopify token swaps your app's client ID and secret for a short-lived Admin API token, so the secret stays in an n8n credential.
- Build order query creates the search for the last 24 hours, and only requests the fields your enabled rules need.
- Fetch recent orders pages through the results, retrying throttled pages.
- Decide tags applies the rules below. It writes nothing.
- In dry-run mode the plan goes straight to Slack. In live mode, Add tags in Shopify sends up to 25 orders per request and Check results counts what Shopify actually accepted.
- Slack gets a short report, with links to the orders that need a human.
- An error branch posts to Slack when a production run fails.
The rules:
| Tag | When |
|---|---|
high-value |
order total ≥ highValueAmount, in shop currency |
first-order |
the customer's order count is 1 |
repeat-customer |
order count ≥ repeatCustomerMinOrders (default 3) |
risk-high / risk-medium
|
Shopify's risk assessment is HIGH/MEDIUM, or the recommendation is CANCEL/INVESTIGATE |
international |
ships outside homeCountry (off by default) |
express |
the shipping line contains "express", "next day" or "priority" |
guest |
no customer on the order (off by default) |
It only adds tags. It never removes one, never edits or cancels an order, and never sends customer names or emails to Slack. The report holds the order number, the total and the tags.
Setup (about 10 minutes)
- In Shopify's Dev Dashboard, create an app with
read_orders,write_ordersandread_customers. Under protected customer data, request access to customer data. The order count lives on the customer, and without it the request fails (more on that below). If you turn on the international rule, the shipping address is also protected data, so include addresses. - Install the app on your store and copy the client ID and secret.
- In n8n, create a Custom Auth credential with
{"body": {"client_id": "…", "client_secret": "…"}}and select it in Get Shopify token. - Fill in Config. Paste your Slack incoming-webhook URL there and in Slack error alert, because the error branch runs as its own execution and can't read Config.
- Under Workflow settings → Error workflow, pick this same workflow.
- Run it once with
dryRun = true. Then set it tofalseand activate it.
Using test orders on a development store? Set includeTestOrders = true, or every Bogus Gateway order gets skipped as a test.
What the report looks like
This is the dry-run report from one of my runs:
*Shopify order tagger* — DRY RUN, nothing was written
Orders checked: 9 (last 24h) · would tag: 5 · alerts: 4
Skipped: test orders 1, cancelled 1, too new 1, waiting for risk analysis 1
*Needs a look:*
• #1001 320.00 EUR → high-value, first-order
• #1003 89.90 EUR → first-order, risk-high (risk HIGH)
• #1008 260.00 EUR → high-value, express
• #1011 75.50 EUR → risk-medium (risk MEDIUM)
*Tags only:*
• #1002 45.00 EUR → repeat-customer
In Slack each order number is a link to the order in your admin. Which new tags count as alerts is up to you (alertTags). Orders that only get quiet tags like repeat-customer are tagged without a message, so the channel stays readable. If you'd also like a message when there's nothing to do, turn on notifyEveryRun.
The gotchas
I built and ran it against a test Shopify API on n8n 2.44.5. These are the things that shaped the design.
1. Use tagsAdd, not orderUpdate
The obvious mutation is orderUpdate(input: {id, tags}). But tags there is the full list: it replaces what's on the order. If a staff member added gift-wrap a minute earlier, it's gone. tagsAdd only adds, and adding a tag that's already there does nothing. So a retry is harmless too.
mutation Tag($id0: ID!, $t0: [String!]!, $id1: ID!, $t1: [String!]!) {
b0_o0: tagsAdd(id: $id0, tags: $t0) { node { id } userErrors { field message } }
b0_o1: tagsAdd(id: $id1, tags: $t1) { node { id } userErrors { field message } }
}
Each order gets its own alias, so one request covers 25 orders. The alias also tells Check results which order a result belongs to. In one test I made Shopify reject the tags on a single order. The other four were tagged, the report listed the error, and the next run picked up that order.
2. Fresh orders aren't finished yet
Right after checkout, Shopify's fraud analysis can still be running. The assessment comes back as PENDING, or there isn't one yet. If you evaluate the order at that moment, every order looks safe.
So the workflow leaves orders younger than minOrderAgeMinutes (10) alone. If risk is still pending, it waits up to maxRiskWaitMinutes (60) before tagging anyway, and notes "risk analysis not finished" in the report. In my tests, an order with a pending assessment was skipped. After I switched its assessment to LOW, the next run tagged it.
3. Don't keep state. Use a marker tag.
How do you avoid tagging and alerting the same order every 15 minutes? You could store processed IDs in a database or n8n's static data. I went simpler. Every handled order also gets auto-tagged, in the same call as its other tags. The search excludes orders that already have it, and the Code node checks again in case the search ever doesn't.
That gives you three useful properties:
- If a write fails, the marker isn't there, so the order is retried next run.
- The lookback window (24h) can be much longer than the schedule (15 min). A missed run or a short n8n outage loses nothing.
- In the Shopify admin you can see which orders the workflow has handled.
The search uses a full UTC timestamp (created_at:>='2026-10-28T17:15:34Z'), not a date like 2026-10-28. Shopify can read date-only values in the store's timezone, and the window then quietly shifts by a few hours.
4. Partial data with errors is worse than a failure
If the app doesn't have protected customer data access, Shopify can still answer with HTTP 200. Your orders come back, but customer is null, and an ACCESS_DENIED error sits next to the data. If you only look at data, every customer becomes a guest. No first-order tag ever appears, and nothing tells you why.
Decide tags treats any GraphQL error as fatal. It stops before writing and puts a hint in the error message:
Shopify returned an error, nothing was tagged: [{"message":"This app is not approved to access
the Customer object…","extensions":{"code":"ACCESS_DENIED"}}] -> Check the app scopes
(read_orders, write_orders, read_customers) and request protected customer data access…
The same goes for a half-loaded order list. If the last page still says hasNextPage: true, the run stops.
5. Small type traps
-
customer.numberOfOrdersis anUnsignedInt64, which arrives as a string ("1"). Convert it before comparing. It includes the current order, so a first order is1, not0. - Use
totalPriceSet.shopMoney, notpresentmentMoney. A customer paying 346.10 USD on a EUR store should be compared against your EUR threshold. - Shopify treats a comma in a tag as a separator, so
high value, VIPbecomes two tags. The workflow refuses tag names that contain a comma or are longer than 40 characters.
6. Throttling arrives as HTTP 200
As with the supplier sync, a throttled GraphQL call can return HTTP 200 with THROTTLED in errors, so n8n's Retry on fail never triggers. Both HTTP nodes use the pagination settings as a retry loop. A throttled read keeps the same cursor, and a throttled write is sent again, up to 6 times, 2 seconds apart. In my test, two throttled reads and two throttled writes still ended with every order tagged once.
One thing to check before you go live
Adding a tag fires the orders/updated webhook. If you have Shopify Flow rules or apps that react to order updates or tags, they'll run on these orders too. That's often the point, since "if tagged risk-high, hold fulfilment" is a great Flow, but look at what's listening first.
Ideas to extend it
- Add a Telegram or email node next to the Slack one.
- A
wholesaletag when the customer has aB2Btag. - A
po-boxtag by checkingaddress1(needs address access). - Put the order count rules on customer tags as well, using
tagsAddon the customer ID (needswrite_customers).
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": "Shopify order auto-tagger + Slack alerts (free, by Tikkivo)",
"nodes": [
{"parameters": {"content": "## Shopify order auto-tagger + alerts\n**Free template by Arturas (Tikkivo)**\n\nEvery 15 minutes it looks at recent orders and **adds** tags: `high-value`, `first-order`, `repeat-customer`, `risk-high`, `risk-medium`, `international`, `express`. Orders that need a human (high value, risky) are posted to Slack with a link. It never removes tags, never edits or cancels orders, and never sends customer names or emails to Slack.\n\n### Setup (10 min)\n1. Shopify **Dev Dashboard** → create an app with scopes `read_orders, write_orders, read_customers`. Under **protected customer data**, request access to customer data (needed for the order count). Install it, 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**. Paste the Slack webhook in Config **and** in **Slack error alert**.\n4. Workflow settings → **Error workflow** → choose *this workflow*.\n5. Run with `dryRun = true`, read the Slack report, then set `dryRun = false` and activate.", "width": 420, "height": 820, "color": 4}, "name": "Sticky: setup", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [-460, -460], "id": "0f7a1c00-0000-4000-8000-000000000000"},
{"parameters": {"content": "### Config cheat sheet\n- **dryRun** (default ON): reports what it *would* tag, writes nothing\n- **lookbackHours**: how far back to look each run (keep it much longer than the schedule; the marker tag prevents double work)\n- **minOrderAgeMinutes**: leave brand-new orders alone so fraud analysis and other apps can finish\n- **maxRiskWaitMinutes**: if risk is still pending after this, tag the order anyway\n- **highValueAmount**: in your **shop currency** (not the customer's)\n- **repeatCustomerMinOrders**: lifetime orders, this one included\n- **homeCountry**: e.g. `LT`. Empty = international rule off (and no address is requested)\n- **any tag empty** = that rule is off\n- **processedTag**: added to every order the workflow has handled, so it's skipped next time. Empty = off\n- **alertTags**: new tags that trigger a Slack alert in live mode", "width": 440, "height": 640, "color": 7}, "name": "Sticky: config", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [-20, -460], "id": "0f7a1c00-0000-4000-8000-000000000001"},
{"parameters": {"content": "### Safety built in\n- Uses `tagsAdd`, which only **adds**. `orderUpdate(tags:)` would replace all tags, including your staff's\n- Throttled responses (HTTP 200 + `THROTTLED`) are retried by the pagination settings\n- Incomplete order list or any GraphQL error → stops **before** writing\n- The marker tag is written in the same call as the other tags, so a failed write is simply retried next run\n- Tagging fires `orders/updated`: check Shopify Flow or apps that react to tags", "width": 460, "height": 320, "color": 6}, "name": "Sticky: safety", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [1300, -460], "id": "0f7a1c00-0000-4000-8000-000000000002"},
{"parameters": {"content": "### Old store app with a `shpat_` token?\nDelete **Get Shopify token**, connect **Config** → **Build order query**. In **Fetch recent orders** and **Add tags in Shopify** switch Authentication to *Generic → Header Auth* with a credential `X-Shopify-Access-Token: shpat_…` and remove the token header row.", "width": 400, "height": 260, "color": 5}, "name": "Sticky: legacy token", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [460, -460], "id": "0f7a1c00-0000-4000-8000-000000000003"},
{"parameters": {"content": "### Error alerts\nRuns when any production execution fails (Workflow settings → Error workflow → this workflow). Paste your Slack webhook URL in the node.", "width": 440, "height": 200, "color": 3}, "name": "Sticky: errors", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [-20, 480], "id": "0f7a1c00-0000-4000-8000-000000000004"},
{"parameters": {"rule": {"interval": [{"field": "minutes", "minutesInterval": 15}]}}, "name": "Every 15 minutes", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.2, "position": [0, 200], "id": "0f7a1c00-0000-4000-8000-000000000005"},
{"parameters": {}, "name": "Run manually", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [0, 360], "id": "0f7a1c00-0000-4000-8000-000000000006"},
{"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": "dryRun", "value": true, "type": "boolean"}, {"id": "cfg4", "name": "lookbackHours", "value": 24, "type": "number"}, {"id": "cfg5", "name": "minOrderAgeMinutes", "value": 10, "type": "number"}, {"id": "cfg6", "name": "maxRiskWaitMinutes", "value": 60, "type": "number"}, {"id": "cfg7", "name": "includeTestOrders", "value": false, "type": "boolean"}, {"id": "cfg8", "name": "skipCancelled", "value": true, "type": "boolean"}, {"id": "cfg9", "name": "highValueAmount", "value": 250, "type": "number"}, {"id": "cfg10", "name": "highValueTag", "value": "high-value", "type": "string"}, {"id": "cfg11", "name": "firstOrderTag", "value": "first-order", "type": "string"}, {"id": "cfg12", "name": "repeatCustomerTag", "value": "repeat-customer", "type": "string"}, {"id": "cfg13", "name": "repeatCustomerMinOrders", "value": 3, "type": "number"}, {"id": "cfg14", "name": "guestTag", "value": "", "type": "string"}, {"id": "cfg15", "name": "riskHighTag", "value": "risk-high", "type": "string"}, {"id": "cfg16", "name": "riskMediumTag", "value": "risk-medium", "type": "string"}, {"id": "cfg17", "name": "homeCountry", "value": "", "type": "string"}, {"id": "cfg18", "name": "internationalTag", "value": "international", "type": "string"}, {"id": "cfg19", "name": "expressKeywords", "value": "express,next day,priority", "type": "string"}, {"id": "cfg20", "name": "expressTag", "value": "express", "type": "string"}, {"id": "cfg21", "name": "processedTag", "value": "auto-tagged", "type": "string"}, {"id": "cfg22", "name": "alertTags", "value": "high-value,risk-high,risk-medium", "type": "string"}, {"id": "cfg23", "name": "slackWebhookUrl", "value": "https://hooks.slack.com/services/XXX/YYY/ZZZ", "type": "string"}, {"id": "cfg24", "name": "notifyEveryRun", "value": false, "type": "boolean"}]}, "options": {}}, "name": "Config", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [220, 280], "id": "0f7a1c00-0000-4000-8000-000000000007"},
{"parameters": {"method": "POST", "url": "={{ $json.shopUrl }}/admin/oauth/access_token", "authentication": "genericCredentialType", "genericAuthType": "httpCustomAuth", "sendBody": true, "specifyBody": "json", "jsonBody": "{ \"grant_type\": \"client_credentials\" }", "options": {}}, "name": "Get Shopify token", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [440, 280], "credentials": {"httpCustomAuth": {"id": "shopifyOrderTag01", "name": "Shopify app (client id + secret)"}}, "id": "0f7a1c00-0000-4000-8000-000000000008"},
{"parameters": {"jsCode": "// Build the order search for this run. Only the fields the enabled rules need are requested.\nconst cfg = $('Config').first().json;\nconst hours = Number(cfg.lookbackHours) > 0 ? Number(cfg.lookbackHours) : 24;\n// Always search in UTC with a full timestamp. A date-only filter like created_at:>=2026-10-28\n// is read in the *store's* timezone, which silently shifts the window.\nconst since = new Date(Date.now() - hours * 3600e3).toISOString();\nconst marker = String(cfg.processedTag || '').trim();\nlet q = `created_at:>='${since}'`;\nif (marker) q += ` tag_not:'${marker}'`; // the Code node double-checks this anyway\nconst fields = [\n 'id', 'name', 'createdAt', 'test', 'cancelledAt', 'tags',\n 'totalPriceSet { shopMoney { amount currencyCode } }',\n 'customer { id numberOfOrders }',\n 'shippingLines(first: 5) { nodes { title } }',\n 'risk { recommendation assessments { riskLevel } }',\n];\n// Address is protected customer data. Only ask for it when the \"international\" rule is on.\nif (String(cfg.homeCountry || '').trim()) fields.push('shippingAddress { countryCodeV2 }');\nconst query = `query Orders($after: String, $q: String!) { orders(first: 50, after: $after, query: $q, sortKey: CREATED_AT) { pageInfo { hasNextPage endCursor } nodes { ${fields.join(' ')} } } }`;\nreturn [{ json: { query, q, since } }];\n"}, "name": "Build order query", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [660, 280], "id": "0f7a1c00-0000-4000-8000-000000000009"},
{"parameters": {"method": "POST", "url": "={{ $('Config').first().json.shopUrl }}/admin/api/{{ $('Config').first().json.apiVersion }}/graphql.json", "sendHeaders": true, "headerParameters": {"parameters": [{"name": "X-Shopify-Access-Token", "value": "={{ $('Get Shopify token').first().json.access_token }}"}]}, "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ query: $('Build order query').first().json.query, variables: { after: null, q: $('Build order query').first().json.q } }) }}", "options": {"pagination": {"pagination": {"parameters": {"parameters": [{"type": "body", "name": "variables", "value": "={{ { after: ($response.body.data && $response.body.data.orders) ? $response.body.data.orders.pageInfo.endCursor : $request.body.variables.after, q: $('Build order query').first().json.q } }}"}]}, "paginationCompleteWhen": "other", "completeExpression": "={{ JSON.stringify($response.body.errors || '').includes('THROTTLED') ? false : !($response.body.data && $response.body.data.orders && $response.body.data.orders.pageInfo.hasNextPage) }}", "limitPagesFetched": true, "maxRequests": 200, "requestInterval": 1000}}}}, "name": "Fetch recent orders", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [880, 280], "retryOnFail": true, "maxTries": 3, "waitBetweenTries": 5000, "id": "0f7a1c00-0000-4000-8000-000000000010"},
{"parameters": {"jsCode": "// Decide tags. Nothing is written here; writes only happen in \"Add tags in Shopify\" when dryRun is false.\nconst cfg = $('Config').first().json;\nconst bool = v => v === true || String(v).trim().toLowerCase() === 'true';\nconst num = (v, d) => (v === '' || v == null || isNaN(Number(v))) ? d : Number(v);\nconst list = s => String(s ?? '').split(',').map(x => x.trim()).filter(Boolean);\nconst lc = s => String(s).toLowerCase();\n\nconst dryRun = bool(cfg.dryRun);\nconst now = Date.now();\nconst since = Date.parse($('Build order query').first().json.since);\nconst minAge = num(cfg.minOrderAgeMinutes, 10) * 60e3;\nconst riskWait = num(cfg.maxRiskWaitMinutes, 60) * 60e3;\nconst marker = String(cfg.processedTag || '').trim();\nconst homeCountry = String(cfg.homeCountry || '').trim().toUpperCase();\nconst expressWords = list(cfg.expressKeywords).map(lc);\nconst alertTags = new Set(list(cfg.alertTags).map(lc));\nconst T = {\n highValue: String(cfg.highValueTag || '').trim(), first: String(cfg.firstOrderTag || '').trim(),\n repeat: String(cfg.repeatCustomerTag || '').trim(), guest: String(cfg.guestTag || '').trim(),\n riskHigh: String(cfg.riskHighTag || '').trim(), riskMedium: String(cfg.riskMediumTag || '').trim(),\n intl: String(cfg.internationalTag || '').trim(), express: String(cfg.expressTag || '').trim(),\n};\nfor (const t of [...Object.values(T), marker]) {\n if (t && (t.includes(',') || t.length > 40)) throw new Error(`Config: tag \"${t}\" is invalid (no commas, max 40 characters).`);\n}\n\n// ---------- 1. Collect pages. Throttled pages were retried by the HTTP node and carry no data. ----------\nlet throttled = 0, last = null; const byId = new Map();\nfor (const { json: p } of $input.all()) {\n const errs = JSON.stringify(p.errors || '');\n if (errs.includes('THROTTLED')) { throttled++; continue; }\n if (p.errors) {\n const hint = errs.includes('ACCESS_DENIED')\n ? ' -> Check the app scopes (read_orders, write_orders, read_customers) and request protected customer data access in the Dev Dashboard.' : '';\n throw new Error('Shopify returned an error, nothing was tagged: ' + errs.slice(0, 300) + hint);\n }\n if (!p.data || !p.data.orders) throw new Error('Unexpected response from Shopify, nothing was tagged: ' + JSON.stringify(p).slice(0, 200));\n for (const o of p.data.orders.nodes) byId.set(o.id, o);\n last = p.data.orders;\n}\nif (!last || last.pageInfo.hasNextPage) throw new Error('Order list is incomplete (pagination stopped early). Nothing was tagged.');\n\n// ---------- 2. Apply the rules ----------\nconst skip = { 'outside window': 0, 'already done': 0, 'test orders': 0, cancelled: 0, 'too new': 0, 'waiting for risk analysis': 0 };\nconst plans = [];\nfor (const o of byId.values()) {\n const created = Date.parse(o.createdAt);\n const has = new Set((o.tags || []).map(lc));\n if (created < since) { skip['outside window']++; continue; } // don't trust the search filter blindly\n if (marker && has.has(lc(marker))) { skip['already done']++; continue; }\n if (o.test && !bool(cfg.includeTestOrders)) { skip['test orders']++; continue; }\n if (o.cancelledAt && bool(cfg.skipCancelled)) { skip.cancelled++; continue; }\n const age = now - created;\n if (age < minAge) { skip['too new']++; continue; }\n const levels = ((o.risk || {}).assessments || []).map(a => a.riskLevel);\n const riskPending = levels.length === 0 || levels.includes('PENDING');\n if (riskPending && age < riskWait) { skip['waiting for risk analysis']++; continue; } // try again next run\n\n const add = [], why = [];\n const money = (o.totalPriceSet || {}).shopMoney || {}; // shop currency, not what the customer paid in\n if (T.highValue && Number(money.amount) >= num(cfg.highValueAmount, Infinity)) add.push(T.highValue);\n const orders = o.customer ? Number(o.customer.numberOfOrders) : null; // UnsignedInt64 arrives as a string\n if (orders === null) { if (T.guest) add.push(T.guest); }\n else if (orders <= 1) { if (T.first) add.push(T.first); }\n else if (T.repeat && orders >= num(cfg.repeatCustomerMinOrders, 3)) add.push(T.repeat);\n const rec = (o.risk || {}).recommendation;\n if (levels.includes('HIGH') || rec === 'CANCEL') { if (T.riskHigh) add.push(T.riskHigh); why.push('risk HIGH'); }\n else if (levels.includes('MEDIUM') || rec === 'INVESTIGATE') { if (T.riskMedium) add.push(T.riskMedium); why.push('risk MEDIUM'); }\n if (riskPending) why.push('risk analysis not finished');\n const cc = (o.shippingAddress || {}).countryCodeV2;\n if (homeCountry && T.intl && cc && cc !== homeCountry) { add.push(T.intl); why.push('ships to ' + cc); }\n const ship = ((o.shippingLines || {}).nodes || []).map(s => lc(s.title || ''));\n if (T.express && expressWords.some(w => ship.some(s => s.includes(w)))) add.push(T.express);\n\n const newTags = [...new Set(add)].filter(t => !has.has(lc(t)));\n const writeTags = marker ? [...newTags, marker] : newTags;\n if (!writeTags.length) continue;\n plans.push({\n id: o.id, name: o.name, total: `${money.amount} ${money.currencyCode}`, newTags, writeTags, why,\n alert: newTags.some(t => alertTags.has(lc(t))),\n url: `${String(cfg.shopUrl).replace(/\\/+$/, '')}/admin/orders/${o.id.split('/').pop()}`,\n });\n}\n\n// ---------- 3. API batches: up to 25 orders per request, one alias per order ----------\nconst size = 25, batches = [];\nfor (let i = 0; i < plans.length; i += size) {\n const chunk = plans.slice(i, i + size), b = batches.length;\n const vars = {}, defs = [], calls = [];\n chunk.forEach((p, j) => {\n vars['id' + j] = p.id; vars['t' + j] = p.writeTags;\n defs.push(`$id${j}: ID!, $t${j}: [String!]!`);\n calls.push(`b${b}_o${j}: tagsAdd(id: $id${j}, tags: $t${j}) { node { id } userErrors { field message } }`);\n });\n batches.push({ ids: chunk.map(p => p.id), body: { query: `mutation Tag(${defs.join(', ')}) { ${calls.join(' ')} }`, variables: vars } });\n}\n\n// ---------- 4. Report (order numbers and totals only, no customer names or emails) ----------\nfor (const p of plans)\n p.line = `• <${p.url}|${p.name}> ${p.total} → ${p.newTags.join(', ') || '(checked, no rule matched)'}${p.why.length ? ' (' + p.why.join(', ') + ')' : ''}`;\nconst skipText = Object.entries(skip).filter(([, n]) => n).map(([k, n]) => `${k} ${n}`).join(', ');\nconst intro = `Orders checked: ${byId.size} (last ${num(cfg.lookbackHours, 24)}h)`;\nconst alerts = plans.filter(p => p.alert), rest = plans.filter(p => !p.alert && p.newTags.length);\nconst out = ['*Shopify order tagger* — DRY RUN, nothing was written',\n `${intro} · would tag: ${plans.length} · alerts: ${alerts.length}`];\nif (skipText) out.push('Skipped: ' + skipText);\nif (alerts.length) out.push('*Needs a look:*', ...alerts.slice(0, 20).map(p => p.line));\nif (rest.length) out.push('*Tags only:*', ...rest.slice(0, 20).map(p => p.line));\nif (rest.length > 20) out.push(`…and ${rest.length - 20} more`);\nconst notify = dryRun ? (plans.length > 0 || bool(cfg.notifyEveryRun)) : (plans.length === 0 && bool(cfg.notifyEveryRun));\nconst text = dryRun ? out.join('\\n') : `*Shopify order tagger* — LIVE\\n${intro} · nothing to tag`;\nreturn [{ json: { dryRun, notify, text, intro, skipText, plans, batches, batchCount: batches.length, ordersChecked: byId.size, throttledReads: throttled } }];\n"}, "name": "Decide tags", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [1100, 280], "id": "0f7a1c00-0000-4000-8000-000000000011"},
{"parameters": {"conditions": {"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2}, "conditions": [{"id": "c1", "leftValue": "={{ $json.dryRun || $json.batchCount === 0 }}", "rightValue": "", "operator": {"type": "boolean", "operation": "true", "singleValue": true}}], "combinator": "and"}, "options": {}}, "name": "Dry run or nothing to tag?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [1320, 280], "id": "0f7a1c00-0000-4000-8000-000000000012"},
{"parameters": {"fieldToSplitOut": "batches", "options": {}}, "name": "Split into API batches", "type": "n8n-nodes-base.splitOut", "typeVersion": 1, "position": [1540, 400], "id": "0f7a1c00-0000-4000-8000-000000000013"},
{"parameters": {"method": "POST", "url": "={{ $('Config').first().json.shopUrl }}/admin/api/{{ $('Config').first().json.apiVersion }}/graphql.json", "sendHeaders": true, "headerParameters": {"parameters": [{"name": "X-Shopify-Access-Token", "value": "={{ $('Get Shopify token').first().json.access_token }}"}]}, "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify($json.body) }}", "options": {"batching": {"batch": {"batchSize": 1, "batchInterval": 1000}}, "pagination": {"pagination": {"parameters": {"parameters": [{"type": "headers", "name": "X-Attempt", "value": "={{ $pageCount + 1 }}"}]}, "paginationCompleteWhen": "other", "completeExpression": "={{ !(JSON.stringify($response.body.errors || '').includes('THROTTLED')) }}", "limitPagesFetched": true, "maxRequests": 6, "requestInterval": 2000}}}}, "name": "Add tags in Shopify", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [1760, 400], "retryOnFail": true, "maxTries": 2, "waitBetweenTries": 5000, "id": "0f7a1c00-0000-4000-8000-000000000014"},
{"parameters": {"jsCode": "// Check results. The HTTP node returns one item per response, throttled retries included, and not always\n// in batch order. The alias (b0_o3 = batch 0, order 3) says which order each result belongs to.\nconst plan = $('Decide tags').first().json;\nconst ok = new Set(); const errors = []; let throttled = 0; const seen = new Set();\nfor (const { json: r } of $input.all()) {\n const errs = JSON.stringify(r.errors || '');\n if (errs.includes('THROTTLED')) { throttled++; continue; }\n if (r.errors) { errors.push(errs.slice(0, 200)); continue; }\n for (const [alias, res] of Object.entries(r.data || {})) {\n const m = alias.match(/^b(\\d+)_o(\\d+)$/); if (!m) continue;\n const id = plan.batches[Number(m[1])].ids[Number(m[2])]; seen.add(id);\n const p = plan.plans.find(x => x.id === id);\n if (res && res.node && !(res.userErrors || []).length) ok.add(id);\n else errors.push(`${p.name}: ${(res.userErrors || []).map(e => e.message).join('; ') || 'not tagged'} (will retry next run)`);\n }\n}\nfor (const p of plan.plans) if (!seen.has(p.id) && !errors.some(e => e.startsWith(p.name + ':')))\n errors.push(`${p.name}: not applied (still throttled or rejected), will retry next run`);\nconst done = plan.plans.filter(p => ok.has(p.id));\nconst alerts = done.filter(p => p.alert), rest = done.filter(p => !p.alert && p.newTags.length);\nconst out = ['*Shopify order tagger* — LIVE',\n `${plan.intro} · tagged: ${done.length}/${plan.plans.length} · alerts: ${alerts.length} · errors: ${errors.length}`];\nif (plan.skipText) out.push('Skipped: ' + plan.skipText);\nif (alerts.length) out.push('*Needs a look:*', ...alerts.slice(0, 20).map(p => p.line));\nif (rest.length) out.push('*Tags only:*', ...rest.slice(0, 20).map(p => p.line));\nif (rest.length > 20) out.push(`…and ${rest.length - 20} more`);\nif (errors.length) out.push('*Errors:*', ...errors.slice(0, 10).map(e => '• ' + e));\nif (throttled) out.push(`Throttled retries: ${throttled}`);\nconst cfg = $('Config').first().json;\nconst always = cfg.notifyEveryRun === true || String(cfg.notifyEveryRun) === 'true';\nreturn [{ json: { notify: alerts.length > 0 || errors.length > 0 || always, text: out.join('\\n'),\n tagged: done.length, alerts: alerts.length, errors: errors.length, throttledRetries: throttled } }];\n"}, "name": "Check results", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [1980, 400], "id": "0f7a1c00-0000-4000-8000-000000000015"},
{"parameters": {"conditions": {"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2}, "conditions": [{"id": "c1", "leftValue": "={{ $json.notify }}", "rightValue": "", "operator": {"type": "boolean", "operation": "true", "singleValue": true}}], "combinator": "and"}, "options": {}}, "name": "Anything to report?", "type": "n8n-nodes-base.filter", "typeVersion": 2.2, "position": [2200, 280], "id": "0f7a1c00-0000-4000-8000-000000000016"},
{"parameters": {"method": "POST", "url": "={{ $('Config').first().json.slackWebhookUrl }}", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ text: $json.text }) }}", "options": {}}, "name": "Send Slack report", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [2420, 280], "retryOnFail": true, "maxTries": 3, "waitBetweenTries": 5000, "id": "0f7a1c00-0000-4000-8000-000000000017"},
{"parameters": {}, "name": "On workflow error", "type": "n8n-nodes-base.errorTrigger", "typeVersion": 1, "position": [0, 580], "id": "0f7a1c00-0000-4000-8000-000000000018"},
{"parameters": {"method": "POST", "url": "https://hooks.slack.com/services/XXX/YYY/ZZZ", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ text: `:warning: *${$json.workflow.name}* failed in node *${$json.execution.lastNodeExecuted || '?'}*\\n${($json.execution.error || {}).message || 'Unknown error'}\\n${$json.execution.url || ''}` }) }}", "options": {}}, "name": "Slack error alert", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [220, 580], "id": "0f7a1c00-0000-4000-8000-000000000019"}
],
"connections": {"Every 15 minutes": {"main": [[{"node": "Config", "type": "main", "index": 0}]]}, "Run manually": {"main": [[{"node": "Config", "type": "main", "index": 0}]]}, "Config": {"main": [[{"node": "Get Shopify token", "type": "main", "index": 0}]]}, "Get Shopify token": {"main": [[{"node": "Build order query", "type": "main", "index": 0}]]}, "Build order query": {"main": [[{"node": "Fetch recent orders", "type": "main", "index": 0}]]}, "Fetch recent orders": {"main": [[{"node": "Decide tags", "type": "main", "index": 0}]]}, "Decide tags": {"main": [[{"node": "Dry run or nothing to tag?", "type": "main", "index": 0}]]}, "Dry run or nothing to tag?": {"main": [[{"node": "Anything to report?", "type": "main", "index": 0}], [{"node": "Split into API batches", "type": "main", "index": 0}]]}, "Split into API batches": {"main": [[{"node": "Add tags in Shopify", "type": "main", "index": 0}]]}, "Add tags in Shopify": {"main": [[{"node": "Check results", "type": "main", "index": 0}]]}, "Check results": {"main": [[{"node": "Anything to report?", "type": "main", "index": 0}]]}, "Anything to report?": {"main": [[{"node": "Send Slack report", "type": "main", "index": 0}]]}, "On workflow error": {"main": [[{"node": "Slack error alert", "type": "main", "index": 0}]]}},
"settings": {"executionOrder": "v1"},
"pinData": {}
}
My products, if you'd rather not build this yourself:
- Tikkivo Catalog Sync is a Shopify app that keeps supplier stock and prices in sync.
- My Gumroad store has more n8n templates for Shopify.
The free workflow above works on its own either way. Questions are welcome in the comments.
Note: I used AI tools to help draft this post. Test the workflow in dry-run/test mode on a development store before using it on a live one.
Top comments (0)