Broken landing pages happen. Big teams. Small teams. Doesn't matter. A bad template ships, PHP warnings leak into the DOM, or the CTA stops rendering. Uptime checks keep showing green because the server returns 200. Meanwhile your campaigns keep spending.
This guide shows how to build a practical n8n watchdog that:
- loads your paid landing pages on a schedule,
- checks the DOM for red flags and for required elements,
- automatically pauses ads tied to the failing URL,
- alerts your team with context and a screenshot,
- and auto-resumes once the page is healthy.
If you just want someone to help you plan guardrails for your stack, you can start a conversation here: dynamicdisorder.co/contact. That link is also at the bottom if you prefer to read first.
Why standard uptime monitoring isn't enough
Traditional uptime says: server reachable, status code 200, latency acceptable. That's useful but incomplete. Plenty of breakages slip through:
- Server-side warnings in HTML: Warning:, Notice:, Fatal error:. Page is still 200.
- Client-side render failure: the script that mounts the hero or the checkout errors silently.
- Consent wall or cookie modal that blocks the CTA in certain geo or language routes.
- AB variant with a missing element that your test scripts don't cover.
- Ad-specific paths: a UTM lands you in a slightly different template that no one smoke-tested.
The fix is simple: watch what the user sees. Fetch the page with a headless browser, assert that bad strings are not present and key elements are present. If assertions fail, stop sending paid clicks there until it's fixed.
The n8n watchdog pattern (overview)
Flow
- Cron: run every 5–10 minutes.
- Config: list of landing pages and ad entities to control.
- Headless fetch: load the URL and get HTML (and optionally a screenshot).
- Checks: negative text regex + positive selector checks.
- State: track consecutive passes and failures.
- Decision: after N failures, pause mapped ads; after K passes, resume.
- Notify: Slack or Telegram with reason, IDs, and screenshot.
Why n8n works well
You get a visual flow, versionable JSON, secrets in one place, easy scheduling, and native nodes for HTTP, Slack, DBs, and OAuth. You can keep it small or grow it into a full incident pipeline.
Guardrails, not heroics
This is not about blaming a developer or an agency. It is about building guardrails so that when things break, your budget and users are protected. Alerts should be actionable. Pauses should be scoped and reversible. Resumes should be automatic when the page is good again. Quiet and boring is the goal.
Configuration: start simple, then evolve
Option A: Static Config node (fastest)
Put a Function node called Config at the start and return an array of landing page objects:
return [
{
json: {
url: "https://example.com/lp1?utm_source=google",
platform: "google",
entityId: "1234567890", // ad or adgroup id
selectorsRequired: ["<h1>", "Buy Now"], // simple contains checks
allowPatterns: [] // regex strings you accept on page
}
},
{
json: {
url: "https://example.com/lp2?utm_source=meta",
platform: "meta",
entityId: "9876543210", // ad or adset id
selectorsRequired: [".hero-title", ".cta-button"],
allowPatterns: ["cookie"] // e.g., allow "cookie" text
}
}
];
Option B: Google Sheets
Create a sheet LandingPages with columns: url | platform | entityId | selectorsRequired | allowPatterns. Ops can edit without touching n8n.
Option C: Database (Supabase/Postgres)
Table landing_pages: id, url, platform, entity_id, selectors_required (text[]), allow_patterns (text[]). Add a state table to track passes/fails per URL. This is best when your list grows or when multiple teams collaborate.
The checks: what to look for
Negative text regex pack (start here)
These should not appear anywhere in the HTML:
Warning:\s|Notice:\s|Fatal error:|Parse error:|Undefined (array key|index|variable)|
Exception in|Stack trace:|SQLSTATE\[|502 Bad Gateway|504 Gateway Time-out|
Service Unavailable|Maintenance|Object not found|Unhandled exception
Tune as needed. Support sites often include "Maintenance" in friendly messages. Add those to allowPatterns if you accept them during planned windows.
Positive selector checks
Confirm that critical elements are present. The simplest approach is string contains on HTML. For higher accuracy, parse the DOM or use a headless browser that can query selectors. Start pragmatic:
- Header and nav markers: header, nav
- Hero headline: .hero h1, or even just "<h1>"
- Primary CTA: .cta, "Buy Now", form[action*="lead"]
- Brand/logo: img[alt*="brand"], or a snippet of your SVG
Optional performance checks
- Page size below X MB
- LCP element exists
- Response time within Y ms
These are nice to have. The budget saver is the negative/positive checks.
Hysteresis: stop flapping
Don't pause on the first blip. Require 2 consecutive failures to pause. Don't resume on the first pass. Require 2 consecutive passes to resume. Keep this state in a table keyed by URL and platform. That way the watchdog stays calm while still reacting quickly.
State fields
- url, platform, entity_id
- paused (bool)
- consecutive_failures, consecutive_passes
- last_reason, last_screenshot_url
- last_change_at
Action scope: pause minimally, resume automatically
- Google Ads: pause the Ad that maps to the failing URL when possible. If multiple ads share a URL and you can't map cleanly, pause the Ad Group that contains those ads. Campaign-level pause only if you purposely tag those campaigns as safe to auto-pause.
- Meta: if several ads share the same URL, pausing the Ad Set is often cleaner; otherwise pause the specific Ad.
- Outbrain/Taboola: pause the creative or the campaign depending on your mapping.
Fail-safe rule: if your mapping from URL to ad entity is uncertain, do not pause. Alert a human at high severity.
The n8n workflow (node-by-node)
You can import the JSON you already have, or assemble it quickly:
Cron
Run every 5–10 minutes. Keep it predictable.
Config
Function node that returns your LP array (Option A), or a sheets/DB node.
Fetch Page
For a first version, an HTTP Request node that returns HTML is fine. If you need JS execution and screenshots, call a headless service (browserless, puppeteer-as-a-service) from the HTTP node and get both HTML and a base64 screenshot back.
Check Page (Function node)
Evaluate status code, negative regex, positive selectors. Produce a result object with statusFlag: PASS | FAIL and reason.
const html = $json.body || '';
const status = $json.statusCode || 0;
const neg = /(Warning:\s|Notice:\s|Fatal error:|SQLSTATE\[|502 Bad Gateway|504 Gateway Time-out|Service Unavailable|Unhandled exception)/i;
let reasons = [];
if (![200,301,302].includes(status)) reasons.push(`HTTP ${status}`);
// allow patterns (optional)
const allow = ($json.allowPatterns || []).some(p => new RegExp(p, 'i').test(html));
// negative text
if (neg.test(html) && !allow) reasons.push('Error text detected');
// required selectors
const required = $json.selectorsRequired || [];
const missing = required.filter(sel => !html.includes(sel));
if (missing.length) reasons.push('Missing selectors: ' + missing.join(', '));
return [{
json: {
url: $json.url,
platform: $json.platform,
entityId: $json.entityId,
statusFlag: reasons.length ? 'FAIL' : 'PASS',
reason: reasons.join(' | ')
}
}];
State Upsert
Use a DB node (Supabase/Postgres) to update consecutive_failures and consecutive_passes. If you want to keep it stateless at first, skip this and accept more noise.
If FAIL
If consecutive_failures >= 2 and paused = false, branch to pause.
Pause Ads
- Google Ads: HTTP Request with OAuth, set status of the ad (or ad group) to PAUSED.
- Meta: Marketing API call to set ad or ad set status to PAUSED.
- Outbrain: pause the campaign/creative.
Notify
Slack or Telegram with a short message, reason, and a screenshot URL if you captured one. Add buttons: "Resume now" and "Acknowledge".
If PASS
If consecutive_passes >= 2 and paused = true, resume the entity and notify.
A minimal importable skeleton
Below is a small extract you can use as a starting point. It uses a Config node, fetches the page, checks for issues, and pauses a Google Ad. Add your credentials and extend it with state and resume logic when ready.
{
"name": "Landing Page Watchdog (Config Node)",
"nodes": [
{
"parameters": {
"triggerTimes": { "item": [ { "mode": "everyX", "value": 5, "unit": "minutes" } ] }
},
"id": "1",
"name": "Cron",
"type": "n8n-nodes-base.cron",
"typeVersion": 1,
"position": [250, 300]
},
{
"parameters": {
"functionCode": "return [ { json: { url: \"https://example.com/lp1?utm_source=google\", platform: \"google\", entityId: \"1234567890\", selectorsRequired: [\"<h1>\", \"Buy Now\"], allowPatterns: [] } }, { json: { url: \"https://example.com/lp2?utm_source=meta\", platform: \"meta\", entityId: \"9876543210\", selectorsRequired: [\"hero-title\", \"cta-button\"], allowPatterns: [\"cookie\"] } } ];"
},
"id": "2",
"name": "Config",
"type": "n8n-nodes-base.function",
"typeVersion": 1,
"position": [450, 300]
},
{
"parameters": {
"url": "={{$json[\"url\"]}}",
"responseFormat": "string",
"options": { "fullResponse": true }
},
"id": "3",
"name": "Fetch Page",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 1,
"position": [650, 300]
},
{
"parameters": {
"functionCode": "const html = $json.body || ''; const status = $json.statusCode || 0; const neg = /(Warning:\\s|Notice:\\s|Fatal error:|SQLSTATE\\[|502 Bad Gateway|504 Gateway Time-out|Service Unavailable|Unhandled exception)/i; let reasons = []; if (![200,301,302].includes(status)) reasons.push(`HTTP ${status}`); const allow = ($json.allowPatterns || []).some(p => new RegExp(p, 'i').test(html)); if (neg.test(html) && !allow) reasons.push('Error text detected'); const required = $json.selectorsRequired || []; const missing = required.filter(sel => !html.includes(sel)); if (missing.length) reasons.push('Missing selectors: ' + missing.join(', ')); return [{ json: { url: $json.url, platform: $json.platform, entityId: $json.entityId, statusFlag: reasons.length ? 'FAIL' : 'PASS', reason: reasons.join(' | ') } }];"
},
"id": "4",
"name": "Check Page",
"type": "n8n-nodes-base.function",
"typeVersion": 1,
"position": [850, 300]
},
{
"parameters": {
"conditions": {
"string": [ { "value1": "={{$json[\"statusFlag\"]}}", "operation": "equal", "value2": "FAIL" } ]
}
},
"id": "5",
"name": "If FAIL",
"type": "n8n-nodes-base.if",
"typeVersion": 1,
"position": [1050, 300]
},
{
"parameters": { "functionCode": "return [{ json: { text: `🚨 LP failed: ${$json.url}\\nReason: ${$json.reason}` } }];" },
"id": "6",
"name": "Build Alert",
"type": "n8n-nodes-base.function",
"typeVersion": 1,
"position": [1250, 200]
},
{
"parameters": { "channel": "landing-alerts", "text": "={{$json[\"text\"]}}" },
"id": "7",
"name": "Send Slack",
"type": "n8n-nodes-base.slack",
"typeVersion": 1,
"position": [1450, 200],
"credentials": { "slackApi": "YOUR_SLACK_CREDENTIAL" }
},
{
"parameters": { "functionCode": "return [{ json: { action: 'pause', platform: $json.platform, entityId: $json.entityId } }];" },
"id": "8",
"name": "Prepare Pause",
"type": "n8n-nodes-base.function",
"typeVersion": 1,
"position": [1250, 400]
},
{
"parameters": {
"url": "https://googleads.googleapis.com/v13/customers/YOUR_CUSTOMER_ID/googleAds:mutate",
"method": "POST",
"authentication": "oAuth2",
"sendBody": true,
"jsonBody": "={ \"mutations\": [ { \"update\": { \"resourceName\": `customers/YOUR_CUSTOMER_ID/ads/${$json.entityId}`, \"status\": \"PAUSED\" }, \"updateMask\": \"status\" } ] }"
},
"id": "9",
"name": "Pause Google Ad",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 1,
"position": [1450, 400],
"credentials": { "googleAdsOAuth2Api": "YOUR_GOOGLE_ADS_CREDENTIAL" }
}
],
"connections": {
"Cron": { "main": [ [ { "node": "Config", "type": "main", "index": 0 } ] ] },
"Config": { "main": [ [ { "node": "Fetch Page", "type": "main", "index": 0 } ] ] },
"Fetch Page": { "main": [ [ { "node": "Check Page", "type": "main", "index": 0 } ] ] },
"Check Page": { "main": [ [ { "node": "If FAIL", "type": "main", "index": 0 } ] ] },
"If FAIL": {
"main": [
[ { "node": "Build Alert", "type": "main", "index": 0 } ],
[ { "node": "Prepare Pause", "type": "main", "index": 0 } ]
]
},
"Build Alert": { "main": [ [ { "node": "Send Slack", "type": "main", "index": 0 } ] ] },
"Prepare Pause": { "main": [ [ { "node": "Pause Google Ad", "type": "main", "index": 0 } ] ] }
}
}
Extend it with:
- a DB Upsert node to maintain counters,
- an If PASS branch with Resume nodes,
- and optional screenshot storage.
Implementation details that avoid surprises
- PHP in production: set display_errors=Off, log_errors=On. Guard array keys and nulls before using functions like trim(). These two changes alone remove most "leaked warnings."
- Selectors per URL: a single global selector list will break with AB tests. Store selectors alongside each URL.
- Consent banners: either accept them in the script, or add friendly strings to allowPatterns so their presence doesn't flag the page as broken.
- CAPTCHAs: if the checker hits a CAPTCHA, switch to alert only to avoid false pauses.
- Maintenance windows: add a flag on the URL row to skip checks during planned deploys.
- Audit log: write "who paused what and why" in human language. It will save time in incident reviews.
ROI: fast math that convinces the finance team
Say your cost per click is €0.60. You push 2,000 clicks per day into a key landing page. A bug goes live at 00:00 and no one sees it until 08:00. That is roughly €400 in wasted traffic for a single URL in one night. If three ad sets or two platforms feed that same page, losses climb fast. The watchdog usually pays for itself the first time it fires.
Case study (anonymized)
A large telco ran paid traffic to a phone-offer page. The top of the page showed raw PHP warnings. The server returned 200. Uptime tools stayed green. The error was only visible in the rendered HTML.
With this watchdog:
- The checker would have matched the negative regex within minutes.
- Slack alert would include the URL, platform entity IDs, and a screenshot of the leaked warnings.
- Ads mapped to that URL would pause after two consecutive fails.
- Once the fix deployed, two consecutive passes would auto-resume the paused entities.
No drama. No finger-pointing. Budget protected.
FAQ
Does this replace QA?
No. QA finds defects before launch. The watchdog is a production guardrail. It catches problems that surface only in real traffic conditions or variant routing.
Why not only rely on uptime?
Because 200 OK just means the server responded. It doesn't mean the page is healthy, the CTA is visible, or the layout renders correctly.
Is it safe to pause ads automatically?
Yes, with guardrails. Pause at the smallest scope possible. Store state. Require consecutive failures to pause and consecutive passes to resume. Keep a fail-safe: if mapping from URL to entity is unclear, do not pause, alert instead.
Can non-technical people maintain the list of URLs?
Yes. Move the Config list to a Google Sheet or a small database later. Keep the first version hardcoded so you can validate the flow quickly.
Does it work across Google, Meta, and native networks?
Yes. You'll use different nodes or API calls per platform, but the pattern is identical: on failure, flip a status field to paused; on recovery, flip back to active.
What about pages behind geofenced content or heavy consent walls?
Run checks from the same region as your buyers and whitelist predictable consent strings. If a consent wall truly blocks the CTA in your legal setup, that is a real failure worth pausing for.
Conclusion
Shipping fast is not the problem. Shipping without guardrails is. A small n8n watchdog closes the gap between "server is up" and "page is healthy." It runs quietly, protects budget, and gives your team clean signals to act on. Start simple with a Config node and a couple of checks. Add state. Add auto-resume. Add screenshots. You can ship the first version in an afternoon and improve from there.
If you want a second set of eyes on your plan or need help choosing the right checks for your stack, reach out: dynamicdisorder.co/contact.