This workflow corresponds to n8n.io template #16436 — we link there as the canonical source.
This workflow follows the Airtable → Gmail recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"nodes": [
{
"id": "783632e4-4c50-484d-b951-d3eb5af286ff",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1712,
368
],
"parameters": {
"width": 480,
"height": 720,
"content": "## Track Google Shopping prices over time in Airtable via ScraperAPI\n\n### How it works\n\n1. The workflow starts with a daily schedule trigger, reading a list from Airtable.\n2. Queries are iterated, fetching data from Google Shopping.\n3. The results are shaped and appended back to Airtable.\n4. Alerts are read and a digest is built for the day's results.\n5. If there are alerts, an email is sent out to notify users.\n\n### Setup steps\n\n- [ ] Ensure Airtable API credentials are configured for accessing the necessary tables.\n- [ ] Set up Scraper API credentials to fetch Google Shopping data.\n- [ ] Configure Gmail account for sending alert emails.\n\n### Customization\n\nYou can customize the alert conditions in the 'Has Alerts?' node or adjust which results are kept in 'Build Alert Digest'."
},
"typeVersion": 1
},
{
"id": "6955cb96-d32d-4fd2-a497-a19c1a160c7e",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1152,
368
],
"parameters": {
"color": 7,
"width": 736,
"height": 272,
"content": "## Trigger and fetch watchlist\n\nInitiates workflow based on schedule and fetches watchlist from Airtable."
},
"typeVersion": 1
},
{
"id": "32fc960e-8d5d-4417-8314-5c6d32006b4e",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"position": [
-304,
368
],
"parameters": {
"color": 7,
"width": 1040,
"height": 272,
"content": "## Iterate and scrape data\n\nIterates over queries to scrape data from Google Shopping."
},
"typeVersion": 1
},
{
"id": "7924c1d7-de44-4fb8-8b92-23a9d1610541",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"position": [
-304,
672
],
"parameters": {
"color": 7,
"width": 752,
"height": 272,
"content": "## Fetch and process alerts\n\nReads alerts from Airtable and processes them into a digest."
},
"typeVersion": 1
},
{
"id": "2ff99092-4477-4b62-ab56-9cd9d5e96b54",
"name": "Sticky Note4",
"type": "n8n-nodes-base.stickyNote",
"position": [
528,
672
],
"parameters": {
"color": 7,
"width": 240,
"height": 352,
"content": "## Send alert notifications\n\nSends alert notifications via email if conditions are met."
},
"typeVersion": 1
},
{
"id": "schedule-trigger",
"name": "Every Morning at 8am",
"type": "n8n-nodes-base.scheduleTrigger",
"position": [
-1100,
480
],
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 8 * * *"
}
]
}
},
"typeVersion": 1.2
},
{
"id": "read-watchlist",
"name": "Fetch Watchlist from Airtable",
"type": "n8n-nodes-base.airtable",
"position": [
-560,
480
],
"parameters": {
"base": {
"__rl": true,
"mode": "list",
"value": ""
},
"table": {
"__rl": true,
"mode": "list",
"value": ""
},
"options": {},
"resource": "record",
"operation": "search",
"returnAll": true,
"filterByFormula": "{active} = TRUE()"
},
"typeVersion": 2.2
},
{
"id": "iterate-queries",
"name": "Loop Over Watchlist Items",
"type": "n8n-nodes-base.splitInBatches",
"position": [
-260,
480
],
"parameters": {
"options": {},
"batchSize": 1
},
"typeVersion": 3
},
{
"id": "scrape-google-shopping",
"name": "Fetch Google Shopping Results",
"type": "n8n-nodes-scraperapi-official.scraperApi",
"maxTries": 2,
"position": [
20,
480
],
"parameters": {
"resource": "sde",
"sdeQuery": "={{ $('Loop Over Watchlist Items').item.json.fields.query }}",
"operation": "googleShopping",
"sdePlatform": "google",
"sdeGoogleOptions": {}
},
"retryOnFail": true,
"typeVersion": 1,
"continueOnFail": true,
"waitBetweenTries": 2000
},
{
"id": "shape-results",
"name": "Flatten Shopping Offers",
"type": "n8n-nodes-base.code",
"position": [
300,
480
],
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "// Flatten Google Shopping response into one item per offer, tagging\n// each row with the current scrape timestamp and an is_alert flag when\n// price <= target_price.\n\nconst numOrNull = (v) => {\n if (v === null || v === undefined || v === '') return null;\n const s = typeof v === 'string' ? v.replace(/[^0-9.]/g, '') : v;\n const n = parseFloat(s);\n return Number.isFinite(n) ? n : null;\n};\n\nconst getBody = (item) => {\n const resp = item && item.json && item.json.response;\n if (!resp || resp.body === undefined) return {};\n if (typeof resp.body === 'string') {\n try { return JSON.parse(resp.body); } catch (e) { return {}; }\n }\n return resp.body;\n};\n\nconst row = $('Loop Over Watchlist Items').item.json.fields || {};\nconst query = row.query || '';\nconst targetPrice = numOrNull(row.target_price);\nconst hasTarget = targetPrice !== null && targetPrice > 0;\nconst scrapedAt = new Date().toISOString();\n\nconst body = getBody($input.first());\nconst offers = body.shopping_results || body.results || [];\n\nif (!Array.isArray(offers) || offers.length === 0) {\n return [];\n}\n\nreturn offers.map((r) => {\n const price = r.extracted_price !== undefined\n ? numOrNull(r.extracted_price)\n : numOrNull(r.price);\n return {\n json: {\n scraped_at: scrapedAt,\n query,\n product_title: r.title || '',\n price,\n currency: r.currency || 'USD',\n merchant: r.source || r.seller || r.merchant || '',\n link: r.link || r.product_link || '',\n product_id: r.product_id || r.id || '',\n is_alert: hasTarget && price !== null && price <= targetPrice,\n },\n pairedItem: { item: 0 },\n };\n});\n",
"language": "javaScript"
},
"typeVersion": 2
},
{
"id": "append-results",
"name": "Append Offer to Airtable",
"type": "n8n-nodes-base.airtable",
"position": [
580,
480
],
"parameters": {
"base": {
"__rl": true,
"mode": "list",
"value": ""
},
"table": {
"__rl": true,
"mode": "list",
"value": ""
},
"columns": {
"value": {
"link": "={{ $json.link }}",
"price": "={{ $json.price }}",
"query": "={{ $json.query }}",
"currency": "={{ $json.currency }}",
"is_alert": "={{ $json.is_alert }}",
"merchant": "={{ $json.merchant }}",
"product_id": "={{ $json.product_id }}",
"scraped_at": "={{ $json.scraped_at }}",
"product_title": "={{ $json.product_title }}"
},
"mappingMode": "defineBelow"
},
"options": {},
"resource": "record",
"operation": "create"
},
"typeVersion": 2.2
},
{
"id": "read-todays-alerts",
"name": "Fetch Today's Alerts",
"type": "n8n-nodes-base.airtable",
"position": [
-260,
780
],
"parameters": {
"base": {
"__rl": true,
"mode": "list",
"value": ""
},
"table": {
"__rl": true,
"mode": "list",
"value": ""
},
"options": {},
"resource": "record",
"operation": "search",
"returnAll": true,
"filterByFormula": "AND({is_alert} = TRUE(), DATETIME_FORMAT({scraped_at}, 'YYYY-MM-DD') = DATETIME_FORMAT(NOW(), 'YYYY-MM-DD'))"
},
"executeOnce": true,
"typeVersion": 2.2
},
{
"id": "build-digest",
"name": "Build Alert Summary",
"type": "n8n-nodes-base.code",
"position": [
20,
780
],
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "// Group today's alert rows by query and keep the cheapest offer per query\n// so a noisy SERP doesn't produce a digest with 40 rows for one product.\n\nconst alerts = $input.all()\n .map((i) => i.json.fields || {})\n .filter((r) => r && r.query);\n\nif (alerts.length === 0) {\n return [{ json: { alertCount: 0, subject: '', html: '' } }];\n}\n\nconst byQuery = new Map();\nfor (const a of alerts) {\n const key = a.query;\n const cur = byQuery.get(key);\n const price = typeof a.price === 'number' ? a.price : Number(a.price);\n if (!cur || (Number.isFinite(price) && price < (cur.price ?? Infinity))) {\n byQuery.set(key, { ...a, price });\n }\n}\n\nconst rows = [...byQuery.values()].sort((a, b) => (a.price ?? 0) - (b.price ?? 0));\n\nconst esc = (s) => String(s == null ? '' : s).replace(/[&<>\"']/g, (c) => ({\n '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''',\n}[c]));\nconst fmt = (n, c) => {\n if (n == null || n === '' || !Number.isFinite(Number(n))) return '\u2014';\n const sym = (c || 'USD') === 'USD' ? '$' : (c + ' ');\n return sym + Number(n).toFixed(2);\n};\n\nconst rowsHtml = rows.map((r) => `<tr>\n <td>${esc(r.query)}</td>\n <td>${esc(r.product_title)}</td>\n <td>${esc(r.merchant)}</td>\n <td style=\"text-align:right;color:#0a7d27;font-weight:600;\">${fmt(r.price, r.currency)}</td>\n <td><a href=\"${esc(r.link)}\">View</a></td>\n</tr>`).join('');\n\nconst subject = `Google Shopping price alert \u2014 ${rows.length} ${rows.length === 1 ? 'query' : 'queries'} at or below target`;\nconst html = `<div style=\"font-family:-apple-system,Segoe UI,sans-serif;max-width:680px;\">\n <h2 style=\"margin:0 0 12px;\">${esc(subject)}</h2>\n <p style=\"color:#555;margin:0 0 16px;\">These offers were spotted on Google Shopping today at or below your target price. Full history lives in your Airtable Results table.</p>\n <table cellpadding=\"8\" cellspacing=\"0\" style=\"border-collapse:collapse;width:100%;font-size:14px;\">\n <thead><tr style=\"background:#f4f4f6;text-align:left;\">\n <th>Query</th><th>Product</th><th>Merchant</th><th style=\"text-align:right;\">Price</th><th>Link</th>\n </tr></thead>\n <tbody>${rowsHtml}</tbody>\n </table>\n</div>`;\n\nreturn [{ json: { alertCount: rows.length, subject, html } }];\n",
"language": "javaScript"
},
"typeVersion": 2
},
{
"id": "has-alerts",
"name": "If Alerts Exist",
"type": "n8n-nodes-base.if",
"position": [
300,
780
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "alert-count-gt-0",
"operator": {
"type": "number",
"operation": "gt"
},
"leftValue": "={{ $json.alertCount }}",
"rightValue": 0
}
]
}
},
"typeVersion": 2.3
},
{
"id": "send-alert-email",
"name": "Send Alerts via Gmail",
"type": "n8n-nodes-base.gmail",
"position": [
576,
864
],
"parameters": {
"sendTo": "",
"message": "={{ $json.html }}",
"options": {},
"subject": "={{ $json.subject }}",
"emailType": "html"
},
"typeVersion": 2.2
}
],
"connections": {
"If Alerts Exist": {
"main": [
[
{
"node": "Send Alerts via Gmail",
"type": "main",
"index": 0
}
],
[]
]
},
"Build Alert Summary": {
"main": [
[
{
"node": "If Alerts Exist",
"type": "main",
"index": 0
}
]
]
},
"Every Morning at 8am": {
"main": [
[
{
"node": "Fetch Watchlist from Airtable",
"type": "main",
"index": 0
}
]
]
},
"Fetch Today's Alerts": {
"main": [
[
{
"node": "Build Alert Summary",
"type": "main",
"index": 0
}
]
]
},
"Flatten Shopping Offers": {
"main": [
[
{
"node": "Append Offer to Airtable",
"type": "main",
"index": 0
}
]
]
},
"Append Offer to Airtable": {
"main": [
[
{
"node": "Loop Over Watchlist Items",
"type": "main",
"index": 0
}
]
]
},
"Loop Over Watchlist Items": {
"main": [
[
{
"node": "Fetch Today's Alerts",
"type": "main",
"index": 0
}
],
[
{
"node": "Fetch Google Shopping Results",
"type": "main",
"index": 0
}
]
]
},
"Fetch Google Shopping Results": {
"main": [
[
{
"node": "Flatten Shopping Offers",
"type": "main",
"index": 0
}
]
]
},
"Fetch Watchlist from Airtable": {
"main": [
[
{
"node": "Loop Over Watchlist Items",
"type": "main",
"index": 0
}
]
]
}
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This workflow runs every morning, reads an active watchlist from Airtable, scrapes Google Shopping results via ScraperAPI, and writes offer snapshots back to Airtable for price history. It then compiles today’s below-target matches and sends an HTML alert digest using Gmail.…
Source: https://n8n.io/workflows/16436/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
Track your product wishlist across Amazon, Walmart, and Google and get an email the moment a price drops. Every morning, reads your products from a Data Table and pulls fresh prices from Amazon, Walma
Code. Uses scheduleTrigger, airtable, html, gmail. Scheduled trigger; 13 nodes.
This workflow will collect order data as it is produced, then send a summary email of all orders at the end of every day, formatted in a table. It receives new orders via webhook and stores in Airtabl
Codeur.com — RSS Project Alert. Uses rssFeedRead, airtable, gmail. Scheduled trigger; 7 nodes.
"## Video Introduction [](https://youtu.be/lQh1fuIrBN8)