n8n template

Search Console clicks and top queries, emailed every Monday

Two Search Console API calls, one Code node and one email. Every Monday you get clicks, impressions and the ten queries that brought the most clicks, each with its change against the week before.

6nodes
2credentials
$0.00API cost per run
n/arun time (not measured)

Every Monday at 07:00 this workflow asks the Search Console API for query rows from the last 7 days and from the 7 days before. A Code node compares the two weeks, and an email node sends the totals and the top 10 queries by clicks, each with its change.

An agent wrote the first version of the JSON from the n8n and Search Console docs. I ran it end to end on my own n8n instance, and it worked after one change: both HTTP Request nodes now use a predefined Google credential instead of a generic OAuth2 one. The rest of what I had to do, mostly access and my own property, was under-explained, so the setup below now spells it out step by step. After that run I also added a Config node for the property and the addresses, and sticky notes with the setup on the canvas, and ran that version end to end as well on 2026-10-28.

Before you start

The workflow itself is six built-in nodes: Schedule Trigger, a Config node (Edit Fields), two HTTP Requests, Code and Send Email. Everything that can go wrong happens before the first run, in access and credentials. Do these in order; the same steps are on sticky notes in the canvas after import.

  1. Access to the property. The Google account you will connect in n8n must see the property in Search Console. Any permission level is enough for this workflow: owner, full user and restricted user can all read Performance data. If the property is missing from that account's property list, a verified owner has to add it under Settings → Users and permissions. The API will not return data for a property the account cannot open.
  2. The exact property name. Copy it from the property picker in Search Console. A domain property is sc-domain:example.com; a URL-prefix property is the full address with the protocol and the trailing slash, such as https://www.example.com/. It goes into the Config node as you copied it; the HTTP Request nodes encode it for the URL.
  3. A Google Cloud project with the API on. In Google Cloud console, pick or create a project, open APIs & Services → Library and enable the Google Search Console API.
  4. The OAuth consent screen. Choose Internal if you are on Google Workspace and only your own organization will sign in. Choose External otherwise, and add your Google account as a test user. An External app stays in Testing mode until you publish it, and in Testing mode consent and tokens expire after 7 days. For a weekly workflow that means a failed run the following Monday, unless you reconnect the credential or publish the app.
  5. An OAuth client. Under Credentials → Create credentials → OAuth client ID, choose Web application. In Authorized redirect URIs, paste the OAuth Redirect URL that n8n shows in the credential form; it has to match exactly, protocol and port included, or Google rejects the sign-in with a redirect URI mismatch error.
  6. The credential in n8n. The JSON expects a Google Search Console OAuth2 API credential, which my n8n lists among the predefined types. Create it with that client's ID and secret and sign in with the account from step 1. If your instance does not list that type, create a Google OAuth2 API credential instead, with the read-only scope https://www.googleapis.com/auth/webmasters.readonly; it already carries Google's authorization and token URLs and asks for offline access. Then pick it in both HTTP Request nodes.
  7. An SMTP credential for the email node.

The workflow

FIG. 01 · live

The workflow, node by node

simulated from the JSON
Complete · replay anytime
  1. Schedule Trigger: Mondays at 07:00 in the instance timezone.
  2. Edit Fields (Config): the Search Console property and the two email addresses, set once and read by the other nodes.
  3. HTTP Request: Search Console query rows from 9 to 3 days ago.
  4. HTTP Request: query rows for the 7 days before that.
  5. Code: totals, the top 10 queries by clicks and the change per query; stops the run if the last 7 days came back empty.
  6. Send Email (SMTP): the report as plain text.

Workflow JSON

