The Automation Wing
Meta Spend Anomaly to Slack: an n8n early-warning for the day the spend jumps
An importable n8n flow that reads Meta account insights daily, compares yesterday against the trailing week and posts to Slack when spend swings beyond your threshold.
A budget doubling overnight is a Tuesday-morning problem if you catch it Tuesday morning, and a five-figure invoice if you catch it in the monthly report. The account itself already knows: yesterday's spend against the trailing week is a plain statistical comparison, no dashboard required. This flow runs that comparison every day and posts to Slack only when the number is genuinely unusual.
It reads two calls against the Graph API's /act_<id>/insights edge, one for yesterday and one for the trailing seven days broken out by day, works out how far yesterday sits from the weekly average, and fires a Slack alert when the swing clears your threshold. It is read-only against the ad account: no budget, campaign or ad set is touched.
Before you start
- You need a Meta system-user access token scoped with
ads_read, and the numeric ad account ID (theact_prefix is added in the URL, keep it out of the config value). - Attach the token as an n8n Header Auth credential (
Authorization: Bearer {token}) on both HTTP Request nodes rather than pasting it into the URL. - Decide two thresholds up front:
SIGMA, a standard-deviation trigger (2 is a reasonable start on accounts with steady daily spend), andPCT, a simple percentage-change backstop for accounts too new to have a stable trailing average.
The flow
{
"name": "Meta Spend Anomaly to Slack",
"nodes": [
{
"parameters": {
"rule": { "interval": [ { "field": "days", "triggerAtHour": 8 } ] }
},
"id": "1",
"name": "Every Morning",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [220, 300]
},
{
"parameters": {
"values": {
"string": [
{ "name": "AD_ACCOUNT_ID", "value": "REPLACE_WITH_ACCOUNT_ID" },
{ "name": "SLACK_CHANNEL", "value": "#paid-social-alerts" }
],
"number": [
{ "name": "SIGMA", "value": 2 },
{ "name": "PCT", "value": 40 }
]
}
},
"id": "2",
"name": "Config",
"type": "n8n-nodes-base.set",
"typeVersion": 2,
"position": [440, 300]
},
{
"parameters": {
"url": "=https://graph.facebook.com/v21.0/act_{{$json.AD_ACCOUNT_ID}}/insights",
"sendQuery": true,
"queryParameters": {
"parameters": [
{ "name": "level", "value": "account" },
{ "name": "date_preset", "value": "yesterday" },
{ "name": "fields", "value": "spend,impressions,cpm,ctr" }
]
},
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"options": {}
},
"id": "3",
"name": "Get Yesterday",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [660, 220]
},
{
"parameters": {
"url": "=https://graph.facebook.com/v21.0/act_{{$json.AD_ACCOUNT_ID}}/insights",
"sendQuery": true,
"queryParameters": {
"parameters": [
{ "name": "level", "value": "account" },
{ "name": "date_preset", "value": "last_7d" },
{ "name": "time_increment", "value": "1" },
{ "name": "fields", "value": "spend,impressions,cpm,ctr" }
]
},
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"options": {}
},
"id": "4",
"name": "Get Trailing 7 Days",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [660, 380]
},
{
"parameters": {
"jsCode": "const cfg = $('Config').first().json;\nconst yesterday = parseFloat(($('Get Yesterday').first().json.data || [{}])[0].spend || 0);\nconst days = ($('Get Trailing 7 Days').first().json.data || []).map(d => parseFloat(d.spend || 0));\nconst mean = days.length ? days.reduce((a, b) => a + b, 0) / days.length : 0;\nconst variance = days.length ? days.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / days.length : 0;\nconst stddev = Math.sqrt(variance);\nconst z = stddev > 0 ? (yesterday - mean) / stddev : 0;\nconst pctChange = mean > 0 ? ((yesterday - mean) / mean) * 100 : 0;\nconst fire = Math.abs(z) > cfg.SIGMA || Math.abs(pctChange) > cfg.PCT;\nreturn [{ json: { yesterday, mean, stddev, z, pctChange, fire } }];"
},
"id": "5",
"name": "Compute Deviation",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [900, 300]
},
{
"parameters": {
"conditions": {
"options": { "caseSensitive": true, "typeValidation": "strict" },
"conditions": [
{
"leftValue": "={{$json.fire}}",
"rightValue": true,
"operator": { "type": "boolean", "operation": "true", "singleValue": true }
}
],
"combinator": "and"
}
},
"id": "6",
"name": "Beyond Threshold?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [1120, 300]
},
{
"parameters": {
"channel": "={{$('Config').first().json.SLACK_CHANNEL}}",
"text": "=Spend anomaly on the account: yesterday {{$json.yesterday.toFixed(2)}} vs 7-day average {{$json.mean.toFixed(2)}} (z {{$json.z.toFixed(2)}}, {{$json.pctChange.toFixed(1)}}% change)",
"otherOptions": {}
},
"id": "7",
"name": "Post to Slack",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.2,
"position": [1340, 220],
"executeOnce": true
}
],
"connections": {
"Every Morning": { "main": [[{ "node": "Config", "type": "main", "index": 0 }]] },
"Config": { "main": [[{ "node": "Get Yesterday", "type": "main", "index": 0 }, { "node": "Get Trailing 7 Days", "type": "main", "index": 0 }]] },
"Get Yesterday": { "main": [[{ "node": "Compute Deviation", "type": "main", "index": 0 }]] },
"Get Trailing 7 Days": { "main": [[{ "node": "Compute Deviation", "type": "main", "index": 0 }]] },
"Compute Deviation": { "main": [[{ "node": "Beyond Threshold?", "type": "main", "index": 0 }]] },
"Beyond Threshold?": { "main": [[{ "node": "Post to Slack", "type": "main", "index": 0 }], []] }
},
"active": false,
"settings": { "executionOrder": "v1" }
}
Set it up
- Import the JSON, then attach a Header Auth credential carrying your Meta system-user token to both "Get Yesterday" and "Get Trailing 7 Days".
- Open the Config node and set
AD_ACCOUNT_IDandSLACK_CHANNEL, and adjustSIGMAandPCTif the defaults do not suit the account's normal spend variance. - Attach a Slack credential to "Post to Slack" and confirm the bot is invited to the target channel.
- Run once by hand to confirm both Graph API calls return data, then activate the daily schedule.
How to read the alert
The z-score answers "how unusual is this compared with the account's own recent behaviour", while the percentage change is a plain-English backstop for a newer account with too few days to have a meaningful standard deviation. Either one clearing its threshold fires the alert, so an account that is normally flat will trigger on a smaller absolute jump than a volatile one, which is the point: the comparison is relative to the account, not a fixed pound figure. "Post to Slack" runs once per execution regardless of how the underlying data is shaped, so a single anomaly produces a single message, never a flood.
One honest limitation: date_preset=yesterday and last_7d both use the account's reporting time zone and Meta's own attribution windows, so a very late-attributing conversion event can nudge the figures for a day or two after the fact. That is fine for a spend anomaly check, since spend itself is not subject to attribution lag, but do not repurpose this same flow for a ROAS or conversion anomaly without accounting for it.