The problem wasn't that no alert came. The problem was that no alert looked exactly like everything working. The execution log stayed green for three weeks while nothing at all happened.
Once an automation is built, attention moves to the next one — and silence starts reading as "still running fine." But automations rarely die loudly. An API changes its response shape and the filter stops matching anything. A sheet column gets renamed by one character. A cron quietly deactivates. Every one of those is recorded as a success.
Why error alerts aren't enough
n8n's Error Trigger is good, but it only catches executions that failed. In practice automations die in three ways, and two of them never reach an Error Trigger.
- Failed — the execution ended in an error.
Error Triggercatches this. It's the problem that's already solved. - Zero results — the execution succeeded but processed nothing. A filter condition no longer matches, the source data changed shape, or a query returned an empty set. From n8n's point of view this run finished without incident.
- Never ran — the workflow didn't fire at all. The cron was switched off, the instance came back from a restart deactivated, or the trigger never fired. There is no execution to alert on, so no error alert can possibly be produced. This is the one that goes unnoticed longest.
How it works
- Every watched workflow writes one row per run into a `workflow_runs` table — its name, timestamp, status, and how many items it actually processed. Adding a single Postgres node at the end of the workflow is the entire change.
- A
Schedule Triggernode wakes the watchdog every hour on the hour. - A
Postgresnode fetches, in one query, the most recent run for each workflow and how many of its last three successful runs returned zero results. - A
Codenode evaluates three conditions — did the last run error (failed), has no run been recorded for more than twice the expected interval (silent), were the last three successful runs all zero (zeroed). - If nothing is wrong, the
Codenode returns an empty array and the workflow ends quietly. The Slack node never executes. Getting no message when everything is healthy is the whole point of the design. - If something is wrong, all findings are grouped into a single Slack message, one line per workflow with an icon per failure type, posted to
#automation-alerts.
What you need
- n8n — self-hosted or Cloud. No community nodes required. This uses four core nodes only:
Schedule Trigger,Postgres,Code,Slack. - One Postgres database — you don't need a new one. Add a single table to a database you already run. Only names, timestamps, and counts are stored, so it stays tiny.
- A Postgres credential — n8n → Credentials →
Postgres. Host, port (5432 by default), database, user, password. If n8n runs in Docker and your database runs on the host machine, the host must behost.docker.internal, notlocalhost. This is where beginners get stuck most often. - A Slack Bot User OAuth Token — create a Slack app, grant the
chat:writeandchannels:readscopes, install it to your workspace, then register the token (it starts withxoxb-) in n8n → Credentials →Slack API. - A Slack channel for alerts — e.g.
#automation-alerts. Run/invite @your-bot-namein it, or posting fails withnot_in_channel. - A list of workflows to watch, each with its expected interval in minutes — 60 for an hourly workflow, 1440 for a daily one. This number is what silence detection measures against.
Step 1 — create the run-history table
This is where watched workflows record their runs. Run the following as-is in psql, DBeaver, or any client. Six columns, nothing more.
CREATE TABLE IF NOT EXISTS workflow_runs (
id bigserial PRIMARY KEY,
workflow_name text NOT NULL, -- match the name shown in n8n
run_at timestamptz NOT NULL DEFAULT now(),
status text NOT NULL, -- 'success' or 'error'
result_count integer NOT NULL DEFAULT 0, -- items this run actually processed
expected_interval_min integer NOT NULL -- expected cadence in minutes; 60 = hourly
);
-- Keeps lookups fast as rows accumulate.
CREATE INDEX IF NOT EXISTS workflow_runs_name_time_idx
ON workflow_runs (workflow_name, run_at DESC);result_count is the most important column in this entire case study. Because it records how many items were processed rather than merely that the run succeeded, zero-result failures become visible. Read three emails, store 3. Read none, store 0.Step 2 — add the recording node to each watched workflow
For every workflow you want watched, append a single `Postgres` node at the very end. Leave the operation on Execute Query and paste the query below, changing daily-sales-report and 60 to match that workflow.
INSERT INTO workflow_runs
(workflow_name, status, result_count, expected_interval_min)
VALUES
('daily-sales-report', 'success', {{ $items().length }}, 60);{{ $items().length }} is the number of items that reached this node — in other words, what this run actually processed. If you count throughput some other way, substitute your own value. To record failures too, add an Error Trigger to the workflow with a second node that inserts the same row with status set to 'error'.
Step 3 — the detection query
This is what sits in the watchdog's Read Run History node. It already ships inside the downloadable JSON, so you can leave it alone — read on only if you want to see how the judgement is made. It returns exactly one row per workflow.
WITH latest AS (
-- Most recent run per workflow
SELECT DISTINCT ON (workflow_name)
workflow_name, run_at, status, result_count, expected_interval_min
FROM workflow_runs
ORDER BY workflow_name, run_at DESC
),
last_three AS (
-- How many of the last three successful runs returned zero
SELECT workflow_name,
count(*) FILTER (WHERE result_count = 0) AS zero_runs,
count(*) AS sampled_runs
FROM (
SELECT workflow_name, result_count,
row_number() OVER (PARTITION BY workflow_name ORDER BY run_at DESC) AS rn
FROM workflow_runs
WHERE status = 'success'
) s
WHERE rn <= 3
GROUP BY workflow_name
)
SELECT l.workflow_name,
l.run_at,
l.status,
l.result_count,
l.expected_interval_min,
round(EXTRACT(EPOCH FROM (now() - l.run_at)) / 60)::int AS minutes_since_last_run,
COALESCE(t.zero_runs, 0) AS zero_runs_in_last_3,
COALESCE(t.sampled_runs, 0) AS sampled_runs
FROM latest l
LEFT JOIN last_three t ON t.workflow_name = l.workflow_name
ORDER BY l.workflow_name;Code node logic was then executed in Node.js against that query's real output, confirming it flags all three failure modes correctly and returns an empty array when everything is healthy. The workflow JSON uses the same node type and version combination as our already-published workflows (scheduleTrigger 1.2 / postgres 2.5 / code 2 / slack 2.3). Posting to a live Slack workspace was not verified at the time of writing. Last verified: 2026-08-15.Setup (25 minutes)
- Create the table — run the Step 1 SQL against your database. You should see
CREATE TABLEfollowed byCREATE INDEXtwice. - Register the Postgres credential — n8n sidebar → Credentials → Add credential → search
Postgres→ fill in host, port, database, user, password → click Test at the top right. Wait for the green check before moving on. A failure here is almost always the host value (see "What you need" above). - Create the Slack app — api.slack.com/apps → Create New App → From scratch → name it and pick your workspace → OAuth & Permissions in the sidebar → under Bot Token Scopes add
chat:writeandchannels:read→ Install to Workspace at the top → copy the token beginningxoxb-. - Register the Slack credential — n8n → Credentials → Add credential →
Slack API→ paste the token → Test. - Invite the bot — open your alert channel in Slack and type
/invite @your-bot-name. Skip this and you'll hitnot_in_channellater. - Import the workflow — download the JSON above, then n8n → Workflows →
...at the top right → Import from File. - Set your channel — open the
Post to #automation-alertsnode and replaceREPLACE_WITH_YOUR_CHANNEL_NAME_OR_IDwith your channel name (e.g.automation-alerts, no#). - Attach the credentials — open
Read Run HistoryandPost to #automation-alertsand pick the credentials you just created from each Credential dropdown. - Add the recording nodes — open each workflow you want watched and append the Step 2 Postgres node, with that workflow's name and interval.
- Test — hit Execute Workflow on the watchdog. With no history recorded yet, nothing happening is the correct result. To see an actual alert, do the next step.
- Trigger an alert on purpose — run
INSERT INTO workflow_runs (workflow_name, run_at, status, result_count, expected_interval_min) VALUES ('test-wf', now() - interval '500 minutes', 'success', 3, 60);then hit Execute Workflow again. A silence alert should appear in Slack. Delete the row afterwards. - Activate — flip the Activate toggle at the top right. It now checks every hour on the hour.
Tuning the thresholds
Two constants sit at the top of the Detect Silence + Zero Runs node. In practice these are the only things you'll ever adjust.
- `SILENCE_TOLERANCE` (default 2) — how many times the expected interval may elapse before a workflow counts as silent. At 2, an hourly workflow is flagged after roughly two hours without a run. Set it too low and ordinary scheduling jitter pages you; too high and discovery is slow. Start at 2 and move to 3 if alerts feel noisy.
- `ZERO_RUN_STREAK` (default 3) — how many consecutive zero-result runs constitute a problem. For workflows where zero is genuinely normal some days (no weekend orders, say), raise it to 5 or more.
What happens in edge cases
- Everything healthy — the
Codenode returns an empty array and the Slack node never runs. Zero alert noise. This should be your default state. - What if the watchdog itself dies? — a genuine limitation of this design; nobody watches the watcher. In production, have the watchdog ping a free external health-check service (healthchecks.io and similar) every hour and email you when the pings stop. Without that one addition, everything else here can quietly become meaningless.
- Days when zero is normal — some workflows legitimately process nothing on weekends and holidays. Raise
ZERO_RUN_STREAK, or exclude those workflow names inside theCodenode. - Workflows that run more often than the hourly check — a workflow running every five minutes can go up to an hour before its silence is noticed. If that's too slow, change the
Schedule Every Hourcron to0 */10 * * * *for a ten-minute cadence. - Slack being down — the alert is simply lost. For anything critical, chain an email node after the Slack node for redundancy.
- The table growing forever — the index keeps queries fast even at thousands of rows a day, but old rows are worth pruning. Put
DELETE FROM workflow_runs WHERE run_at < now() - interval '90 days';in a monthly workflow.
A workflow can show as successful while still producing no useful result.— n8n community forum