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]
{{ $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.
{
"table": "contacted_log",
"columns": [
{ "name": "customer_id", "type": "string" },
{ "name": "campaign_type", "type": "string" },
{ "name": "sent_at", "type": "dateTime" }
]
}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.
const raw = String($json.price);
const numeric = parseFloat(raw.replace(/[^0-9.]/g, ''));
return { ...$json, price_numeric: numeric };Compare Datasets
Input A: yesterday_snapshot
Input B: today_snapshot
Fields to Match: sku
Output: Differences{{ $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.
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.
Schedule Trigger
Trigger: Weekly, Monday 08:00
Google Ads
Resource: Campaign
Operation: Get Many
Date Range: Last 30 Daysreturn 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.
HTTP Request
Authentication: Generic OAuth2
Batching:
Items per Batch: 5
Interval: 1000 ms
Settings:
Retry On Fail: true
Max Tries: 3const 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.
Error Trigger
-> Slack (or Send Email)
text: "Workflow '{{ $json.workflow.name }}' failed
at node '{{ $json.execution.lastNodeExecuted }}':
{{ $json.execution.error.message }}"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.