DEV Community

Arturas Tikkivo
Arturas Tikkivo

Posted on

A free n8n workflow for Shopify low-stock alerts (and 4 gotchas)

Shopify tells you a product is sold out. It usually tells you after a customer already tried to buy it. What most small stores actually want is a nudge a few days earlier: "these four variants are running low."

I build Shopify apps and n8n automations, and a low-stock alert looks like a 10-minute job. It isn't, quite. The first version I wrote missed most of the catalog, warned about gift cards, and repeated the same list every four hours until it got muted.

Below is a free, stripped-down workflow you can import, the four gotchas it handles, and a few n8n habits that saved me time. I'm being upfront about testing: I ran it on n8n 2.44.5 against a mock of the Shopify Admin GraphQL API, not a live store. The last section shows how to try it on a free dev store before you trust it.

What the workflow does

  1. Every 4 hours (or when you click Execute workflow) it starts.
  2. Config holds four values: shop URL, API version, threshold and Slack webhook URL.
  3. Get access token swaps your app's client ID and secret for a 24-hour Admin API token.
  4. Fetch all variants calls productVariants in GraphQL and keeps paging until hasNextPage is false.
  5. Find changes filters to active, tracked variants at or below the threshold, then compares them with the last run.
  6. Send to Slack posts one message, but only when something changed.

Six nodes, two triggers, no community nodes.

The workflow (import it)

Copy this, then in n8n open a new workflow and paste with Ctrl/Cmd+V (or use Import from file):

