How to Build a Shopify Multi-Location Inventory Report with n8n









A Shopify multi-location inventory report in n8n gives you one clean view of how much stock sits in each warehouse and retail branch, refreshed on a schedule and written straight to a Google Sheet. If you sell from more than one location, the Shopify admin makes you click into each product to see the per-location split, and there is no built-in export that lays it all out side by side. This guide builds that export with four nodes, and the same workflow is available as a ready-to-import template at the end.

What it does

Once a week, this workflow asks Shopify for your catalog and the stock level of every variant at every location, flattens that nested data into simple rows, and appends them to a Google Sheet with the run date attached. The result is a running log you can filter by location, sort by lowest stock, or drop into a pivot table to compare warehouses.

Each row answers a single question: how many units of this variant are available at this location, as of this date. Over a few weeks the Sheet becomes a stock-movement history you can chart, without touching a paid inventory app.

  ┌────────────────────────────────────────────────────────────────┐
  │  SHOPIFY MULTI-LOCATION INVENTORY REPORT                        │
  │                                                                │
  │  [Schedule: weekly] → [Shopify GraphQL] → [Code: flatten]      │
  │                                              ↓                 │
  │                                   [Google Sheets: append]      │
  └────────────────────────────────────────────────────────────────┘
  

Why it beats the default

Shopify’s own inventory screen shows one product at a time. To answer “where is my stock low across all warehouses this week”, you would open dozens of products by hand, or pay for an app that does roughly this and charges monthly. The report you build here is yours, runs on your own schedule, and lands the data where you already work with numbers.

The technical win is the single GraphQL query. The older REST approach needs one call for your locations, one for inventory levels, and one for variant titles, then a merge to join them by ID. GraphQL returns the product title, variant, SKU, location name, and available quantity together in one response, so you replace three calls and a merge with a single HTTP Request node. Fewer calls means you stay comfortably inside Shopify’s rate limits even as the catalog grows. This report is one piece of a wider stack you can assemble with n8n Shopify automation.

What you need

  • An n8n instance (Cloud or self-hosted), version 1.0 or newer.
  • A Shopify store with two or more locations set up under Settings, Locations.
  • A Shopify Admin API access token with the read_products and read_inventory scopes. Create it with the 2026 Dev Dashboard method, covered in the connect Shopify to n8n guide. Do not use the old admin custom-app flow; it was removed.
  • A Google account and one empty Google Sheet to receive the rows.

Building from scratch takes about 30 minutes. Importing the template takes under 10.

Node-by-node list

# Node Type Job
1 Weekly Monday 7am Schedule Trigger Fires the run once a week
2 Get Inventory (Shopify GraphQL) HTTP Request One GraphQL call for products, variants, locations, stock
3 Flatten To Rows Code Turns nested JSON into one row per variant per location
4 Append To Report Sheet Google Sheets Appends every row with the run date

Step-by-step build

1. Add the Schedule Trigger

Add a Schedule Trigger node. Set the interval to Weeks, every 1 week, trigger on Monday at hour 7. That gives you a fresh snapshot at the start of each week. You can change this to daily if you move stock quickly, but weekly keeps the Sheet readable.

2. Call Shopify with one GraphQL query

Add an HTTP Request node named Get Inventory (Shopify GraphQL) and configure it:

  • Method: POST
  • URL: https://YOUR_STORE.myshopify.com/admin/api/2026-04/graphql.json
  • Authentication: Predefined Credential Type, then Shopify Access Token API, and select your credential.
  • Send Body: on. Body Content Type: JSON.

Paste this into the JSON body field:

{
  "query": "query { products(first: 50) { edges { node { title variants(first: 20) { edges { node { sku title inventoryItem { inventoryLevels(first: 10) { edges { node { location { name } quantities(names: [\"available\"]) { name quantity } } } } } } } } } } }"
}
📌

The query asks for quantities(names: ["available"]), the current Admin API way to read stock. It needs API version 2026-04 and the read_inventory scope, or the field comes back empty.

3. Flatten the response with a Code node

The GraphQL response is deeply nested: products contain variants, variants contain inventory levels, each level has a location and a quantity. Add a Code node named Flatten To Rows to turn that into flat rows:

const resp = $input.first().json;
const products = (resp && resp.data && resp.data.products && resp.data.products.edges) || [];
const reportDate = new Date().toISOString().slice(0, 10);
const rows = [];

for (const p of products) {
  const productTitle = p.node.title;
  const variants = (p.node.variants && p.node.variants.edges) || [];
  for (const v of variants) {
    const item = v.node.inventoryItem || {};
    const levels = (item.inventoryLevels && item.inventoryLevels.edges) || [];
    for (const l of levels) {
      const q = (l.node.quantities || []).find((x) => x.name === 'available');
      rows.push({ json: {
        report_date: reportDate,
        product: productTitle,
        variant: v.node.title || '',
        sku: v.node.sku || '',
        location: (l.node.location && l.node.location.name) || '',
        available: q ? q.quantity : 0,
      }});
    }
  }
}
return rows.length ? rows : [{ json: { report_date: reportDate, product: '', variant: '', sku: '', location: '', available: 0 } }];

After this node, each item looks like a single tidy record ready for a spreadsheet:

{
  "report_date": "2026-07-31",
  "product": "Trailhead Merino Hoodie",
  "variant": "Medium / Slate",
  "sku": "HD-MER-M-SLT",
  "location": "Austin, TX Warehouse",
  "available": 42
}

4. Append rows to Google Sheets

Add a Google Sheets node named Append To Report Sheet. Set Operation to Append, pick your document and the target sheet (gid=0 is the first tab), and set the mapping to Map Automatically. Because the Code node already emits the exact column names, add a header row to your Sheet once with these columns:

Column Type Example
report_date Date 2026-07-31
product Text Trailhead Merino Hoodie
variant Text Medium / Slate
sku Text HD-MER-M-SLT
location Text Austin, TX Warehouse
available Number 42

Save the workflow and run it once by hand. Your Sheet fills with one row per variant per location. From here, a filter by location or a sort by available ascending gives you the low-stock picture in seconds.

Common mistakes

  • Missing the read_inventory scope. If every row shows available: 0, your token lacks read_inventory. Recreate the token in the Dev Dashboard with both product and inventory read scopes.
  • Wrong API version. The quantities(names: ...) field needs a 2024-04 or newer Admin API. Keep 2026-04 in the URL. Older versions used a flat available field that behaves differently.
  • Only 50 products returned. The template requests the first 50 products. For a larger catalog, read pageInfo.hasNextPage and pageInfo.endCursor, then loop the HTTP Request with an after cursor until there are no more pages. Do not raise first above 250; that is the hard ceiling per page.
  • Sending the body as raw text. In the HTTP Request node, set Body Content Type to JSON and paste into the JSON field. Pasting the query as raw text or form data makes Shopify reject it with a parse error.
  • Sheet columns out of order. Auto mapping matches on header names, not position, so your header row must spell the six column names exactly as the Code node outputs them.

Cost at realistic volume

Every service in this build sits on a free tier at report volume. A weekly run is four executions a month, nowhere near any n8n Cloud plan limit, and self-hosted n8n is free to run. Google Sheets is free. Shopify’s Admin API costs nothing to query; a single weekly GraphQL call is a rounding error against your rate limit.

Service Usage per month Cost
n8n (self-hosted) ~4 executions $0
Shopify Admin API ~4 GraphQL calls $0
Google Sheets ~4 appends $0

Run it daily instead of weekly and you are at roughly 30 executions a month, still free on every tier. The only real cost is the 30 minutes to build it, which the template removes.

Ready-to-import template

This guide is free to follow top to bottom. If you would rather skip the build, the ready-to-import template drops all four nodes onto your canvas already wired, so you only add your Shopify and Google credentials and pick your Sheet. Prefer it fully done for you? See our done-for-you service.

Download the template ($14) →

Instant download · Works on n8n Cloud and self-hosted

FAQ

Does this report work with Shopify’s multiple locations feature?

Yes. The GraphQL query reads inventoryLevels for every location attached to each variant, so a store with a main warehouse plus two retail branches returns a row per location automatically. You do not list your locations anywhere in the workflow; Shopify returns whatever locations hold stock for that item.

Why use GraphQL instead of the REST inventory_levels endpoint?

REST forces you to fetch locations, inventory levels, and variant titles as three separate calls and stitch them together by ID. One GraphQL query returns the product title, variant, SKU, location name, and available quantity together, so a single HTTP Request node replaces three plus a merge, which is fewer nodes and far fewer API calls.

How many products does the query cover?

The template requests the first 50 products, each with up to 20 variants and 10 locations. That covers most small and mid-size catalogs in one run. For larger stores you add cursor pagination on the products connection, looping until hasNextPage is false, which the guide explains in the common mistakes section.

What Shopify permission scope does it need?

Your access token needs read_products and read_inventory. Add both scopes when you create the app in the Shopify Dev Dashboard, then generate the Admin API token. Without read_inventory the inventoryLevels field returns null and your report shows zero available for every location.

Can I send the report to Slack or email instead of Google Sheets?

Yes. The Code node outputs plain rows, so you can swap the Google Sheets node for a Gmail or Slack node, or add one after it. A common pattern is to keep the Sheet as the archive and add a Gmail node that emails a short summary of any location where available drops below a threshold.

Related guides

Manage Shopify From Telegram With n8n: Two-Way Bot Guide








To manage Shopify from Telegram with n8n, you build a two-way bot that answers slash commands like /orders, /lowstock, /sales, and /find right inside a chat window. Instead of unlocking your phone and loading the Shopify admin every time you want a number, you type one word and the bot queries the Shopify Admin API and replies in seconds. This guide walks through all twelve nodes, the exact build steps, the mistakes to avoid, and the running cost at real volume.

What it does

Most Shopify owners already get pushed notifications: a new order lands, a Telegram ping fires. That is one direction only. This workflow flips it around so you can ask the store questions on demand, from the same Telegram chat you already keep open.

You send the bot a command and it answers:

  • /orders returns your five most recent orders with totals and payment status.
  • /lowstock lists every variant at or below a stock threshold you set.
  • /sales reports how many orders came in today and the running total.
  • /find 1042 looks up a single order by its name or number and returns its status.

It is the difference between a notification feed and a control panel. When a customer emails asking where their order is, or a supplier asks what you need reordered, the answer is one message away instead of a login, a search, and three taps. This fits into the wider n8n Shopify automation stack as the query layer that sits alongside your existing alert workflows.

Why it beats the default

The default way to check these numbers is the Shopify mobile app. It works, but it is heavy for a quick glance: it loads a full dashboard, it wants you logged in, and it does not let you script or combine views. If you want “low stock across every variant under 5 units” you scroll and count.

A Telegram bot beats that for three reasons. It is instant, because the reply is plain text with no interface to render. It is shareable, because you can add a warehouse assistant to the chat and they get the same commands without a Shopify staff seat. And it is extendable, because every command is just an n8n branch you control, so adding a new report is a five-minute job rather than a feature request to an app vendor.

It also pairs cleanly with push alerts. Keep order alerts on Telegram for events that reach out to you, and use this bot for the questions you reach out to ask. Together they cover both directions from one chat.

What you need

  • An n8n instance, either n8n Cloud or self-hosted version 1.0 or newer.
  • A Telegram bot token from @BotFather, which takes about two minutes to create.
  • A Shopify custom app access token with read scopes for orders and products. Follow how to connect Shopify to n8n in 2026 using the Dev Dashboard method to generate it.
  • Your store domain in the form your-store.myshopify.com.

Estimated build time is 30 to 40 minutes from scratch, or under 10 minutes if you import the template and paste in your credentials.

Node-by-node list

The workflow is twelve nodes. A single Telegram Trigger feeds a parser, a Switch routes on the command, four HTTP Request nodes hit the Shopify Admin API version 2026-04, four Code nodes format the reply, and one Telegram node sends it back.

Telegram Trigger
      |
Parse Command (Code)
      |
Route Command (Switch)
  |     |      |       |
/orders /lowstock /sales /find
  |     |      |       |