n8n 2.44.6verified 2026-10-28
{
  "name": "GSC weekly report by email",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "weeks",
              "triggerAtDay": [
                1
              ],
              "triggerAtHour": 7
            }
          ]
        }
      },
      "id": "2f6a1c1e-5b0d-4d7e-9a43-1c8b0f6e7a01",
      "name": "Monday 07:00",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        0
      ]
    },
    {
      "id": "b2c7d9e1-3f4a-4b6c-8d2e-6f1a3c5b7d06",
      "name": "Config",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        0
      ],
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "c1a2b3c4-d5e6-4f70-8a91-0b2c3d4e5f07",
              "name": "property",
              "value": "sc-domain:example.com",
              "type": "string"
            },
            {
              "id": "d2b3c4d5-e6f7-4a81-9b02-1c3d4e5f6a08",
              "name": "fromEmail",
              "value": "reports@example.com",
              "type": "string"
            },
            {
              "id": "e3c4d5e6-f7a8-4b92-8c13-2d4e5f6a7b09",
              "name": "toEmail",
              "value": "you@example.com",
              "type": "string"
            }
          ]
        },
        "options": {}
      }
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://www.googleapis.com/webmasters/v3/sites/{{ encodeURIComponent($('Config').first().json.property) }}/searchAnalytics/query",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googleSearchConsoleOAuth2Api",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ startDate: $today.minus({ days: 9 }).toFormat('yyyy-MM-dd'), endDate: $today.minus({ days: 3 }).toFormat('yyyy-MM-dd'), dimensions: ['query'], rowLimit: 1000 }) }}",
        "options": {}
      },
      "id": "7c3e9d52-0a4b-4f1e-8d6c-2b9a5e4f3a02",
      "name": "GSC last 7 days",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        440,
        0
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://www.googleapis.com/webmasters/v3/sites/{{ encodeURIComponent($('Config').first().json.property) }}/searchAnalytics/query",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googleSearchConsoleOAuth2Api",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ startDate: $today.minus({ days: 16 }).toFormat('yyyy-MM-dd'), endDate: $today.minus({ days: 10 }).toFormat('yyyy-MM-dd'), dimensions: ['query'], rowLimit: 1000 }) }}",
        "options": {}
      },
      "id": "a91b4f07-6e2d-4c3a-b5f8-3d0c7e1a9b03",
      "name": "GSC previous 7 days",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        660,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Compares the last 7 days of Search Console query rows with the 7 days before.\n// Totals are sums over the returned query rows: Search Console leaves out\n// anonymized queries, so they come out lower than the Performance report totals.\nconst current = $('GSC last 7 days').first().json.rows ?? [];\nconst previous = $('GSC previous 7 days').first().json.rows ?? [];\nif (current.length === 0) {\n  throw new Error('Search Console returned no query rows for the last 7 days. Check the property in the Config node and that the Google account behind the credential has access to it in Search Console.');\n}\nconst sum = (rows, key) => rows.reduce((s, r) => s + (r[key] ?? 0), 0);\nconst signed = (n) => (n > 0 ? '+' : '') + n;\nconst before = new Map(previous.map((r) => [r.keys[0], r.clicks]));\nconst clicks = sum(current, 'clicks');\nconst impressions = sum(current, 'impressions');\nconst clicksBefore = sum(previous, 'clicks');\nconst impressionsBefore = sum(previous, 'impressions');\nconst top = [...current]\n  .sort((a, b) => b.clicks - a.clicks)\n  .slice(0, 10)\n  .map((r, i) => {\n    const prev = before.get(r.keys[0]);\n    // Missing from the previous rows is not the same as new: both requests return at most\n    // 1,000 rows and Search Console may omit rows.\n    const change = prev === undefined ? 'not in the previous rows' : signed(r.clicks - prev);\n    return (i + 1) + '. ' + r.keys[0] + ': ' + r.clicks + ' clicks (' + change + ')';\n  });\nconst text = [\n  'Clicks: ' + clicks + ' (' + signed(clicks - clicksBefore) + ' vs the previous 7 days)',\n  'Impressions: ' + impressions + ' (' + signed(impressions - impressionsBefore) + ')',\n  previous.length === 0 ? 'The previous 7 days returned no rows, so no query has a change.' : '',\n  '',\n  'Top 10 queries by clicks:',\n  ...top,\n].filter((line, i) => line !== '' || i === 3).join('\\n');\nreturn [{ json: { subject: 'Search Console weekly: ' + clicks + ' clicks (' + signed(clicks - clicksBefore) + ')', text } }];"
      },
      "id": "d4e8a2b6-1f9c-4a7d-9e3b-4c2f8a6d1e04",
      "name": "Compare weeks",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        0
      ]
    },
    {
      "parameters": {
        "fromEmail": "={{ $('Config').first().json.fromEmail }}",
        "toEmail": "={{ $('Config').first().json.toEmail }}",
        "subject": "={{ $json.subject }}",
        "emailFormat": "text",
        "text": "={{ $json.text }}",
        "options": {}
      },
      "id": "e5f1b3c7-2a0d-4b8e-8f4c-5d3a9b7e2f05",
      "name": "Send report",
      "type": "n8n-nodes-base.emailSend",
      "typeVersion": 2.1,
      "position": [
        1100,
        0
      ]
    },
    {
      "id": "f4d5e6f7-a8b9-4c03-9d24-3e5f6a7b8c10",
      "name": "Note: before you start",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -620,
        -300
      ],
      "parameters": {
        "content": "## Before you start\n\n1. **Access.** The Google account you connect below must see the property in Search Console. Any permission level works, restricted user included. No property in its list? A verified owner adds the account under Settings → Users and permissions.\n2. **Google Cloud.** Pick or create a project and enable the **Google Search Console API** (APIs & Services → Library).\n3. **OAuth consent screen.** Internal if you are on Google Workspace; otherwise External with your account added as a test user. An External app in Testing mode loses consent and tokens after 7 days: reconnect the credential or publish the app.\n4. **OAuth client.** Credentials → OAuth client ID → Web application. Paste the OAuth Redirect URL from the n8n credential form into Authorized redirect URIs, character for character.\n5. **n8n credential.** Google Search Console OAuth2 API with that client ID and secret, then Sign in with Google. Not in your credential list? Use Google OAuth2 API with the scope `https://www.googleapis.com/auth/webmasters.readonly` and pick it in both HTTP nodes instead.\n6. **SMTP credential** for the email node.\n\nThen work through the numbered notes from left to right.",
        "height": 640,
        "width": 540
      }
    },
    {
      "id": "a5e6f7a8-b9c0-4d14-8e35-4f6a7b8c9d11",
      "name": "Note: step 1 config",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        160,
        -300
      ],
      "parameters": {
        "content": "### 1 · Set your property and addresses\n\nOpen **Config** and change:\n- `property`: copy it from the Search Console property picker. Domain property: `sc-domain:example.com`. URL prefix: `https://www.example.com/`, with the trailing slash.\n- `fromEmail`, `toEmail`.",
        "height": 260,
        "width": 220
      }
    },
    {
      "id": "b6f7a8b9-c0d1-4e25-9f46-5a7b8c9d0e12",
      "name": "Note: step 2 credentials",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        400,
        -300
      ],
      "parameters": {
        "content": "### 2 · Pick the credential in BOTH nodes\n\nOpen **GSC last 7 days** and **GSC previous 7 days** one by one and select your Google Search Console OAuth2 API credential in each. A red warning on a node means it still has no credential.\n\nThe URL reads the property from Config; do not edit it here.",
        "height": 260,
        "width": 420
      }
    },
    {
      "id": "c7a8b9c0-d1e2-4f36-8a57-6b8c9d0e1f13",
      "name": "Note: step 3 smtp",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1040,
        -300
      ],
      "parameters": {
        "content": "### 3 · Pick your SMTP credential\n\nOpen **Send report** and select it. The addresses come from Config.",
        "height": 260,
        "width": 240
      }
    },
    {
      "id": "d8b9c0d1-e2f3-4a47-9b68-7c9d0e1f2a14",
      "name": "Note: step 4 test",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        820,
        160
      ],
      "parameters": {
        "content": "### 4 · Test, then switch it on\n\nClick **Execute workflow** and read the email. An empty last week stops the run at **Compare weeks** with an error, so nothing is sent. When the email looks right, activate the workflow: it runs every Monday at 07:00 in your instance's timezone.",
        "height": 180,
        "width": 460
      }
    }
  ],
  "connections": {
    "Monday 07:00": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config": {
      "main": [
        [
          {
            "node": "GSC last 7 days",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "GSC last 7 days": {
      "main": [
        [
          {
            "node": "GSC previous 7 days",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "GSC previous 7 days": {
      "main": [
        [
          {
            "node": "Compare weeks",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compare weeks": {
      "main": [
        [
          {
            "node": "Send report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "pinData": {},
  "settings": {
    "executionOrder": "v1"
  }
}

The Config node at the start holds the three values you change: the property (sc-domain:example.com in the JSON) and the sender and recipient addresses (reports@example.com and you@example.com). Both HTTP Request nodes build the API URL from the property, and the email node reads both addresses, so nothing else needs editing.

Step by step

  1. Import the JSON: copy it and paste it onto an empty n8n canvas.
  2. Open Config and set your property and the two addresses.
  3. Open both HTTP Request nodes, one by one. Authentication is already set to a predefined credential of type Google Search Console OAuth2 API; select your credential from the setup above in each. A red warning on a node means it still has none.
  4. Open the email node and select your SMTP credential.
  5. Run the workflow once by hand and read the email.
  6. Activate it. The schedule uses the timezone of your n8n instance.

The date range runs from 9 to 3 days before the run. Without a dataState parameter the API returns only finalized data, so the most recent days may be missing anyway; the workflow leaves them out on purpose.

The HTTP Request node builds the body of the first request exactly like this; the second one shifts both dates back by 7 days:

JSON.stringify({
  startDate: $today.minus({ days: 9 }).toFormat('yyyy-MM-dd'),
  endDate: $today.minus({ days: 3 }).toFormat('yyyy-MM-dd'),
  dimensions: ['query'],
  rowLimit: 1000,
})

What the email looks like

FIG. 02 · replayed log

The Compare weeks code on example data

Complete · replay anytime

Two example weeks

13:30:42 input: example data, 12 rows last 7 days, 10 rows previous 7 days13:30:42 running the Compare weeks node code unchanged13:30:42 subject: Search Console weekly: 210 clicks (+29)13:30:42 Clicks: 210 (+29 vs the previous 7 days)13:30:42 Impressions: 7282 (+763)13:30:42 Top 10 queries by clicks:13:30:42 1. example widget pricing: 48 clicks (+9)13:30:42 2. example widget: 41 clicks (-4)13:30:42 3. widget size chart: 27 clicks (+6)13:30:42 4. how to fix a widget: 22 clicks (0)13:30:42 5. widget vs gadget: 19 clicks (+7)13:30:42 6. widget warranty: 14 clicks (-1)13:30:42 7. cheap widgets: 11 clicks (-2)13:30:42 8. widget colors: 9 clicks (+3)13:30:42 9. widget return policy: 7 clicks (not in the previous rows)13:30:42 10. widget for beginners: 6 clicks (not in the previous rows)13:30:42 1 item out · 0.2 ms
weekrowsclicksimpressions
last 7 days122107,282
previous 7 days101816,519
example data · 1 email body built

Example data, not a real property. The Compare weeks node code ran unchanged on 2026-10-28 with Node v26.8.2; the lines are its output.

An empty week

13:30:42 input: example data, 0 rows last 7 days, 10 rows previous 7 days13:30:42 running the Compare weeks node code unchanged13:30:42 Error: Search Console returned no query rows for the last 7 days. Check the property in the Config node and that the Google account behind the credential has access to it in Search Console.13:30:42 execution failed · no email sent

0 rows

execution failed · no email

Example data with no rows for the last 7 days, run on 2026-10-28 with Node v26.8.2: the node throws, so n8n marks the execution failed and sends nothing.

The log above is not a run against a real property. It is the code from the Compare weeks node, copied from the JSON without changes and run on 2026-10-28 on two invented weeks of query rows. The first tab shows the subject and the body the email node sends. Two queries that only appear in the last 7 days are marked “not in the previous rows”. The second tab feeds it a week with no rows: the node throws, so nothing goes out.

Cost per run

Google limits the Search Console API with quotas per site, user and project rather than a price per call. One run makes 2 API calls and sends 1 email. What is left is your own n8n execution and your SMTP provider, which this page does not price.

How it fails loudly

If the last 7 days come back without any query rows, the Code node throws an error. The message names the likely causes: the wrong property in the URL, or a credential without access to it. n8n marks the execution as failed and no email goes out, so an empty week never looks like a quiet week. An expired or revoked credential fails earlier, because the HTTP Request node stops on the API's error response.

What the totals leave out

The totals in the email are sums over the query rows the workflow receives, and each request asks for at most 1,000 rows. That cap is a setting in the workflow, not an API limit: rowLimit accepts up to 25,000. On a site with more than 1,000 queries in a week, the totals miss the rest. The comparison between weeks also gets skewed when one week has more queries than the other.

Search Console also leaves out anonymized queries when you group by query. Both effects make the email's totals lower than the Performance report. If your site is past the cap, raise rowLimit in both requests. A third request without dimensions returns one aggregate row, which should come closer to the report's totals; I have not tested that variant.

FAQ

Can I get more than the top 10 queries?

Yes. Change .slice(0, 10) in the Code node. A query missing from the previous week's rows is marked “not in the previous rows”, not “new”, because the previous week is capped at the same 1,000 rows.

Why do the numbers differ from the Search Console report?

The workflow adds up the query rows it receives. Search Console does not return anonymized queries as rows, and the row cap can cut off the long tail; the report's totals include both.

Drafted with an agent from the repository and the owner's notes; the workflow JSON was written by the agent and checked against n8n 2.44.6 on 2026-10-23; I ran it end to end on my own n8n instance, and the setup steps come from that run; the example log was run by the agent on 2026-10-28.

Reviewed by David S on 2026-10-28.

Sources

Last updated 28 October 2026.