Automating Post-Draft Fact Verification: An n8n Webhook Architecture for Pre-Publishing QA
Language models are useful drafting engines, but they are notoriously relaxed about factual accuracy. Left unmonitored, an LLM will invent web links to non-existent domains, cite fictitious research papers, or synthesize quote attributions out of statistical probability. Publishing those errors carries real consequences: search engine quality systems, such as Google’s SpamBrain, evaluate patterns of low-quality or manipulative site content, while broken outbound links sap reader trust.
Relying on manual line-by-line editorial checks for high-volume content pipelines quickly becomes a operational constraint. To handle draft validation automatically before content reaches your Content Management System (CMS), you can assemble an asynchronous verification queue using open-source workflow automation.
The core approach involves intercepting drafts via webhooks, extracting claim candidates and hyperlinked assets, executing live network and entity checks, and routing flagged issues to an editorial Slack channel before final approval.
Pipeline Architecture Overview
The system uses n8n, an open-source workflow automation platform, to create a validation layer between draft generation and CMS scheduling.
[Draft Generator / CMS Trigger]
│
▼
[n8n Webhook Node (POST)]
│
▼
[JavaScript Code Node: Extract URLs & Entities]
│
┌─────────┴─────────┐
▼ ▼
[HTTP Request Node] [Perplexity / Search API]
(HTTP Status & Headers) (Entity & Citation Check)
└─────────┬─────────┘
▼
[Validation Rules Engine]
│
┌─────────┴─────────┐
▼ ▼
(All Pass) (Flags Found)
│ │
▼ ▼
[CMS Publish Endpoint] [Slack Review Queue]
When a content generation job completes—or when an author changes a post status to “Ready for Review”—the source system fires a POST payload containing the raw document Markdown or HTML to an n8n Webhook node.
Phase 1: Payload Parsing and Outbound Link Extraction
The payload hits an active Webhook node set to listen for JSON POST requests. The primary objective of the first downstream stage is extracting every outbound URL and named entity candidate without altering the raw text.
In n8n, pass the article body into a standard Code Node (JavaScript) running regex and DOM-parsing logic:
// Extract all Markdown and standard HTML outbound links
const text = $json.body.content;
const urlRegex = /https?:\/\/[^\s\)\"\'>]+/g;
const matches = text.match(urlRegex) || [];
// Deduplicate and filter out internal domains
const internalDomain = "example.com";
const externalUrls = [...new Set(matches)].filter(url => {
try {
const parsed = new URL(url);
return !parsed.hostname.includes(internalDomain);
} catch (e) {
return false;
}
});
return externalUrls.map(url => ({ json: { url } }));
This node splits the workflow into an array of isolated link items, allowing n8n to execute downstream checks across multiple threads.
Phase 2: Async HTTP Status & Redirect Validation
An outbound link generated by an LLM generally fails in one of three ways: 1. 404 / 5xx Status: The URL path or domain does not exist. 2. Redirect Loops / Soft 404s: The link resolves to a generic domain park, paywall, or search results page. 3. Invalid SSL Certificates: The domain target is misconfigured or expired.
To validate these endpoints, loop the extracted URLs into an HTTP Request node.
Node Configuration Settings:
- Method:
GET(orHEADto conserve bandwidth, falling back toGETifHEADis refused) - URL:
={{ $json.url }} - Options:
- Enable Never Error: Set
Never Errortotrueunder the node’s settings so 4xx and 5xx responses do not crash the workflow execution. - Enable Include Response Headers and Status: Captures final status codes and redirect history.
- Set Timeout: 5000ms to prevent stalled connections from clogging execution memory.
Following the HTTP Request node, insert an IF Node to evaluate the status code:
- Pass Condition: Status code is
200through302. - Fail Condition: Status code is
404,403,500+, or returns an empty payload.
URLs that fail this gate are tagged with link_error: true along with the returned HTTP status code for editorial context.
Phase 3: Claim Extraction and Grounding Checks
Verifying that a link simply returns a 200 OK status does not guarantee that the cited page actually supports the assertion made in the draft text. LLMs often point valid URLs to sources that have nothing to do with the accompanying text claim.
To automate grounding checks:
- Extract Claim Blocks: Use a Code Node to parse paragraph blocks containing specific attribution triggers (e.g., direct quotes, percentage assertions, named source references like “according to”, “reported by”, or “stated in”).
- Execute a Grounding Call: Route extracted claim blocks and their target URLs through a secondary call to an external search or citation API, such as the Perplexity API or a custom search endpoint using an HTTP Request node.
{
"model": "sonar-pro",
"messages": [
{
"role": "system",
"content": "You are a factual verification assistant. Verify if the provided Source URL explicitly supports the target Claim. Respond only with a JSON object containing 'supported' (boolean) and 'reason' (string)."
},
{
"role": "user",
"content": "Claim: {{ $json.claim }}\nSource URL: {{ $json.url }}"
}
]
}
If the returned response sets "supported": false, the item is flagged with hallucination_risk: true.
Phase 4: Constructing the Slack Editorial Review Queue
Once all parallel execution branches complete, pass the items into an Aggregate Node to consolidate the results back into a single document object.
A Switch Node splits the workflow into two paths based on the aggregated payload:
- Clean Path (Zero Flags): The document passes directly to the Webhook endpoint of your publishing tool or headless CMS (e.g., WordPress, Ghost, or Strapi) with status set to
draft_verified. - Flagged Path (1+ Flags Detected): The workflow posts a formatted block message to an internal editorial Slack channel via the native n8n Slack node.
Example Slack Payload Layout:
{
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "⚠️ Fact-Checking Audit Flagged: Action Required"
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Article Title:* {{ $json.title }}\n*Draft ID:* {{ $json.draft_id }}"
}
},
{
"type": "divider"
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Detected Errors:*\n• Broken URL: `<{{ $json.flagged_url }}|Link>` returned HTTP {{ $json.status_code }}\n• Unverified Claim: \"_{{ $json.flagged_claim }}_\" — Grounding model reported source mismatch."
}
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {
"type": "plain_text",
"text": "Open Draft in CMS"
},
"url": "https://cms.example.com/edit/{{ $json.draft_id }}"
}
]
}
]
}
Operational Trade-Offs and Failure Modes
Automating pre-publishing QA reduces human verification workload, but it comes with distinct operational considerations:
- Execution Rate Limits: Running high-volume HTTP checks against external sites will occasionally trigger rate limits (e.g., HTTP
429 Too Many Requests) or Cloudflare anti-bot challenges. Always configure the HTTP Request node with backoff retry options (e.g., 2 retries with exponential backoff). - Grounding API Overhead: Executing LLM-based factual validation queries on every sentence adds latency and micro-transaction costs. Limit entity extraction checks strictly to high-risk claim structures (numerical metrics, quotes, and direct citations) rather than generic narrative prose.
- Human Oversight Boundaries: This workflow is designed as an automated safety net, not a replacement for an editor. The system’s role is to highlight questionable claims and broken links before a draft hits the index—leaving final narrative judgment, tone, and contextual accuracy to human review.
This article was generated with the help of AI.