[Get   [Get    [Get   [Find
 Recent Products Today  Order]  <- HTTP Request (Admin API)
 Orders]      Orders]
  |     |      |       |
[Format][Format][Format][Format] <- Code (build reply text)
  \     |      |      /
     Send Telegram Reply
# Node Type Job
1 Telegram Trigger telegramTrigger Receives every message sent to your bot
2 Parse Command code Splits the message into command and argument, grabs the chat ID
3 Route Command switch Sends the run down one of four branches by command
4 Get Recent Orders httpRequest GET last 5 orders
5 Get Products httpRequest GET products with variant inventory
6 Get Today Orders httpRequest GET orders created since midnight
7 Find Order httpRequest GET one order by name
8 Format Orders code Turns the order list into a text reply
9 Format Low Stock code Filters variants under the threshold
10 Format Sales code Sums today's order totals
11 Format Order code Formats a single order lookup
12 Send Telegram Reply telegram Sends the formatted text back to the chat

Step-by-step build

  1. Add a Telegram Trigger node. Attach your Telegram credential, set Updates to message, and save. n8n gives it a webhook that Telegram will call on every message.
  2. Add a Code node named Parse Command. Paste the parser below. It reads the message text, splits off the first word as the command, keeps the rest as the argument, and stores the chat ID so the reply goes back to the right place.
    const msg = $input.first().json.message || {};
    const text = (msg.text || '').trim();
    const parts = text.split(/\s+/);
    const command = (parts[0] || '').toLowerCase();
    const arg = parts.slice(1).join(' ');
    return [{ json: { command, arg, chatId: msg.chat ? msg.chat.id : '' } }];
  3. Add a Switch node named Route Command. Create four rules, each a String equals check on {{ $json.command }} matching /orders, /lowstock, /sales, and /find. Name the outputs so the canvas stays readable.
  4. On the first output add an HTTP Request node named Get Recent Orders. Method GET, URL https://your-store.myshopify.com/admin/api/2026-04/orders.json, authentication set to Generic Header Auth with your Shopify token credential. Add query parameters status=any, limit=5, and a fields list of name, total_price, financial_status, fulfillment_status, created_at.
  5. On the second output add Get Products, same auth, URL ending /products.json, with limit=250 and fields=title,variants. Variant inventory rides along in the response.
  6. On the third output add Get Today Orders, URL ending /orders.json, with status=any, limit=250, fields=total_price,created_at, and a created_at_min value of {{ $now.startOf('day').toISO() }} so only today's orders return.
  7. On the fourth output add Find Order, URL ending /orders.json, with status=any, limit=1, and a name parameter of {{ $('Parse Command').item.json.arg }} so it searches for whatever number the user typed.
  8. Behind each HTTP node add a Code node that builds the reply text. For Format Low Stock, the code loops every variant and keeps those at or below the threshold:
    const products = $input.first().json.products || [];
    const threshold = 5;
    const low = [];
    for (const p of products) {
      for (const v of (p.variants || [])) {
        if (typeof v.inventory_quantity === 'number' && v.inventory_quantity <= threshold) {
          low.push(p.title + ' - ' + v.inventory_quantity + ' left');
        }
      }
    }
    const message = low.length
      ? 'Low stock (<= ' + threshold + '):\n\n' + low.join('\n')
      : 'All variants are above the low-stock threshold.';
    return [{ json: { message } }];
  9. Add one Telegram node named Send Telegram Reply. Set Chat ID to {{ $('Parse Command').item.json.chatId }} and Text to {{ $json.message }}. Wire all four Format nodes into it, since only one branch ever runs per message.
  10. Save, toggle the workflow Active, and message your bot /sales to confirm it replies.
💡

Tip: to make the bot answer only you, add a line in Parse Command that compares msg.chat.id to your own chat ID and returns an empty command otherwise. That single check stops strangers who find the bot handle from pulling your store data.

Common mistakes

  • Leaving the Shopify token with write scopes it does not need. This bot only reads, so grant read_orders and read_products and nothing more.
  • Forgetting the created_at_min value on the sales branch. Without it the node returns your entire order history and the total is meaningless.
  • Wiring the Format nodes into separate Telegram nodes. One shared Telegram node is cleaner and avoids four near-identical configs drifting apart.
  • Using an old Admin API version in the URL. Stick to a current version like 2026-04 so field names and behavior match this guide.
  • Testing before the workflow is Active. The Telegram Trigger only receives live messages once the workflow is switched on.

Cost at realistic volume

The running cost is close to zero. Telegram charges nothing for bot messages. Each command is one or two Shopify Admin API calls, and Shopify's REST limit is two calls per second per store, so even rapid checking never comes near the ceiling.

Usage Commands per day Shopify API calls n8n executions
Solo owner ~20 ~30 20
Owner plus assistant ~60 ~90 60
Small team ~150 ~230 150

On n8n Cloud's Starter plan the small-team column still fits inside the monthly execution allowance, and self-hosted users pay only their server cost. There is no per-message fee anywhere in the chain.

🚀 Manage Shopify From Telegram template

The full guide above is free to follow. If you would rather skip the build, the ready-to-import template gives you all twelve nodes wired and validated, so you only paste in your Telegram and Shopify credentials and set your store domain. Prefer it done for you? Our done-for-you service installs and configures it on your instance.

Download the template ($19) →

Instant download · Works on n8n Cloud and self-hosted

FAQ

Can I manage Shopify from Telegram without writing code?

Yes. The two Code nodes in this build are already written for you, so once you import the template and attach your Telegram and Shopify credentials the bot works. You only edit your store domain and, if you want, the low-stock threshold. No coding experience is required to run it.

Is a Telegram bot secure enough to query my Shopify store?

It is safe when you lock it down. The bot talks to Shopify through a private access token stored in n8n, never in Telegram. Add a chat ID check in the Parse Command node so the bot answers only your own account, and the token never leaves your n8n instance.

Will this bot let me change orders, or only read data?

This version is read-only by design. Every command runs a GET request against the Shopify Admin API and reports back. That keeps it safe to hand to staff. You can extend it later with write actions like tagging or fulfilling an order by adding a POST request node behind a new command.

How many Shopify API calls does the bot use?

One command triggers one or two Admin API calls. Even a busy owner checking the bot 100 times a day stays far under Shopify's rate limits and the free n8n execution tiers. There is no per-message cost from Telegram either, so the running cost is effectively zero.

Does this work on n8n Cloud and self-hosted?

Both. The workflow uses only core n8n nodes, so it imports cleanly on n8n Cloud and any self-hosted instance. The Telegram Trigger needs a public webhook URL, which n8n Cloud provides automatically and self-hosted users get through a tunnel or a reverse proxy.

Related guides

Shopify draft order invoice follow-up with n8n









A Shopify draft order invoice follow-up in n8n quietly chases the quotes your store already sent but never got paid for. If you run wholesale, custom, or quote-first orders, you create draft orders and email an invoice link, then the customer gets busy and the draft sits open for weeks. This guide builds a small n8n workflow that checks your open draft orders every morning, finds the ones older than three days, and emails each customer a friendly reminder with their secure checkout link, no manual chasing required.

Prefer to skip the build? The full guide below is free to follow. If you would rather import a tested workflow and be live in ten minutes, grab the ready-made template.

What it does

Draft orders are how Shopify handles quotes and manually created orders. You add products, set a price, and Shopify gives you an invoice_url, a secure page where the customer can pay. The problem is that once you send that link, nothing follows up. A draft can stay open indefinitely while the customer forgets, and you have no built-in reminder.

This workflow closes that gap. On a daily schedule it pulls every open draft order from the Shopify Admin API, keeps only the ones that are older than a threshold you set (three days by default) and still unpaid, then sends each customer a plain-text reminder email containing their original invoice link. Paid or cancelled drafts are ignored automatically, because Shopify no longer marks them as open.

It connects three services: Shopify (source of the draft orders), a small Code node (the aging and filtering logic), and Gmail (the reminder email). No spreadsheets, no third-party app, no customer data leaving your own accounts.

Why it beats the default

Shopify can email an invoice the moment you create a draft, but it will not chase that invoice for you. Shopify Flow, the built-in automation tool, does not expose draft order events either, so you cannot build this natively. That leaves most merchants doing one of three things: manually scrolling the Drafts screen every few days, exporting to a spreadsheet, or simply letting quotes go cold.

An n8n workflow beats all three because it is unattended and precise. It runs on a cron schedule with no human trigger, it reads the live draft status straight from the Admin API so it never reminds someone who already paid, and the reminder text, timing, and threshold are all yours to edit. You also own the whole thing: the email goes out from your Gmail, and nothing depends on a paid Shopify app subscription.

What you need

  • An n8n instance (Cloud or self-hosted, version 1.0 or newer).
  • A Shopify store with the Draft orders feature (available on all plans) and an Admin API access token. If you have not connected Shopify to n8n yet, follow how to connect Shopify to n8n (2026 method) first, then come back.
  • The Admin API scope read_draft_orders granted to your custom app.
  • A Gmail account connected to n8n for sending the reminders.

Build time is about 30 minutes from scratch, or under 10 minutes if you import the template and paste in your two credentials.

📌

This workflow uses the Shopify Admin REST API version 2026-04. If you copy the URL, keep the version current so Shopify does not return a deprecation warning.

Node-by-node list

Four core nodes, wired in a straight line. Here is the whole shape before we build it.

┌──────────────────────────────────────────────────────────────┐
│  SHOPIFY DRAFT ORDER INVOICE FOLLOW-UP                        │
│                                                              │
│  [Schedule 8am] → [HTTP: get open drafts] → [Code: filter]   │
│                                                    ↓         │
│                                        [Gmail: send reminder] │
│                                        (runs once per draft)  │
└──────────────────────────────────────────────────────────────┘
  
# Node Type Job
1 Every morning 8am Schedule Trigger Fires the workflow once a day.
2 Get open draft orders HTTP Request Pulls all open drafts from the Shopify Admin API.
3 Filter drafts needing reminder Code Keeps only open, unpaid drafts older than 3 days with an email.
4 Send invoice reminder Gmail Emails each remaining customer their invoice link.

Step-by-step build

Step 1 — Schedule Trigger

Add a Schedule Trigger node. Set the interval to Days and pick the hour it should run, for example 8. This makes the workflow run once every morning. A daily cadence is usually right for quote chasing; hourly would feel pushy and eat API calls for no benefit.

Step 2 — HTTP Request: get open draft orders

Add an HTTP Request node named Get open draft orders. Configure it like this:

  1. Method: GET
  2. URL: https://YOUR_STORE.myshopify.com/admin/api/2026-04/draft_orders.json?status=open&limit=250
  3. Authentication: Generic Credential Type then Header Auth.
  4. In the Header Auth credential, set Name to X-Shopify-Access-Token and Value to your Admin API access token.

The status=open query is the important part: Shopify returns only drafts that are still unpaid, so you never chase a completed order. The response looks like this:

{
  "draft_orders": [
    {
      "id": 1023491007,
      "name": "#D42",
      "status": "open",
      "email": "james.carter@gmail.com",
      "invoice_url": "https://your-store.myshopify.com/12345/invoices/abc123",
      "total_price": "1240.00",
      "currency": "USD",
      "created_at": "2026-07-22T14:30:00-04:00",
      "customer": { "first_name": "James", "email": "james.carter@gmail.com" }
    }
  ]
}

Step 3 — Code: filter drafts needing reminder

Add a Code node. This is where the aging logic lives. It walks the list of drafts, drops anything that is not open, has no invoice link, is younger than the threshold, or has no email address, and returns one clean item per draft that deserves a reminder.

const DAYS_BEFORE_REMINDER = 3;
const cutoff = Date.now() - DAYS_BEFORE_REMINDER * 24 * 60 * 60 * 1000;

const drafts = $input.first().json.draft_orders || [];
const out = [];

for (const d of drafts) {
  if (d.status !== 'open') continue;
  if (!d.invoice_url) continue;

  const created = new Date(d.created_at).getTime();
  if (created > cutoff) continue; // too fresh to chase yet

  const email = d.email || (d.customer && d.customer.email);
  if (!email) continue;

  const firstName = (d.customer && d.customer.first_name) || 'there';

  out.push({
    json: {
      email,
      first_name: firstName,
      draft_name: d.name,
      invoice_url: d.invoice_url,
      total_price: d.total_price,
      currency: d.currency,
    },
  });
}

return out;
💡

Tip: Because the node returns an array of items, the Gmail node after it runs once per item automatically. You do not need a separate loop node.

Step 4 — Gmail: send invoice reminder

Add a Gmail node, operation Send. Map the fields to expressions from the Code node:

  1. To: ={{ $json.email }}
  2. Subject: =Your quote {{ $json.draft_name }} is ready to complete
  3. Email Type: Text
  4. Message: a short reminder that references {{ $json.first_name }}, the total {{ $json.total_price }} {{ $json.currency }}, and the link {{ $json.invoice_url }}.

Save the workflow and switch it to Active. From tomorrow morning it runs on its own. To test right now, open a draft in Shopify, backdate is not possible, so temporarily set DAYS_BEFORE_REMINDER = 0 in the Code node and click Execute workflow: you should receive the reminder for any open draft with an email on file.

Common mistakes

  • Chasing paid orders. This only happens if you drop the status=open query. Keep it, and Shopify never hands you a completed draft.
  • Missing the read_draft_orders scope. Without it the HTTP node returns a 403. Add the scope in your custom app and reinstall it.
  • Using an old API version. A URL like /admin/api/2022-01/ may still work but will warn or break later. Use 2026-04.
  • Daily re-sends. Left as-is, an open draft gets an email every day. If that is too much, tag the draft after the first send and skip tagged drafts, or run the schedule every few days instead of daily.
  • Drafts with no email. Manually created drafts sometimes have no customer attached. The Code node skips these, but it is worth checking your Drafts screen so real quotes are not silently dropped.

Cost at realistic volume

This workflow is close to free to run. It makes one Shopify API call per day, well inside every plan’s limits. Gmail’s free tier sends up to 500 emails a day, and a store creating even a few dozen quotes a week will never approach that. n8n itself is free when self-hosted; on n8n Cloud a daily run plus a handful of reminder emails is a rounding error against the Starter plan’s monthly execution allowance.

Service Usage per day Cost
Shopify Admin API 1 request $0 (included)
Gmail send Typically under 20 emails $0 (free tier)
n8n 1 scheduled execution $0 self-hosted

🚀 Get the Shopify draft order invoice follow-up template

The guide above is free to follow. The download is the exact validated workflow JSON, so you skip the build entirely: import it, paste your Shopify and Gmail credentials, and set your reminder threshold. Want it done for you instead? See our done-for-you setup service.

Download the template ($12) →

Instant download · Works on n8n Cloud and self-hosted

Frequently asked questions

Does this workflow charge the customer automatically?

No. It only emails the secure invoice_url that Shopify already generates for each draft order. The customer clicks that link and completes checkout themselves, so payment always happens through your normal Shopify checkout, never inside n8n.

How do I stop reminding a customer who already paid?

When a draft order is paid, Shopify changes its status from open to completed. The workflow filters on status open, so a paid or converted draft is skipped automatically on the next run. You never have to remove people by hand.

Can I wait longer than three days before the reminder?

Yes. The delay lives in one line of the Code node: DAYS_BEFORE_REMINDER = 3. Change it to 5, 7, or any number of days. You can also duplicate the workflow to send a second nudge at a longer interval for drafts that stay open.

Will it send the same reminder every day forever?

By default it can re-send daily while the draft stays open. To send once only, add a Shopify tag or a note attribute after the first email, then skip drafts that already carry that marker. The template includes a comment showing where to add this guard.

Does this work on both n8n Cloud and self-hosted?

Yes. It uses only core n8n nodes: Schedule Trigger, HTTP Request, Code, and Gmail. There are no community nodes to install, so the same JSON imports and runs identically on n8n Cloud and any self-hosted instance.

Related guides

Shopify Google Shopping product feed with n8n











A Shopify Google Shopping product feed built in n8n pulls every product and variant from your store on a schedule and writes them into a Google Sheet formatted as a Merchant Center feed, so your catalog shows up in free Google Shopping listings without a paid app. This guide builds the four-node workflow from scratch, explains each field Google requires, and hands you a ready-to-import template if you would rather skip the wiring and be live in about ten minutes.

What it does

The workflow runs on a schedule, reads your full Shopify catalog, reshapes it into the exact columns Google Merchant Center expects, and keeps a Google Sheet in sync as your feed source. Merchant Center then pulls that sheet on its own fetch schedule, and your products appear in the free Google Shopping tab and in the Shopping listings across Search.

Here is the shape of it end to end:

  Schedule (daily)
        |
        v
  Shopify: get all products  ---> [ {product, variants[], images[]}, ... ]
        |
        v
  Code: build feed rows      ---> one row PER VARIANT
        |                          id, title, description, link,
        |                          image_link, price, availability...
        v
  Google Sheets (appendOrUpdate on id)  ===>  Merchant Center reads this sheet
  

Because the sheet is the single source of truth, you never re-upload a file. n8n keeps the rows current, Merchant Center reads them on schedule, and your listings follow along. This is one of the highest-leverage jobs in the whole n8n Shopify automation toolkit: free traffic, set up once.

Why it beats the default

Shopify’s own Google channel and most feed apps work, but they hide the mapping. You get whatever title and description they decide to send, limited rules, and in the case of many third-party apps a monthly fee that scales with your catalog size. A feed you own in a Google Sheet flips all of that.

  • Full control over how title and description are built, including stripping HTML and appending variant names.
  • No per-product app pricing. The whole stack (n8n self-hosted, Shopify API, Google Sheets, Merchant Center) is free at the volumes most stores run.
  • The feed is plain data in a sheet you can open, audit, and fix by hand if Google flags a row.
  • You decide which products are included: filter drafts, hidden collections, or zero-price items before they ever reach Google.

What you need

  • An n8n instance, cloud or self-hosted.
  • A Shopify store and an Admin API access token. If you have not connected Shopify to n8n yet, follow connect Shopify to n8n (2026) first, using the Dev Dashboard method.
  • A Google account with Google Sheets OAuth set up in n8n.
  • A free Google Merchant Center account.
  • About 30 minutes to build from scratch, or under 10 with the template.

Node-by-node list

# Node Type Job
1 Every morning scheduleTrigger Fires the workflow once a day at 4am.
2 Get all products shopify Returns every product with its variants and images.
3 Build feed rows code Flattens products into one Merchant-format row per variant.
4 Write feed sheet googleSheets Append-or-update each row in the feed sheet, matched on id.

Step-by-step build

1 Every morning (Schedule Trigger)

Add a Schedule Trigger. Set the rule to trigger every day at hour 4. This is your feed refresh cadence; you will line it up with the Merchant Center fetch time in the last step.

💡

If prices or stock swing during the day, add a second interval (for example hour 4 and hour 16) so the sheet is fresh twice a day.

2 Get all products (Shopify)

Add a Shopify node. Set Resource to Product, Operation to Get Many, and turn on Return All so pagination is handled for you. Attach your Shopify credential. Each item that leaves this node is one product, with a variants array and an images array inside it.

{
  "id": 8123456789,
  "title": "Ceramic Pour-Over Kettle",
  "handle": "ceramic-pour-over-kettle",
  "vendor": "Northwind Coffee",
  "body_html": "<p>A slow-pour kettle for even extraction.</p>",
  "images": [{ "id": 111, "src": "https://cdn.shopify.com/.../kettle.jpg" }],
  "variants": [
    { "id": 45001, "title": "Matte White", "price": "48.00", "sku": "KET-WHT",
      "barcode": "0810000000015", "inventory_quantity": 12,
      "inventory_management": "shopify", "inventory_policy": "deny", "image_id": 111 }
  ]
}

3 Build feed rows (Code)

Add a Code node and paste the script below. It loops every product, then every variant inside it, and emits one clean row per variant with the attribute names Merchant Center expects. Set STORE_DOMAIN to your storefront domain so the link column points at the right product page.

const STORE_DOMAIN = 'your-store.myshopify.com';
const CURRENCY = 'USD';

const rows = [];

for (const item of $input.all()) {
  const p = item.json;
  const handle = p.handle;
  const vendor = p.vendor || '';
  const description = (p.body_html || '')
    .replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 5000);
  const firstImage = (p.images && p.images[0] && p.images[0].src)
    || (p.image && p.image.src) || '';

  for (const v of (p.variants || [])) {
    let imageLink = firstImage;
    if (v.image_id && p.images) {
      const match = p.images.find(img => img.id === v.image_id);
      if (match) imageLink = match.src;
    }
    const tracked = v.inventory_management === 'shopify';
    const inStock = !tracked || v.inventory_quantity > 0 || v.inventory_policy === 'continue';

    rows.push({ json: {
      id: String(v.id),
      item_group_id: String(p.id),
      title: p.title + (v.title && v.title !== 'Default Title' ? ' - ' + v.title : ''),
      description,
      link: `https://${STORE_DOMAIN}/products/${handle}?variant=${v.id}`,
      image_link: imageLink,
      availability: inStock ? 'in_stock' : 'out_of_stock',
      price: `${v.price} ${CURRENCY}`,
      condition: 'new',
      brand: vendor,
      mpn: v.sku || String(v.id),
      gtin: v.barcode || ''
    }});
  }
}