{
  "name": "Shopify low-stock alert (free, by Tikkivo)",
  "nodes": [
    {
      "id": "a1b2c3d4-0001-4000-8000-000000000001",
      "name": "Every 4 hours",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        0
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "hours",
              "hoursInterval": 4
            }
          ]
        }
      }
    },
    {
      "id": "a1b2c3d4-0001-4000-8000-000000000002",
      "name": "Run manually",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        200
      ],
      "parameters": {}
    },
    {
      "id": "a1b2c3d4-0001-4000-8000-000000000003",
      "name": "Config",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        100
      ],
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "c1",
              "name": "shopUrl",
              "value": "https://your-store.myshopify.com",
              "type": "string"
            },
            {
              "id": "c2",
              "name": "apiVersion",
              "value": "2026-10",
              "type": "string"
            },
            {
              "id": "c3",
              "name": "threshold",
              "value": 5,
              "type": "number"
            },
            {
              "id": "c4",
              "name": "slackWebhookUrl",
              "value": "https://hooks.slack.com/services/XXX/YYY/ZZZ",
              "type": "string"
            }
          ]
        },
        "options": {}
      }
    },
    {
      "id": "a1b2c3d4-0001-4000-8000-000000000007",
      "name": "Get access token",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        440,
        100
      ],
      "credentials": {
        "httpCustomAuth": {
          "id": "shopifyAppFree001",
          "name": "Shopify app (client id + secret)"
        }
      },
      "parameters": {
        "method": "POST",
        "url": "={{ $json.shopUrl }}/admin/oauth/access_token",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpCustomAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "{ \"grant_type\": \"client_credentials\" }",
        "options": {}
      }
    },
    {
      "id": "a1b2c3d4-0001-4000-8000-000000000004",
      "name": "Fetch all variants",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        660,
        100
      ],
      "parameters": {
        "method": "POST",
        "url": "={{ $('Config').first().json.shopUrl }}/admin/api/{{ $('Config').first().json.apiVersion }}/graphql.json",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "X-Shopify-Access-Token",
              "value": "={{ $('Get access token').first().json.access_token }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ query: \"query ($after: String) { productVariants(first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { id sku displayName inventoryQuantity inventoryItem { tracked } product { title status } } } }\", variables: { after: null } }) }}",
        "options": {
          "pagination": {
            "pagination": {
              "parameters": {
                "parameters": [
                  {
                    "type": "body",
                    "name": "variables",
                    "value": "={{ { after: $response.body.data.productVariants.pageInfo.endCursor } }}"
                  }
                ]
              },
              "paginationCompleteWhen": "other",
              "completeExpression": "={{ !$response.body.data || !$response.body.data.productVariants.pageInfo.hasNextPage }}",
              "limitPagesFetched": true,
              "maxRequests": 200,
              "requestInterval": 500
            }
          }
        }
      }
    },
    {
      "id": "a1b2c3d4-0001-4000-8000-000000000005",
      "name": "Find changes",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        100
      ],
      "parameters": {
        "jsCode": "// Free version: flatten pages -> filter -> alert only on CHANGES (dedupe via static data)\nconst cfg = $('Config').first().json;\nconst threshold = Number(cfg.threshold);\n\n// 1) Flatten every page the HTTP node fetched. Fail loudly on GraphQL errors\n//    (Shopify returns HTTP 200 + an \"errors\" array for throttling and missing scopes).\nconst variants = [];\nfor (const page of $input.all()) {\n  if (page.json.errors) throw new Error('Shopify GraphQL error: ' + JSON.stringify(page.json.errors));\n  variants.push(...page.json.data.productVariants.nodes);\n}\n\n// 2) Keep only variants that can really \"run out\"\nconst low = variants.filter(v =>\n  v.product.status === 'ACTIVE' &&      // skip draft/archived products\n  v.inventoryItem.tracked &&            // skip \"don't track quantity\" (gift cards etc.)\n  v.inventoryQuantity <= threshold);\n\n// 3) Compare with what we alerted about last time\nconst memory = $getWorkflowStaticData('global');\nconst before = memory.low || {};                     // { variantId: qty }\nconst now = Object.fromEntries(low.map(v => [v.id, v.inventoryQuantity]));\n\nconst newlyLow = low.filter(v => !(v.id in before));\nconst soldOut  = low.filter(v => v.id in before && before[v.id] > 0 && v.inventoryQuantity <= 0);\nconst restocked = Object.keys(before).filter(id => !(id in now));\n\nmemory.low = now;   // only persisted on production (active, scheduled) runs\n\nif (!newlyLow.length && !soldOut.length && !restocked.length) return [];  // nothing new -> stop quietly\n\nconst line = v => `• ${v.displayName} (${v.sku || 'no SKU'}): ${v.inventoryQuantity} left`;\nconst parts = [];\nif (newlyLow.length) parts.push(`*Low stock (≤ ${threshold}):*\\n` + newlyLow.map(line).join('\\n'));\nif (soldOut.length)  parts.push('*Sold out since last check:*\\n' + soldOut.map(line).join('\\n'));\nif (restocked.length) parts.push(`*Back above ${threshold}:* ${restocked.length} variant(s)`);\n\nreturn [{ json: {\n  text: parts.join('\\n\\n'),\n  scanned: variants.length, pages: $input.all().length,\n  lowNow: low.length, newlyLow: newlyLow.length, soldOut: soldOut.length, restocked: restocked.length,\n} }];"
      }
    },
    {
      "id": "a1b2c3d4-0001-4000-8000-000000000006",
      "name": "Send to Slack",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1100,
        100
      ],
      "parameters": {
        "method": "POST",
        "url": "={{ $('Config').first().json.slackWebhookUrl }}",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ text: $json.text }) }}",
        "options": {}
      }
    }
  ],
  "connections": {
    "Every 4 hours": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run manually": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config": {
      "main": [
        [
          {
            "node": "Get access token",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get access token": {
      "main": [
        [
          {
            "node": "Fetch all variants",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch all variants": {
      "main": [
        [
          {
            "node": "Find changes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find changes": {
      "main": [
        [
          {
            "node": "Send to Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {}
}
Enter fullscreen mode Exit fullscreen mode

Setup takes about five minutes:

  • Create an app in Shopify's Dev Dashboard with the read_products and read_inventory scopes, and install it on your store. Copy the client ID and secret.
  • Create the credential in n8n: type Custom Auth, with this JSON: {"body": {"client_id": "YOUR_ID", "client_secret": "YOUR_SECRET"}} Select it in the Get access token node.
  • Edit Config: your https://your-store.myshopify.com URL, threshold and Slack incoming-webhook URL.
  • Click Execute workflow, check the Slack message, then activate it.

Gotcha 1: pagination (or: why you only see 100 products)

A GraphQL connection returns one page. If you don't follow pageInfo.endCursor, your alert silently covers the first page of variants and nothing else. Nobody notices until the product that ran out is on page 3.

You don't need a loop with an IF node for this. The HTTP Request node has pagination built in. In Options → Pagination I use "Update a parameter in each request", with a body parameter called variables:

// Pagination parameter (type: body, name: variables)
{{ { after: $response.body.data.productVariants.pageInfo.endCursor } }}

// "Complete when" -> Other -> Complete expression
{{ !$response.body.data || !$response.body.data.productVariants.pageInfo.hasNextPage }}
Enter fullscreen mode Exit fullscreen mode

n8n deep-merges that object into the JSON body, so variables.after is replaced on every request and the query stays intact. Each page comes out as one item, and the Code node flattens them:

const variants = [];
for (const page of $input.all()) {
  if (page.json.errors) throw new Error('Shopify GraphQL error: ' + JSON.stringify(page.json.errors));
  variants.push(...page.json.data.productVariants.nodes);
}
Enter fullscreen mode Exit fullscreen mode

I also set Max pages to 200, so a bug can never turn into an endless loop.

If you're still on REST: it paginates with a Link header and a page_info cursor, not page numbers. Shopify has marked the REST Admin API as legacy, though, so for new work I'd go straight to GraphQL.

Gotcha 2: variants vs inventory levels

inventoryQuantity on a variant is the total across all locations. That's fine for a single warehouse. If you have a shop plus a warehouse, "3 left" might mean three in the shop and none where you ship from.

For per-location alerts, ask the inventory item for its levels instead:

inventoryItem {
  tracked
  inventoryLevels(first: 10) {
    nodes {
      location { name }
      quantities(names: ["available"]) { name quantity }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

(I haven't run that variant against the mock, so treat it as a starting point.)

Two filters matter just as much as the number:

const low = variants.filter(v =>
  v.product.status === 'ACTIVE' &&   // skip draft/archived products
  v.inventoryItem.tracked &&         // skip "don't track quantity" (gift cards, made-to-order)
  v.inventoryQuantity <= threshold);
Enter fullscreen mode Exit fullscreen mode

Without them you get daily alerts about gift cards and a product you archived last year.

Gotcha 3: rate limits that don't look like errors

Shopify's GraphQL API is rate-limited by query cost, not by request count. Each query has a calculated cost, and your app has a bucket that refills over time. The catch is that a throttled request can come back as HTTP 200 with an errors array (code THROTTLED). The HTTP node sees 200 and carries on happily.

Three things handle it:

  • first: 100 instead of the maximum 250, so each query stays cheaper.
  • A 500 ms Interval between requests in the pagination settings.
  • The throw in the Code node above, so a throttled or permission-denied response fails the execution loudly instead of producing a wrong "all good".

If you want real numbers for your store, look at extensions.cost in a response. It shows the requested cost, the actual cost and the bucket's current state.

Gotcha 4: the same alert every 4 hours

This one kills most stock alerts. If the list doesn't change, the message shouldn't arrive. n8n has a small per-workflow key-value store for exactly this, workflow static data:

const memory = $getWorkflowStaticData('global');
const before = memory.low || {};                      // { variantId: qty }
const now = Object.fromEntries(low.map(v => [v.id, v.inventoryQuantity]));

const newlyLow  = low.filter(v => !(v.id in before));
const soldOut   = low.filter(v => v.id in before && before[v.id] > 0 && v.inventoryQuantity <= 0);
const restocked = Object.keys(before).filter(id => !(id in now));

memory.low = now;
if (!newlyLow.length && !soldOut.length && !restocked.length) return []; // stop quietly
Enter fullscreen mode Exit fullscreen mode

Returning an empty array ends the run there, so Slack is never called and you don't need an IF node.

The caveat that confuses everyone: according to n8n's docs, static data isn't saved when you test in the editor. It's saved on production runs of an active workflow. So in the editor you'll see the full list every time. That's expected, not a bug. (In my test it was also saved when I ran the workflow with the n8n execute CLI.)

The other lessons

Set an error workflow. Create a tiny workflow that starts with an Error Trigger and sends you a message. Then choose it in Workflow settings → Error workflow. A silent failure is worse than no alert, because you think you're covered. Error workflows only fire for production runs, not manual tests.

Keep secrets in credentials, not in Config. The client secret lives in an encrypted n8n credential. Config only holds things you'd be happy to screenshot. One thing to know: the 24-hour access token shows up in that node's saved execution data. If that bothers you, stop saving successful executions in the workflow settings.

Expect tokens to expire. Apps created in the Dev Dashboard get tokens through the client credentials grant, and those tokens last 24 hours. So the workflow fetches a fresh token on every run instead of you pasting one in. If you still have an old app created in the Shopify admin with a permanent shpat_ token, delete the token node and put that token straight into the header.

Pin data while you build. After one real fetch, pin the output of Fetch all variants (the pin icon in the output panel). Then you can rework the Code node and Slack message as often as you like without calling Shopify again. Unpin it before you activate.

How I tested it

I ran it with the n8n CLI against a local mock of the Shopify GraphQL API. The mock serves 8 variants in pages of 3, so pagination really runs. Those variants include a draft product, an untracked gift card and two variants above the threshold. Real output:

=== RUN 1 (first run)
Shopify GraphQL requests: 3   cursors: [None, '3', '6']
Slack messages sent: 1
*Low stock (≤ 5):*
• Amber Candle - Large (CND-01): 0 left
• Amber Candle - Small (CND-02): 3 left
• Linen Tote - Natural (TOTE-1): 4 left
• Clay Mug - Blue (MUG-1): 5 left

=== RUN 2 (nothing changed)
Shopify GraphQL requests: 3
Slack messages sent: 0

=== RUN 3 (CND-02 3->0, TOTE-1 4->20, MUG-2 40->2)
Slack messages sent: 1
*Low stock (≤ 5):*
• Clay Mug - Green (MUG-2): 2 left

*Sold out since last check:*
• Amber Candle - Small (CND-02): 0 left

*Back above 5:* 1 variant(s)

=== RUN 4 (wrong client secret)
n8n exit code 1, 0 GraphQL requests, 0 Slack messages -> execution marked "error"
Enter fullscreen mode Exit fullscreen mode

The draft Oak Board and the untracked gift card never show up, run 2 stays silent, and a bad secret fails instead of posting nonsense.

A mock is only as good as my reading of Shopify's docs, so please try it on a development store first. Create a free dev store from the Dev Dashboard, add a few products with tracked inventory, and set one to 2 and one to 0. Point Config at that store and a test Slack channel, then run it manually. Change a quantity and run it again. Once the messages look right, activate it.


I used AI assistance to help write and edit this post and to build the test setup. The workflow above was run and tested against a mock Shopify API on n8n 2.44.5, not yet on a live store.

My products, if you'd rather not build it yourself: the paid n8n Shopify Low-Stock Alerts template adds per-SKU and tag-based thresholds, Telegram and email, admin links and supplier reorder drafts. And if your stock comes from a supplier's CSV or feed, my Catalog Sync app keeps Shopify prices and quantities in sync with it. The free workflow above works on its own either way. Questions are welcome in the comments.

Top comments (0)