{
  "name": "Lumeor | Shopify order exception checks",
  "nodes": [
    {
      "parameters": {
        "content": "## Shopify order exception starter\n\nEvery new order is checked before it reaches the warehouse. Code checks the address, PO boxes on carriers that cannot deliver to them, billing and shipping country mismatches, Shopify's risk recommendation, oversold line items and repeat orders. Claude reads only the order note, note attributes and line item properties and classifies them (change request, delivery instruction, gift message, complaint, nothing actionable), quoting the sentence that triggered a change request. Exceptions are tagged, optionally held, posted to Slack with a suggested customer email and logged to a sheet. Orders that pass are left alone.\n\n### Setup\n1. Create a custom app with (since January 1, 2026, new custom apps are created in the Shopify Dev Dashboard; apps created earlier in the admin keep working) `read_orders`, `write_orders`, `read_merchant_managed_fulfillment_orders`, `write_merchant_managed_fulfillment_orders` (and the third-party equivalents if a 3PL fulfills) and `read_inventory`. Add it to n8n as a Shopify Access Token credential (the trigger uses the app secret to verify the HMAC signature on each webhook).\n2. Replace `REPLACE_WITH_SHOP` in both GraphQL URLs. Keep the API version inside Shopify's support window.\n3. In **Rule checks and build prompt**, review `PO_BOX_BLOCKED_CARRIERS`, `DUPLICATE_WINDOW_MINUTES` and `SHADOW_MODE`. Shadow mode (the default) tags orders without holding them; switch it off after a week of comparing tags with what your team would have caught.\n4. Set `REPLACE_WITH_SLACK_CHANNEL_ID` and `REPLACE_WITH_SHEET_ID` (tab \"Order exceptions\").\n5. Credentials: Shopify Access Token, Anthropic API, Slack, Google Sheets OAuth2.\n\nPayment details are never sent to the model. `claude-haiku-5-5` is a cheaper option for high-volume, simple classification like this.\n\nTemplate by Lumeor Studio, https://www.lumeor.studio/guides/automate-shopify-order-exceptions/",
        "height": 900,
        "width": 580,
        "color": 7
      },
      "id": "7c75d771-3b8d-4022-8e01-c81316831327",
      "name": "About this template",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -600,
        -300
      ]
    },
    {
      "parameters": {
        "authentication": "accessToken",
        "topic": "orders/create"
      },
      "id": "c1c7394a-cf07-4b01-96e8-ed2b68adcccd",
      "name": "Shopify: order created",
      "type": "n8n-nodes-base.shopifyTrigger",
      "typeVersion": 1,
      "position": [
        0,
        300
      ],
      "webhookId": "9a191bc9-96dc-4d60-b634-a59432e51a71"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://REPLACE_WITH_SHOP.myshopify.com/admin/api/2026-07/graphql.json",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "shopifyAccessTokenApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ query: 'query($id: ID!) { order(id: $id) { id name risk { recommendation } lineItems(first: 50) { nodes { title quantity variant { id title inventoryQuantity inventoryPolicy } } } fulfillmentOrders(first: 10) { nodes { id status } } } }', variables: { id: $json.admin_graphql_api_id } }) }}",
        "options": {
          "timeout": 20000
        }
      },
      "id": "3d27f560-ae46-4358-bf32-3edfbc0fe8e7",
      "name": "Shopify: risk, stock and fulfillment orders",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        220,
        300
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Config\nconst PO_BOX_BLOCKED_CARRIERS = ['ups', 'fedex', 'dhl'];\nconst DUPLICATE_WINDOW_MINUTES = 30;\nconst SHADOW_MODE = true; // true: tag only. false: also place a fulfillment hold.\n\nconst PO_BOX = /\\b(p\\.?\\s*o\\.?\\s*box|post\\s+office\\s+box|pmb)\\b/i;\nconst state = $getWorkflowStaticData('global');\nstate.recent = (state.recent || []).filter((r) => Date.now() - r.at < DUPLICATE_WINDOW_MINUTES * 60000);\n\nreturn $input.all().map((item, i) => {\n  const o = $('Shopify: order created').itemMatching(i).json;\n  const g = item.json.data?.order || null;\n  const s = o.shipping_address || null;\n  const b = o.billing_address || null;\n  const reasons = [];\n\n  if (!g) reasons.push({ code: 'exc-lookup', text: 'Could not load risk and stock data from Shopify' + (item.json.error ? ': ' + item.json.error.message : '') });\n  if (o.line_items?.some((l) => l.requires_shipping) && !s) reasons.push({ code: 'exc-address', text: 'Shipping required but no shipping address' });\n  if (s) {\n    const missing = ['address1', 'city', 'zip', 'country_code'].filter((k) => !String(s[k] || '').trim());\n    if (missing.length) reasons.push({ code: 'exc-address', text: 'Address missing ' + missing.join(', ') });\n    const carrier = (o.shipping_lines || []).map((l) => (l.title + ' ' + (l.code || '')).toLowerCase()).join(' ');\n    if (PO_BOX.test((s.address1 || '') + ' ' + (s.address2 || '')) && PO_BOX_BLOCKED_CARRIERS.some((c) => carrier.includes(c))) {\n      reasons.push({ code: 'exc-pobox', text: 'PO box with a carrier that does not deliver to PO boxes (' + carrier.trim() + ')' });\n    }\n    if (b && b.country_code && s.country_code && b.country_code !== s.country_code) reasons.push({ code: 'exc-country', text: 'Billing country ' + b.country_code + ' differs from shipping country ' + s.country_code });\n  }\n  const rec = g?.risk?.recommendation;\n  if (rec === 'CANCEL' || rec === 'INVESTIGATE') reasons.push({ code: 'exc-risk', text: 'Shopify risk recommendation: ' + rec });\n  for (const li of g?.lineItems?.nodes || []) {\n    if (li.variant && li.variant.inventoryQuantity < 0) reasons.push({ code: 'exc-oversell', text: li.title + ' is oversold (available ' + li.variant.inventoryQuantity + ')' });\n  }\n  const dupKey = String(o.email || '').toLowerCase() + '|' + String(s?.address1 || '').toLowerCase().replace(/\\W/g, '');\n  const dup = o.email ? state.recent.find((r) => r.key === dupKey && r.id !== o.id) : null;\n  if (dup) reasons.push({ code: 'exc-duplicate', text: 'Same email and address as order ' + dup.name + ' within ' + DUPLICATE_WINDOW_MINUTES + ' minutes' });\n  state.recent.push({ key: dupKey, id: o.id, name: o.name, at: Date.now() });\n\n  // Free text only. Never send payment details to the model.\n  const freeText = [\n    o.note ? 'Order note: ' + o.note : '',\n    ...(o.note_attributes || []).map((a) => 'Note attribute ' + a.name + ': ' + a.value),\n    ...(o.line_items || []).flatMap((l) => (l.properties || []).filter((p) => !String(p.name).startsWith('_')).map((p) => 'Line item \"' + l.title + '\" property ' + p.name + ': ' + p.value)),\n  ].filter(Boolean).join('\\n');\n\n  return {\n    json: {\n      orderId: o.id,\n      orderGid: o.admin_graphql_api_id,\n      orderName: o.name,\n      email: o.email,\n      customerName: [s?.first_name, s?.last_name].filter(Boolean).join(' ') || o.customer?.first_name || '',\n      shadowMode: SHADOW_MODE,\n      openFulfillmentOrders: (g?.fulfillmentOrders?.nodes || []).filter((f) => ['OPEN', 'SCHEDULED'].includes(f.status)).map((f) => f.id),\n      reasons,\n      hasFreeText: freeText.length > 0,\n      claudeRequest: freeText ? {\n        model: 'claude-opus-5-5',\n        max_tokens: 1500,\n        system: 'You read the free-text fields of an online store order for the operations team. Classify each into one category. A change request asks to change the items, size, color, quantity or address. For change requests and complaints, quote the exact sentence that triggered it and draft a short, friendly email to the customer confirming what they want. Otherwise draft_email is null.',\n        messages: [{ role: 'user', content: '<order_text>\\n' + freeText + '\\n</order_text>' }],\n        output_config: {\n          effort: 'low',\n          format: {\n            type: 'json_schema',\n            schema: {\n              type: 'object',\n              additionalProperties: false,\n              required: ['category', 'trigger_quote', 'summary', 'draft_email'],\n              properties: {\n                category: { type: 'string', enum: ['change_request', 'delivery_instruction', 'gift_message', 'complaint', 'nothing_actionable'] },\n                trigger_quote: { anyOf: [{ type: 'string' }, { type: 'null' }] },\n                summary: { type: 'string', description: 'One line for the ops team.' },\n                draft_email: { anyOf: [{ type: 'string' }, { type: 'null' }] },\n              },\n            },\n          },\n        },\n      } : null,\n    },\n    pairedItem: { item: i },\n  };\n});\n"
      },
      "id": "e2b61697-ade3-4cc9-9ca3-41b320fb9735",
      "name": "Rule checks and build prompt",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        300
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "db77e27a-e8d1-4e07-8f20-22533ebd0f41",
              "leftValue": "={{ $json.hasFreeText }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "1f68f09b-9076-4e9d-973b-d4519cbcea52",
      "name": "Has free text?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        660,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "anthropicApi",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.claudeRequest) }}",
        "options": {
          "timeout": 120000
        }
      },
      "id": "b8816682-f9ae-4bce-ab1b-65f618970d0d",
      "name": "Claude: classify order notes",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        880,
        200
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Pull the structured JSON out of a Messages API response.\n// Opus 5.5 always thinks, so a (possibly empty) thinking block can come before the text block.\nfunction parseClaude(res) {\n  if (!res || typeof res !== 'object') return { error: 'Empty response from the Messages API' };\n  if (res.stop_reason === 'refusal') {\n    return { error: 'Model declined the request (refusal' + (res.stop_details?.category ? ': ' + res.stop_details.category : '') + ')' };\n  }\n  if (res.stop_reason === 'max_tokens') return { error: 'Response hit max_tokens before the JSON was complete' };\n  const block = (res.content || []).find((b) => b.type === 'text');\n  if (!block) return { error: 'No text block in the response' };\n  try {\n    return { data: JSON.parse(block.text) };\n  } catch (e) {\n    return { error: 'Could not parse model JSON: ' + e.message };\n  }\n}\n\nreturn $input.all().map((item, i) => {\n  // Items arrive either from Claude (success or error output) or straight from the If node (no free text).\n  const fromRules = 'orderGid' in item.json;\n  const o = fromRules ? item.json : $('Rule checks and build prompt').itemMatching(i).json;\n  const { claudeRequest, ...order } = o;\n  const reasons = [...order.reasons];\n  let note = null;\n  if (!fromRules) {\n    const { data, error } = parseClaude(item.json.error ? null : item.json);\n    if (error) reasons.push({ code: 'exc-note-unread', text: 'Order has notes but AI classification failed: ' + (item.json.error?.message || error) });\n    else {\n      note = data;\n      if (data.category === 'change_request') reasons.push({ code: 'exc-change', text: 'Change request: \"' + (data.trigger_quote || data.summary) + '\"' });\n      if (data.category === 'complaint') reasons.push({ code: 'exc-complaint', text: 'Complaint in order note: \"' + (data.trigger_quote || data.summary) + '\"' });\n    }\n  }\n  const tags = [...new Set(reasons.map((r) => r.code))];\n  const holdIds = order.shadowMode ? [] : order.openFulfillmentOrders;\n  // One GraphQL request: tag the order and hold each open fulfillment order (aliases h0, h1, ...).\n  const holdBlocks = holdIds.map((id, n) => 'h' + n + ': fulfillmentOrderHold(id: \"' + id + '\", fulfillmentHold: { reason: OTHER, reasonNotes: $notes }) { fulfillmentOrder { id status } userErrors { field message } }').join(' ');\n  // GraphQL rejects declared-but-unused variables, so $notes is only declared when there are holds.\n  const mutation = 'mutation($id: ID!, $tags: [String!]!' + (holdIds.length ? ', $notes: String' : '') + ') { tagsAdd(id: $id, tags: $tags) { userErrors { field message } } ' + holdBlocks + ' }';\n  const variables = { id: order.orderGid, tags };\n  if (holdIds.length) variables.notes = reasons.map((r) => r.text).join('; ').slice(0, 250);\n  return {\n    json: {\n      ...order,\n      isException: reasons.length > 0,\n      reasons,\n      tags,\n      note,\n      held: holdIds.length > 0,\n      graphql: { query: mutation, variables },\n    },\n    pairedItem: { item: i },\n  };\n});\n"
      },
      "id": "6ab9f32e-ad08-4fcc-9062-aa3a7bd39c8d",
      "name": "Decide exception",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        300
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "3cea8fd6-8b61-41a9-a651-1721935ef50f",
              "leftValue": "={{ $json.isException }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "e537b1d3-e3f7-45fa-81bf-73eaf6e32290",
      "name": "Exception?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1320,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://REPLACE_WITH_SHOP.myshopify.com/admin/api/2026-07/graphql.json",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "shopifyAccessTokenApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.graphql) }}",
        "options": {
          "timeout": 20000
        }
      },
      "id": "eb91f7fc-31ef-4c28-a330-400bb6e93053",
      "name": "Shopify: tag and hold order",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1540,
        200
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_SLACK_CHANNEL_ID",
          "mode": "id"
        },
        "text": "=:package: Order *{{ $('Decide exception').item.json.orderName }}* needs review ({{ $('Decide exception').item.json.held ? 'fulfillment on hold' : 'tagged only, not held' }})\nCustomer: {{ $('Decide exception').item.json.customerName }} <{{ $('Decide exception').item.json.email }}>\n{{ $('Decide exception').item.json.reasons.map(r => '- ' + r.code + ': ' + r.text).join('\\n') }}\n{{ ($json.errors || $json.error || Object.values($json.data || {}).some(x => x?.userErrors?.length)) ? ':warning: Tag or hold failed: ' + JSON.stringify($json.errors || $json.error || Object.values($json.data).flatMap(x => x?.userErrors || [])) : '' }}\n{{ $('Decide exception').item.json.note?.draft_email ? 'Suggested email:\\n> ' + $('Decide exception').item.json.note.draft_email.replace(/\\n/g, '\\n> ') : '' }}\nhttps://admin.shopify.com/store/REPLACE_WITH_SHOP/orders/{{ $('Decide exception').item.json.orderId }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "72fc0fe0-25ae-4e50-a0dd-d2b884d9e68e",
      "name": "Slack: exception for review",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1760,
        120
      ]
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "value": "REPLACE_WITH_SHEET_ID",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Order exceptions",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "logged_at": "={{ $now.toISO() }}",
            "order": "={{ $('Decide exception').item.json.orderName }}",
            "reason_codes": "={{ $('Decide exception').item.json.tags.join(', ') }}",
            "details": "={{ $('Decide exception').item.json.reasons.map(r => r.text).join(' | ') }}",
            "held": "={{ $('Decide exception').item.json.held }}",
            "note_category": "={{ $('Decide exception').item.json.note?.category ?? '' }}"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "logged_at",
              "displayName": "logged_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "order",
              "displayName": "order",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "reason_codes",
              "displayName": "reason_codes",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "details",
              "displayName": "details",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "held",
              "displayName": "held",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "note_category",
              "displayName": "note_category",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ]
        },
        "options": {}
      },
      "id": "649e5c66-6a3f-438b-bdff-5fbd9ce7a3c1",
      "name": "Log exception",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        1760,
        320
      ]
    },
    {
      "parameters": {},
      "id": "6ee140d1-f30d-495f-a286-fb7222a7d12d",
      "name": "Passed: no action",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1540,
        440
      ]
    }
  ],
  "connections": {
    "Shopify: order created": {
      "main": [
        [
          {
            "node": "Shopify: risk, stock and fulfillment orders",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Shopify: risk, stock and fulfillment orders": {
      "main": [
        [
          {
            "node": "Rule checks and build prompt",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Rule checks and build prompt": {
      "main": [
        [
          {
            "node": "Has free text?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has free text?": {
      "main": [
        [
          {
            "node": "Claude: classify order notes",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Decide exception",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude: classify order notes": {
      "main": [
        [
          {
            "node": "Decide exception",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Decide exception",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Decide exception": {
      "main": [
        [
          {
            "node": "Exception?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Exception?": {
      "main": [
        [
          {
            "node": "Shopify: tag and hold order",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Passed: no action",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Shopify: tag and hold order": {
      "main": [
        [
          {
            "node": "Slack: exception for review",
            "type": "main",
            "index": 0
          },
          {
            "node": "Log exception",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