return rows;

After this node, a single variant looks like a finished feed row:

{
  "id": "45001",
  "item_group_id": "8123456789",
  "title": "Ceramic Pour-Over Kettle - Matte White",
  "description": "A slow-pour kettle for even extraction.",
  "link": "https://your-store.myshopify.com/products/ceramic-pour-over-kettle?variant=45001",
  "image_link": "https://cdn.shopify.com/.../kettle.jpg",
  "availability": "in_stock",
  "price": "48.00 USD",
  "condition": "new",
  "brand": "Northwind Coffee",
  "mpn": "KET-WHT",
  "gtin": "0810000000015"
}

4 Write feed sheet (Google Sheets)

Create a Google Sheet with a header row whose columns exactly match the keys above: id, item_group_id, title, description, link, image_link, availability, price, condition, brand, mpn, gtin. Then add a Google Sheets node:

  1. Operation: Append or Update Row.
  2. Document: pick your feed spreadsheet.
  3. Sheet: pick the tab (the template targets gid=0).
  4. Mapping: Map Automatically, and set the column to match on to id.

Append-or-update on id means a variant that already exists is overwritten in place instead of duplicated, so the sheet stays clean run after run.

5 Point Merchant Center at the sheet

In Google Merchant Center, go to Products, add a primary feed, choose Google Sheets as the source, select this spreadsheet, and set a fetch schedule an hour or so after your n8n run. Save and activate the workflow in n8n. From then on, n8n refreshes the sheet each morning and Merchant Center reads it shortly after.

Common mistakes

  • Sending one row per product instead of per variant. Sizes and colors are separate offers; skip the variant loop and half your catalog goes missing.
  • Price without a currency. Google needs 48.00 USD, not 48.00. The Code node appends the currency for you, so keep that format.
  • Sheet headers that do not match the attribute names. image_link spelled image, or a stray capital letter, and Google ignores the column.
  • Using Append instead of Append or Update. Plain append stacks a fresh copy of every variant on each run and the feed balloons with duplicates.
  • Missing images. Offers with no image_link are disapproved, so the fallback to the first product image matters; make sure every product has at least one image in Shopify.
  • Forgetting item_group_id. Without it, Google treats each variant as an unrelated product instead of grouping them under one listing.

Cost at realistic volume

This workflow is effectively free to run. Self-hosted n8n has no per-execution charge; on n8n Cloud one scheduled run per day is a rounding error against any plan. The Shopify Admin API is free to read from. Google Sheets and Google Merchant Center are both free.

Catalog size Rows written / day Monthly cost
200 products (~500 variants) ~500 $0
1,000 products (~2,500 variants) ~2,500 $0
5,000 products (~12,000 variants) ~12,000 $0 (well inside Sheets limits)

Compare that with feed apps that commonly charge a monthly fee per active product count, and the case for owning the feed is easy to make.

Ready-to-import template

The full guide above is free to follow. If you would rather not wire four nodes and format a sheet by hand, the ready-to-import template drops the exact workflow into n8n in a couple of clicks. Add your Shopify and Google credentials, set your store domain, and your feed is live. Want it done for you end to end? See our done-for-you setup service.

