{
  "name": "Daily SEO digest + silent-failure alert (Google Search Console)",
  "nodes": [
    {
      "id": "ac68ca1c-101a-46fb-969c-46fbdbeccea2",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -352,
        208
      ],
      "parameters": {
        "width": 480,
        "height": 864,
        "content": "## Daily SEO digest + silent-failure alert (Google Search Console)\n\n### How it works\n\nThis workflow runs every morning to pull Google Search Console performance data, aggregate it, and append the daily metrics to a Google Sheet. It then reads recent sheet rows, compares the latest metrics with yesterday\u2019s values, and posts a digest to Discord. After a successful digest it sends a heartbeat ping, while a separate error path sends a failure ping if the workflow breaks silently.\n\n### Setup steps\n\n- Configure the daily schedule trigger for the desired local timezone and run time.\n- Set up Google Search Console API access in the HTTP Request node, including OAuth credentials and the correct verified site/property URL.\n- Configure the Google Sheets nodes with the target spreadsheet, sheet/tab, and matching columns for the aggregated SEO metrics.\n- Add the Discord credential or webhook/channel settings for the digest message.\n- Replace the Checkilo heartbeat and failure ping URLs with the correct monitor endpoints.\n\n### Customization\n\nAdjust the aggregation code, comparison thresholds, Discord message format, and the date range used for Google Search Console data to match the SEO metrics you want to track."
      },
      "typeVersion": 1
    },
    {
      "id": "94ebf0a7-24af-4341-86c8-0cef024937a7",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        208,
        208
      ],
      "parameters": {
        "color": 7,
        "width": 400,
        "height": 320,
        "content": "## Schedule and fetch data\n\nStarts the workflow each morning and requests the latest Google Search Console data from the API."
      },
      "typeVersion": 1
    },
    {
      "id": "58ba758d-85f3-40d3-acba-f9eea2c5faf5",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        640,
        208
      ],
      "parameters": {
        "color": 7,
        "width": 400,
        "height": 320,
        "content": "## Aggregate and store metrics\n\nTransforms the raw Search Console response into daily summary metrics and appends them to the tracking Google Sheet."
      },
      "typeVersion": 1
    },
    {
      "id": "ce371f83-6bdf-4d28-b435-f04b30fe71c9",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1072,
        208
      ],
      "parameters": {
        "color": 7,
        "width": 400,
        "height": 320,
        "content": "## Read and compare history\n\nReads stored sheet rows and calculates the change between the latest daily metrics and yesterday\u2019s values."
      },
      "typeVersion": 1
    },
    {
      "id": "6435f94b-f390-48d3-b833-460924c2d3fe",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1504,
        208
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 320,
        "content": "## Send digest heartbeat\n\nPosts the SEO digest to Discord, then sends a success heartbeat ping to confirm the daily workflow completed."
      },
      "typeVersion": 1
    },
    {
      "id": "ace364a8-08ef-42bb-99c7-f193406a6188",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        608,
        560
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 320,
        "content": "## Error failure alert\n\nSeparate lower-canvas error branch that triggers on workflow failures and sends a failure ping to the monitoring endpoint."
      },
      "typeVersion": 1
    },
    {
      "id": "node-schedule",
      "name": "When Every Day at 08:00",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        260,
        360
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "days",
              "triggerAtHour": 8
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "node-gsc",
      "name": "Post to GSC API",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        460,
        360
      ],
      "parameters": {
        "url": "https://www.googleapis.com/webmasters/v3/sites/REPLACE_WITH_URL_ENCODED_SITE/searchAnalytics/query",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"startDate\": \"{{ $today.minus({ days: 2 }).toFormat('yyyy-MM-dd') }}\",\n  \"endDate\": \"{{ $today.minus({ days: 2 }).toFormat('yyyy-MM-dd') }}\",\n  \"dimensions\": [\"query\"],\n  \"rowLimit\": 100\n}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googleOAuth2Api"
      },
      "typeVersion": 4.2
    },
    {
      "id": "node-aggregate",
      "name": "Aggregate GSC Data",
      "type": "n8n-nodes-base.code",
      "position": [
        688,
        368
      ],
      "parameters": {
        "jsCode": "const rows = ($input.first().json.rows) || [];\nlet clicks = 0, impressions = 0, posWeighted = 0;\nfor (const r of rows) {\n  clicks += r.clicks;\n  impressions += r.impressions;\n  posWeighted += r.position * r.impressions;\n}\nconst avgPosition = impressions ? (posWeighted / impressions) : 0;\nconst topQueries = rows\n  .slice()\n  .sort((a, b) => (b.clicks - a.clicks) || (b.impressions - a.impressions))\n  .slice(0, 5)\n  .map(r => r.keys[0]);\nconst date = $today.minus({ days: 2 }).toFormat('yyyy-MM-dd');\nreturn [{ json: {\n  date,\n  clicks,\n  impressions,\n  avgPosition: Math.round(avgPosition * 10) / 10,\n  topQueries: topQueries.join(', ')\n} }];"
      },
      "typeVersion": 2
    },
    {
      "id": "node-sheet",
      "name": "Append to Google Sheets",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        896,
        368
      ],
      "parameters": {
        "columns": {
          "value": {},
          "schema": [],
          "mappingMode": "autoMapInputData",
          "matchingColumns": []
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultName": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "REPLACE_WITH_SHEET_ID"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "node-getrows",
      "name": "Read Google Sheets Data",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        1120,
        368
      ],
      "parameters": {
        "options": {},
        "operation": "read",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultName": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "REPLACE_WITH_SHEET_ID"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "node-delta",
      "name": "Compare With Previous Day",
      "type": "n8n-nodes-base.code",
      "position": [
        1328,
        368
      ],
      "parameters": {
        "jsCode": "const today = $('Aggregate GSC Data').first().json;\nconst rows = $input.all().map(i => i.json);\n// rows are oldest-first; the row we just appended is last, yesterday is second-to-last\nconst prev = rows.length >= 2 ? rows[rows.length - 2] : null;\nconst pct = (curr, oldVal) => {\n  const o = Number(oldVal);\n  if (prev === null || !o) return null;\n  return Math.round(((curr - o) / o) * 1000) / 10;\n};\nreturn [{ json: {\n  ...today,\n  clicksChange: pct(today.clicks, prev && prev.clicks),\n  imprChange: pct(today.impressions, prev && prev.impressions)\n} }];"
      },
      "typeVersion": 2
    },
    {
      "id": "node-discord",
      "name": "Send Digest to Discord",
      "type": "n8n-nodes-base.discord",
      "position": [
        1552,
        368
      ],
      "parameters": {
        "content": "=\ud83d\udcca SEO \u2014 {{ $json.date }}\nClicks: {{ $json.clicks }}{{ $json.clicksChange === null ? '' : ' (' + ($json.clicksChange >= 0 ? '+' : '') + $json.clicksChange + '% vs yesterday)' }}\nImpressions: {{ $json.impressions }}{{ $json.imprChange === null ? '' : ' (' + ($json.imprChange >= 0 ? '+' : '') + $json.imprChange + '%)' }}\nAvg position: {{ $json.avgPosition }}\nTop queries: {{ $json.topQueries }}{{ ($json.clicksChange !== null && $json.clicksChange <= -20) ? '\\n\\n\u26a0\ufe0f Clicks dropped ' + Math.abs($json.clicksChange) + '% vs yesterday \u2014 worth a look' : '' }}",
        "options": {},
        "authentication": "webhook"
      },
      "typeVersion": 2
    },
    {
      "id": "node-heartbeat",
      "name": "Send Heartbeat Ping",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1760,
        368
      ],
      "parameters": {
        "url": "https://ping.checkilo.app/REPLACE_WITH_YOUR_PING_SLUG",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"total_clicks\": {{ $('Aggregate GSC Data').first().json.clicks }},\n  \"total_impressions\": {{ $('Aggregate GSC Data').first().json.impressions }},\n  \"avg_position\": {{ $('Aggregate GSC Data').first().json.avgPosition }},\n  \"top_query\": {{ JSON.stringify(($('Aggregate GSC Data').first().json.topQueries || '').split(',')[0].trim()) }}\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "node-error-trigger",
      "name": "When Error Occurs",
      "type": "n8n-nodes-base.errorTrigger",
      "position": [
        660,
        720
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "node-fail-ping",
      "name": "Post Error Ping Fail",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        880,
        720
      ],
      "parameters": {
        "url": "https://ping.checkilo.app/REPLACE_WITH_YOUR_PING_SLUG/fail",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"error\": {{ JSON.stringify($json.execution?.error?.message || 'unknown') }},\n  \"failed_node\": {{ JSON.stringify($json.execution?.lastNodeExecuted || 'unknown') }}\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    }
  ],
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "connections": {
    "Post to GSC API": {
      "main": [
        [
          {
            "node": "Aggregate GSC Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When Error Occurs": {
      "main": [
        [
          {
            "node": "Post Error Ping Fail",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Aggregate GSC Data": {
      "main": [
        [
          {
            "node": "Append to Google Sheets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Digest to Discord": {
      "main": [
        [
          {
            "node": "Send Heartbeat Ping",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Append to Google Sheets": {
      "main": [
        [
          {
            "node": "Read Google Sheets Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Google Sheets Data": {
      "main": [
        [
          {
            "node": "Compare With Previous Day",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When Every Day at 08:00": {
      "main": [
        [
          {
            "node": "Post to GSC API",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compare With Previous Day": {
      "main": [
        [
          {
            "node": "Send Digest to Discord",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}