AutomationFlowsSlack & Telegram › Track Weekly Google Keyword Rankings with Searchapi and Slack

Track Weekly Google Keyword Rankings with Searchapi and Slack

BySam Gale @sam-searchapi on n8n.io

This workflow runs weekly to track your Google rankings for multiple keywords using SearchApi, compares current positions to the previous run stored in an n8n Data table, and posts a formatted movement report to Slack. Runs every week on a schedule. Loads your target domain,…

Cron / scheduled trigger★★★★☆ complexity14 nodes@Searchapi/N8N Nodes SearchapiData TableSlack
Slack & Telegram Trigger: Cron / scheduled Nodes: 14 Complexity: ★★★★☆ Added:

This workflow corresponds to n8n.io template #17202 — we link there as the canonical source.

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 →

Download .json
{
  "name": "SEO Rank Tracker - Multi-Keyword Monitoring",
  "nodes": [
    {
      "id": "sched",
      "name": "Schedule Weekly",
      "type": "n8n-nodes-base.scheduleTrigger",
      "notes": "Runs every Monday at 08:00. Change the interval to daily/monthly if you prefer.",
      "position": [
        0,
        288
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "weeks",
              "triggerAtDay": [
                1
              ],
              "triggerAtHour": 8
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "settings",
      "name": "Settings",
      "type": "n8n-nodes-base.set",
      "notes": "The only node you normally need to edit. Set your domain, keyword list (comma separated), and Google location.",
      "position": [
        224,
        288
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "a1",
              "name": "domain",
              "type": "string",
              "value": "searchapi.io"
            },
            {
              "id": "a2",
              "name": "keywords",
              "type": "string",
              "value": "search api, serp api, google search api"
            },
            {
              "id": "a3",
              "name": "location",
              "type": "string",
              "value": "United States"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "split",
      "name": "Split Keywords",
      "type": "n8n-nodes-base.code",
      "notes": "Turns the comma-separated keyword list into one item per keyword so each gets its own search.",
      "position": [
        672,
        288
      ],
      "parameters": {
        "jsCode": "// Build one request per keyword per page (first 5 pages = top 50 results).\n// The SearchApi node's num field is ignored by the API, so we page through\n// with the page parameter (10 results per page) to get real depth.\nconst cfg = $('Settings').first().json;\nconst domain = (cfg.domain || '').trim();\nconst location = (cfg.location || '').trim();\nconst PAGES = 5;\n\nconst keywords = (cfg.keywords || '')\n  .split(',').map(k => k.trim()).filter(k => k.length);\n\nif (!keywords.length) {\n  throw new Error('No keywords found. Add a comma-separated list in the Settings node.');\n}\n\nconst out = [];\nfor (const keyword of keywords) {\n  for (let page = 1; page <= PAGES; page++) {\n    out.push({ json: { keyword, domain, location, page } });\n  }\n}\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "searchapi",
      "name": "SearchApi - Google",
      "type": "@searchapi/n8n-nodes-searchapi.searchApi",
      "notes": "Live Google results via SearchApi.io. Runs once per keyword per page. Note: the node's num field is ignored by the API, so depth comes from the page parameter (10 results/page, 5 pages = top 50).",
      "onError": "continueRegularOutput",
      "maxTries": 3,
      "position": [
        896,
        288
      ],
      "parameters": {
        "q": "={{ $json.keyword }}",
        "pagination": {
          "page": "={{ $json.page }}"
        },
        "timeFilters": {},
        "searchOptions": {},
        "requestOptions": {},
        "advancedOptions": {},
        "languageSettings": {},
        "locationSettings": {
          "location": "={{ $json.location }}"
        }
      },
      "retryOnFail": true,
      "typeVersion": 1,
      "waitBetweenTries": 2000
    },
    {
      "id": "rank",
      "name": "Rank Report",
      "type": "n8n-nodes-base.code",
      "notes": "Finds your domain's best organic position, then compares it to last week's run (stored in workflow static data) to compute movement. Sorted by biggest mover.",
      "position": [
        1120,
        288
      ],
      "parameters": {
        "jsCode": "// Aggregate each keyword's best organic position across the 5 fetched pages,\n// then compare to last week's positions from the Data table.\n// SearchApi numbers results 1-10 within each page, so the true rank is\n// (page - 1) * 10 + position.\n\nfunction rootDomain(url) {\n  if (!url) return '';\n  let h = String(url).replace(/^https?:\\/\\//i, '').replace(/^www\\./i, '');\n  h = h.split('/')[0].split('?')[0].toLowerCase();\n  return h;\n}\n\nconst domain = ($('Settings').first().json.domain || '').trim();\nconst target = rootDomain(domain);\nconst PER_PAGE = 10;\n\n// Previous positions from the Data table (keyword -> position).\nconst previous = {};\nfor (const row of $('Get Previous Ranks').all()) {\n  const k = row.json.keyword;\n  const p = row.json.position;\n  if (k != null && typeof p === 'number') previous[k] = p;\n}\n\n// Best (lowest) absolute rank per keyword across all pages.\nconst best = {};\nconst seen = [];\nfor (const item of $input.all()) {\n  const serp = item.json || {};\n  const sp = serp.search_parameters || {};\n  const keyword = sp.q || serp.keyword || '';\n  if (!keyword) continue;\n  if (!seen.includes(keyword)) seen.push(keyword);\n\n  const page = Number(sp.page || serp.page || 1);\n  const organic = Array.isArray(serp.organic_results) ? serp.organic_results : [];\n  for (const r of organic) {\n    if (rootDomain(r.link || r.domain) === target) {\n      const abs = (page - 1) * PER_PAGE + (Number(r.position) || 0);\n      if (!best[keyword] || abs < best[keyword].position) {\n        best[keyword] = { position: abs, url: r.link || '', title: r.title || '' };\n      }\n    }\n  }\n}\n\nconst depth = PER_PAGE * 5;\nconst now = new Date().toISOString();\nconst results = [];\n\nfor (const keyword of seen) {\n  const b = best[keyword];\n  const position = b ? b.position : null;\n  const rankingUrl = b ? b.url : '';\n  const title = b ? b.title : '';\n\n  const prev = previous[keyword];\n  const hadPrev = typeof prev === 'number';\n\n  let change = null, movement = '';\n  if (position == null && !hadPrev) movement = 'Not ranking (top ' + depth + ')';\n  else if (position == null && hadPrev) movement = 'Dropped out (was #' + prev + ')';\n  else if (!hadPrev) movement = 'New';\n  else if (position === prev) { change = 0; movement = 'No change'; }\n  else if (position < prev) { change = prev - position; movement = 'Up ' + change; }\n  else { change = prev - position; movement = 'Down ' + Math.abs(change); }\n\n  results.push({ json: {\n    keyword, domain, position,\n    previous_position: hadPrev ? prev : null,\n    change, movement, url: rankingUrl, title, last_checked: now\n  }});\n}\n\nresults.sort((a, b) => {\n  const av = a.json.change == null ? -1 : Math.abs(a.json.change);\n  const bv = b.json.change == null ? -1 : Math.abs(b.json.change);\n  return bv - av;\n});\n\nreturn results;"
      },
      "typeVersion": 2
    },
    {
      "id": "note_header",
      "name": "Note Header",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -640,
        -400
      ],
      "parameters": {
        "width": 540,
        "height": 856,
        "content": "## SEO Rank Tracker (SearchApi)\n\nTrack your Google position for multiple keywords every week, get a clean summary in Slack, and see how each keyword moved since last time. Positions are stored in a native n8n Data table, so there is no external database and no extra credentials to manage.\n\n### How it works\n1. Runs weekly and reads your domain, keyword list, and location from the Settings node.\n2. Loads last week's saved positions from a Data table.\n3. Fetches the first 5 Google result pages for each keyword via SearchApi (top 50).\n4. Finds your domain's best position and compares it to last week (up / down / new / dropped out).\n5. Posts a clean summary to Slack and saves this run's positions back to the Data table.\n\n### Setup\n- [ ] Add your SearchApi credential to the \"SearchApi - Google\" node (free key at searchapi.io).\n- [ ] In \"Get Previous Ranks\" and \"Save Ranks\", select a Data table with columns: keyword, position, url, last_checked.\n- [ ] Connect Slack in the \"Send to Slack\" node and pick a channel. No Slack? Delete that node and read the \"Format Report\" output instead.\n- [ ] Edit domain, keywords, and location in the \"Settings\" node.\n- [ ] Activate. The first run sets a baseline; movement shows from the second run.\n\n### Customization tips\nThe clean summary is built in \"Format Report\". Swap \"Send to Slack\" for an Email or Google Sheets node to deliver it differently."
      },
      "typeVersion": 1
    },
    {
      "id": "1d0f5f01-cc0b-4f18-a025-c3738cea5e33",
      "name": "Get Previous Ranks",
      "type": "n8n-nodes-base.dataTable",
      "notes": "Reads last run's saved positions from the Data table so movement can be computed. Returns nothing on the very first run (that run becomes the baseline).",
      "position": [
        448,
        288
      ],
      "parameters": {
        "operation": "get",
        "returnAll": true,
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "Rank History"
        }
      },
      "typeVersion": 1.1,
      "alwaysOutputData": true
    },
    {
      "id": "5b6ef1ba-f19c-4194-abea-4271cefcb0d3",
      "name": "Only Ranking",
      "type": "n8n-nodes-base.filter",
      "notes": "Only keywords where the domain actually ranked (position >= 1) get written back to the table.",
      "position": [
        1344,
        96
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c1",
              "operator": {
                "type": "number",
                "operation": "gte"
              },
              "leftValue": "={{ $json.position }}",
              "rightValue": 1
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "689f81f2-36ca-4a75-85f0-90e11213151e",
      "name": "Save Ranks",
      "type": "n8n-nodes-base.dataTable",
      "notes": "Writes each keyword's current position back to the Data table (matched on keyword) so next week has something to compare against.",
      "position": [
        1568,
        96
      ],
      "parameters": {
        "columns": {
          "value": {
            "url": "={{ $json.url }}",
            "keyword": "={{ $json.keyword }}",
            "position": "={{ $json.position }}",
            "last_checked": "={{ $json.last_checked }}"
          },
          "schema": [
            {
              "id": "keyword",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "keyword",
              "defaultMatch": false
            },
            {
              "id": "position",
              "type": "number",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "position",
              "defaultMatch": false
            },
            {
              "id": "url",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "url",
              "defaultMatch": false
            },
            {
              "id": "last_checked",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "last_checked",
              "defaultMatch": false
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "filters": {
          "conditions": [
            {
              "keyName": "keyword",
              "keyValue": "={{ $json.keyword }}"
            }
          ]
        },
        "options": {},
        "matchType": "allConditions",
        "operation": "upsert",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "Rank History"
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "0017aef4-bfad-4e96-9945-031c26ae85c7",
      "name": "Section 1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -64,
        -208
      ],
      "parameters": {
        "color": 7,
        "width": 440,
        "height": 656,
        "content": "## 1. Schedule & settings\nRuns weekly, then loads your domain, keyword list, and location from the Settings node."
      },
      "typeVersion": 1
    },
    {
      "id": "3bf66830-0278-4bb8-8ebb-f0d2ff267a83",
      "name": "Section 2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        416,
        -208
      ],
      "parameters": {
        "color": 7,
        "width": 620,
        "height": 656,
        "content": "## 2. Read history & search\nLoads last week's positions from the Data table, then fetches the first 5 Google pages per keyword via SearchApi (10 results/page = top 50)."
      },
      "typeVersion": 1
    },
    {
      "id": "231785bf-8558-4d04-b37d-6471f1f1aa66",
      "name": "Section 3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1072,
        -208
      ],
      "parameters": {
        "color": 7,
        "width": 640,
        "height": 652,
        "content": "## 3. Report, notify & save\nRank Report builds the table. Format Report turns it into a clean summary and Send to Slack posts it. Ranking keywords are also saved back to the Data table."
      },
      "typeVersion": 1
    },
    {
      "id": "cdb936b1-5e06-46c9-8457-84eccd7af37c",
      "name": "Format Report",
      "type": "n8n-nodes-base.code",
      "notes": "Builds a clean Slack Block Kit message (header, dividers, one line per keyword) plus a plain-text fallback. Read this node's output even if you don't use Slack.",
      "position": [
        1344,
        288
      ],
      "parameters": {
        "jsCode": "const rows = $input.all().map(i => i.json);\nconst domain = rows.length ? rows[0].domain : '';\nconst date = new Date().toISOString().slice(0, 10);\n\nfunction icon(r) {\n  if (r.position == null && r.previous_position == null) return ':white_circle:';\n  if (r.position == null) return ':warning:';\n  if (r.previous_position == null) return ':new:';\n  if (r.change > 0) return ':large_green_circle:';\n  if (r.change < 0) return ':red_circle:';\n  return ':white_circle:';\n}\n\nfunction detail(r) {\n  if (r.position == null && r.previous_position == null) return 'not in top 50';\n  if (r.position == null) return 'not in top 50 (was #' + r.previous_position + ')';\n  if (r.previous_position == null) return '#' + r.position + ' (new)';\n  if (r.change > 0) return '#' + r.position + ' (up ' + r.change + ', was #' + r.previous_position + ')';\n  if (r.change < 0) return '#' + r.position + ' (down ' + Math.abs(r.change) + ', was #' + r.previous_position + ')';\n  return '#' + r.position + ' (no change)';\n}\n\nconst lines = rows.map(r => icon(r) + '  *' + r.keyword + '*  -  ' + detail(r));\nconst up = rows.filter(r => r.change > 0).length;\nconst down = rows.filter(r => r.change < 0).length;\nconst fresh = rows.filter(r => r.previous_position == null && r.position != null).length;\n\nlet summary = rows.length + ' keywords  \u00b7  ' + up + ' up  \u00b7  ' + down + ' down';\nif (fresh) summary += '  \u00b7  ' + fresh + ' new';\n\n// Plain-text fallback (used in notifications / if you drop Slack).\nconst text = 'SEO Rank Tracker - ' + domain + ' (' + date + ')\\n' + lines.join('\\n') + '\\n' + summary;\n\n// Block Kit layout for a clean Slack message.\nconst blocks = [\n  { type: 'header', text: { type: 'plain_text', text: ':bar_chart: SEO Rank Tracker', emoji: true } },\n  { type: 'context', elements: [ { type: 'mrkdwn', text: '*' + domain + '*  \u00b7  ' + date } ] },\n  { type: 'divider' },\n  { type: 'section', text: { type: 'mrkdwn', text: lines.join('\\n') } },\n  { type: 'divider' },\n  { type: 'context', elements: [ { type: 'mrkdwn', text: summary } ] }\n];\n\nreturn [{ json: { text, blocks: JSON.stringify({ blocks }) } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "f8caad31-cef3-4553-959e-89112f4e4d6c",
      "name": "Send to Slack",
      "type": "n8n-nodes-base.slack",
      "notes": "Posts the Block Kit summary to a Slack channel. Connect your Slack credential and choose a channel. No Slack? Delete this node and read the Format Report output.",
      "position": [
        1568,
        288
      ],
      "parameters": {
        "text": "={{ $json.text }}",
        "select": "channel",
        "blocksUi": "={{ $json.blocks }}",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": ""
        },
        "messageType": "block",
        "otherOptions": {
          "includeLinkToWorkflow": false
        },
        "authentication": "oAuth2"
      },
      "typeVersion": 2.3
    }
  ],
  "settings": {
    "binaryMode": "separate",
    "executionOrder": "v1"
  },
  "connections": {
    "Settings": {
      "main": [
        [
          {
            "node": "Get Previous Ranks",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Rank Report": {
      "main": [
        [
          {
            "node": "Only Ranking",
            "type": "main",
            "index": 0
          },
          {
            "node": "Format Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Only Ranking": {
      "main": [
        [
          {
            "node": "Save Ranks",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Report": {
      "main": [
        [
          {
            "node": "Send to Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split Keywords": {
      "main": [
        [
          {
            "node": "SearchApi - Google",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Schedule Weekly": {
      "main": [
        [
          {
            "node": "Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Previous Ranks": {
      "main": [
        [
          {
            "node": "Split Keywords",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "SearchApi - Google": {
      "main": [
        [
          {
            "node": "Rank Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}
Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

This workflow runs weekly to track your Google rankings for multiple keywords using SearchApi, compares current positions to the previous run stored in an n8n Data table, and posts a formatted movement report to Slack. Runs every week on a schedule. Loads your target domain,…

Source: https://n8n.io/workflows/17202/ — original creator credit. Request a take-down →

More Slack & Telegram workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

Slack & Telegram

This workflow is an automated employee time tracking and reporting system that monitors weekly work hours via TMetric, then delivers personalized summaries directly to each team member on Slack. It co

HTTP Request, Item Lists, Data Table +1
Slack & Telegram

This workflow monitors GitHub for high-risk activities to ensure that only authorized users can modify the repository. It periodically polls GitHub for events such as PushEvent, MemberEvent, and Publi

Data Table, HTTP Request, Slack
Slack & Telegram

This workflow runs daily to read recurrence rules from a Notion database, creates due task pages in a Notion tasks database, prevents duplicate spawns using an n8n Data Table ledger, and posts a summa

Notion, Data Table, Slack
Slack & Telegram

This workflow fully automates your team's daily standup process using Slack for communication, Notion for structured data storage, and Redis for real-time session management.

Notion, Slack, Redis
Slack & Telegram

debug. Uses httpRequest, slack, redis, mailgun. Scheduled trigger; 60 nodes.

HTTP Request, Slack, Redis +2