n8n · scheduled orchestration + error handling

FDA signal → scored outreach queue, with the failure handling verified

A scheduled n8n workflow that turns live FDA enforcement filings into a ranked, grounded outreach queue — with a dedicated error workflow proven by deliberately breaking a live run, not just configured and left untested.

The run is committed, not screenshotted

This workflow really was executed headlessly, and the run is committed in output/sample-run.json — not a screenshot of a canvas.

Be precise about what that file is. It is a summary I wrote from the run (per-node status, item counts, timings, the funnel, the briefs), not n8n’s raw resultData.runData dump. The numbers in it come from an actual n8n execute against the live openFDA API; the schema around them is mine, chosen to be readable. If you want the native output, re-run the command in Reproduce below and compare — the funnel should match, and the per-firm “N days ago” fields should have advanced by however many days have passed since the commit.

The problem it solves

Signal-based outbound only works if the signal refreshes on its own. The moment it depends on someone remembering to re-run a script, it goes stale, and reps start calling accounts whose trigger expired months ago. Worse, when the refresh fails, the queue doesn’t empty — it silently serves yesterday’s data and nobody knows.

So the interesting part of this workflow isn’t the happy path. It’s what happens when the API is down.

The workflow

Daily 07:00 ─┐
             ├─→ Fetch openFDA ──→ Roll Up + Score ──→ Filter ≥60 ──→ Build Briefs
Manual Run ──┘        │
                      └── (on error, after 3 retries) ──→ Alert: Fetch Failed

Two entry points: the Schedule Trigger is the intended scheduled entry (daily 07:00); the Manual Trigger exists so the workflow can be executed from CLI and CI, which is how the committed run below was produced. A configured schedule is not presented as proof of sustained production operation.

The manual trigger is there because the first CLI run failed — n8n execute returned “Missing node to start execution”, since a Schedule Trigger alone gives the CLI no entrypoint. Adding the manual trigger fixed it. Recording that because “it worked first time” would be the less useful claim.

Production markers

These are the specific things GTM Engineer postings ask for, and where each lives.

MarkerWhere
Fixed-delay retryretryOnFail: true, maxTries: 3, waitBetweenTries: 5000 on the HTTP node
Error branchonError: continueErrorOutput routes a failed fetch to a dedicated alert node instead of killing the run
Rate limitingbatching: { batchSize: 1, batchInterval: 1000 } — deliberate throttling against a public API
Timeout30s ceiling on the fetch
Fail-closed guardthe scoring node throws on an empty response rather than emitting an empty queue — a silent empty queue is worse than a loud failure
ObservabilityalwaysOutputData so a zero-result fetch is still visible in the run log

The alert node emits a structured payload — severity, stage, message, and an explicit action: "Queue is stale. Do not send from it until this clears." Wiring that to Slack or PagerDuty is scheduler config; it is not claimed here as built.

A dedicated error workflow, verified firing

The node-level alert above only catches a failure in the one node it's wired to. workflows/gtm-error-handler.json is a separate, registered workflow-level handler (via settings.errorWorkflow) that catches a crash anywhere in the run, classifies it, and routes accordingly — and it was proven by deliberately breaking a live workflow, not by reading the docs.

Verified, 2026-08-24. A probe workflow (workflows/_error-path-probe.json) fails on demand. A webhook-mode execution failed; the handler fired 80ms later (mode: error), classified the failure code_defect, and generated a page-intent payload. Both executions are committed at output/error-workflow-fired-2026-08-23.json. No external notification delivery is claimed.

Three things that only surfaced by actually running it

That third gotcha was a real bug in the classifier's first version — it keyed on error.name, so it never matched a code defect and fell through to unclassified. It still emitted a page-intent payload (the conservative default held), but the specific rule was wrong. Re-keyed on the [line N] suffix n8n only appends to code it evaluated itself. Re-tested end to end: category went unclassified → code_defect.

Verified run

Executed via n8n execute on n8n 2.35.7, against the live openFDA API.

NodeStatusItems outTime
Manual Runsuccess11 ms
Fetch openFDA Enforcementsuccess11,886 ms
Roll Up + Score Firmssuccess3442 ms
Filter ≥ 60success1142 ms
Build Grounded Briefssuccess1013 ms

Funnel: 100 raw enforcement records → 34 firms after roll-up → 11 cleared the score threshold → 10 briefs. The 11 → 10 step is not attrition — it is a hardcoded cap. Build Grounded Briefs does rows.slice(0, 10), so the queue never emits more than 10 briefs per run no matter how many firms clear the threshold. That is a deliberate volume guard on a daily job, but it is a code decision and not a property of the data, so it is stated here rather than left to look like a drop-off. The other numbers do move between runs, because the data is live — that is the point of a scheduled signal pipeline, and the committed sample-run.json is one dated snapshot, not a fixed fixture.

Run it yourself

npm install n8n
export N8N_USER_FOLDER="$PWD/.n8n-home"
npx n8n import:workflow --input=workflows/fda-signal-to-outreach-queue.json
npx n8n execute --id=fdaSignalQueue01

No API key and no paid data — openFDA is a public federal endpoint.

Scoring

Mirrors the disclosed rubric in atlas-signal-engine (max 100): severity by worst classification, recency of the most recent event, systemic count of distinct event_ids, a quality-system reason flag, and an ongoing-status bonus. It is a buying-trigger score, not a quality verdict — it measures how acute a firm’s compliance pain is right now, not whether the firm is good or bad.

Honesty guardrails

Files

workflows/fda-signal-to-outreach-queue.json   the main workflow (importable)
workflows/gtm-error-handler.json              dedicated error workflow
workflows/_error-path-probe.json              fixture that fails on demand, for testing the above
output/sample-run.json                        committed real execution output
output/native-execution-2026-08-23.json       n8n's own unedited resultData.runData dump
output/error-workflow-fired-2026-08-23.json   the failure + the handler run, 80ms apart

Built something similar for your stack?

I build signal-detection, enrichment and outbound systems inside your own tools. You own everything — nothing is locked to me.