n8n for AdTech SaaS: 5 Automations That Scale Campaign Ops and Keep Ad Data Compliant
Running a DSP, SSP, DMP, or attribution platform means managing enormous data volumes, complex bidding pipelines, and tight regulatory requirements — all at the same time.
Most AdTech teams default to Zapier or Make for operations workflows. That works until you hit GDPR consent chain requirements, CCPA audience data restrictions, or Apple ATT postback latency — at which point routing ad data through a third-party cloud becomes a compliance and competitive liability.
n8n runs on your infrastructure. Every impression event, consent string, audience segment, and postback fires inside your VPC — never touched by Zapier's servers.
Here are 5 workflows your AdTech platform needs, with full import-ready JSON.
Workflow 1: Campaign Budget Pacing Alert
The problem: Campaigns underspend or overspend without warning. By the time your ops team catches it, the flight window has half-burned.
What this workflow does: Checks budget pacing every 15 minutes against expected spend curve. Alerts #campaign-ops when a campaign is >15% behind or >10% ahead of expected pacing.
{
"name": "Campaign Budget Pacing Alert",
"nodes": [
{
"name": "Every 15 Minutes",
"type": "n8n-nodes-base.scheduleTrigger",
"parameters": { "rule": { "interval": [{ "field": "minutes", "minutesInterval": 15 }] } }
},
{
"name": "Query Active Campaigns",
"type": "n8n-nodes-base.postgres",
"parameters": {
"operation": "executeQuery",
"query": "SELECT campaign_id, name, total_budget, spent_amount, flight_start, flight_end, EXTRACT(EPOCH FROM (NOW()-flight_start))/EXTRACT(EPOCH FROM (flight_end-flight_start)) AS pct_elapsed FROM campaigns WHERE status='ACTIVE' AND NOW() BETWEEN flight_start AND flight_end"
}
},
{
"name": "Calculate Pacing",
"type": "n8n-nodes-base.code",
"parameters": {
"jsCode": "const items = $input.all(); const results = []; for (const item of items) { const d = item.json; const expectedSpend = d.total_budget * d.pct_elapsed; const actualSpend = parseFloat(d.spent_amount); const pacingPct = expectedSpend > 0 ? (actualSpend / expectedSpend) * 100 : 100; let status = 'OK'; if (pacingPct < 85) status = 'UNDERPACING'; else if (pacingPct > 110) status = 'OVERPACING'; if (status !== 'OK') results.push({ json: { ...d, expected_spend: expectedSpend.toFixed(2), actual_spend: actualSpend.toFixed(2), pacing_pct: pacingPct.toFixed(1), status } }); } return results;"
}
},
{
"name": "Dedup (15-min window)",
"type": "n8n-nodes-base.code",
"parameters": {
"jsCode": "const seen = $getWorkflowStaticData('global'); const now = Date.now(); const results = []; for (const item of $input.all()) { const key = item.json.campaign_id; const last = seen[key] || 0; if (now - last > 15 * 60 * 1000) { seen[key] = now; results.push(item); } } return results;"
}
},
{
"name": "Slack #campaign-ops",
"type": "n8n-nodes-base.slack",
"parameters": {
"channel": "#campaign-ops",
"text": "{{ $json.status }} — Campaign *{{ $json.name }}* ({{ $json.campaign_id }}): pacing at {{ $json.pacing_pct }}% | Spent ${{ $json.actual_spend }} vs expected ${{ $json.expected_spend }}"
}
}
]
}
Workflow 2: Ad Creative Rejection Triage
The problem: Ad exchange rejection webhooks arrive with raw error codes. Your ops team has to manually classify each rejection and route it to the right team.
What this workflow does: Receives rejection webhooks, classifies by rejection reason (brand safety, content policy, technical error, billing), and routes to the right Slack channel + logs to Postgres for trend analysis.
{
"name": "Ad Creative Rejection Triage",
"nodes": [
{
"name": "Rejection Webhook",
"type": "n8n-nodes-base.webhook",
"parameters": { "path": "ad-rejection", "responseMode": "onReceived", "responseData": "firstEntryJson" }
},
{
"name": "Classify Rejection",
"type": "n8n-nodes-base.code",
"parameters": {
"jsCode": "const d = $input.first().json; const code = (d.rejection_code || '').toUpperCase(); let category = 'OTHER'; let team = '#ad-ops'; if (['BRAND_SAFETY','SENSITIVE_CONTENT','ADULT','HATE_SPEECH'].some(c => code.includes(c))) { category = 'BRAND_SAFETY'; team = '#brand-safety'; } else if (['POLICY_VIOLATION','PROHIBITED_CONTENT','TRADEMARK'].some(c => code.includes(c))) { category = 'POLICY'; team = '#legal-ops'; } else if (['INVALID_CREATIVE','RENDER_ERROR','TIMEOUT','FORMAT_ERROR'].some(c => code.includes(c))) { category = 'TECHNICAL'; team = '#ad-tech'; } else if (['BILLING','BUDGET_EXCEEDED','NO_FUNDS'].some(c => code.includes(c))) { category = 'BILLING'; team = '#campaign-ops'; } return [{ json: { ...d, category, team } }];"
}
},
{
"name": "Log to Postgres",
"type": "n8n-nodes-base.postgres",
"parameters": {
"operation": "insert",
"table": "creative_rejections",
"columns": "creative_id,campaign_id,rejection_code,category,exchange,ts",
"columnData": "{{ $json.creative_id }},{{ $json.campaign_id }},{{ $json.rejection_code }},{{ $json.category }},{{ $json.exchange }},NOW()"
}
},
{
"name": "Slack Alert",
"type": "n8n-nodes-base.slack",
"parameters": {
"channel": "{{ $json.team }}",
"text": "Creative rejection [{{ $json.category }}] — Creative `{{ $json.creative_id }}` on campaign `{{ $json.campaign_id }}` | Exchange: {{ $json.exchange }} | Code: {{ $json.rejection_code }}"
}
}
]
}
Workflow 3: Audience Segment Sync Health Monitor
The problem: Audience segments pushed from your DMP to DSP partners go stale or fail silently. You find out when a buyer complains about empty segments.
What this workflow does: Hourly check of segment sync status per partner. Flags segments that haven't synced in 2+ hours as STALE, 6+ hours as CRITICAL. Alerts #data-ops.
{
"name": "Audience Segment Sync Health Monitor",
"nodes": [
{
"name": "Every Hour",
"type": "n8n-nodes-base.scheduleTrigger",
"parameters": { "rule": { "interval": [{ "field": "hours", "hoursInterval": 1 }] } }
},
{
"name": "Query Sync Status",
"type": "n8n-nodes-base.postgres",
"parameters": {
"operation": "executeQuery",
"query": "SELECT segment_id, segment_name, partner, last_sync_at, segment_size, EXTRACT(EPOCH FROM (NOW()-last_sync_at))/3600 AS hours_since_sync FROM segment_sync_log WHERE active=true ORDER BY hours_since_sync DESC LIMIT 100"
}
},
{
"name": "Flag Stale Segments",
"type": "n8n-nodes-base.code",
"parameters": {
"jsCode": "const results = []; for (const item of $input.all()) { const h = parseFloat(item.json.hours_since_sync); let status = null; if (h >= 6) status = 'CRITICAL'; else if (h >= 2) status = 'STALE'; if (status) results.push({ json: { ...item.json, status, hours_since_sync: h.toFixed(1) } }); } return results;"
}
},
{
"name": "Dedup per Segment",
"type": "n8n-nodes-base.code",
"parameters": {
"jsCode": "const seen = $getWorkflowStaticData('global'); const now = Date.now(); const ttl = { CRITICAL: 30*60*1000, STALE: 60*60*1000 }; const results = []; for (const item of $input.all()) { const key = `${item.json.segment_id}_${item.json.partner}`; const last = seen[key] || 0; if (now - last > ttl[item.json.status]) { seen[key] = now; results.push(item); } } return results;"
}
},
{
"name": "Slack #data-ops",
"type": "n8n-nodes-base.slack",
"parameters": {
"channel": "#data-ops",
"text": "{{ $json.status }} — Segment *{{ $json.segment_name }}* ({{ $json.segment_id }}) not synced to {{ $json.partner }} in {{ $json.hours_since_sync }}h | Size: {{ $json.segment_size }} users"
}
}
]
}
Why self-host? Audience segment data contains user behavioral IDs and consent strings under IAB TCF 2.0. Routing these through Zapier's infrastructure creates a sub-processor under GDPR Art. 28 and expands your CCPA disclosure obligations. With n8n on your VPC, the data never leaves your network.
Workflow 4: Attribution Postback Validation Pipeline
The problem: Mobile attribution postbacks arrive from MMP partners with missing or malformed fields. Bad postbacks corrupt your attribution data before anyone notices.
What this workflow does: Validates every inbound postback for required fields, deduplicates by click_id, flags anomalies (conversion lag >24h, duplicate conversions, impossible geo), and logs clean records to Postgres.
{
"name": "Attribution Postback Validation Pipeline",
"nodes": [
{
"name": "Postback Webhook",
"type": "n8n-nodes-base.webhook",
"parameters": { "path": "attribution-postback", "responseMode": "onReceived", "responseData": "firstEntryJson" }
},
{
"name": "Validate & Score",
"type": "n8n-nodes-base.code",
"parameters": {
"jsCode": "const d = $input.first().json; const required = ['click_id','campaign_id','event_type','ts','device_id']; const missing = required.filter(f => !d[f]); const lagHours = d.click_ts ? (new Date(d.ts) - new Date(d.click_ts)) / 3600000 : 0; const flags = []; if (missing.length) flags.push(`MISSING_FIELDS:${missing.join(',')}`); if (lagHours > 24) flags.push(`HIGH_CONVERSION_LAG:${lagHours.toFixed(1)}h`); if (d.revenue && parseFloat(d.revenue) > 10000) flags.push('ANOMALOUS_REVENUE'); return [{ json: { ...d, validation_flags: flags, is_valid: missing.length === 0 } }];"
}
},
{
"name": "Check Duplicate click_id",
"type": "n8n-nodes-base.postgres",
"parameters": {
"operation": "executeQuery",
"query": "SELECT COUNT(*) AS cnt FROM attribution_events WHERE click_id='{{ $json.click_id }}'"
}
},
{
"name": "Route Valid vs Invalid",
"type": "n8n-nodes-base.if",
"parameters": {
"conditions": { "boolean": [{ "value1": "={{ $json.is_valid && $node['Check Duplicate click_id'].json.cnt == 0 }}", "value2": true }] }
}
},
{
"name": "Insert Clean Record",
"type": "n8n-nodes-base.postgres",
"parameters": {
"operation": "insert",
"table": "attribution_events",
"columns": "click_id,campaign_id,event_type,device_id,revenue,ts,mmp_partner,created_at",
"columnData": "{{ $json.click_id }},{{ $json.campaign_id }},{{ $json.event_type }},{{ $json.device_id }},{{ $json.revenue || 0 }},{{ $json.ts }},{{ $json.mmp_partner }},NOW()"
}
},
{
"name": "Log Invalid to Quarantine",
"type": "n8n-nodes-base.postgres",
"parameters": {
"operation": "insert",
"table": "attribution_quarantine",
"columns": "raw_payload,validation_flags,received_at",
"columnData": "{{ JSON.stringify($json) }},{{ $json.validation_flags.join('|') }},NOW()"
}
},
{
"name": "Slack Anomaly Alert",
"type": "n8n-nodes-base.slack",
"parameters": {
"channel": "#attribution-ops",
"text": "Attribution anomaly — click_id `{{ $json.click_id }}` campaign `{{ $json.campaign_id }}` | Flags: {{ $json.validation_flags.join(', ') }}"
}
}
]
}
Workflow 5: Weekly AdTech Platform KPI Dashboard
The problem: Your exec team wants weekly numbers on impressions served, fill rate, win rate, publisher revenue, and platform spend — assembled from 3 different Postgres schemas.
What this workflow does: Every Monday at 8 AM, queries your ad server DB, assembles KPIs with WoW deltas (via $getWorkflowStaticData), and emails a formatted HTML dashboard to CRO, CTO, and VP Revenue.
{
"name": "Weekly AdTech Platform KPI Dashboard",
"nodes": [
{
"name": "Monday 8 AM",
"type": "n8n-nodes-base.scheduleTrigger",
"parameters": { "rule": { "interval": [{ "field": "cronExpression", "expression": "0 8 * * 1" }] } }
},
{
"name": "Query This Week Metrics",
"type": "n8n-nodes-base.postgres",
"parameters": {
"operation": "executeQuery",
"query": "SELECT SUM(impressions) AS impressions, SUM(clicks) AS clicks, SUM(bid_requests) AS bid_requests, SUM(bid_wins) AS bid_wins, SUM(revenue_usd) AS publisher_revenue, SUM(spend_usd) AS advertiser_spend FROM ad_server_daily WHERE date >= NOW() - INTERVAL '7 days'"
}
},
{
"name": "Calculate KPIs + WoW Delta",
"type": "n8n-nodes-base.code",
"parameters": {
"jsCode": "const d = $input.first().json; const prev = $getWorkflowStaticData('global'); const fillRate = d.bid_requests > 0 ? ((d.bid_wins / d.bid_requests) * 100).toFixed(1) : 0; const ctr = d.impressions > 0 ? ((d.clicks / d.impressions) * 100).toFixed(2) : 0; const wow = (curr, key) => { const p = prev[key] || curr; const delta = ((curr - p) / p * 100).toFixed(1); return delta > 0 ? `+${delta}%` : `${delta}%`; }; const kpis = { impressions: parseInt(d.impressions).toLocaleString(), fill_rate: fillRate + '%', ctr: ctr + '%', pub_revenue: '$' + parseFloat(d.publisher_revenue).toFixed(0), adv_spend: '$' + parseFloat(d.advertiser_spend).toFixed(0), impressions_wow: wow(d.impressions, 'impressions'), revenue_wow: wow(d.publisher_revenue, 'publisher_revenue') }; Object.assign(prev, { impressions: d.impressions, publisher_revenue: d.publisher_revenue }); return [{ json: kpis }];"
}
},
{
"name": "Build HTML Email",
"type": "n8n-nodes-base.code",
"parameters": {
"jsCode": "const d = $input.first().json; const html = `<h2>AdTech Platform Weekly KPIs</h2><table border=1 cellpadding=6><tr><th>Metric</th><th>This Week</th><th>WoW</th></tr><tr><td>Impressions Served</td><td>${d.impressions}</td><td>${d.impressions_wow}</td></tr><tr><td>Fill Rate</td><td>${d.fill_rate}</td><td>—</td></tr><tr><td>CTR</td><td>${d.ctr}</td><td>—</td></tr><tr><td>Publisher Revenue</td><td>${d.pub_revenue}</td><td>${d.revenue_wow}</td></tr><tr><td>Advertiser Spend</td><td>${d.adv_spend}</td><td>—</td></tr></table>`; return [{ json: { html } }];"
}
},
{
"name": "Gmail to Leadership",
"type": "n8n-nodes-base.gmail",
"parameters": {
"operation": "send",
"toList": "cro@company.com",
"bccList": "cto@company.com,vp-revenue@company.com",
"subject": "AdTech Platform — Weekly KPIs {{ new Date().toISOString().slice(0,10) }}",
"emailType": "html",
"message": "{{ $json.html }}"
}
}
]
}
Why AdTech platforms self-host n8n
| Concern | Zapier / Make | n8n (self-hosted) |
|---|---|---|
| GDPR consent string routing | Leaves your network | Stays in your VPC |
| IAB TCF 2.0 sub-processor chain | Adds Zapier as sub-processor | No additional sub-processor |
| Audience segment data (CCPA) | Third-party cloud | Your infrastructure |
| Attribution postback latency | 100–500ms added delay | Sub-10ms (same network) |
| Bidding algorithm IP | Exposed in workflow logs | Air-gapped from third parties |
| Cost at 10M ops/month | Zapier Teams: $3,499+ | $40/mo VPS |
The compliance reality: Every GDPR consent string, user segment ID, and attribution event that flows through Zapier's servers makes Zapier a data processor under GDPR Art. 4(8). That's a BAA negotiation, an Art. 28 DPA requirement, and a disclosure in your privacy policy. Programmatic ad data at scale — n8n removes all of that.
Get all 15 n8n templates pre-built
These workflows are samples. The full FlowKit template pack includes 15 production-ready n8n workflows — Email Auto-Responder, AI Customer Support Bot, Lead Capture to CRM, Price Monitor, Daily Report Generator, and more.
Browse templates at stripeai.gumroad.com
Each template includes:
- Import-ready workflow JSON
- Step-by-step setup guide
- Variable reference sheet
All templates work with n8n self-hosted (free) and n8n Cloud.
Top comments (0)