Download the template ($13) →

Instant download · Works on n8n Cloud and self-hosted

Frequently asked questions

Can Google Merchant Center read a Google Sheet as a product feed?

Yes. In Merchant Center you add a primary feed with Google Sheets as the source, then set a fetch schedule. Merchant Center reads the sheet on that schedule, so keeping the sheet current with n8n keeps your listings current. No paid app or file host is needed.

Do I need one row per product or one row per variant?

One row per variant. Each purchasable variant is a distinct offer with its own id, price, and availability. The workflow flattens every product into its variants and links them with item_group_id so Google groups sizes and colors under one product.

Will this replace the Shopify Google and YouTube channel app?

It can. The n8n feed gives you full control over titles, descriptions, and which products are included, with no app in the middle. Some merchants run both: the app for conversion tracking and the sheet feed for catalog control. Choose based on how much mapping control you want.

How often should the feed refresh?

A daily run matched to your Merchant Center fetch schedule is enough for most stores. If prices or stock change through the day, run it every few hours. Merchant Center still only reads on its own fetch schedule, so match the two so they line up.

What if a product has no images or is out of stock?

Google disapproves offers with no image_link, so the Code node falls back to the first product image. Out-of-stock variants stay in the feed with availability set to out_of_stock, which is correct: Google prefers a stable feed over rows that vanish and reappear.

Related guides

n8n
Shopify
Google Sheets
Google Merchant Center
automation

How to Sync Shopify Orders to Airtable With n8n









Sending your Shopify orders to Airtable with n8n gives you a live, filterable order database that updates the moment a customer checks out. This guide builds a three-node workflow: a Shopify trigger fires on every new order, a Set node flattens the fields you care about, and an Airtable upsert writes one clean row per order. No CSV exports, no manual copy-paste, and no duplicate rows when Shopify resends the same webhook twice.

What it does

Every time a shopper completes checkout, Shopify fires an orders/create event. This workflow catches that event, pulls out the fields that actually matter for reporting, and writes them into an Airtable table as a single row. Because it uses an upsert keyed on the Shopify order ID, a resent webhook updates the existing row instead of creating a second one.

The result is an Airtable base that mirrors your order history in real time. You get order number, customer name, email, order total, currency, financial status, fulfillment status, a readable line-item summary, and the order date, all typed and sortable. From there you can build filtered views (unfulfilled orders, orders over $200, orders from repeat buyers) or link the table to inventory and customer tables you already keep in Airtable.

Why it beats the default

Shopify already lets you export orders, but the export is a manual CSV: you click, you wait, you download, and the file is stale the second you open it. If you want a shared, always-current view for a virtual assistant or a fulfillment partner, a static CSV is the wrong tool.

Airtable fixes that. It gives you typed fields, saved views, grouping, and a mobile app, plus linked records so an order row can point at a product or customer record elsewhere in the base. Pairing it with n8n means the sync is event-driven, not scheduled, so a row appears within seconds of checkout. This is part of a wider pattern of n8n Shopify automation that keeps your back office in sync without paid connector apps.

If you are already logging orders to a spreadsheet, this is the natural upgrade. A flat sheet cannot group by fulfillment status, filter to a date range, and stay readable at the same time. Airtable can.

What you need

  • An n8n instance, either n8n Cloud or a self-hosted install.
  • A Shopify store with a custom app created through the 2026 Shopify Dev Dashboard. If you have not connected Shopify to n8n yet, follow how to connect Shopify to n8n in 2026 first, then come back here.
  • An Airtable account. The free plan is fine to start.
  • An Airtable base with a table whose column names match the fields below. Create the columns before you run the workflow so the mapping resolves cleanly.

Build time is about 20 minutes from scratch, or under 10 minutes if you import the ready-made template at the end of this guide.

Node-by-node list

# Node Type Job
1 Shopify Trigger shopifyTrigger Fires on every new order (orders/create)
2 Map Order Fields set (v3.4) Flattens the order payload into ten clean fields
3 Upsert to Airtable airtable (v2.1) Writes or updates one row, matched on Order ID
┌───────────────────────────────────────────────────────────┐
│  SHOPIFY ORDERS TO AIRTABLE                                │
│                                                           │
│  [Shopify Trigger] → [Map Order Fields] → [Upsert Airtable]│
│    orders/create        Set v3.4            match: Order ID │
└───────────────────────────────────────────────────────────┘

Step-by-step build

  1. Create a new workflow in n8n and add a Shopify Trigger node. Select your Shopify credential (created via the Dev Dashboard method linked above), then set the topic to orders/create. n8n registers the webhook with Shopify automatically when you activate the workflow.
  2. Add a Set node named Map Order Fields and connect the trigger to it. Switch the node to “Manual Mapping” and add one assignment per field you want in Airtable. Use these values:
    • Order ID (string): ={{ $json.id }}
    • Order Number (string): ={{ $json.name }}
    • Customer Name (string): ={{ (($json.customer && $json.customer.first_name) || '') + ' ' + (($json.customer && $json.customer.last_name) || '') }}
    • Email (string): ={{ $json.email }}
    • Total (number): ={{ Number($json.total_price) }}
    • Currency (string): ={{ $json.currency }}
    • Financial Status (string): ={{ $json.financial_status }}
    • Fulfillment Status (string): ={{ $json.fulfillment_status || 'unfulfilled' }}
    • Items (string): ={{ ($json.line_items || []).map(i => i.quantity + 'x ' + i.title).join(', ') }}
    • Order Date (string): ={{ $json.created_at }}
  3. In Airtable, create a table with columns that match those field names exactly. Set Total to a Number or Currency field and Order Date to a Date field; the rest can stay single-line text.
  4. Add an Airtable node named Upsert to Airtable and connect the Set node to it. Choose your Airtable Personal Access Token credential, set Resource to Record and Operation to Upsert, then pick your base and table from the dropdowns.
  5. Set the matching column to Order ID. This is what turns a repeated webhook into an update instead of a duplicate. Map each remaining column to the matching field from the Set node, for example Order Number to ={{ $json['Order Number'] }}.
  6. Save the workflow and toggle it Active. n8n registers the Shopify webhook at this point, so the trigger only fires on orders placed after activation.
  7. Place a test order (or use Shopify’s “Create order” in the admin) and watch a new row appear in Airtable within a few seconds.
Tip: After a successful test order, open the execution in n8n and expand the Shopify Trigger output. Every field you might ever want (shipping address, discount codes, note attributes, tags) is in that payload, ready to add as another assignment.

The order fields written to Airtable

Column Type Example
Order ID Text 5123456789012
Order Number Text #1042
Customer Name Text Emily Rodriguez
Email Text emily.rodriguez@outlook.com
Total Number 148.00
Currency Text USD
Financial Status Text paid
Fulfillment Status Text unfulfilled
Items Text 2x Ceramic Mug, 1x Gift Box
Order Date Date 2026-07-26T14:30:00-04:00
Note: Column names in Airtable must match the assignment names in the Set node character for character. A trailing space or a lowercase letter is enough to make the mapping silently skip that field.

Common mistakes

  • No matching column on the upsert. If you leave the operation on Create, or forget to set Order ID as the match field, every webhook retry adds a duplicate row. Always upsert on Order ID.
  • Mismatched column names. Airtable will not error on a name that does not exist; it just drops the value. Create the columns first and copy the names exactly.
  • Total stored as text. If the Airtable column is single-line text, you cannot sum or filter it numerically. Use a Number or Currency field and keep the Number() wrapper in the Set node.
  • Expecting a fulfillment status on new orders. A brand-new order is almost always unfulfilled, and Shopify sends null. The || 'unfulfilled' fallback keeps the column readable.
  • Using the old custom-app auth. Shopify removed the legacy admin custom-app flow. Create the app through the 2026 Dev Dashboard, as covered in the connection guide linked earlier.

Cost at realistic volume

The stack is close to free for a small store. Self-hosted n8n is free; n8n Cloud starts around $24 per month and its Starter tier covers roughly 2,500 workflow executions, which is 2,500 orders. One order equals one execution here, so a store doing 500 orders a month uses a fifth of that allowance.

Airtable’s free plan holds 1,000 records per base. At 500 orders a month you fill that in two months, so a growing store will want the Team plan (about $20 per seat per month) which raises the limit to 50,000 records per base. Until then, the free tier is enough to prove the workflow out. There are no per-order connector fees, which is the main saving over a paid Shopify-to-Airtable app.

Get the Shopify Orders to Airtable template

The guide above is free to follow, node by node. If you would rather skip the build, the ready-to-import template drops straight into n8n with the trigger, field mapping, and upsert already wired, so you only add your credentials and base ID.

Download the template ($12) →

Instant download · Works on n8n Cloud and self-hosted. Want it built and connected for you? See our done-for-you setup service.

Frequently asked questions

Do I need a paid Airtable plan for this workflow?

No. The Airtable free plan holds up to 1,000 records per base, which covers a young store for months. When you outgrow it, the Team plan raises the limit to 50,000 records per base. The workflow itself is identical on either plan.

Will Shopify resending a webhook create duplicate rows?

No, as long as you keep the upsert set to match on Order ID. Shopify occasionally retries a webhook, and an upsert updates the existing row instead of adding a new one. If you switch the operation to create, you will get duplicates on every retry.

Can I add more Shopify fields to the Airtable table later?

Yes. Add a new assignment in the Map Order Fields node pointing at the Shopify field you want, create a matching column in Airtable with the same name, then map it in the Upsert node. The order payload includes shipping address, discount codes, tags, and more.

Does this replace the Shopify order CSV export?

For most reporting it does. The CSV export is a manual, static snapshot, while this workflow keeps an Airtable base current in real time. You can still export the Airtable view to CSV any time you need a file, so you lose nothing by switching.

What if I already send orders to Google Sheets?

You can run both, or replace the Sheets step. Airtable adds typed fields, filtered views, linked records, and a mobile app that a flat sheet cannot match. The build is nearly the same, so switching the final node from Google Sheets to Airtable takes a few minutes.

Related guides

Shopify discount code performance report with n8n







A Shopify discount code performance report built with n8n rolls every paid order up by the promo code it used, then logs the result to a Google Sheet and posts a weekly Slack summary. Shopify tells you a code exists, but not whether it earns its keep. This workflow reads the discount data that already sits on each order, counts redemptions, sums the money you gave away, and sums the revenue those orders brought in, so you can tell a code that drives sales from one that just discounts customers who would have bought anyway.

What it does

Once a week the workflow wakes up on a schedule, asks Shopify for every paid order from the last 30 days, and reads the discount_codes field that Shopify attaches to each order. For every code it builds a single row: how many times the code was redeemed, the total discount given, the total revenue of the orders that used it, and the average order value. It writes those rows to a Discount Report tab in Google Sheets and posts a plain Slack summary with the totals and the top five codes by revenue.

The result is a running scoreboard for your promotions. Instead of a vague sense that “the newsletter code does well”, you get numbers: WELCOME10 redeemed 42 times, gave away $310, and brought in $2,140 of revenue at a $51 average order. Line that up next to FLASH20 and it becomes obvious which promotion to repeat and which to retire.

Take a single order for $60 that used the code SPRING15 for $9 off. The workflow adds one to that code’s redemption count, adds $9 to its discount-given total, and adds $60 to its revenue total. Repeat that across every order in the window and each code ends the run with a clean three-number story: how often, how much given, how much earned.

Why it beats the default

Shopify’s built-in Discounts page shows a redemption count and, on some plans, the total discount amount. What it does not show cleanly is the revenue each code pulled in, side by side, in a place you can sort, chart, and keep month over month. Most owners end up exporting orders to a spreadsheet and rebuilding a pivot table by hand every time they want to compare campaigns.

This approach reads the same order data automatically and keeps a permanent, growing history in a sheet you own. Because the report lands in Google Sheets, you can pivot it, chart redemptions over time, or share it with a marketing partner without giving anyone access to your store admin. And because it runs on your own n8n instance, there is no per-seat analytics subscription and no app skimming your order feed. It is also a natural building block in a wider Shopify automation stack, since the same order pull can later feed margin, refund, or restock flows.

