n8n Automation Workflow guide Intermediate

Automate Store Marketing with n8n: Abandoned Carts, Price Drops and Ads Reporting

Cart recovery, price-drop alerts and ad-spend reporting eat manual hours in small stores; three n8n workflows automate all three with shared suppression state.

Tested with

N8n
1.108.2

Before you start

  • A self-hosted n8n instance running 1.108.2 (or the ability to upgrade to it), with admin access to Settings and environment variables
  • API or database access to the shop's order/cart status (order ID, customer ID, cart contents, timestamps)
  • SMTP credentials for n8n's built-in Send Email node
  • Product catalog access with SKU and price fields, refreshable at least once a day
  • A Google Ads account with API access already authorized in n8n, plus API/OAuth2 credentials for at least one other ad platform (e.g. Meta Ads)
  • A Slack webhook URL or a mailbox to receive failure alerts
  • Basic familiarity with n8n nodes, triggers and expressions
On this page

Automating three separate marketing workflows in n8n and diffing catalog data with the Compare Datasets node, plus checking a shared Data Table before every send. Everything below was built and described against n8n 1.108.2 — the 1.108.2 release notes (opens in a new tab) list a single fix relevant here: waiting-webhook validation when n8n sits behind a reverse proxy. Three independent workflows do the work; one shared table keeps them from stepping on each other.

Stop the abandoned-cart sequence the moment a customer completes checkout

Start the workflow from whatever tells you a cart went cold. If the shop platform supports it, use a Webhook node (opens in a new tab) fired by the shop’s own cart-abandonment event. Otherwise use a Schedule Trigger (opens in a new tab) that polls every few minutes for carts idle 30 minutes or more. Either way, the trigger only needs to hand off a cart ID and customer ID — the status checks happen downstream, not here.

The Wait node (opens in a new tab) resumes on one of four conditions: after a time interval, at a specified time, on an incoming webhook call, or on form submission. For a T+1h / T+24h / T+72h sequence, use time interval and chain three Wait nodes with increasing delays. Waits under 65 seconds stay in memory; anything longer is offloaded to the database and reloaded on resume, so the sequence survives an n8n restart mid-wait.

The mistake that produces angry customers: checking order status once before the first email and assuming it still holds three days later. Re-fetch the order or cart status after every single Wait node, not just at the start. Feed that fetch into an If node (opens in a new tab) comparing status against 'paid' or 'completed' — a match ends the workflow, a mismatch sends the next email and moves on to the next Wait.

