Use a SERP API in n8n: daily Google rank alerts
Use a Google SERP API in n8n with the HTTP Request node: daily keyword checks, rank history in Google Sheets and Slack alerts when rankings change.
CrawlerData teamPublished 13 min read
Contents
n8n is a good home for small SEO automations: it has a scheduler, Google Sheets and Slack nodes, and you can see every run. What it does not have is a built-in Google search node. You don’t need one. Any SERP API that takes an HTTP request works through n8n’s HTTP Request node, and this guide shows how with the CrawlerData Google Search API.
The workflow you will build, and can import at the end:
- Runs every day at 06:00.
- Searches Google for each keyword in a list.
- Finds where your domain ranks on page 1.
- Appends the positions to a Google Sheet, so you have a history.
- Posts one Slack message when a keyword moves 3 or more places, enters page 1 or drops off it.
Node types and versions were checked against the n8n source and docs in October 2026. Everything here uses n8n’s core nodes, so there is nothing extra to install.
Step 1: create the API credential
The API authenticates with your key as a Bearer token. In n8n, create a credential of type Header Auth (n8n docs):
- Name:
Authorization - Value:
Bearer sk_...(your API key from the dashboard)
Name it CrawlerData API. n8n stores it encrypted and the HTTP Request node adds it as a header, so the key never appears in the workflow JSON you export or share.
You also need a Google Sheets credential and a Slack credential, created the usual way in n8n.
Step 2: prepare the sheet
Create a Google Sheet with a tab called Ranks and this header row:
date | keyword | country | position | url | previous_position | change
The workflow writes one row per keyword per day. Copy the spreadsheet ID from its URL (the long string between /d/ and /edit).
Step 3: the request the HTTP Request node sends
Each keyword becomes one request:
POST https://api.crawlerdata.com/v1/jobs
Authorization: Bearer sk_...
Content-Type: application/json
{ "endpoint": "google-search", "params": { "query": "serp api", "country": "us" }, "queue": "live" }
In n8n that is an HTTP Request node with:
- Method: POST, URL:
https://api.crawlerdata.com/v1/jobs - Authentication: Generic Credential Type → Header Auth →
CrawlerData API - Send Body: on, Body Content Type: JSON, Specify Body: Using JSON, with this expression:
{{ JSON.stringify({ endpoint: 'google-search', params: { query: $json.keyword, country: $json.country }, queue: 'live' }) }}
Two options matter more than they look:
- Timeout: 180000. The node’s default timeout is 10,000 ms (source). A live request waits for the full Google results page before it answers, so give it room; the search behind the example below took about 8 seconds. With the default, n8n can give up on a search that is still running on our side.
- Batching: 1 item per batch, 1,000 ms interval. The node runs once per input item, and without batching it sends them all at once. The API allows 100 requests per minute and 20 per second per key, so one request a second keeps a long keyword list within both.
The live queue returns the finished result in the same response. A search that fails still comes back as HTTP 200, with job.status set to "failed" and credits set to 0, so the next node checks the status instead of relying on n8n’s error handling. Here is the start of a real response for serp api in the US, from 13 October 2026:
{
"job": { "status": "completed", "credits": 1, "latencyMs": 8323, "completedAt": "2026-10-13T03:42:51.070Z" },
"result": {
"organic_results": [
{ "position": 1, "title": "...", "link": "https://serpapi.com/" },
{ "position": 2, "title": "...", "link": "https://dataforseo.com/apis/serp-api" }
]
}
}
link is the real destination URL, not a Google redirect, so matching a domain is a hostname comparison.
Step 4: find the rank and compare
A Code node pairs each response with its keyword, looks for your domain in organic_results and compares with the last row in the sheet for that keyword:
// Find the domain in each result and compare with the last stored position.
const MIN_MOVE = 3; // alert when a keyword moves this many places, enters or leaves page 1
const keywords = $('Keywords').all().map((i) => i.json);
const previous = $('Read previous ranks').all().map((i) => i.json).filter((r) => r.keyword);
const today = new Date().toISOString().slice(0, 10);
const last = {};
for (const row of previous) last[`${row.keyword}|${row.country}`] = row; // later rows win
const host = (url) => { try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return ''; } };
const out = [];
$input.all().forEach((item, i) => {
const { keyword, country, domain } = keywords[i];
if (item.json.job?.status !== 'completed') return; // failed searches are not charged; skip them
const results = item.json.result?.organic_results ?? [];
const hit = results.find((r) => host(r.link) === domain || host(r.link).endsWith(`.${domain}`));
const position = hit ? hit.position : '';
const prev = last[`${keyword}|${country}`];
const before = prev && prev.position !== '' ? Number(prev.position) : '';
let change = '';
if (prev) {
if (before === '' && position !== '') change = 'entered page 1';
else if (before !== '' && position === '') change = 'left page 1';
else if (before !== '' && Math.abs(before - position) >= MIN_MOVE) change = before > position ? 'up' : 'down';
}
out.push({ json: { date: today, keyword, country, position, url: hit ? hit.link : '', previous_position: before, change } });
});
return out;
Notes on the logic:
$('Keywords').all()and$('Read previous ranks').all()read other nodes’ output (n8n docs). The HTTP Request node returns one item per keyword in the same order, so indexipairs them.- Failed searches are dropped, so a failure never looks like your site falling off page 1.
- An empty
positionmeans “not on page 1”. If you need positions further down, addpageto the request; each page is another request and another credit. MIN_MOVEfilters out noise. Positions move by one or two places day to day for many queries.
A second Code node turns the changes into one Slack message, or returns no items so the Slack node does not run:
// One Slack message for the whole run, or nothing when there is nothing to say.
const moves = $input.all().map((i) => i.json).filter((r) => r.change);
const failed = $('Search Google').all().filter((i) => i.json.job?.status !== 'completed').length;
if (!moves.length && !failed) return [];
const fmt = (p) => (p === '' ? 'not on page 1' : `#${p}`);
const lines = moves.map((r) => `• ${r.keyword} (${r.country}): ${fmt(r.previous_position)} → ${fmt(r.position)}`);
if (failed) lines.push(`${failed} search(es) failed and were not charged. They will run again tomorrow.`);
return [{ json: { text: `Rank changes on ${moves[0]?.date ?? 'today'}:\n${lines.join('\n')}` } }];
We ran both Code nodes outside n8n against the real response above, with a made-up previous position of 8 and one failed search. The output was:
Rank changes on 2026-10-13:
• serp api (us): #8 → #2
1 search(es) failed and were not charged. They will run again tomorrow.
Step 5: import the whole workflow
Copy the JSON below, then in n8n open a new workflow and paste it onto the canvas (or use Import from File). After importing:
- Select your credentials on Read previous ranks, Search Google, Append ranks and Post to Slack.
- Replace
YOUR_SPREADSHEET_IDin both Google Sheets nodes, and#seo-alertswith your channel. - Edit the keyword list and
DOMAINin the Keywords node. - Run it once by hand, check the sheet, then activate the workflow so the schedule runs.
{
"name": "Daily Google rank check (CrawlerData)",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "days",
"triggerAtHour": 6
}
]
}
},
"name": "Every day at 06:00",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
0,
0
]
},
{
"parameters": {
"operation": "read",
"documentId": {
"__rl": true,
"mode": "id",
"value": "YOUR_SPREADSHEET_ID"
},
"sheetName": {
"__rl": true,
"mode": "name",
"value": "Ranks"
},
"options": {}
},
"name": "Read previous ranks",
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.5,
"position": [
220,
0
],
"alwaysOutputData": true
},
{
"parameters": {
"jsCode": "// One item per keyword. Edit this list, or replace this node with a Google Sheets read.\nconst DOMAIN = 'example.com';\nconst COUNTRY = 'us';\nconst keywords = [\n 'serp api',\n 'google search api',\n 'rank tracking api',\n];\nreturn keywords.map((keyword) => ({ json: { keyword, country: COUNTRY, domain: DOMAIN } }));"
},
"name": "Keywords",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
440,
0
]
},
{
"parameters": {
"method": "POST",
"url": "https://api.crawlerdata.com/v1/jobs",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ endpoint: 'google-search', params: { query: $json.keyword, country: $json.country }, queue: 'live' }) }}",
"options": {
"batching": {
"batch": {
"batchSize": 1,
"batchInterval": 1000
}
},
"timeout": 180000
}
},
"name": "Search Google",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
660,
0
],
"retryOnFail": true,
"maxTries": 2,
"waitBetweenTries": 5000,
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "// Find the domain in each result and compare with the last stored position.\nconst MIN_MOVE = 3; // alert when a keyword moves this many places, enters or leaves page 1\nconst keywords = $('Keywords').all().map((i) => i.json);\nconst previous = $('Read previous ranks').all().map((i) => i.json).filter((r) => r.keyword);\nconst today = new Date().toISOString().slice(0, 10);\n\nconst last = {};\nfor (const row of previous) last[`${row.keyword}|${row.country}`] = row; // later rows win\n\nconst host = (url) => { try { return new URL(url).hostname.replace(/^www\\./, ''); } catch { return ''; } };\n\nconst out = [];\n$input.all().forEach((item, i) => {\n const { keyword, country, domain } = keywords[i];\n if (item.json.job?.status !== 'completed') return; // failed searches are not charged; skip them\n const results = item.json.result?.organic_results ?? [];\n const hit = results.find((r) => host(r.link) === domain || host(r.link).endsWith(`.${domain}`));\n const position = hit ? hit.position : '';\n const prev = last[`${keyword}|${country}`];\n const before = prev && prev.position !== '' ? Number(prev.position) : '';\n let change = '';\n if (prev) {\n if (before === '' && position !== '') change = 'entered page 1';\n else if (before !== '' && position === '') change = 'left page 1';\n else if (before !== '' && Math.abs(before - position) >= MIN_MOVE) change = before > position ? 'up' : 'down';\n }\n out.push({ json: { date: today, keyword, country, position, url: hit ? hit.link : '', previous_position: before, change } });\n});\nreturn out;"
},
"name": "Compare with last run",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
880,
0
]
},
{
"parameters": {
"operation": "append",
"documentId": {
"__rl": true,
"mode": "id",
"value": "YOUR_SPREADSHEET_ID"
},
"sheetName": {
"__rl": true,
"mode": "name",
"value": "Ranks"
},
"columns": {
"mappingMode": "autoMapInputData",
"value": {},
"matchingColumns": [],
"schema": []
},
"options": {}
},
"name": "Append ranks",
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.5,
"position": [
1100,
-100
]
},
{
"parameters": {
"jsCode": "// One Slack message for the whole run, or nothing when there is nothing to say.\nconst moves = $input.all().map((i) => i.json).filter((r) => r.change);\nconst failed = $('Search Google').all().filter((i) => i.json.job?.status !== 'completed').length;\nif (!moves.length && !failed) return [];\n\nconst fmt = (p) => (p === '' ? 'not on page 1' : `#${p}`);\nconst lines = moves.map((r) => `• ${r.keyword} (${r.country}): ${fmt(r.previous_position)} → ${fmt(r.position)}`);\nif (failed) lines.push(`${failed} search(es) failed and were not charged. They will run again tomorrow.`);\nreturn [{ json: { text: `Rank changes on ${moves[0]?.date ?? 'today'}:\\n${lines.join('\\n')}` } }];"
},
"name": "Summarise changes",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1100,
100
]
},
{
"parameters": {
"resource": "message",
"operation": "post",
"select": "channel",
"channelId": {
"__rl": true,
"mode": "name",
"value": "#seo-alerts"
},
"text": "={{ $json.text }}",
"otherOptions": {
"includeLinkToWorkflow": false
}
},
"name": "Post to Slack",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.2,
"position": [
1320,
100
]
}
],
"connections": {
"Every day at 06:00": {
"main": [
[
{
"node": "Read previous ranks",
"type": "main",
"index": 0
}
]
]
},
"Read previous ranks": {
"main": [
[
{
"node": "Keywords",
"type": "main",
"index": 0
}
]
]
},
"Keywords": {
"main": [
[
{
"node": "Search Google",
"type": "main",
"index": 0
}
]
]
},
"Search Google": {
"main": [
[
{
"node": "Compare with last run",
"type": "main",
"index": 0
}
]
]
},
"Compare with last run": {
"main": [
[
{
"node": "Append ranks",
"type": "main",
"index": 0
},
{
"node": "Summarise changes",
"type": "main",
"index": 0
}
]
]
},
"Summarise changes": {
"main": [
[
{
"node": "Post to Slack",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
The node versions are HTTP Request 4.2, Schedule Trigger 1.2, Code 2, Google Sheets 4.5 and Slack 2.2, which current n8n supports. We imported this JSON into n8n 2.42.7 with its import:workflow command to check that it loads. If your n8n rejects a version, add that node by hand with the settings described above.
On the first run the sheet is empty, so Read previous ranks has Always Output Data switched on. Without it, an empty read returns no items and the workflow would stop there. The first run writes positions and sends no alerts; alerts start on day two.
Troubleshooting
- “timeout of 10000ms exceeded” or similar. The Timeout option is missing or was reset. Set it to 180000 on the Search Google node.
- 401 from the API. The Header Auth value must include the word
Bearer, a space, then the key. A key pasted on its own is rejected. - The workflow stops after Read previous ranks. Switch on
Always Output Datain that node’s settings. An empty sheet returns no items otherwise. - Rows land in the wrong columns. The header row must match the field names exactly, in lower case:
date,keyword,country,position,url,previous_position,change. - Slack says the channel was not found. Invite the Slack app behind your credential to the channel, or select the channel from the list instead of typing its name.
- Every position is empty. Check
DOMAINin the Keywords node: it must be the bare hostname, such asexample.com, withouthttps://or a path.
Variations
- Keywords from a sheet. Replace the Keywords Code node with a Google Sheets read of a
Keywordstab withkeyword,countryanddomaincolumns. The rest of the workflow is unchanged. - Mobile rankings. Add
device: 'mobile'toparamsand adevicecolumn to the sheet. Desktop and mobile results can differ; see desktop vs mobile Google results. - Large keyword lists. Submit on the
fastqueue with apostbackUrlpointing to an n8n Webhook node, and handle results as they arrive. Same price per search, and no long-running HTTP calls. - A real database. Google Sheets is fine for a few thousand rows a month. For larger lists, swap the Sheets nodes for Postgres or another database node, as in the Python rank tracker, which uses the same API.
Option: give an n8n AI agent the API through MCP
If you build AI agents in n8n rather than fixed workflows, there is a second route. n8n’s MCP Client Tool node connects an AI Agent node to a remote MCP server and exposes that server’s tools to the agent (n8n docs). Set it up with:
- Endpoint:
https://api.crawlerdata.com/mcp - Server Transport: HTTP Streamable
- Authentication: Bearer Auth, with your API key
The agent then has 8 tools: google_search, google_news, google_images, google_local, google_autocomplete, google_shopping_search, google_shopping_sellers and google_shopping_product. Each call costs the same credits as the matching API request. This suits open-ended questions (“what’s ranking for this keyword in Germany, and who advertises?”). For a fixed daily check, the plain HTTP Request workflow above is cheaper and more predictable, because you decide exactly which requests run.
What it costs
One search is 1 credit, per keyword, country, device and results page. Failed searches are free.
- 50 keywords, one country, daily: 1,500 credits a month, 9,000 over 6 months. One $50 Starter pack (50,000 credits, valid 6 months) covers that with room to spare, so you could add a second country and mobile checks for the same pack.
- 500 keywords, desktop and mobile, daily: 30,000 credits a month, 180,000 over 6 months. Four Starter packs ($200), about $33 a month.
See pricing for the larger packs, which go down to $0.30 per 1,000 searches. For the rank-tracking use case as a whole, see SEO rank tracking.
Frequently asked questions
- Do I need a custom n8n node for CrawlerData?
- No. n8n's built-in HTTP Request node and a Header Auth credential cover it: the API is one POST request per search, and the workflow in this post is ready to import.
- Why does the HTTP Request node time out?
- Its default timeout is 10 seconds. A live request waits for the full Google results page before it answers, so set the node's Timeout option to 180000 milliseconds, as the workflow in this post does.
- How do I stay within the API rate limits from n8n?
- Use the HTTP Request node's Batching option. One item per batch with a 1,000 ms interval starts at most 60 searches a minute, under the limit of 100 requests per minute and 20 per second per API key.
- Can an n8n AI agent use CrawlerData directly?
- Yes. n8n's MCP Client Tool node connects an AI Agent to a remote MCP server. Point it at https://api.crawlerdata.com/mcp with Bearer authentication and the agent gets 8 Google Search and Google Shopping tools.
- What does the workflow cost to run?
- 1 credit per keyword, per country, per day. 50 keywords in one country is 1,500 credits a month. n8n's own costs depend on your plan or hosting.
- n8n
- SERP API
- Google Search API
- Rank tracking
- Automation
- Tutorial