What you need

  • An n8n instance, either the free self-hosted community edition or any n8n Cloud plan.
  • A Shopify store connected to n8n. If you have not done this yet, follow connect Shopify to n8n with the 2026 Dev Dashboard method first. It takes about ten minutes and is the prerequisite for every step below.
  • A Google account with a blank spreadsheet that has a tab named Discount Report.
  • A Slack workspace and a channel to post the summary in. Prefer email? Swap the final Slack node for a Gmail node and send the same summary text to yourself.

The Shopify connection needs read access to orders. Everything else here uses standard read and append permissions, so the workflow can never change a product, an order, or a customer.

Node-by-node list

Every Monday 8am   (Schedule Trigger)
      |
Config and date range   (Code: build the 30-day created_at_min)
      |
Get paid orders   (HTTP Request: Shopify Admin API, orders.json)
      |
Aggregate by code   (Code: roll orders up by discount code)
      |
Append to report   (Google Sheets: append one row per code)
      |
Build summary   (Code: reduce to one Slack message)
      |
Post summary to Slack   (Slack: post the weekly report)
  • Every Monday 8am is a Schedule Trigger set to weekly. It is the only trigger and it fires the run.
  • Config and date range is a Code node that computes created_at_min from a single lookbackDays value, so changing the window is a one-line edit.
  • Get paid orders is an HTTP Request node calling the Shopify Admin API orders.json endpoint, filtered to paid orders inside the window, returning only the fields the report needs.
  • Aggregate by code is a Code node that walks every order’s discount_codes array and builds one clean row per code.
  • Append to report is a Google Sheets node that appends those rows to the Discount Report tab.
  • Build summary is a Code node that reduces all the code rows into a single plain-text summary.
  • Post summary to Slack sends that summary to your chosen channel.

Step-by-step build

  1. Create a new workflow in n8n and add a Schedule Trigger. Set it to weekly, Monday, hour 8. Rename it Every Monday 8am.
  2. Add a Code node named Config and date range. Paste the snippet that sets lookbackDays = 30 and returns createdMin as an ISO date. This is the one place you change the reporting window.
  3. Add an HTTP Request node named Get paid orders. Method GET, URL https://YOUR_STORE.myshopify.com/admin/api/2026-04/orders.json. Set authentication to Predefined Credential Type, choose your Shopify access token credential. Turn on Send Query and add: status=any, financial_status=paid, created_at_min={{ $json.createdMin }}, limit=250, and fields=id,name,created_at,total_price,discount_codes.
  4. Add a Code node named Aggregate by code. Paste the aggregation script from the template. It flattens the orders response, walks each order’s discount_codes array, and outputs one item per code with Uses, Discount Given, Revenue With Code, and Avg Order Value.
  5. Add a Google Sheets node named Append to report. Operation Append, pick your spreadsheet, and select the Discount Report tab. Leave mapping on Auto-map so the column headers match the fields the Code node emits.
  6. Add a Code node named Build summary. Paste the summary script. It totals redemptions, discount given, and revenue, then lists the top five codes by revenue as one message.
  7. Add a Slack node named Post summary to Slack. Resource Message, Operation Post, select your channel, and set the text to {{ $json.text }}. Save, then toggle the workflow Active.

Common mistakes

  • Reading all orders instead of paid ones. Without financial_status=paid, unpaid and cancelled orders inflate redemption counts. Keep the filter so the report reflects money that actually changed hands.
  • Expecting automatic discounts to appear. The discount_codes array only lists code-based discounts a customer typed or clicked. Automatic discounts with no code will not show up here, which is by design for a code performance report.
  • Comparing across different windows. If you switch lookbackDays between runs, the numbers stop being comparable. Pick a window, keep it stable, and let the Date column separate each run in the sheet.
  • Wrong API version. Use a current Admin API version such as 2026-04 in the URL. An old version can drop or rename fields the aggregation script expects.
  • Assuming one run covers everything. At more than 250 orders in the window, add pagination on the Get paid orders node or shorten the schedule, otherwise the oldest orders in the window are silently left out.

Cost at realistic volume

Everything here runs on free tiers. n8n community edition is free to self-host, and n8n Cloud counts this as one execution per week. Google Sheets is free with any Google account, and appending a handful of rows sits far inside the daily quotas. Slack posting is free on any workspace. The Shopify Admin API calls are free on every plan.

Store size Orders per 30 days Runs per month Monthly cost
Small Up to 250 4 $0
Growing 250 to 1,000 Switch to weekly with pagination $0
High volume 1,000+ Weekly, paginated $0 self-hosted

Ready-to-import template

The full guide above is free to follow. If you would rather skip the build, the downloadable template is the exact seven-node workflow, validated and ready to import, so you only add your credentials and spreadsheet. Prefer it done for you? See our done-for-you setup service.

Download the template ($19) →

Instant download · Works on n8n Cloud and self-hosted

FAQ

Where does the discount data come from?

From the orders themselves. Every paid Shopify order carries a discount_codes array listing the code applied and the amount it took off. The workflow reads that field directly, so there is no separate report to export and no third-party app sitting between you and your own order data.

Does it track revenue or just redemptions?

Both. For each code it counts redemptions, sums the discount given, and sums the total order value those redemptions brought in. That lets you see not only which codes are popular but which ones actually drive revenue versus which quietly erode your margin for little return.

How often does it run?

The template ships with a weekly schedule every Monday at 8am, looking back 30 days. Change the Schedule Trigger to daily or monthly, and adjust the lookbackDays value in the Config node to match. Both are plain fields you can edit without touching any other node.

Will it handle a large order volume?

The template pulls up to 250 orders per run, which covers most monthly windows for small and growing stores. For higher volume, add cursor pagination on the Get paid orders node using the page_info parameter, or shorten the schedule to weekly so each run reads fewer orders.

Do I need a paid n8n plan?

No. It runs on the free self-hosted community edition or any n8n Cloud plan. The only accounts you need are Shopify, a Google account for Sheets, and a Slack workspace for the summary. Swap Slack for a Gmail node if you would rather get the report by email.

Related guides

Shopify profit margin tracker with Google Sheets and n8n







A Shopify profit margin tracker built with Google Sheets and n8n turns your raw paid orders into a per-order net-margin report every week, without paying for another analytics app. Shopify shows you revenue, but revenue is not profit. This workflow pulls each paid order, matches every line item to its cost from a simple Google Sheet, subtracts an estimated payment fee, and logs the real net margin per order so you can see which sales actually made money and which quietly lost it.

What it does

Once a week the workflow wakes up on a schedule, asks Shopify for every paid order from the last seven days, and reads a Google Sheet where you have listed the unit cost of each SKU. For every order it computes revenue, cost of goods sold, an estimated payment fee, gross profit, net profit, and a net margin percentage. It writes one row per order to a Margin Report tab and emails you a plain summary: total revenue, total net profit, average margin, and the five lowest-margin products in the period.

The result is the number most Shopify dashboards hide. You stop guessing whether a busy week was a profitable week, and you get an early warning when a discount, a supplier price rise, or a heavy-shipping product starts eating your margin.

Take an order for three units of SKU MUG-01 at $18 each. Revenue is $54. If the sheet lists a unit cost of $7, cost of goods is $21. The estimated fee is 2.9 percent of $54 plus 30 cents, about $1.87. Gross profit is $33 and net profit is $31.13, a net margin near 57.6 percent. That single row is what the workflow writes to your sheet, and rolling it up across the week is what the email reports.

Why it beats the default

Shopify Analytics reports gross sales, discounts, and returns, but it does not know your cost of goods. Neither does it net out payment processing fees per order. That leaves most store owners exporting orders to a spreadsheet by hand, pasting in costs, and rebuilding the same formulas every month. Third-party profit apps solve it, but they charge a monthly fee and want deep access to your store.

This approach keeps the cost data in a sheet you own, runs on your own n8n instance, and costs nothing beyond the accounts you already have. Because costs live in a spreadsheet rather than buried in the automation, anyone on your team can keep them current. And since it is n8n, you can later branch the same order data into Slack alerts, restock triggers, or a monthly PDF without rebuilding anything. See the full n8n Shopify automation hub for related builds.

What you need

  • An n8n instance, either the free self-hosted community edition or n8n Cloud.
  • A Shopify store and access to the Admin API. Follow the 2026 Dev Dashboard method in our connect Shopify to n8n guide to create a custom app and copy its Admin API access token. The old admin custom-app screen has been removed, so use the Dev Dashboard flow.
  • A Google account for Google Sheets (cost data plus the report) and Gmail (the summary email).
  • A Google Sheet with two tabs: a COGS tab holding SKU and Unit Cost columns, and a Margin Report tab with the nine report headers.

Build time is about 30 to 40 minutes from scratch, or under 10 minutes if you import the ready-made template below.

Node-by-node list

Seven nodes, wired in a straight line from schedule to email:

[Weekly schedule] -> [Config] -> [Get paid orders] -> [Read COGS]
        -> [Compute margins] -> [Append margin report] -> [Email summary]
  
  • Weekly schedule (Schedule Trigger) fires every Monday at 6am.
  • Config (Set) holds the lookback window and the payment fee assumptions in one place.
  • Get paid orders (HTTP Request) calls the Shopify Admin API for paid orders in the window.
  • Read COGS (Google Sheets) reads your SKU-to-cost table.
  • Compute margins (Code) joins orders to costs and does the math.
  • Append margin report (Google Sheets) writes one row per order.
  • Email summary (Gmail) sends the period totals and the worst-margin products.

Step-by-step build

  1. Add a Schedule Trigger named Weekly schedule. Set the interval to weeks, every 1 week, on day 1 (Monday) at hour 6. This defines how far back each run looks together with the next node.
  2. Add a Set node named Config. Create three fields: createdMin as a string with the expression {{ $now.minus({ days: 7 }).toISO() }}, feeRate as a number set to 0.029, and flatFee as a number set to 0.3. Adjust the fee values to match your payment provider.
  3. Add an HTTP Request node named Get paid orders. Method GET, URL https://YOUR_STORE.myshopify.com/admin/api/2026-04/orders.json. Set authentication to Generic Credential Type, Header Auth, and create a header credential with name X-Shopify-Access-Token and your Admin API token as the value. Turn on Send Query Parameters and add status=any, financial_status=paid, created_at_min={{ $json.createdMin }}, limit=250, and fields=id,name,created_at,customer,line_items,financial_status.
  4. Add a Google Sheets node named Read COGS. Operation Read Rows, pick your spreadsheet, and select the COGS tab. This returns one item per SKU with its unit cost.
  5. Add a Code node named Compute margins. Paste the join-and-calculate script from the template. It builds a SKU-to-cost map from Read COGS, walks each order’s line items, and outputs one item per order plus a shared summary block.
  6. Add a Google Sheets node named Append margin report. Operation Append, select the Margin Report tab, and map the nine columns: Date, Order, Customer, Revenue, COGS, Fees, Gross Profit, Net Profit, Margin %.
  7. Add a Gmail node named Email summary. Send to your own address, and reference {{ $('Compute margins').first().json.totalNet }}, avgMargin, and worstProducts in the body. Save, then toggle the workflow Active.

Common mistakes

  • Leaving SKUs blank on products. The cost match keys on SKU, so any line item without a SKU is treated as zero cost and shows an inflated margin. Fill in SKUs before you trust the numbers.
  • Mismatched SKU text. A cost of MUG-01 in the sheet will not match mug-01 from Shopify if you also change surrounding spaces. The script trims whitespace, but keep the SKU spelling identical.
  • Confusing gross margin with net margin. Gross profit is revenue minus cost of goods. Net profit here also subtracts the estimated payment fee. The headline number in the email is net.
  • Forgetting returns. This tracks paid orders, not refunds. If returns are material for you, pair it with a refund tracker so the two reports tell the full story.
  • Wrong API version. Use a current Admin API version such as 2026-04. An old version in the URL can drop fields the script expects.

Cost at realistic volume

Everything here runs on free tiers. n8n community edition is free to self-host; n8n Cloud plans count this as one execution per week. Google Sheets and Gmail are free with any Google account, and reading a cost tab plus appending a few dozen rows sits far inside the daily API quotas. The Shopify Admin API calls are free on any plan.

