It's 11pm and a customer has just written "where is my order??" for the second time. The answer is sitting in your Shopify admin: shipped Tuesday, with the carrier, due Thursday, tracking link attached. Nobody on your team will send it until tomorrow morning. By then you've got a chargeback thread open and a one-star review half written.
This is the n8n workflow we build to answer that email in two minutes, written up the way you'd want it if you were paying for it. What it does. What each email costs you. Which replies the model is allowed to send on its own and which it isn't. And the actual template you can paste into your own n8n. It's the same shape as the pipeline that resolves 65% of a store's 5,000 monthly emails without a person, which you can read about in the CollageDepot case study. It uses only what you already have: a Gmail inbox, your Shopify store, Slack, and one API key.
The model drafts. Your code decides. Money waits for a person.
A support email arrives, the workflow finds the order number, looks the order up in Shopify, and sends the email plus the live order record to a model with a fixed answer format. The model returns an intent, a language, a confidence and a drafted reply. Then your code decides what happens: order-status and policy answers go straight back to the customer, anything involving money or an order change becomes a Gmail draft a person releases, complaints and mismatches go to Slack with the reason. The model call costs about two cents. The person it replaces costs you about $2.20 an email and gets there the next morning.
What the workflow does, in one screen
Fourteen working nodes, six stages. Every stage writes what it found onto the email for you, so the reply is written from your order record and not from the customer's description of it.
- Catch the emailWhere does the ticket arrive?A Gmail trigger polls your support inbox every minute for new mail. A Settings node holds the eight things you'll edit: store name, shop domain, return window, shipping promise, brand voice, the intents allowed to send, the confidence bar, the Slack channel.
- Normalise and find the orderWhat are we looking at?Sender, subject and the message text are cleaned into fixed fields, quoted replies stripped. A Code node pulls an order number like #1042 out of the subject or body, and falls back to the sender's email address.
- Look up the order in ShopifyWhat does the record actually say?One GraphQL call to your own Admin API: status, fulfilment, carrier, tracking, delivery estimate, line items, the email on the order. Allowed to fail, and the model is told when it did.
- Classify and draftWhat is this, and what would your best agent write?One HTTP call to the Messages API with a JSON schema the answer must match: intent, language, confidence, whether a person is needed, a one-line summary for your team, and a reply in the customer's language.
- Decide the route in codeSend, draft, or escalate?A Code node applies your rules. Safe intents above your confidence bar send. Money and order changes become drafts. Complaints, mismatched emails and anything the model wasn't sure about go to a person. The model never picks the route.
- ActWho does what?Send: Gmail replies in the thread with your disclosure line. Draft: Gmail saves a reply in the thread and Slack tells the team why it's waiting. Escalate: Slack gets the summary, the reason and the link. Spam does nothing.
That's the whole thing. If you've read our guide to ecommerce customer service automation, this is its six-stage pipeline built out for you as a template, with the decisions that guide could only describe.
Why the order lookup is the whole game
Every template library has a "where is my order" auto-reply you can install in ten minutes. Most of them skip the part that makes it work for you. A reply written without the order record is a template, and "your order is on its way" is true of every order and useful to none. A reply written with the record says the parcel left the warehouse on Tuesday with a named carrier, is due Thursday, and here's the tracking link. That's a resolved ticket. Your customer doesn't reply, doesn't call, doesn't open a dispute.
Two things about the lookup are worth your attention, because they decide whether this runs for years or breaks next quarter.
First, the template talks to Shopify's GraphQL Admin API, not the REST one. Shopify declared the REST Admin API legacy on 1 October 2024, and since 1 April 2025 new public apps have to be built on GraphQL alone. The orders query takes a search string, so one call finds the order by name or by your customer's email: name:#1042 for a reference, or email:dana@example.com when there isn't one, most recent first. That's the whole integration, and it's a plain HTTP Request node with your store's access token, so it's yours to change.
Second, the workflow checks that the email address on the order matches the address that wrote to you. If it doesn't, nothing about the order goes to anyone. Your team gets the mismatch as the reason in Slack, and the customer's tracking link stays private. Gorgias, a Shopify help desk, puts "where is my order" at 18% of incoming requests on average, the single largest class you handle. Answering it from the record, and only to the person the record belongs to, is where most of your resolution rate comes from.
What one email costs to handle
Here's the arithmetic on a single support email, using the same $22 loaded hour we use across the agent cost article. Handling one email by hand means you read it, open the order in Shopify, check the carrier, write the reply and close the ticket. We take that as six minutes, the same figure the ecommerce guide uses. Substitute yours.
| Who or what handles the email | Per email | 5,000 emails a month |
|---|---|---|
| A person, six minutes at $22 an hour | $2.20 | $11,000 and 500 hours |
| Classify-and-draft call, Claude Opus 5 (about 1,900 tokens in, 420 out) | $0.020 | $100 |
| Classify-and-draft call, Claude Haiku 4.5, same tokens | $0.004 | $20 |
| Shopify order lookup on your own Admin API | $0 | $0 |
The model call isn't your bill, though. Your bill is people, and it depends on how many emails send, how many wait for a two-minute review, and how many someone handles from scratch. Here are three mixes on the same 5,000 emails, with a review costed at $0.73.
| Mix | Sends on its own | Drafted for review | Handled by a person | Per email | Per month | Human hours a day |
|---|---|---|---|---|---|---|
| Manual, every email handled | 0% | 0% | 100% | $2.20 | $11,000 | 22.7 |
| Template defaults, day one | 40% | 35% | 25% | $0.83 | $4,128 | 8.3 |
| Tuned, CollageDepot's mix | 65% | 15% | 20% | $0.57 | $2,848 | 5.7 |
What the model decides, and what it doesn't
This is the part the template libraries skip, so we'll be precise about it for you, because it's what makes the workflow safe to leave running overnight.
The model gets the customer's email, the order record, your policy facts and your brand voice line. It returns exactly one thing to you: a JSON object that matches a schema you set. An intent from a fixed list, the language the customer wrote in, a confidence from 0 to 1, whether a person is needed and why, a one-line summary for your team, and the reply. The request uses the API's structured-output format, so the answer fits the schema or it isn't returned at all. No stray sentence breaks your next node at 2am.
Then the model's job ends. A few lines of your code decide the route. An intent sends only if it's on your allowed list, the confidence clears your bar, the model didn't ask for a person, and, for order questions, the order was found and the email matched. Everything else becomes a draft or an escalation. The reply that sends gets your disclosure line appended, the one from Settings, so an EU customer is told an automated system wrote it. Since 2 August 2026, Article 50 of the EU AI Act requires that, and it's a two-line setting rather than a project.
| Class of email | If the reply is wrong, it costs you | Expected loss at 95% accuracy | Sends unsupervised? |
|---|---|---|---|
| Order status with tracking | About $3.67, one human follow-up | $0.18 | Yes |
| Policy answer: returns window, shipping times | About $3.67, the same | $0.18 | Yes |
| Address change before dispatch | About $15, a redelivery | $0.75 | No, draft it |
| Discount or goodwill credit | About $20, the credit | $1.00 | No, draft it |
| Refund or return approval | About $40, a typical order | $2.00 | No, draft it |
| Cancel an order already picked | About $60, the order plus the pick | $3.00 | No, draft it |
The same logic covers the model declining or failing. A refusal, a parse error, an intent that isn't on the list: the Code node doesn't guess. It routes the email to a person with the reason attached. Silent failure is the one thing your support inbox must never do, because an email that vanishes looks exactly like a quiet day.
The template, node by node
Fourteen working nodes plus four sticky notes with the setup instructions. You edit one node; the rest run as they are.
| Node | What it does | You edit it? |
|---|---|---|
| New support email | Gmail trigger, polls every minute, unread mail in the inbox. Carries a pinned sample email so you can test without sending one. | Label filter, if you use one |
| Settings | Store name, shop subdomain, return window, shipping promise, brand voice, the intents allowed to send, confidence bar, Slack channel, disclosure line. | Yes, this is the one |
| Normalise the email | Sender address and name, subject, message text with quoted replies stripped, message and thread IDs. | No |
| Find the order reference | Pulls #1042-style references from subject or body; builds the Shopify search from the reference or the sender's email. | If your order prefix differs |
| Look up the order in Shopify | One GraphQL query on your Admin API, three most recent matches, allowed to fail. | Credential only |
| Prepare the brief | Picks the matching order, checks the email matches, writes the record as plain text for the model. | No |
| Classify and draft with Claude | One POST to the Messages API with the schema. Header Auth credential holds your key. | Credential only |
| Decide the route | Applies your rules: send, draft, escalate or skip. Appends the disclosure line to sends. | No |
| Route | Switch on the decision, with skip as the fallback. | No |
| Reply to the customer | Gmail reply in the same thread, plain text, to the sender only. | No |
| Save a draft for review | Gmail draft in the same thread, addressed to the customer, ready to edit and send. | No |
| Ask the team to review | Slack: intent, confidence, reason, the summary and the thread link. | Channel comes from Settings |
| Escalate to a person | Slack, for complaints, mismatched emails, refusals and errors, with the reason. | Channel comes from Settings |
| Nothing to do | Where spam and newsletters end, so they never get a reply. | No |
The model call is deliberately a plain HTTP Request node rather than a vendor node, and so is the Shopify lookup. They survive your n8n upgrades, because the HTTP node's shape hasn't changed in years while the AI nodes get rewritten every few months, and they're yours to point elsewhere. To use a different model you change a URL, two headers and a body, and keep the JSON schema so the routing code still works. To use WooCommerce, you change one query.
Get the template
Copy the JSON below, open a new workflow in your n8n, and paste it onto the canvas. n8n creates all eighteen nodes with the connections and the sticky notes. Then follow the yellow overview note. Edit Settings, add a Header Auth credential named x-api-key for the model node, create a Shopify custom app with the read_orders scope and put its token in an n8n Shopify Access Token credential for the lookup node, select your Gmail and Slack credentials, and click Test workflow. The pinned sample email runs the whole thing end to end before a real customer writes to you.
workflow.json, 14 working nodes and 4 notes, checked node by node against the n8n package it runs in.
{
"name": "Answer Shopify support emails with Claude, draft refunds for review and escalate to Slack",
"nodes": [
{
"parameters": {
"pollTimes": {
"item": [
{
"mode": "everyMinute"
}
]
},
"simple": false,
"filters": {
"readStatus": "unread",
"labelIds": [
"INBOX"
]
},
"options": {}
},
"id": "amp-shop-01",
"name": "New support email",
"type": "n8n-nodes-base.gmailTrigger",
"typeVersion": 1.3,
"position": [
0,
300
]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "s-01",
"name": "storeName",
"type": "string",
"value": "Your Store"
},
{
"id": "s-02",
"name": "shopSubdomain",
"type": "string",
"value": "your-store"
},
{
"id": "s-03",
"name": "returnWindowDays",
"type": "number",
"value": 30
},
{
"id": "s-04",
"name": "shippingPromise",
"type": "string",
"value": "3 to 5 working days domestically and 7 to 12 working days internationally"
},
{
"id": "s-05",
"name": "brandVoice",
"type": "string",
"value": "Warm, brief and plain. Use the customer's first name, no exclamation marks, no corporate phrases."
},
{
"id": "s-06",
"name": "autoSendIntents",
"type": "string",
"value": "order_status,shipping_policy,return_policy_question,product_question"
},
{
"id": "s-07",
"name": "confidenceBar",
"type": "number",
"value": 0.8
},
{
"id": "s-08",
"name": "slackChannel",
"type": "string",
"value": "#support"
},
{
"id": "s-09",
"name": "signature",
"type": "string",
"value": "The Your Store team"
},
{
"id": "s-10",
"name": "disclosureLine",
"type": "string",
"value": "This reply was written by an automated assistant. If anything is unclear, reply to this email and a person will pick it up."
}
]
},
"includeOtherFields": true,
"options": {}
},
"id": "amp-shop-02",
"name": "Settings",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
220,
300
]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "n-01",
"name": "fromAddress",
"type": "string",
"value": "={{ ($json.from && $json.from.value && $json.from.value[0] && $json.from.value[0].address ? String($json.from.value[0].address) : '').toLowerCase().trim() }}"
},
{
"id": "n-02",
"name": "fromName",
"type": "string",
"value": "={{ ($json.from && $json.from.value && $json.from.value[0] && $json.from.value[0].name ? String($json.from.value[0].name) : '').split(' ')[0] }}"
},
{
"id": "n-03",
"name": "subject",
"type": "string",
"value": "={{ String($json.subject || '').trim() }}"
},
{
"id": "n-04",
"name": "text",
"type": "string",
"value": "={{ String($json.text || '').split(/\\r?\\n(?:On .{5,160}wrote:|-{2,} ?Original Message|From: .{3,120}\\r?\\n)/)[0].trim().slice(0, 4000) }}"
},
{
"id": "n-05",
"name": "isReply",
"type": "boolean",
"value": "={{ Boolean($json.inReplyTo || ($json.headers && $json.headers['in-reply-to'])) }}"
},
{
"id": "n-06",
"name": "gmailMessageId",
"type": "string",
"value": "={{ $json.id }}"
},
{
"id": "n-07",
"name": "threadId",
"type": "string",
"value": "={{ $json.threadId }}"
},
{
"id": "n-08",
"name": "receivedAt",
"type": "string",
"value": "={{ $json.date || $now.toISO() }}"
}
]
},
"includeOtherFields": true,
"options": {}
},
"id": "amp-shop-03",
"name": "Normalise the email",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
440,
300
]
},
{
"parameters": {
"jsCode": "// Finds an order reference like #1042 in the subject or the body and builds\n// the Shopify search string. No reference: search by the sender's address.\nconst m = $input.first().json;\nconst hay = (m.subject || '') + '\\n' + (m.text || '');\nconst hit = hay.match(/#\\s?(\\d{3,7})\\b/) || hay.match(/\\border\\s*(?:no\\.?|number|ref(?:erence)?)?\\s*:?\\s*#?(\\d{3,7})\\b/i);\nconst orderName = hit ? '#' + hit[1] : '';\nconst safe = (s) => String(s || '').replace(/[\"\\\\]/g, '');\nconst searchQuery = orderName ? 'name:' + orderName : (m.fromAddress ? 'email:\"' + safe(m.fromAddress) + '\"' : '');\nreturn [{ json: { ...m, orderName, searchQuery, lookupBy: orderName ? 'order number' : 'sender email' } }];"
},
"id": "amp-shop-04",
"name": "Find the order reference",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
660,
300
]
},
{
"parameters": {
"method": "POST",
"url": "=https://{{ $('Settings').first().json.shopSubdomain }}.myshopify.com/admin/api/2026-07/graphql.json",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "shopifyAccessTokenApi",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "content-type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ query: \"query ($q: String!) { orders(first: 3, query: $q, sortKey: PROCESSED_AT, reverse: true) { edges { node { name email processedAt cancelledAt displayFinancialStatus displayFulfillmentStatus totalPriceSet { shopMoney { amount currencyCode } } shippingAddress { city country } fulfillments { status estimatedDeliveryAt trackingInfo { company number url } } lineItems(first: 10) { edges { node { title quantity } } } } } } }\", variables: { q: $json.searchQuery || 'name:#0' } }) }}",
"options": {
"timeout": 15000
}
},
"id": "amp-shop-05",
"name": "Look up the order in Shopify",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
880,
300
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "// Picks the matching order, checks the email on it against the sender, and\n// writes the record as plain text for the model. Nothing here calls a model.\nconst res = $input.first().json || {};\nconst mail = $('Find the order reference').first().json;\nconst edges = res && res.data && res.data.orders && Array.isArray(res.data.orders.edges) ? res.data.orders.edges : [];\nconst lookupError = res && res.errors ? JSON.stringify(res.errors).slice(0, 300) : (res && res.error ? String(res.error.message || res.error).slice(0, 300) : '');\nlet order = null;\nif (mail.orderName) order = (edges.find((e) => e.node && e.node.name === mail.orderName) || {}).node || null;\nelse if (edges.length) order = edges[0].node;\nconst orderFound = !!order;\nconst emailMatches = !!(order && order.email && mail.fromAddress && order.email.toLowerCase() === mail.fromAddress);\nconst lines = [];\nif (order) {\n lines.push('Order ' + order.name + ', placed ' + String(order.processedAt || '').slice(0, 10) + (order.cancelledAt ? ', CANCELLED ' + String(order.cancelledAt).slice(0, 10) : ''));\n lines.push('Payment: ' + (order.displayFinancialStatus || 'unknown') + '. Fulfilment: ' + (order.displayFulfillmentStatus || 'unknown') + '.');\n if (order.totalPriceSet && order.totalPriceSet.shopMoney) lines.push('Total: ' + order.totalPriceSet.shopMoney.amount + ' ' + order.totalPriceSet.shopMoney.currencyCode);\n if (order.shippingAddress) lines.push('Ships to: ' + [order.shippingAddress.city, order.shippingAddress.country].filter(Boolean).join(', '));\n const items = ((order.lineItems || {}).edges || []).map((e) => e.node.quantity + ' x ' + e.node.title);\n if (items.length) lines.push('Items: ' + items.join('; '));\n for (const f of order.fulfillments || []) {\n const t = (f.trackingInfo || [])[0] || {};\n lines.push('Shipment: ' + (f.status || 'unknown') + (t.company ? ', carrier ' + t.company : '') + (t.number ? ', tracking ' + t.number : '') + (t.url ? ', ' + t.url : '') + (f.estimatedDeliveryAt ? ', estimated delivery ' + String(f.estimatedDeliveryAt).slice(0, 10) : ''));\n }\n if (!(order.fulfillments || []).length) lines.push('Shipment: not yet dispatched.');\n}\nreturn [{ json: { ...mail, orderFound, emailMatches, orderName: order ? order.name : mail.orderName, orderText: lines.join('\\n'), lookupError } }];"
},
"id": "amp-shop-06",
"name": "Prepare the brief",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1100,
300
]
},
{
"parameters": {
"method": "POST",
"url": "https://api.anthropic.com/v1/messages",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "anthropic-version",
"value": "2023-06-01"
},
{
"name": "anthropic-beta",
"value": "server-side-fallback-2026-07-01"
},
{
"name": "content-type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({\n model: 'claude-opus-5',\n max_tokens: 1500,\n fallbacks: 'default',\n output_config: { effort: 'low', format: { type: 'json_schema', schema: {\n \"type\": \"object\",\n \"properties\": {\n \"intent\": {\n \"type\": \"string\",\n \"enum\": [\n \"order_status\",\n \"shipping_policy\",\n \"return_policy_question\",\n \"product_question\",\n \"return_or_refund_request\",\n \"cancel_or_change_order\",\n \"address_change\",\n \"damaged_or_missing_item\",\n \"discount_or_credit_request\",\n \"complaint_or_escalation\",\n \"wholesale_or_partnership\",\n \"spam_or_newsletter\"\n ],\n \"description\": \"The single best intent for this email.\"\n },\n \"language\": {\n \"type\": \"string\",\n \"description\": \"ISO 639-1 code of the language the customer wrote in, for example en, es, fr, de.\"\n },\n \"confidence\": {\n \"type\": \"number\",\n \"description\": \"How sure you are about the intent and the reply, from 0 to 1. Below 0.8 means a person should look.\"\n },\n \"needs_person\": {\n \"type\": \"boolean\",\n \"description\": \"True if a person must handle this: complaint, legal or chargeback threat, anger, a promise you cannot make, anything unclear.\"\n },\n \"reason\": {\n \"type\": \"string\",\n \"description\": \"One short sentence: why the confidence is what it is, or why a person is needed.\"\n },\n \"summary\": {\n \"type\": \"string\",\n \"description\": \"One sentence for the support team: who wrote, what they want, what the order record shows.\"\n },\n \"reply\": {\n \"type\": \"string\",\n \"description\": \"The full reply to the customer in their language. Plain text, no subject line, no signature. Uses only the order record and the store facts. Never promises a refund, credit, replacement, cancellation or address change; says a person will confirm those.\"\n }\n },\n \"required\": [\n \"intent\",\n \"language\",\n \"confidence\",\n \"needs_person\",\n \"reason\",\n \"summary\",\n \"reply\"\n ],\n \"additionalProperties\": false\n } } },\n system: 'You are the customer support assistant for ' + $('Settings').first().json.storeName + '. ' +\n 'Store facts you may use: returns are accepted within ' + $('Settings').first().json.returnWindowDays + ' days of delivery; shipping takes ' + $('Settings').first().json.shippingPromise + '. ' +\n 'Brand voice: ' + $('Settings').first().json.brandVoice + ' ' +\n 'Rules: answer only from the order record and the store facts above; if no order record is provided, ask for the order number and the email used at checkout, do not guess; ' +\n 'never promise a refund, credit, replacement, cancellation or address change, say that a person will confirm within one working day; ' +\n 'never invent tracking, dates or stock; write the reply in the language the customer wrote in; plain text, no subject line, no sign-off name; ' +\n 'if the email is a complaint, a legal or chargeback threat, or anything you are unsure about, set needs_person to true and explain why in reason.',\n messages: [{ role: 'user', content:\n 'CUSTOMER EMAIL\\nFrom: ' + $json.fromName + ' <' + $json.fromAddress + '>' +\n '\\nSubject: ' + $json.subject +\n '\\nIs a reply in an existing thread: ' + $json.isReply +\n '\\n\\n' + $json.text +\n '\\n\\nORDER RECORD (found: ' + $json.orderFound + ', email on the order matches the sender: ' + $json.emailMatches + ')\\n' + ($json.orderText || 'none') +\n ($json.lookupError ? '\\n\\nThe order lookup failed: ' + $json.lookupError : '')\n }]\n}) }}",
"options": {
"timeout": 90000
}
},
"id": "amp-shop-07",
"name": "Classify and draft with Claude",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1320,
300
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "// Reads the model's JSON and decides the route with your rules. The model\n// never picks the route. Anything unclear goes to a person, never guessed.\nconst res = $input.first().json;\nconst mail = $('Prepare the brief').first().json;\nconst settings = $('Settings').first().json;\nconst allowed = String(settings.autoSendIntents || '').split(',').map((s) => s.trim()).filter(Boolean);\nconst bar = Number(settings.confidenceBar);\nlet out = null; let failure = '';\nif (!res || res.error) failure = 'the model call failed: ' + (res && res.error ? String(res.error.message || res.error).slice(0, 160) : 'no response');\nelse if (res.stop_reason === 'refusal') failure = 'the model declined to answer';\nelse { const block = Array.isArray(res.content) ? res.content.find((b) => b.type === 'text') : null; try { out = JSON.parse(block.text); } catch (e) { failure = 'the model returned no usable JSON'; } }\nlet route = 'escalate', reason = failure;\nif (out) {\n if (out.intent === 'spam_or_newsletter') { route = 'skip'; reason = 'spam or newsletter'; }\n else if (out.intent === 'complaint_or_escalation' || out.intent === 'wholesale_or_partnership') { route = 'escalate'; reason = out.intent.replace(/_/g, ' '); }\n else if (mail.orderFound && !mail.emailMatches) { route = 'escalate'; reason = 'the email on order ' + mail.orderName + ' does not match the sender'; }\n else if (mail.isReply) { route = 'draft'; reason = 'a reply in an existing thread; the workflow only sends the first answer'; }\n else if (!allowed.includes(out.intent)) { route = 'draft'; reason = out.intent.replace(/_/g, ' ') + ' is not on the send list'; }\n else if (out.needs_person) { route = 'draft'; reason = out.reason || 'the model asked for a person'; }\n else if (!(Number(out.confidence) >= bar)) { route = 'draft'; reason = 'confidence ' + out.confidence + ' is below the bar of ' + bar; }\n else if (out.intent === 'order_status' && !mail.orderFound) { route = 'draft'; reason = 'no order found for ' + (mail.searchQuery || 'this sender') + '; the draft asks for the order number'; }\n else { route = 'send'; reason = 'on the send list at confidence ' + out.confidence; }\n}\nconst body = out ? String(out.reply || '').trim() : '';\nconst draftSubject = /^s*res*:/i.test(mail.subject || '') ? mail.subject : 'Re: ' + (mail.subject || 'your message');\nconst replyText = body + '\\n\\n' + settings.signature + '\\n\\n' + settings.disclosureLine;\nconst draftText = body + '\\n\\n' + settings.signature;\nreturn [{ json: { ...mail, ...(out || {}), route, reason, replyText, draftText, draftSubject, threadLink: 'https://mail.google.com/mail/u/0/#all/' + mail.threadId, decidedAt: new Date().toISOString() } }];"
},
"id": "amp-shop-08",
"name": "Decide the route",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1540,
300
]
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r-0",
"leftValue": "={{ $json.route }}",
"rightValue": "send",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "send"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r-1",
"leftValue": "={{ $json.route }}",
"rightValue": "draft",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "draft"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r-2",
"leftValue": "={{ $json.route }}",
"rightValue": "escalate",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "escalate"
}
]
},
"options": {
"fallbackOutput": "extra"
}
},
"id": "amp-shop-09",
"name": "Route",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [
1760,
300
]
},
{
"parameters": {
"resource": "message",
"operation": "reply",
"messageId": "={{ $json.gmailMessageId }}",
"emailType": "text",
"message": "={{ $json.replyText }}",
"options": {
"replyToSenderOnly": true,
"appendAttribution": false
}
},
"id": "amp-shop-10",
"name": "Reply to the customer",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2.2,
"position": [
2000,
40
]
},
{
"parameters": {
"resource": "draft",
"operation": "create",
"subject": "={{ $json.draftSubject }}",
"emailType": "text",
"message": "={{ $json.draftText }}",
"options": {
"threadId": "={{ $json.threadId }}",
"sendTo": "={{ $json.fromAddress }}"
}
},
"id": "amp-shop-11",
"name": "Save a draft for review",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2.2,
"position": [
2000,
240
]
},
{
"parameters": {
"select": "channel",
"channelId": {
"__rl": true,
"mode": "name",
"value": "={{ $('Settings').first().json.slackChannel }}"
},
"text": "=DRAFT WAITING {{ $('Decide the route').first().json.fromName || 'a customer' }} <{{ $('Decide the route').first().json.fromAddress }}> · {{ $('Decide the route').first().json.intent }} · confidence {{ $('Decide the route').first().json.confidence }}\nWhy it waited: {{ $('Decide the route').first().json.reason }}\nSummary: {{ $('Decide the route').first().json.summary }}\nOrder: {{ $('Decide the route').first().json.orderName || 'none found' }}\nOpen the thread, check the draft, send: {{ $('Decide the route').first().json.threadLink }}",
"otherOptions": {}
},
"id": "amp-shop-12",
"name": "Ask the team to review",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.2,
"position": [
2220,
240
]
},
{
"parameters": {
"select": "channel",
"channelId": {
"__rl": true,
"mode": "name",
"value": "={{ $('Settings').first().json.slackChannel }}"
},
"text": "=NEEDS A PERSON {{ $json.fromName || 'a customer' }} <{{ $json.fromAddress || 'no address' }}> · {{ $json.intent || 'unclassified' }}\nReason: {{ $json.reason }}\nSummary: {{ $json.summary || 'none' }}\nSubject: {{ $json.subject }}\nOrder: {{ $json.orderName || 'none found' }}\nThread: {{ $json.threadLink }}",
"otherOptions": {}
},
"id": "amp-shop-13",
"name": "Escalate to a person",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.2,
"position": [
2000,
440
]
},
{
"parameters": {},
"id": "amp-shop-14",
"name": "Nothing to do",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
2000,
640
]
},
{
"parameters": {
"content": "## Answer Shopify support emails with Claude, draft refunds for review and escalate to Slack\n\nClaude drafts a reply from the Shopify order record and your store facts. Your code decides the route: order-status and policy answers send, money and order changes become Gmail drafts a person releases, complaints go to Slack. The model never picks the route and never refunds, cancels or credits anything.\n\n### How it works\n1. The Gmail trigger polls the inbox every minute; Settings holds what you edit.\n2. An order reference like #1042 is found, or the sender's address is used.\n3. One GraphQL call to your Shopify Admin API returns status, fulfilment, tracking and the order's email.\n4. One Claude call returns intent, language, confidence, summary and reply as JSON matching a fixed schema.\n5. Code applies your send list, confidence bar and email-match check; unclear cases go to a person.\n6. Send: Gmail reply with the disclosure line. Draft: Gmail draft plus Slack note. Escalate: Slack. Spam: nothing.\n\n### Setup\n1. Edit **Settings**: store facts, brand voice, send list, confidence bar, Slack channel, signature, disclosure line.\n2. Select your Gmail credential on the trigger and both Gmail nodes.\n3. Add a Shopify Access Token credential (custom app, read_orders scope) on **Look up the order in Shopify**.\n4. Create a Header Auth credential named `x-api-key` with your Anthropic key on **Classify and draft with Claude**.\n5. Select your Slack credential on both Slack nodes, then click **Test workflow**: a sample email is pinned.\n\n### Customization tips\nWiden the send list only after a fortnight of drafts read well; a help desk can replace the trigger and the Gmail nodes.\n\nBuilt by Amplence. The costs and the gate behind the design: amplence.com/blog/n8n-shopify-customer-support-workflow",
"height": 800,
"width": 800,
"color": 1
},
"id": "amp-shop-15",
"name": "Overview",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-40,
-600
]
},
{
"parameters": {
"content": "## 1. Catch and prepare\nGmail trigger with Simplify off (so the body text arrives), Settings you edit, fields normalised, order reference found or the sender's address used.",
"height": 230,
"width": 830,
"color": 7
},
"id": "amp-shop-16",
"name": "Section: catch and prepare",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-40,
230
]
},
{
"parameters": {
"content": "## 2. Look up and draft\nOne GraphQL call to Shopify, the record written as plain text, then one Claude call that returns a fixed JSON answer: intent, language, confidence, summary, reply.",
"height": 240,
"width": 830,
"color": 7
},
"id": "amp-shop-17",
"name": "Section: look up and draft",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
850,
120
]
},
{
"parameters": {
"content": "## 3. Decide and act\nYour code decides send, draft, escalate or skip. Sends go back in the thread with the disclosure line; drafts wait in Gmail with a Slack note; escalations reach Slack with the reason.",
"height": 880,
"width": 700,
"color": 7
},
"id": "amp-shop-18",
"name": "Section: decide and act",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
1720,
-40
]
}
],
"connections": {
"New support email": {
"main": [
[
{
"node": "Settings",
"type": "main",
"index": 0
}
]
]
},
"Settings": {
"main": [
[
{
"node": "Normalise the email",
"type": "main",
"index": 0
}
]
]
},
"Normalise the email": {
"main": [
[
{
"node": "Find the order reference",
"type": "main",
"index": 0
}
]
]
},
"Find the order reference": {
"main": [
[
{
"node": "Look up the order in Shopify",
"type": "main",
"index": 0
}
]
]
},
"Look up the order in Shopify": {
"main": [
[
{
"node": "Prepare the brief",
"type": "main",
"index": 0
}
]
]
},
"Prepare the brief": {
"main": [
[
{
"node": "Classify and draft with Claude",
"type": "main",
"index": 0
}
]
]
},
"Classify and draft with Claude": {
"main": [
[
{
"node": "Decide the route",
"type": "main",
"index": 0
}
]
]
},
"Decide the route": {
"main": [
[
{
"node": "Route",
"type": "main",
"index": 0
}
]
]
},
"Route": {
"main": [
[
{
"node": "Reply to the customer",
"type": "main",
"index": 0
}
],
[
{
"node": "Save a draft for review",
"type": "main",
"index": 0
}
],
[
{
"node": "Escalate to a person",
"type": "main",
"index": 0
}
],
[
{
"node": "Nothing to do",
"type": "main",
"index": 0
}
]
]
},
"Save a draft for review": {
"main": [
[
{
"node": "Ask the team to review",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {
"New support email": [
{
"json": {
"id": "19a1c2d3e4f50607",
"threadId": "19a1c2d3e4f50607",
"labelIds": [
"UNREAD",
"INBOX"
],
"sizeEstimate": 2210,
"subject": "Where is my order #1042?",
"from": {
"value": [
{
"address": "dana@example.com",
"name": "Dana Whitfield"
}
],
"text": "Dana Whitfield <dana@example.com>"
},
"to": {
"value": [
{
"address": "support@your-store.com",
"name": "Your Store"
}
],
"text": "Your Store <support@your-store.com>"
},
"date": "2026-09-20T21:03:00.000Z",
"messageId": "<CA+sample1042@mail.gmail.com>",
"text": "Hi,\n\nI ordered two framed prints on the 12th (order #1042) and the tracking page hasn't moved since Tuesday. Can you tell me where it is? I need them before the weekend.\n\nThanks,\nDana",
"html": "<div>Hi,<br><br>I ordered two framed prints on the 12th (order #1042) and the tracking page hasn't moved since Tuesday. Can you tell me where it is? I need them before the weekend.<br><br>Thanks,<br>Dana</div>",
"textAsHtml": "<p>Hi,</p><p>I ordered two framed prints on the 12th (order #1042) and the tracking page hasn't moved since Tuesday. Can you tell me where it is? I need them before the weekend.</p><p>Thanks,<br>Dana</p>",
"headers": {
"message-id": "Message-ID: <CA+sample1042@mail.gmail.com>",
"content-type": "Content-Type: multipart/alternative"
}
}
}
]
},
"settings": {
"executionOrder": "v1"
},
"meta": {
"templateCredsSetupCompleted": false
}
}We validate the file the way you'd want any template validated. Every node type and version is checked against the n8n package it will run in. Every parameter name is checked against what that node actually declares. Every connection is checked against a node that exists. That doesn't prove the workflow will answer your customers well. It proves it will load and run without you debugging our typos first, and the first fortnight of drafts proves the rest.
What to change for your store
You'll want to change four things, and the template is built so each is one edit.
Your policy facts live in Settings, not in the prompt. Your return window, your shipping promise and the sentence about your brand voice are three fields, and the model is told to answer from them and nothing else. When your Christmas cut-off changes, you change a field.
Your send list is a comma-separated field. It starts as order status and policy answers. When a fortnight of drafts shows the model gets damaged-item replies right, you add the class and it starts sending. Take one off just as easily when something changes.
The confidence bar is a number, 0.8 by default. Lower it and more sends; raise it and more waits. Watch your drafts folder for a week before you touch it.
The channel is Gmail because most small stores start there. If your mail lives in a help desk, the trigger becomes that help desk's webhook and the two Gmail actions become its reply and its internal note; the middle of the workflow doesn't change. Our ecommerce automation page covers the help-desk version and the four-language setup CollageDepot runs.
Where it goes wrong
- Replying to the wrong person. The customer writes from a different address than the one on the order, and a template happily sends them someone else's tracking link. The email match is the check that stops it. Keep it.
- Replying to a thread twice. The trigger picks up your customer's follow-up and the workflow answers again. The template only sends for the first message in a thread; anything later becomes a draft. If you remove that rule, you'll have a bot arguing with a customer.
- The model as the router. The moment you let the model return "send" and act on it, you've handed a business rule to a probabilistic system. Keep the send list and the confidence bar in code, where you can change them on a Tuesday.
- Promising what the policy doesn't. A reply that offers a refund the policy doesn't allow is a refund you'll honour. The model is told to answer from the policy fields only, and every money class is drafted anyway. Both rules, not one.
- Answering newsletters and spam. Marketing mail hits your support address too. The spam intent ends at a node that does nothing. Don't wire it to a reply, or you'll be thanking a newsletter for its email.
- Polling every ten seconds. Each poll is an execution. Every minute is fast enough for email; nobody expects a reply in forty seconds.
The same rules hold for every agent we build for you. The handoff article is the longer version of why the draft-for-review tier exists and what your Slack message should contain.
What to require from whoever builds it
Whether that's us or anyone else, this is what you should see before a support pipeline goes live on your inbox.
- The policy facts written down as fields, with a note of who can change them and when they were last checked.
- The order record visible inside every draft reply, and a test with an order number that doesn't exist.
- The email-match check in place, and a test with a customer writing from a second address.
- The classify-and-draft call returning a fixed JSON schema, not free text, and a test of what happens when it returns nothing.
- The route decided in code from the send list and the confidence bar, never by the model.
- No refund, credit, cancellation or address change executed by the workflow; the person releases the reply and takes the action in Shopify.
- The disclosure line on every sent reply if you sell into the EU.
- Reply-once-per-thread, and a test with a customer who replies to the reply.
- A fortnight of drafts reviewed before any class is added to the send list, with the corrections counted by class.
Frequently Asked Questions
Does it need a help desk like Gorgias or Zendesk?
No. The template runs on your Gmail inbox, which is where most small stores start. If you already use a help desk, the trigger and the two Gmail actions swap for its webhook, reply and note; the lookup, the model call and the routing don't change.
Can it refund or cancel orders?
Not on its own, by design. It writes the reply that explains what will happen and saves it as a draft. Someone on your team sends it and takes the action in Shopify. The arithmetic above is why: a wrong refund costs about $40, and no 95% classifier clears that bar.
Which languages does it handle?
The model reports the language and replies in it, so a Spanish email gets a Spanish reply with no extra setup from you. Your policy fields stay in English; the model translates the facts, not the policy. Review the first drafts in each language before you add that language's classes to the send list.
Can I use OpenAI or another model?
Yes. The model call is a plain HTTP Request, so you change the URL, the headers and the body. Keep the JSON schema for the answer or update the routing node to match, otherwise every email goes to a person.
What Shopify permissions does it need?
A custom app with the read_orders scope, nothing else. The workflow never writes to your store. Shopify's custom app flow gives you an Admin API access token for a single store, and you paste it into n8n's Shopify credential once.
What happens to emails it can't classify?
They go to your team in Slack with the reason: no order found, email mismatch, low confidence, a refusal, or a parse error. Nothing is dropped and nothing is guessed. Count those reasons weekly; the most common one is your next improvement.
Want this running on your support inbox by next week?
Send us the inbox, the store and the Slack channel, and we'll install the template, write your policy fields with you, and review the first fortnight of drafts, whether you keep us on afterwards or not.
Book a free support-automation review ↗Sources: Amplence, CollageDepot case study and ecommerce customer service automation guide; Shopify, GraphQL Admin API orders query and API search syntax; Shopify, REST Admin API legacy notice; Gorgias, "What's the secret to reducing WISMO requests?" (updated 13 November 2023); Anthropic pricing (list prices as of June 2026); EU AI Act, Article 50.



