{
  "name": "Lumeor | AI ad performance report (Google Ads + Meta + BigQuery)",
  "nodes": [
    {
      "parameters": {
        "content": "## AI ad performance reporting\nEvery morning this workflow pulls yesterday's campaign metrics from Google Ads and Meta Ads, normalizes them to one schema and upserts them into BigQuery. On Mondays it compares the last 7 days with the 7 before, flags anomalies with simple rules in code (spend jumps, CPA jumps, spend with zero conversions), has Claude write a short commentary from those figures only, checks every number, and posts to Slack.\n\n**Setup**\n1. Create the BigQuery table with the SQL in the yellow note.\n2. Credentials: Google Ads OAuth2 (needs an approved developer token), Facebook Graph API access token with ads_read, Google BigQuery OAuth2, Anthropic, Slack.\n3. Replace REPLACE_WITH_GOOGLE_ADS_CUSTOMER_ID, REPLACE_WITH_GOOGLE_ADS_MANAGER_ID (use the same ID if you have no manager account), REPLACE_WITH_META_AD_ACCOUNT_ID (digits only), REPLACE_WITH_GCP_PROJECT, REPLACE_WITH_DATASET, REPLACE_WITH_REPORT_CHANNEL and REPLACE_WITH_ALERTS_CHANNEL.\n4. In \"Normalize rows\", set META_CONVERSION_ACTION to the action that counts as a result (purchase, lead, and so on).\n\n**Known limits of this starter:** n8n's Google Ads node returns campaign totals without conversion value, so Google ROAS is not computed, and clicks use the interactions metric. For trailing 30-day restatement, conversion value and ad-level data, replace that node with an HTTP Request running your own GAQL query. Dates use the n8n instance time zone for the Monday check and UTC in BigQuery.\n\nModel: claude-opus-5-5. For simple, high-volume classification steps, claude-haiku-5-5 is a cheaper option.\n\nTemplate by Lumeor Studio, https://www.lumeor.studio/guides/ai-ad-performance-reporting/",
        "height": 920,
        "width": 600,
        "color": 7
      },
      "id": "3e774dfa-a50a-4b25-bf6a-958add155d9b",
      "name": "About this template",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -80,
        -480
      ]
    },
    {
      "parameters": {
        "content": "### BigQuery table (run once)\n```sql\nCREATE TABLE `REPLACE_WITH_GCP_PROJECT.REPLACE_WITH_DATASET.ad_daily` (\n  report_date DATE NOT NULL,\n  platform STRING NOT NULL,\n  account_id STRING,\n  campaign_id STRING NOT NULL,\n  campaign_name STRING,\n  spend NUMERIC,\n  impressions INT64,\n  clicks INT64,\n  conversions NUMERIC,\n  conversion_value NUMERIC,\n  loaded_at TIMESTAMP\n)\nPARTITION BY report_date;\n```",
        "height": 520,
        "width": 480,
        "color": 6
      },
      "id": "7d5b1a40-aa53-4df6-8970-5fb6d345a574",
      "name": "BigQuery setup",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        560,
        -480
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 6 * * *"
            }
          ]
        }
      },
      "id": "003ded60-6270-4f29-9e07-0847bd8e6f42",
      "name": "Daily at 6am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        300
      ]
    },
    {
      "parameters": {
        "resource": "campaign",
        "operation": "getAll",
        "managerCustomerId": "REPLACE_WITH_GOOGLE_ADS_MANAGER_ID",
        "clientCustomerId": "REPLACE_WITH_GOOGLE_ADS_CUSTOMER_ID",
        "additionalOptions": {
          "dateRange": "YESTERDAY",
          "campaignStatus": "all"
        }
      },
      "id": "0d024828-1e23-4321-ac02-d9e7e90c4e2e",
      "name": "Get Google Ads campaigns (yesterday)",
      "type": "n8n-nodes-base.googleAds",
      "typeVersion": 1,
      "position": [
        220,
        300
      ],
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "method": "GET",
        "url": "https://graph.facebook.com/v24.0/act_REPLACE_WITH_META_AD_ACCOUNT_ID/insights",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "facebookGraphApi",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "level",
              "value": "campaign"
            },
            {
              "name": "date_preset",
              "value": "yesterday"
            },
            {
              "name": "fields",
              "value": "campaign_id,campaign_name,spend,impressions,clicks,actions,action_values,date_start,account_id"
            },
            {
              "name": "limit",
              "value": "500"
            }
          ]
        },
        "options": {}
      },
      "id": "c15cbf33-02ce-419d-b750-ae6dc2bd0bf9",
      "name": "Get Meta campaign insights (yesterday)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        440,
        300
      ],
      "executeOnce": true,
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Maps both platforms to one schema: report_date, platform, account_id, campaign_id, campaign_name,\n// spend, impressions, clicks, conversions, conversion_value.\nconst META_CONVERSION_ACTION = 'purchase';\nconst yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().slice(0, 10);\nconst num = (v) => (v === undefined || v === null || v === '' ? 0 : Number(v));\nconst rows = [];\nfor (const { json: c } of $('Get Google Ads campaigns (yesterday)').all()) {\n  if (!c.id) continue; // empty item when the account had no campaigns\n  rows.push({\n    report_date: yesterday,\n    platform: 'google_ads',\n    account_id: 'REPLACE_WITH_GOOGLE_ADS_CUSTOMER_ID',\n    campaign_id: String(c.id),\n    campaign_name: c.name || '',\n    spend: (num(c.costMicros) / 1e6).toFixed(2),\n    impressions: String(Math.round(num(c.impressions))),\n    clicks: String(Math.round(num(c.interactions))),\n    conversions: String(num(c.conversions)),\n    conversion_value: null,\n  });\n}\nfor (const r of $input.first().json.data || []) {\n  const pick = (list) => (list || []).filter((a) => a.action_type === META_CONVERSION_ACTION).reduce((s, a) => s + num(a.value), 0);\n  rows.push({\n    report_date: r.date_start || yesterday,\n    platform: 'meta_ads',\n    account_id: String(r.account_id || ''),\n    campaign_id: String(r.campaign_id),\n    campaign_name: r.campaign_name || '',\n    spend: num(r.spend).toFixed(2),\n    impressions: String(Math.round(num(r.impressions))),\n    clicks: String(Math.round(num(r.clicks))),\n    conversions: String(pick(r.actions)),\n    conversion_value: pick(r.action_values).toFixed(2),\n  });\n}\nreturn [{ json: { report_date: yesterday, row_count: rows.length, rows } }];"
      },
      "id": "77bd26e3-9395-450c-b546-292463780a90",
      "name": "Normalize rows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        660,
        300
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "projectId": {
          "__rl": true,
          "value": "REPLACE_WITH_GCP_PROJECT",
          "mode": "id"
        },
        "sqlQuery": "MERGE `REPLACE_WITH_GCP_PROJECT.REPLACE_WITH_DATASET.ad_daily` T\nUSING (\n  SELECT\n    DATE(JSON_VALUE(r, '$.report_date')) AS report_date,\n    JSON_VALUE(r, '$.platform') AS platform,\n    JSON_VALUE(r, '$.account_id') AS account_id,\n    JSON_VALUE(r, '$.campaign_id') AS campaign_id,\n    JSON_VALUE(r, '$.campaign_name') AS campaign_name,\n    SAFE_CAST(JSON_VALUE(r, '$.spend') AS NUMERIC) AS spend,\n    SAFE_CAST(JSON_VALUE(r, '$.impressions') AS INT64) AS impressions,\n    SAFE_CAST(JSON_VALUE(r, '$.clicks') AS INT64) AS clicks,\n    SAFE_CAST(JSON_VALUE(r, '$.conversions') AS NUMERIC) AS conversions,\n    SAFE_CAST(JSON_VALUE(r, '$.conversion_value') AS NUMERIC) AS conversion_value\n  FROM UNNEST(JSON_QUERY_ARRAY(@rows_json)) AS r\n) S\nON T.report_date = S.report_date AND T.platform = S.platform AND T.campaign_id = S.campaign_id\nWHEN MATCHED THEN UPDATE SET\n  campaign_name = S.campaign_name, account_id = S.account_id, spend = S.spend, impressions = S.impressions,\n  clicks = S.clicks, conversions = S.conversions, conversion_value = S.conversion_value, loaded_at = CURRENT_TIMESTAMP()\nWHEN NOT MATCHED THEN INSERT\n  (report_date, platform, account_id, campaign_id, campaign_name, spend, impressions, clicks, conversions, conversion_value, loaded_at)\n  VALUES (S.report_date, S.platform, S.account_id, S.campaign_id, S.campaign_name, S.spend, S.impressions, S.clicks, S.conversions, S.conversion_value, CURRENT_TIMESTAMP())",
        "options": {
          "queryParameters": {
            "namedParameters": [
              {
                "name": "rows_json",
                "value": "={{ JSON.stringify($json.rows) }}"
              }
            ]
          }
        }
      },
      "id": "42ee19f7-b767-4699-9183-82ac755e7869",
      "name": "Upsert daily rows in BigQuery",
      "type": "n8n-nodes-base.googleBigQuery",
      "typeVersion": 2.1,
      "position": [
        880,
        300
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "ee5cb151-ce22-43ff-a6e7-88c93e7f7844",
              "leftValue": "={{ $now.weekday === 1 }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "77b5524b-6158-4837-a7dc-f3f71f532a52",
      "name": "Is it Monday?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1100,
        300
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "projectId": {
          "__rl": true,
          "value": "REPLACE_WITH_GCP_PROJECT",
          "mode": "id"
        },
        "sqlQuery": "SELECT\n  platform,\n  campaign_id,\n  ANY_VALUE(campaign_name) AS campaign_name,\n  SUM(IF(report_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY), spend, 0)) AS spend_this,\n  SUM(IF(report_date < DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY), spend, 0)) AS spend_prev,\n  SUM(IF(report_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY), conversions, 0)) AS conv_this,\n  SUM(IF(report_date < DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY), conversions, 0)) AS conv_prev,\n  SUM(IF(report_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY), conversion_value, 0)) AS value_this,\n  SUM(IF(report_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY), clicks, 0)) AS clicks_this,\n  SUM(IF(report_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY), impressions, 0)) AS impressions_this,\n  COUNT(DISTINCT IF(report_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY), report_date, NULL)) AS days_this\nFROM `REPLACE_WITH_GCP_PROJECT.REPLACE_WITH_DATASET.ad_daily`\nWHERE report_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 14 DAY) AND report_date < CURRENT_DATE()\nGROUP BY platform, campaign_id",
        "options": {}
      },
      "id": "ff7afb74-64a9-4917-ab62-4d4fa154ddf5",
      "name": "Get last 14 days by campaign",
      "type": "n8n-nodes-base.googleBigQuery",
      "typeVersion": 2.1,
      "position": [
        1320,
        280
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Week-over-week totals and three anomaly rules. Every figure is pre-formatted for the model.\nconst MIN_SPEND = 100; // ignore tiny campaigns\nconst SPEND_JUMP = 0.5; // +50% week over week\nconst CPA_JUMP = 0.4; // +40% week over week\nconst rows = $input.all().map((i) => i.json).filter((r) => r.campaign_id);\nconst n = (v) => Number(v || 0);\nconst usd = (v) => '$' + n(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });\nconst pct = (a, b) => (b > 0 ? Math.round(((a - b) / b) * 100) + '%' : 'n/a');\nconst totals = {};\nfor (const r of rows) {\n  const t = (totals[r.platform] = totals[r.platform] || { spend_this: 0, spend_prev: 0, conv_this: 0, conv_prev: 0, value_this: 0 });\n  for (const k of Object.keys(t)) t[k] += n(r[k]);\n}\nconst platforms = Object.entries(totals).map(([platform, t]) => ({\n  platform,\n  spend_last_7_days: usd(t.spend_this),\n  spend_change: pct(t.spend_this, t.spend_prev),\n  conversions_last_7_days: String(Math.round(t.conv_this)),\n  cpa_last_7_days: t.conv_this ? usd(t.spend_this / t.conv_this) : 'n/a',\n  roas_last_7_days: platform === 'meta_ads' && t.spend_this ? (t.value_this / t.spend_this).toFixed(2) + 'x' : 'n/a',\n}));\nconst flagged = [];\nfor (const r of rows) {\n  const st = n(r.spend_this), sp = n(r.spend_prev), ct = n(r.conv_this), cp = n(r.conv_prev);\n  const reasons = [];\n  if (st >= MIN_SPEND && sp > 0 && (st - sp) / sp > SPEND_JUMP) reasons.push(`spend up ${pct(st, sp)} (${usd(sp)} to ${usd(st)})`);\n  if (ct >= 3 && cp >= 3 && st / ct > (sp / cp) * (1 + CPA_JUMP)) reasons.push(`CPA up ${pct(st / ct, sp / cp)} (${usd(sp / cp)} to ${usd(st / ct)})`);\n  if (st >= MIN_SPEND && ct === 0) reasons.push(`spent ${usd(st)} with zero conversions`);\n  if (reasons.length) flagged.push({ platform: r.platform, campaign: r.campaign_name, reasons: reasons.join('; '), spend_last_7_days: usd(st) });\n}\nflagged.sort((a, b) => n(b.spend_last_7_days.replace(/[$,]/g, '')) - n(a.spend_last_7_days.replace(/[$,]/g, '')));\nconst missingDays = rows.filter((r) => n(r.days_this) < 7).length;\nconst payload = { platforms, flagged: flagged.slice(0, 15), campaigns_with_missing_days: String(missingDays) };\nconst table = [\n  '*Paid media, last 7 days vs prior 7*',\n  ...platforms.map((p) => `- ${p.platform}: spend ${p.spend_last_7_days} (${p.spend_change}), conversions ${p.conversions_last_7_days}, CPA ${p.cpa_last_7_days}, ROAS ${p.roas_last_7_days}`),\n  '',\n  flagged.length ? '*Flagged campaigns*' : '_No campaigns flagged._',\n  ...flagged.slice(0, 15).map((f) => `- ${f.platform} / ${f.campaign}: ${f.reasons}`),\n  missingDays ? `\\n_Data warning: ${missingDays} campaigns are missing days this week._` : '',\n].join('\\n');\nreturn [{ json: { payload, table_text: table } }];"
      },
      "id": "f9757966-c0a5-4483-a216-488e61453c55",
      "name": "Compute changes and anomalies",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1540,
        280
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "anthropicApi",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ model: 'claude-opus-5-5', max_tokens: 2000, system: \"You write the weekly paid media note for a marketing lead.\\n- 4 to 6 plain sentences, then exactly 3 suggested checks.\\n- Use only figures that appear in the JSON payload, copied exactly as written. Do not calculate new numbers.\\n- Focus on the flagged campaigns and the biggest platform-level changes.\\n- Do not claim causes. Say what changed and where, and suggest what to check (for example tracking, budgets, audiences, creative fatigue).\", output_config: { \"format\": { \"type\": \"json_schema\", \"schema\": { \"type\": \"object\", \"properties\": { \"summary\": { \"type\": \"string\" }, \"checks\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } } }, \"required\": [ \"summary\", \"checks\" ], \"additionalProperties\": false } } }, messages: [ { role: 'user', content: 'Weekly paid media payload (JSON):\\n' + JSON.stringify($json.payload) } ] }) }}",
        "options": {
          "timeout": 120000
        }
      },
      "id": "6401fce8-7768-43b9-8383-9de0e56f7610",
      "name": "Write weekly commentary",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1760,
        280
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Commentary may only quote numbers that exist in the payload. Otherwise post the table alone and alert.\nconst { payload, table_text: table } = $('Compute changes and anomalies').first().json;\nconst res = $input.first().json;\nconst norm = (t) => t.replace(/,/g, '').replace(/\\.0+(%|x)?$/, '$1');\nconst tokens = (s) => (String(s).match(/\\$?\\d[\\d,]*(?:\\.\\d+)?[%x]?/g) || []).map(norm);\nconst allowed = new Set(tokens(JSON.stringify(payload)));\nlet text = null;\nlet problem = null;\nif (res.error) problem = `Claude API call failed: ${typeof res.error === 'string' ? res.error : res.error.message}`;\nelse if (res.stop_reason === 'refusal' || res.stop_reason === 'max_tokens') problem = `Claude stopped with stop_reason=${res.stop_reason}`;\nelse {\n  // Opus 5.5 may return a thinking block first, so find the text block explicitly.\n  const block = (res.content || []).find((b) => b.type === 'text');\n  if (!block) problem = 'Claude response had no text block';\n  else {\n    try {\n      const d = JSON.parse(block.text);\n      const all = `${d.summary} ${(d.checks || []).join(' ')}`;\n      const unknown = [...new Set(tokens(all))].filter((t) => !allowed.has(t));\n      if (unknown.length) problem = `Commentary used numbers not in the data: ${unknown.join(', ')}`;\n      else text = `${d.summary}\\n*Suggested checks*\\n${(d.checks || []).map((c, i) => `${i + 1}. ${c}`).join('\\n')}`;\n    } catch (e) {\n      problem = 'Claude returned invalid JSON';\n    }\n  }\n}\nconst out = [];\nif (problem) out.push({ json: { error: problem, details: { flagged: payload.flagged.length } } });\nout.push({ json: { message: text ? `${table}\\n\\n${text}` : `${table}\\n\\n_Commentary withheld: it did not pass the number check._` } });\nreturn out;"
      },
      "id": "cab9a44e-394c-4cb4-b2ca-f19064330a9a",
      "name": "Check numbers",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1980,
        280
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_REPORT_CHANNEL",
          "mode": "name"
        },
        "text": "={{ $json.message }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "6674ffb4-e80b-425a-b237-d1f5bb438426",
      "name": "Post weekly summary",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        2200,
        260
      ]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_ALERTS_CHANNEL",
          "mode": "name"
        },
        "text": "=:warning: *Ad performance report needs attention*\n*Reason:* {{ typeof $json.error === 'string' ? $json.error : (($json.error && $json.error.message) || 'Unknown error') }}\n*Details:* {{ JSON.stringify($json.details || $json.error || {}).slice(0, 600) }}\n*Execution:* {{ $execution.id }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "85b156c5-a62d-4ca8-b87c-cd67180d01cb",
      "name": "Alert: ad report needs attention",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        2200,
        480
      ]
    }
  ],
  "connections": {
    "Daily at 6am": {
      "main": [
        [
          {
            "node": "Get Google Ads campaigns (yesterday)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Google Ads campaigns (yesterday)": {
      "main": [
        [
          {
            "node": "Get Meta campaign insights (yesterday)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Meta campaign insights (yesterday)": {
      "main": [
        [
          {
            "node": "Normalize rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize rows": {
      "main": [
        [
          {
            "node": "Upsert daily rows in BigQuery",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upsert daily rows in BigQuery": {
      "main": [
        [
          {
            "node": "Is it Monday?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is it Monday?": {
      "main": [
        [
          {
            "node": "Get last 14 days by campaign",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get last 14 days by campaign": {
      "main": [
        [
          {
            "node": "Compute changes and anomalies",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compute changes and anomalies": {
      "main": [
        [
          {
            "node": "Write weekly commentary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write weekly commentary": {
      "main": [
        [
          {
            "node": "Check numbers",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Check numbers",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check numbers": {
      "main": [
        [
          {
            "node": "Post weekly summary",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Alert: ad report needs attention",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