Store size Orders per week Runs per month Monthly cost
Small Up to 50 4 $0
Growing 50 to 250 4 $0
High volume 250+ Switch to daily, 30 $0 self-hosted

Ready-to-import template

The full guide above is free to follow. If you would rather skip the build, the downloadable template is the exact seven-node workflow, validated and ready to import, so you only add your credentials and spreadsheet. Prefer it done for you? See our done-for-you setup service.

Download the template ($19) →

Instant download · Works on n8n Cloud and self-hosted

FAQ

Where does the cost data come from?

From a Google Sheet tab you fill in once, mapping each SKU to its unit cost. The workflow reads that tab on every run, so keeping margins accurate is a matter of updating a spreadsheet cell, not editing the automation itself.

Does this include Shopify payment fees?

It applies an estimated fee of 2.9 percent plus 30 cents per order by default, which you can change in the Config node. It is an estimate, not a reconciled payout, but it lands close enough to flag which orders and products are quietly losing money.

How often does it run?

The template ships with a weekly schedule every Monday at 6am, looking back seven days. Change the Schedule Trigger to daily or monthly, and adjust the lookback window in the Config node to match. Both live in plain fields you can edit without code.

Will it work with a large order volume?

The template pulls up to 250 orders per run, which covers most weekly windows. For higher volume, add pagination on the Get paid orders node using the page_info cursor, or shorten the schedule to daily so each run handles fewer orders.

Do I need a paid n8n plan?

No. This runs on the free self-hosted community edition or any n8n Cloud plan. The only accounts you need are Shopify, a Google account for Sheets and Gmail, and an n8n instance to run the seven nodes on a schedule.

Related guides

Shopify product image alt text generator with n8n







A Shopify product image alt text generator in n8n closes one of the quietest gaps in a store: every photo you upload ships with an empty alt attribute, and nobody ever goes back to fill them in. This build watches for new products, sends each image without alt text to Google Gemini, and writes the returned description straight back to Shopify through the GraphQL Admin API. Eleven nodes, no theme edits, no app subscription.

What it does

The workflow fires on the Shopify products/create webhook. When a product lands, it asks Shopify for that product’s media, keeps only the images whose alt attribute is empty, and processes each one on its own.

  1. Downloads the image file from the Shopify CDN.
  2. Sends the image bytes to Gemini together with the product title, type, vendor and tags so the model has context as well as pixels.
  3. Cleans the answer: one line, no wrapping quotes, no trailing period, hard capped at 125 characters.
  4. Writes it back with the fileUpdate GraphQL mutation, which is what actually changes the alt attribute on a Shopify MediaImage.
  5. Sends you one Telegram message per product listing every alt text it just wrote, so you can spot a bad one in five seconds.
 Shopify products/create
          |
   Fetch Product Media  (GraphQL: title, type, vendor, tags, media[])
          |
    Split Out Images  -->  one item per media node
          |
   Only Images Missing Alt  (alt empty AND image url present)
          |
      Download Image  (binary from the Shopify CDN)
          |
     Image To Base64  (buffer -> base64 + product context)
          |
   Gemini Writes Alt Text  (gemini-2.5-flash, inline_data)
          |
      Build Alt Text  (trim, strip quotes, cap 125 chars)
          |
 Update Alt Text In Shopify  (fileUpdate mutation)
          |
   Collect Results  -->  Notify Telegram (one digest per product)

Why it beats the default

Shopify does not generate alt text. The admin gives you a small “Add alt text” link under each image and that is the whole feature. On a ten product drop with four photos each that is forty text boxes, so in practice it never happens and the catalog ships blind.

The apps that do this fall into two camps. Bulk SEO apps charge a monthly fee and usually template the alt text from the product title, which produces forty images all described as “Cotton Crew Neck Tee” with nothing to distinguish the back view from the folded flat lay. Vision based apps read the picture but lock you into their prompt and their credit system.

Running it in n8n gives you the vision model and the prompt. Because the image bytes go to Gemini as inline_data, the model describes what is in the frame, and because the product title, type, vendor and tags travel in the same request, it also knows the words your customers search for. You own the prompt, so if the output reads too clinical you change one sentence and rerun.

Tip: keep alt text descriptive rather than keyword stuffed. Google has said for years that alt text written for humans outperforms alt text written for crawlers, and screen reader users are the other half of the audience.

What you need

  • An n8n instance, cloud or self hosted, version 1.60 or newer.
  • A Shopify custom app with the read_products and write_products scopes. If you have not connected the two yet, follow connecting Shopify to n8n in 2026 first, since the old admin custom app screen is gone and the Dev Dashboard flow is the current one.
  • A Google AI Studio API key for Gemini. The free tier covers a normal catalog.
  • A Telegram bot and your chat id for the digest message. Optional, delete the last two nodes if you do not want it.
  • About 30 minutes to build by hand.

Node-by-node list

# Node Type Job
1 New Product Created Shopify Trigger Listens to products/create
2 Fetch Product Media HTTP Request GraphQL query for title, type, vendor, tags, media
3 Split Out Images Split Out One item per media node
4 Only Images Missing Alt Filter Keeps empty alt with a usable image url
5 Download Image HTTP Request Fetches the file as binary
6 Image To Base64 Code Buffer to base64, carries product context
7 Gemini Writes Alt Text HTTP Request Vision call to gemini-2.5-flash
8 Build Alt Text Set Cleans and caps the string
9 Update Alt Text In Shopify HTTP Request fileUpdate mutation
10 Collect Results Aggregate Rolls the per image items into one
11 Notify Telegram Telegram One digest per product