flowchart TD
    A[Trigger: cart idle 30m+] --> B[Fetch order status]
    B --> C{Status = paid?}
    C -->|yes| Z[End - no email]
    C -->|no| D[Send Email #1]
    D --> E[Wait 1h]
    E --> F[Fetch order status]
    F --> G{Status = paid?}
    G -->|yes| Z
    G -->|no| H[Send Email #2]
    H --> I[Wait 24h]
    I --> J[Fetch order status]
    J --> K{Status = paid?}
    K -->|yes| Z
    K -->|no| L[Send Email #3]
    L --> M[Wait 72h]
    M --> N[Fetch order status]
    N --> O{Status = paid?}
    O -->|yes| Z
    O -->|no| P[Sequence complete]
js
{{ $json.orderStatus !== 'paid' && $json.orderStatus !== 'completed' }}

The If node routes true to the next Send Email node and false to the end of the workflow.

You should now see: a cart that never converts receiving three emails across three days, while a cart marked paid between sends produces at most one email and the workflow stops silently at the next check.

Share a suppression table across workflows so no customer gets two emails an hour

Data Tables (opens in a new tab) store rows inside the n8n instance itself, no external Postgres or Airtable base required. On a self-hosted instance the default cap is 50MB across all tables combined, raised with the N8N_DATA_TABLES_MAX_SIZE_BYTES environment variable.

Build one shared table, contacted_log, with three columns: customer_id, campaign_type, sent_at. Every marketing workflow in this guide reads and writes to the same table, which is what makes the suppression cross-workflow instead of per-workflow.

Before any send in the cart-recovery or price-drop workflow, query contacted_log for that customer within a suppression window — six hours is a reasonable starting point. An If node checks whether any row matched; a match skips the send. After every send, insert a row so the other workflow sees it on its own next check.

n8n does not enforce a send-frequency cap on its own. This table lookup is the cap — if two workflows both fire for the same customer in the same window, the table decides which one wins, not send order.

contacted_log schema
{
  "table": "contacted_log",
  "columns": [
    { "name": "customer_id", "type": "string" },
    { "name": "campaign_type", "type": "string" },
    { "name": "sent_at", "type": "dateTime" }
  ]
}
text
Data Table: Get Rows (contacted_log)
  filter: customer_id = {{ $json.customerId }}
          sent_at >= now() - 6h
  -> If: rows.length > 0
       true  -> No-op (suppressed)
       false -> Send Email
                  -> Data Table: Insert Row (contacted_log)
                       customer_id, campaign_type, sent_at = now()
flowchart LR
    subgraph CR[Cart-recovery workflow]
        A1[Check status] --> A2{Suppressed?}
        A2 -->|no| A3[Send Email] --> A4[Insert row]
        A2 -->|yes| A5[Skip]
    end
    subgraph PD[Price-drop workflow]
        B1[Match wishlist] --> B2{Suppressed?}
        B2 -->|no| B3[Send Email] --> B4[Insert row]
        B2 -->|yes| B5[Skip]
    end
    T[(contacted_log Data Table)]
    A2 -. read .-> T
    A4 -. write .-> T
    B2 -. read .-> T
    B4 -. write .-> T

You should now see: triggering both workflows for the same customer inside the suppression window results in only the first send going out; the second routes to its no-op branch.

Detect a genuine price drop with the Compare Datasets node

Take two catalog snapshots — yesterday’s and today’s — either stored as Data Table rows or pulled fresh from the shop’s product API each run. Both need at minimum a SKU (or product ID) and a price field.

The Compare Datasets node (opens in a new tab) matches two inputs on a shared key field and can output up to four streams: matches, differences, input-A-only and input-B-only. Use the differences stream, then narrow it further — a differences match only means something changed, not that the price fell.

Normalize both price fields to a plain number in a Code node before the diff. Otherwise '$19.00' and '19.00' register as different values, and a pure formatting change fires a false price-drop email.

After the diff, add a Filter node (opens in a new tab) keeping only rows where the new price is lower than the old one. Store today’s snapshot as tomorrow’s comparison baseline, so every run diffs against the immediately prior day rather than a fixed reference point.

js
const raw = String($json.price);
const numeric = parseFloat(raw.replace(/[^0-9.]/g, ''));
return { ...$json, price_numeric: numeric };
text
Compare Datasets
  Input A: yesterday_snapshot
  Input B: today_snapshot
  Fields to Match: sku
  Output: Differences
js
{{ $json.today.price_numeric < $json.yesterday.price_numeric }}

You should now see: a product with a genuine price cut appearing in the output list, while a product whose price field only changed currency formatting or symbol drops out at the filter.

Notify wishlisters about a price drop without duplicating the cart-recovery email

Maintain a wishlist table mapping customer_id to product_id — pulled from the shop’s own wishlist feature if it exposes one, or kept inside n8n as another Data Table if not.

Match the confirmed price-drop list from the previous section against wishlist rows on product_id. Each match is a candidate notification: one customer, one product that dropped.

Route every match through the same contacted_log check before sending — a customer mid-cart-recovery sequence should not also get a price-drop email in the same window. Aggregate matches by customer_id first, so someone with three dropped wishlist items gets one email listing all three, not three separate emails.

After sending, insert into contacted_log with campaign_type set to 'price_drop', so a cart-recovery send later in the same window sees it and backs off too.

text
Price-drop list (from previous section)
  -> Join on product_id against wishlist rows
  -> Aggregate by customer_id (collect matched product list)
  -> Data Table: Get Rows (contacted_log, suppression check)
  -> If: suppressed?
       true  -> Skip
       false -> Send Email (one row per customer, all matched items)
                  -> Data Table: Insert Row (campaign_type = 'price_drop')

You should now see: each wishlisted customer whose product dropped in price getting one consolidated email listing every matching item, while anyone inside the cart-recovery suppression window is skipped until it expires.

Pull Google Ads spend and conversions into a weekly digest

n8n’s Google Ads node (opens in a new tab) exposes exactly one resource, Campaign, with two operations: Get and Get Many. That’s the entire surface area — there is no Ad Group or Keyword resource built in.

Get Many already returns impressions, cost in micros, conversions and click-through rate per campaign, filtered by a Date Range parameter running from Today through Last 30 Days, or All Time. The exact field set comes from the node’s campaign field definitions (opens in a new tab).

A weekly Schedule Trigger — Monday 08:00, for example — starts the digest. Cost comes back in micros, millionths of the currency unit, so divide by 1,000,000 in a Code node before displaying it anywhere a human reads it.

None of the node’s preset ranges map exactly to “last calendar week” — Last 30 Days is the closest built-in option. Filter the returned rows client-side, in a Code or Filter node, down to the Monday-to-Sunday window you actually want.

text
Schedule Trigger
  Trigger: Weekly, Monday 08:00

Google Ads
  Resource: Campaign
  Operation: Get Many
  Date Range: Last 30 Days
js
return items.map(item => ({
  ...item.json,
  cost_currency: item.json.costMicros / 1000000,
}));

You should now see: a per-campaign list with impressions, spend in normal currency units, conversions and CTR for the prior week.

Add other ad platforms to the digest with HTTP Request batching

Most ad platforms outside Google have no dedicated n8n node. Call their REST API directly with the HTTP Request node (opens in a new tab), using generic OAuth2 or an API-key header for authentication.

The HTTP Request node’s Batching control sets items per batch and an interval in milliseconds between batches, throttling calls against a rate-limited API instead of firing every request at once. This fixes the pitfall where one platform’s numbers just go missing with no visible error: an unthrottled burst that hits a rate limit fails the node outright, and without retry-on-fail, that platform’s row silently disappears from the digest.

A Merge node (opens in a new tab) combines the Google Ads branch with the HTTP Request branch or branches into one array. Format the combined list into a single Slack message or email with one row per platform, rather than sending a separate message per source.

text
HTTP Request
  Authentication: Generic OAuth2
  Batching:
    Items per Batch: 5
    Interval: 1000 ms
  Settings:
    Retry On Fail: true
    Max Tries: 3
js
const rows = items.map(item => item.json);
const lines = rows.map(r => `${r.platform}: $${r.spend} spend, ${r.conversions} conversions`);
return [{ json: { text: lines.join('\n') } }];
sequenceDiagram
    participant W1 as Unbatched workflow
    participant W2 as Batched workflow
    participant P as Ad Platform API
    W1->>P: request 1
    W1->>P: request 2
    W1->>P: request 3
    W1->>P: request 4
    P-->>W1: 429 rate limited
    Note over W1: node fails, no retry, row missing from digest
    W2->>P: request 1
    P-->>W2: 200 OK
    Note over W2: wait 1000ms (batch interval)
    W2->>P: request 2
    P-->>W2: 429 rate limited
    Note over W2: retry on fail
    W2->>P: request 2 (retry)
    P-->>W2: 200 OK

You should now see: a weekly Slack message or email listing spend and conversions for every configured platform, with a transient rate-limit response retried automatically instead of dropping that platform’s row.

Get alerted the moment a workflow fails silently overnight

Build a second, separate workflow that starts with an Error Trigger node (opens in a new tab) and nothing else feeding it. In each of the three marketing workflows’ Settings, set Error Workflow to point at this one.

The Error Trigger node receives the failed workflow’s name, the node that failed, and the error message as its input data. Route that straight into a Slack message or Send Email node so the failure is visible within seconds, not discovered the next morning when a report never arrived.

Test by deliberately breaking a credential in one workflow — wrong API key, wrong SMTP password — and running it. Confirm the alert lands before trusting the setup with a real overnight schedule.

text
Error Trigger
  -> Slack (or Send Email)
       text: "Workflow '{{ $json.workflow.name }}' failed
              at node '{{ $json.execution.lastNodeExecuted }}':
              {{ $json.execution.error.message }}"
text
Workflow Settings
  Error Workflow: [select the error-handling workflow]
sequenceDiagram
    participant M as Marketing workflow
    participant N as n8n runtime
    participant E as Error workflow
    participant S as Slack
    M->>N: node execution fails
    N->>E: invoke Error Trigger (workflow, node, message)
    E->>S: post alert
    Note over M: original run stops here

You should now see: forcing a failure in any of the three marketing workflows producing a Slack or email alert within seconds instead of the workflow simply stopping unnoticed.

Know what these three workflows do not cover yet

The built-in Send Email node (opens in a new tab) talks to plain SMTP and never sets In-Reply-To or References headers. A three-email recovery sequence lands as three separate threads in the customer’s inbox, not one conversation — reference the previous email’s content directly in the next one’s body if continuity matters.

n8n versions up to 1.108.1 had a bug validating Wait-node resume webhooks behind a reverse proxy. Version 1.108.2, what this guide is verified against, fixes it — confirm the instance is actually upgraded before relying on webhook-based Wait resumption in production.

Data Tables cap at 50MB by default on self-hosted instances. A long-running catalog snapshot history or a busy contacted_log will hit that ceiling eventually — raise N8N_DATA_TABLES_MAX_SIZE_BYTES or prune old rows on a schedule before it does.

n8n’s Sustainable Use License (opens in a new tab) permits unlimited internal use of everything built here. It does not permit reselling n8n itself or offering it as a hosted service to others.

Not covered in this guide: SMS or push notification channels, multi-currency price normalization beyond basic numeric parsing, and consent or unsubscribe management for marketing email. All three are required before any of this goes into a real production rollout.

Frequently asked questions

What's the difference between the Filter node and the If node in n8n?

The If node routes each item down one of two branches (true or false) so the rest of the workflow can react differently; the Filter node has a single output and simply drops items that fail the condition, passing the rest through unchanged.

Why does my n8n Wait node webhook resume fail behind Nginx or Traefik?

n8n versions up to 1.108.1 had a bug validating waiting-webhook calls when n8n runs behind a reverse proxy; upgrading to 1.108.2 fixes it, and the proxy must still forward the original host and protocol headers unchanged.

Sources

  1. n8n documentation n8n Primary source
  2. n8n@1.108.2 release notes Primary source
  3. Wait node docs Primary source
  4. Send Email node docs Primary source
  5. Schedule Trigger node docs Primary source
  6. Data tables docs Primary source
  7. Compare Datasets node docs Primary source
  8. Merge node docs Primary source
  9. Filter node docs Primary source
  10. If node docs Primary source
  11. HTTP Request node docs Primary source
  12. Google Ads node docs Primary source
  13. Google Ads node source (campaign operations and fields) Primary source
  14. Shopify Trigger node docs Primary source
  15. Loop Over Items (Split In Batches) node docs Primary source
  16. Webhook node docs Primary source
  17. Airtable Trigger node docs Primary source
  18. Sustainable Use License docs Primary source
  19. Handle errors gracefully docs Primary source
8 min read