Step-by-step build

  1. New Product Created. Add a Shopify Trigger, set authentication to Access Token, pick your Shopify credential and set Topic to products/create. n8n registers the webhook with your store as soon as you activate the workflow. The payload includes admin_graphql_api_id, which is the product gid the next node needs.
  2. Fetch Product Media. Add an HTTP Request node, method POST, url https://YOUR_STORE.myshopify.com/admin/api/2026-04/graphql.json. Set Authentication to Predefined Credential Type and choose Shopify Access Token API so the header is signed for you. Set Body Content Type to JSON, switch Specify Body to Using JSON, and paste this expression:
    {{ JSON.stringify({
      query: 'query ($id: ID!) { product(id: $id) { title productType vendor tags media(first: 20) { nodes { ... on MediaImage { id alt image { url } } } } } }',
      variables: { id: $json.admin_graphql_api_id }
    }) }}

    Building the body with JSON.stringify rather than typing raw JSON matters here, because a product title with a quote or an apostrophe in it would otherwise break the request.

  3. Split Out Images. Add a Split Out node and set Field To Split Out to data.product.media.nodes. A product with four photos now produces four items.
    {
      "id": "gid://shopify/MediaImage/28374651",
      "alt": null,
      "image": { "url": "https://cdn.shopify.com/s/files/1/0742/.../tee-back.jpg" }
    }
  4. Only Images Missing Alt. Add a Filter node with two conditions joined by AND: {{ $json.alt }} is empty, and {{ $json.image.url }} is not empty. Turn on Convert Types Where Required. The first condition protects alt text you wrote by hand. The second drops videos and 3D models, which come back from the same media connection with no image url.
  5. Download Image. Add an HTTP Request node with url {{ $json.image.url }}. Open Options, add Response, set Response Format to File and Put Output In Field to data. The node now outputs binary rather than JSON.
  6. Image To Base64. Add a Code node in Run Once For All Items mode. Gemini needs base64 in the request body, and n8n binary data is not directly readable from an expression when the instance stores binaries on disk, so read it through the helper:
    const product = $('Fetch Product Media').first().json.data.product;
    const source = $('Only Images Missing Alt').all();
    const out = [];
    
    for (let i = 0; i < items.length; i++) {
      const buffer = await this.helpers.getBinaryDataBuffer(i, 'data');
      const meta = items[i].binary.data;
      out.push({
        json: {
          id: source[i].json.id,
          imageUrl: source[i].json.image.url,
          mimeType: meta.mimeType || 'image/jpeg',
          base64: buffer.toString('base64'),
          productTitle: product.title,
          productType: product.productType || '',
          vendor: product.vendor || '',
          tags: (product.tags || []).join(', '),
        },
      });
    }
    
    return out;

    The media gid is pulled back from the filter node because a file response leaves items[i].json empty.

  7. Gemini Writes Alt Text. Add an HTTP Request node, POST to https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent. Set Authentication to Generic Credential Type, then Header Auth, and create a credential with name x-goog-api-key and your AI Studio key as the value. Specify Body as JSON with this expression:
    {{ JSON.stringify({
      contents: [{ parts: [
        { text: 'You write alt text for ecommerce product photos. Describe what is visible in the image in one plain sentence under 120 characters. Name the product type, colour, material and any visible detail. Do not start with "image of" or "photo of". Do not add marketing language, brand slogans or a full stop at the end. Context from the store listing: title "' + $json.productTitle + '", type "' + $json.productType + '", vendor "' + $json.vendor + '", tags "' + $json.tags + '". Return the alt text only.' },
        { inline_data: { mime_type: $json.mimeType, data: $json.base64 } }
      ]}],
      generationConfig: { temperature: 0.2, maxOutputTokens: 120 }
    }) }}
  8. Build Alt Text. Add a Set node (Edit Fields) and turn Include Other Input Fields off. Create three string assignments:
    • mediaId = {{ $('Image To Base64').item.json.id }}
    • productTitle = {{ $('Image To Base64').item.json.productTitle }}
    • altText = {{ ($json.candidates[0].content.parts[0].text || '').replace(/[\r\n]+/g, ' ').replace(/^["']|["'.]$/g, '').trim().slice(0, 125) }}

    That last expression is the guardrail. Whatever Gemini returns becomes one line, unquoted, under 125 characters.

  9. Update Alt Text In Shopify. Another HTTP Request node, POST to the same graphql.json url with the Shopify predefined credential. Body:
    {{ JSON.stringify({
      query: 'mutation ($files: [FileUpdateInput!]!) { fileUpdate(files: $files) { files { id alt } userErrors { field message } } }',
      variables: { files: [{ id: $json.mediaId, alt: $json.altText }] }
    }) }}

    Read userErrors on your first test run. Shopify GraphQL returns HTTP 200 even when the mutation is rejected, so a scope problem shows up there and nowhere else.

  10. Collect Results and Notify Telegram. Add an Aggregate node set to Aggregate All Item Data with Output Field Name updated, then a Telegram node with your chat id and this text:
    Alt text written for {{ $json.updated.length }} image(s) on "{{ $('Build Alt Text').first().json.productTitle }}"
    
    {{ $('Build Alt Text').all().map(i => '- ' + i.json.altText).join('\n') }}

    If every image on a product already had alt text, the filter empties the branch and nothing after it runs, so you get no message at all. That is the behaviour you want.

  11. Save and activate, then create a test product with two photos. Within a few seconds the Telegram digest arrives and both images show alt text in the Shopify admin under Media.

Common mistakes

  • Reading binary data from an expression. {{ $binary.data.data }} works on an instance that keeps binaries in memory and returns a filesystem reference on one that does not. The Code node helper works in both cases, which is why it is there.
  • Writing raw JSON in the body field. Product titles contain apostrophes and quotes. Build every GraphQL body with JSON.stringify and the escaping is handled for you.
  • Sending the ProductImage gid to fileUpdate. The mutation expects the MediaImage id that the media connection returns. An id taken from a REST payload is a different object and the call fails inside userErrors.
  • Forgetting write_products. A read only token queries the media fine and then silently refuses the mutation. Check userErrors before you assume the workflow ran.
  • Letting the model write a paragraph. Without maxOutputTokens and the 125 character slice you end up with alt attributes long enough that screen readers truncate them anyway.
  • Splitting on images instead of media. The alt attribute now lives on the media object. Split the media connection and the id you carry is already the right one.

Cost at realistic volume

Assume a store that publishes 60 new products a month with an average of four photos each, so 240 images.

Item Volume Cost
Gemini 2.5 Flash vision calls 240 images, roughly 300 tokens in and 40 out each Free tier, or about $0.03 on the paid tier
Shopify Admin API 480 GraphQL calls $0, well inside the rate limit
n8n Cloud Starter 60 executions, one per product Covered by the base plan
Self hosted n8n Same $0 beyond your server
Bulk alt text app for comparison Same catalog $10 to $30 a month, forever

The interesting number is the execution count. Because the trigger fires once per product and every image is handled as an item inside that single run, a 60 product month costs 60 executions rather than 240.

Note: Gemini free tier limits are per minute as well as per day. A 40 image bulk import can trip the per minute cap, so add a Loop Over Items node with a small batch size before the Gemini call if you plan to backfill an entire catalog in one go.

Ready-to-import template

Get the Shopify Alt Text Generator template

Every step above is free to follow and the guide is complete. The download is the same workflow as a validated JSON file: import it, attach your Shopify, Gemini and Telegram credentials, change the store domain in two nodes and it runs. It skips the build, the GraphQL typing and the base64 debugging.

Download the template ($19) →

Instant download · Works on n8n Cloud and self hosted · Want it installed and tuned for your catalog? See our done for you services.

FAQ

Does alt text actually help Shopify SEO?

It helps in two places. Google Images uses alt text as a primary relevance signal for product photos, and screen readers read it aloud, which is an accessibility requirement in most markets. Neither one moves a ranking overnight, but empty alt attributes across a whole catalog cost you traffic you never see.

Can I run this over products I already published?

Yes. Swap the Shopify trigger for a Schedule Trigger and query products with a GraphQL products connection instead of a single product. Everything after Split Out Images works unchanged because it only ever sees one media node at a time.

Why does the workflow use GraphQL instead of the REST images endpoint?

Shopify has been moving product and media writes to GraphQL, and the fileUpdate mutation is the supported way to change the alt attribute on a MediaImage. Using it now means the workflow does not break the next time a REST product endpoint is retired.

What happens if Gemini returns something odd?

The Build Alt Text node strips line breaks, strips wrapping quotes and a trailing period, then cuts the string at 125 characters. A bad answer therefore becomes a short harmless sentence rather than a paragraph of markdown. You can still edit any image in the Shopify admin afterwards.

Does the workflow overwrite alt text I wrote by hand?

No. The Only Images Missing Alt filter drops every media node whose alt attribute already has a value, so anything you or a copywriter wrote survives. Only genuinely empty images reach Gemini, which also keeps the API bill down.

Related guides

Shopify order cancellation automation with n8n







A Shopify order cancellation automation in n8n turns a cancelled order into three things that otherwise sit on somebody’s to-do list: a clear email to the customer, a row in a Google Sheets log you can actually audit, and an alert to your ops address when the order shipped before it was cancelled so inventory will not come back on its own. Six nodes, one webhook, no polling. Here is the whole build.

What it does

The workflow listens to the Shopify orders/cancelled webhook. The moment an order is cancelled, whether from the admin, the API, or an app, Shopify pushes the full order payload to n8n and the workflow does three things in parallel.

  1. Emails the customer a plain confirmation naming the order, the items removed, the amount refunded so far, and the reason on record.
  2. Appends one row to a Cancellations sheet with eleven columns, so you can count cancellations by reason and by month without exporting anything.
  3. Checks whether the order was already fulfilled or partially fulfilled at the moment of cancellation. If it was, Shopify will not put that stock back, so the workflow emails your ops address with the exact line items to check.
┌──────────────────────────────────────────────────────────────┐
│  SHOPIFY ORDER CANCELLATION AUTOMATION                       │
│                                                              │
│  [Order Cancelled] → [Build Cancellation Data]               │
│                             │                                │
│              ┌──────────────┼──────────────┐                 │
│              ↓              ↓              ↓                 │
│   [Email Customer]  [Log To Sheets]  [Needs Restock?]        │
│                                             │ true           │
│                                             ↓                │
│                                    [Alert Ops To Restock]    │
└──────────────────────────────────────────────────────────────┘

Why it beats the default

Shopify’s built-in cancellation email is a receipt. It confirms the cancellation and stops there. Three gaps stay open.

The first is the restock gap. When you cancel an order in the Shopify admin you get a restock checkbox, and it only covers items that were never fulfilled. Cancel an order that already shipped and those units stay deducted from your inventory even though the goods may be on their way back to you. Nothing in Shopify tells you this happened. Two weeks later a product shows as out of stock while three units sit in a returns bin. This workflow catches that case on the spot and names the line items.

The second gap is the reason log. Shopify stores cancel_reason on the order, but there is no report that groups cancellations by reason over time. Once every cancellation lands in a sheet, “customer changed mind” versus “inventory” versus “fraud” becomes a number you can act on rather than a hunch.

The third gap is refund clarity. The default email does not tell a customer how much has actually been refunded or when it will land. That single missing sentence is what generates the follow-up support ticket. Saying “refunded so far, typically 5 to 10 business days” up front removes most of them.

What you need

  • An n8n instance, cloud or self-hosted, version 1.0 or newer.
  • A Shopify credential in n8n. Use the 2026 Dev Dashboard method described in how to connect Shopify to n8n in 2026. The old admin custom-app flow has been removed, so follow the current path. The trigger needs the read_orders scope.
  • A Gmail credential, used for both the customer email and the ops alert. A Google Workspace address on your own domain sends better than a free Gmail address.
  • A Google Sheets credential and one spreadsheet with a tab named Cancellations.
  • An internal address to receive restock alerts, for example ops@yourstore.com.

The Cancellations sheet

Create the tab and put these exact headers in row 1. The Google Sheets node maps by column name, so a typo here is the single most common reason rows land blank.

Column Example What it holds
Cancelled At 2026-07-20T14:22:09-04:00 Shopify timestamp of the cancellation
Order Number 1043 The customer-facing number
Order ID 5390214782033 Internal id, useful for API lookups
Customer Email emily.rodriguez@outlook.com Who was notified
Items 2 x Cedar Candle, 1 x Linen Throw Flattened line items
Order Total 128.00 Order value before refunds
Refund Total 128.00 Sum of refund transactions at webhook time
Currency USD Shop currency code
Cancel Reason customer customer, inventory, fraud, declined, other
Fulfillment Status fulfilled Status at the moment of cancellation
Manual Restock TRUE Whether stock needs a human check

Node-by-node list

# Node name Type Job
1 Order Cancelled Shopify Trigger Subscribes to orders/cancelled and receives the order payload
2 Build Cancellation Data Edit Fields (Set) v3.4 Flattens twelve fields, including a computed refund total and a restock flag
3 Email Customer Confirmation Gmail v2.1 Sends the HTML cancellation email to the buyer
4 Log Cancellation To Sheets Google Sheets v4.7 Appends one row to the Cancellations tab
5 Needs Manual Restock? IF v2.2 Routes on the boolean restock flag
6 Alert Ops To Restock Gmail v2.1 Emails ops the line items to physically check

Step-by-step build

  1. Create a new workflow and name it Shopify Order Cancellation Automation.
  2. Add a Shopify Trigger node, rename it to Order Cancelled, set Authentication to Access Token, attach your Shopify credential, and set Topic to orders/cancelled. n8n registers the webhook with Shopify when you activate the workflow, so there is nothing to configure on the Shopify side.
  3. Add an Edit Fields node named Build Cancellation Data. Leave Mode on Manual Mapping and add twelve assignments. The straightforward ones map directly: order_number to {{ $json.order_number }}, order_id to {{ $json.id }}, customer_email to {{ $json.email }}, cancelled_at, currency, and order_total to {{ $json.total_price }}.
  4. Add customer_name as a string with a fallback, because guest checkouts can arrive with no customer object attached:
    {{ $json.customer && $json.customer.first_name ? $json.customer.first_name : 'there' }}
  5. Add cancel_reason as {{ $json.cancel_reason || 'unspecified' }}. Shopify leaves this null when an order is cancelled through the API without a reason, and a null in the email body reads badly.
  6. Add items_list, which flattens the line items into one readable string used by both emails and the sheet:
    {{ ($json.line_items || []).map(i => i.quantity + ' x ' + i.title).join(', ') }}
  7. Add refund_total. Refunds live in a nested array of transactions, so sum them and fix to two decimals:
    {{ ($json.refunds || []).reduce((sum, r) => sum + (r.transactions || []).reduce((t, x) => t + parseFloat(x.amount || 0), 0), 0).toFixed(2) }}
  8. Add fulfillment_status as {{ $json.fulfillment_status || 'unfulfilled' }}. Shopify sends null rather than the string “unfulfilled” when nothing has shipped, which would otherwise break the next expression.
  9. Add needs_manual_restock and set its type to Boolean, not string. This is the flag the IF node reads:
    {{ ['fulfilled', 'partial'].includes($json.fulfillment_status) }}
  10. Add a Gmail node named Email Customer Confirmation. Set To to {{ $json.customer_email }}, Subject to Your order #{{ $json.order_number }} has been cancelled, and Email Type to HTML. Write the body naming the items, the refunded amount, and the 5 to 10 business day window. Under Options, turn off Append n8n attribution.
  11. Add a Google Sheets node named Log Cancellation To Sheets. Operation is Append Row in Sheet. Pick your spreadsheet, choose the Cancellations tab, set Mapping Column Mode to Map Each Column Manually, and fill the eleven columns from the table above.
  12. Add an IF node named Needs Manual Restock?. Left value {{ $json.needs_manual_restock }}, condition type Boolean, operator is true. Leave the right value empty since is true is a single-value operator.
  13. Add a second Gmail node on the true branch named Alert Ops To Restock. Send it to your ops address with subject Manual restock needed: order #{{ $json.order_number }} and a body naming items_list, the fulfillment status, and the refunded amount. Leave the false branch unconnected.
  14. Wire Build Cancellation Data to all three of the customer email, the Sheets node, and the IF node from its single output. n8n runs the three branches in sequence but each receives the same item, so a Gmail hiccup does not block the sheet row.
  15. Save, then toggle Active. Activation is what registers the webhook with Shopify.

Test with a real but cheap order rather than the n8n test button. Place a $1 order in your store, fulfil it, then cancel it from the admin. That single pass exercises both branches: you should receive the customer email, see a new sheet row, and get the ops alert because the order was already fulfilled.

Common mistakes

Storing the restock flag as a string

If needs_manual_restock is typed as a string, the value arrives as "true" and an IF node running strict type validation refuses to compare it to a boolean. Set the field type to Boolean in the Edit Fields node and the comparison works. This is the single most common failure in this build.

Assuming fulfillment_status is always a string

For an order where nothing shipped, Shopify sends "fulfillment_status": null. Call .includes() on that without the || 'unfulfilled' fallback and the node throws on roughly nine out of ten cancellations, since most cancellations happen before shipping.

Sheet headers that do not match exactly

Order number is not Order Number. The Google Sheets node matches on the exact header string, and a mismatch produces a row with blank cells rather than an error, so it fails quietly for weeks. Copy the headers from the table above.

Expecting a refund amount on every cancellation

Cancelling and refunding are separate operations. An order cancelled without a refund fires the webhook with an empty refunds array, so refund_total is 0.00. The email wording of “refunded so far” keeps that accurate instead of promising money that is not moving.

Deactivating and reactivating repeatedly

Each activation registers a fresh webhook subscription with Shopify. Toggling the workflow on and off many times while testing can leave orphaned subscriptions that deliver duplicate payloads. If you start seeing double emails, list your webhooks through the Admin API and delete the stale ones.

Cost at realistic volume

This workflow is webhook-driven, so it consumes nothing at all on days with no cancellations. That matters because cancellations are rare compared to orders.

Store volume Cancellations / month n8n executions Monthly cost
500 orders ~10 (2%) 10 $0 self-hosted, negligible on cloud
2,000 orders ~50 50 $0 self-hosted
10,000 orders ~250 250 $0 self-hosted, well inside the n8n Starter plan

Gmail’s free sending limits are 500 messages a day for a personal account and 2,000 for Workspace, so even the busiest case here uses a fraction of one day’s quota. Google Sheets API quotas are far above this. The realistic cost is your n8n hosting, which you are already paying for if you run any other n8n Shopify automation.

Ready-to-import template

The full guide above is free to follow and the workflow is described exactly as it is built, node for node. If you would rather skip the twelve field mappings and the two email bodies, the ready-to-import JSON drops the finished workflow onto your canvas with credential placeholders already in place and the workflow set inactive. You attach three credentials, paste your spreadsheet id, and switch it on. Want it installed and tested on your store instead? That is what our done-for-you service is for.

Download the template ($13) →

Instant download · Works on n8n Cloud and self-hosted

FAQ

Does Shopify restock inventory automatically when an order is cancelled?

Only when you tick the restock box at cancellation time, and only for items that were not yet fulfilled. Anything already shipped stays deducted from inventory, which is exactly the case this workflow flags with a separate email to your ops address.

Will the orders/cancelled webhook fire for orders cancelled by an app?

Yes. The webhook is emitted by Shopify whenever the order moves to a cancelled state, whether that came from the admin, the Admin API, or a third party app acting on your store. The payload shape is identical in all three cases.

Why does refund_total sometimes show 0.00 on a cancelled order?

Cancellation and refund are separate events in Shopify. If you cancelled without refunding, or the refund is processed a moment later, the refunds array is still empty when the webhook fires. The email wording stays accurate because it reports what has been refunded so far.

Can I send the ops alert to Slack or Telegram instead of Gmail?

Yes. Replace the Alert Ops To Restock node with a Slack or Telegram node and map the same fields. Slack v2 needs the channel set as a resource locator rather than a plain string, so pick the channel from the dropdown instead of typing it.

How do I stop the customer email going out for test orders?

Add a Filter node after Build Cancellation Data that drops items where the order tags contain test, or where the customer email matches your own address. Test orders are rare enough that most stores skip this and simply delete the stray email.

Related guides

How to Build a Shopify Fulfillment Delay Alert with n8n









A Shopify fulfillment delay alert in n8n scans your store every morning, finds paid orders that have sat unfulfilled past your shipping window, and sends a single Telegram digest to whoever packs boxes. No paid app, no manual export from the admin. This guide builds the whole thing from scratch with five nodes: a schedule trigger, a Shopify API call, a small filter, a condition, and a Telegram message. You will have it running in about thirty minutes.

Prefer to skip the setup? Grab the ready-made template and be up and running in under ten minutes.

What it does

Every store has a quiet failure mode. An order comes in, the payment clears, and then it slips through the cracks. Maybe the item was on a shelf nobody checked, maybe the packer was out sick, maybe the notification email got buried. The customer paid days ago and is still staring at an “unfulfilled” status. By the time they email to ask, the damage to trust is already done.

This workflow is the safety net. Once a day it asks Shopify a simple question: which paid orders are still unfulfilled and older than my promised window? It collects the answer, formats it into a short list, and posts it to a Telegram chat your team already watches. If nothing is overdue, it stays silent. If three orders are two days late, you see them at 9am with names, ages, and totals, before the customers do.

It sits inside the broader family of n8n Shopify automation flows: small, single-purpose jobs that each remove one recurring manual check from your day.

Why it beats the default

Shopify does show unfulfilled orders in the admin, so why automate it? Because the admin is a place you have to remember to visit. A screen you check “when you get a chance” is a screen you skip on the busy days, which are exactly the days orders pile up. A delay alert flips the model from pull to push. You stop hunting for problems and let the problems find you.

The paid apps that do this are fine, but they charge a monthly fee for a query you can run for free, and they lock the logic inside their dashboard. With n8n you own the threshold, the message wording, the destination, and the schedule. Want the alert only on weekdays? One setting. Want to exclude pre-orders? One line in the filter. Want it in Slack and email? Add two nodes. Nothing about the flow is hidden from you.

There is also the matter of noise. A good alert is quiet until it matters. This one sends nothing when the backlog is clear, so the day it does fire, your team pays attention instead of tuning it out.

What you need

  • A working n8n instance, either self-hosted or on n8n Cloud.
  • A Shopify store where you can create a custom app and read orders.
  • A Shopify Admin API connection in n8n. If you have not set that up yet, follow connect Shopify to n8n, which walks through the 2026 Shopify Dev Dashboard method step by step.
  • A Telegram bot token from @BotFather and the chat ID of the group or channel that should receive alerts.

Build time is around thirty minutes from scratch, or under ten minutes if you import the template and drop in your credentials.

Node-by-node list

The whole flow is five nodes in a straight line. Here is the map before we build it.

┌──────────────────────────────────────────────────────────────┐
│  SHOPIFY FULFILLMENT DELAY ALERT                             │
│                                                              │
│  [Schedule Trigger]  daily at 9:00                          │
│         ↓                                                    │
│  [Get Unfulfilled Orders]  Shopify Admin API                │
│         ↓                                                    │
│  [Flag Delayed Orders]  Code: filter by age                 │
│         ↓                                                    │
│  [Any Delays?]  IF delayed_count > 0                        │
│         ↓ true                                               │
│  [Send Telegram Alert]  digest to your team                 │
└──────────────────────────────────────────────────────────────┘
  
# Node Type Job
1 Schedule Trigger scheduleTrigger Fires the check once a day
2 Get Unfulfilled Orders httpRequest Pulls paid, unfulfilled orders from Shopify
3 Flag Delayed Orders code Keeps orders older than your threshold, builds the message
4 Any Delays? if Continues only when something is overdue
5 Send Telegram Alert telegram Delivers the digest

Step-by-step build

1 Schedule Trigger

Add a Schedule Trigger node. Set the trigger interval to Days, every 1 day, and pick a fire time that lands before your team starts packing, such as 9:00 in your store’s timezone. Confirm the workflow timezone under Settings so the check runs on your clock, not the server’s.

💡

Tip: If you want weekday-only alerts, switch the trigger to a Cron expression like 0 9 * * 1-5. Monday through Friday, 9am, nothing on the weekend.

2 Get Unfulfilled Orders

Add an HTTP Request node named Get Unfulfilled Orders. This asks Shopify for the orders that matter. Configure it like this:

  • Method: GET
  • URL: https://YOUR_STORE.myshopify.com/admin/api/2026-04/orders.json
  • Authentication: Predefined Credential Type, then Shopify Access Token API, and select the credential you created in the connection guide.
  • Send Query Parameters: on. Add these five:
Parameter Value
status open
fulfillment_status unfulfilled
financial_status paid
limit 250
fields id,name,created_at,customer,total_price

Those filters do the heavy lifting server-side. You get back only open orders that are paid and not yet fulfilled, and only the handful of fields you need. Run the node once. The output is a single item whose orders array looks like this:

{
  "orders": [
    {
      "id": 5312002019,
      "name": "#1042",
      "created_at": "2026-07-16T14:22:05-04:00",
      "total_price": "129.00",
      "customer": { "first_name": "Emily", "last_name": "Rodriguez" }
    }
  ]
}
📌

Note: Use a current Admin API version in the URL. This guide uses 2026-04. Do not use the old admin custom-app screens; the 2026 Dev Dashboard method in the connection guide is the supported path.

3 Flag Delayed Orders

Add a Code node named Flag Delayed Orders. This is where you decide what “late” means and turn the raw list into a readable message. Set it to run once for all items and paste this JavaScript:

// How many days old before an order counts as delayed
const DELAY_DAYS = 2;

const orders = $input.first().json.orders || [];
const now = Date.now();
const delayed = [];

for (const o of orders) {
  const ageDays = Math.floor((now - new Date(o.created_at).getTime()) / 86400000);
  if (ageDays >= DELAY_DAYS) {
    const name = o.customer
      ? `${o.customer.first_name} ${o.customer.last_name}`
      : 'Guest';
    delayed.push({ order: o.name, ageDays, name, total: o.total_price });
  }
}

delayed.sort((a, b) => b.ageDays - a.ageDays);

const lines = delayed.map(
  d => `• ${d.order} — ${d.ageDays}d old — ${d.name} — $${d.total}`
);

const summary = delayed.length
  ? `⚠️ ${delayed.length} paid order(s) unfulfilled ${DELAY_DAYS}+ days:\n\n${lines.join('\n')}`
  : '';

return [{ json: { delayed_count: delayed.length, summary } }];

Change the single DELAY_DAYS value to match your promise. A same-day store might use 1; a made-to-order shop might use 5. The node outputs one clean item: a count and a ready-to-send summary string.

{
  "delayed_count": 2,
  "summary": "⚠️ 2 paid order(s) unfulfilled 2+ days:\n\n• #1042 — 3d old — Emily Rodriguez — $129.00\n• #1039 — 2d old — Michael Chen — $54.00"
}

4 Any Delays?

Add an IF node named Any Delays? so the workflow stays silent on clear days. Create one condition of type Number:

  • Value 1: ={{ $json.delayed_count }}
  • Operator: is greater than
  • Value 2: 0

Wire the true branch onward to Telegram. Leave the false branch empty. When the backlog is clear, delayed_count is zero, the condition fails, and nobody gets pinged.

5 Send Telegram Alert

Add a Telegram node named Send Telegram Alert on the true branch. Choose your Telegram bot credential, set the operation to Send Message, put your group or channel ID in Chat ID, and set the Text field to an expression:

={{ $json.summary }}

Save the workflow and toggle it Active. That is the whole build. Tomorrow at 9am it runs on its own.

💡

Tip: To alert two places at once, connect a second node, say Gmail, to the same true branch. Both read the same summary field, so the message stays identical everywhere.

Common mistakes

A few things trip people up on the first run. None are hard to fix.

Symptom Likely cause Fix
Alert fires for orders that already shipped Missing the fulfillment_status=unfulfilled query param Add all five query parameters exactly as listed
Unpaid or draft orders show up financial_status=paid not set Add the paid filter so only real, paid orders count
401 or 403 from Shopify Credential missing the read_orders scope Re-issue the token with Orders read access in the Dev Dashboard
Alert never sends even with old orders IF comparing text instead of a number Set the condition type to Number, not String
Runs at the wrong hour Workflow timezone left on the server default Set the timezone in workflow Settings to your store’s

Cost at realistic volume

This is about as cheap as automation gets. The Shopify Admin API is free on every plan. A Telegram bot is free. The only variable is where n8n runs.

Setup Runs per month Cost
Self-hosted n8n ~30 (one a day) $0 beyond your server
n8n Cloud Starter ~30 executions Well inside the plan’s monthly quota
Shopify Admin API ~30 calls Free, nowhere near rate limits

Even if you run it hourly instead of daily, you are looking at roughly 720 tiny API calls a month, still free and still far under Shopify’s limits. Compare that to a $15 to $30 monthly app that does the same single query.

🚀 Get the Shopify Fulfillment Delay Alert template

The guide above is free to follow. If you would rather skip the build, download the ready-to-import workflow with all five nodes wired and the filter logic already in place, then just add your Shopify and Telegram credentials. Prefer it installed and tuned to your store’s promise window for you? See our done-for-you services.

Download the template ($14.99) →

Instant download · Works on n8n Cloud and self-hosted

Frequently asked questions

How does the workflow decide an order is delayed?

It compares each paid, unfulfilled order’s created_at timestamp to the current time. If the gap is equal to or greater than your delay threshold, two days by default, the order is flagged. You set the threshold as a single number in the Code node, so a same-day promise and a five-day lead time both work.

Will it alert me about the same order every day until it ships?

Yes, by design. As long as an order stays paid and unfulfilled past the threshold, it appears in the daily digest. That repetition is the point of an escalation alert. If you prefer one ping per order, add a Shopify tag such as delay-alerted and skip orders that already carry it.

Does this cost anything to run?

No. It uses the Shopify Admin API, which is free on every plan, a Telegram bot, which is free, and n8n, which is free when self-hosted. On n8n Cloud the run counts as one execution per day, comfortably inside the Starter plan’s monthly allowance.

Can I send the alert to Slack or email instead of Telegram?

Yes. Replace the Telegram node with a Slack, Gmail, or Send Email node. The Code node already builds a plain-text summary in a field called summary, so the only change is which node reads that field and where it delivers the message.

What if I have more than 250 unfulfilled orders?

The single HTTP Request returns up to 250 orders per page. High-volume stores should enable pagination on the HTTP Request node so it follows the Link header and pulls every page before the Code node filters them. For most stores, 250 unfulfilled orders is far more than a healthy backlog.

Related guides

n8n
Shopify
Telegram
fulfillment
automation