aiecommerce24 min read

n8n Error Handling: Catch Failed, Stalled and Empty Workflows Before Anyone Else Does (+ Free Template)

Last tested on n8n 2.46, November 2026.

When an n8n node fails at 2am, the default behaviour is a red row in the execution list that nobody sees until something downstream breaks. Setting up an n8n error workflow fixes the noisy crashes, yet the failures that hurt most are the quiet ones where a schedule stops firing after a credential lapses or a supplier feed returns an empty file for a week and marks every run green. Most n8n error handling guides cover the crash and ignore the silence, while standalone schedule watchers can’t tell you when they break. By the end of this guide, you’ll have one monitoring workflow that catches crashes, stalls, bursts and empty runs, alerts once per outage, confirms recovery, and posts a morning all-clear.

What you’ll build

We’ll follow three scheduled workflows across an n8n instance: Import supplier feed (every 2 hours), Sync inventory (hourly) and Publish new products (weekdays at 9, cron 0 9 * * 1-5). When Import supplier feed hits a 503 error at its Do the work node, the monitor saves the failure and posts one Slack alert with the node name, the error and a direct link to the execution:

Slack alert: Import supplier feed failed, stopped at Do the work, with the 503 error and an Open the execution link

Every 15 minutes, the Watch lane checks the n8n API and your run logs to flag error bursts, runs that finished with zero items, schedules that missed their window, and workflows that have recovered:

Three Watch messages in Slack: Import supplier feed failing, Sync inventory empty, Publish new products stalled and Sync inventory recovered

At 08:00 every morning, the Digest lane posts either a breakdown of active issues, recent failures and uncovered workflows, or a single all-clear line so you never have to guess whether silence means health or a dead monitor:

n8n daily health digest listing an empty and a failing workflow and 8 failures in the last 24 hours

n8n daily health digest: All clear, 3 workflows watched, no failures in the last 24 hours

All six lanes live on a single workflow canvas backed by three n8n Data Tables:

The n8n workflow monitoring template canvas with Setup, Catch, Log, Watch, Digest and Demo lanes

Error handling inside a workflow, and why it isn’t enough

By default, a failed node stops the workflow immediately and records a red execution in your history without notifying anyone. Good n8n error handling best practices start inside the workflow itself, using three node-level tools before an external monitor ever gets involved.

Turning on Retry On Fail in a node’s Settings tab (up to 5 tries, spaced up to 5,000 ms apart) absorbs transient rate limits and brief network blips before they become real incidents. On an n8n instance I run for a store, in the 30 days to 8 November 2026, 33 workflows failed 1,930 times between them, and 1,081 of those (56%) were a single workflow hitting an image host’s rate limit. Retries stop temporary blips from waking anyone up, and alerting once per outage rather than once per failure is what keeps an alert channel readable instead of muted.

For non-critical steps that are allowed to fail, set On Error to Continue (using error output) and wire that second output to a fallback path. Be careful here, because enabling the error output without wiring anything to it swallows the failure and marks the execution green. When the opposite happens and an HTTP request returns 200 OK with an empty payload or a missing required field, drop in a Stop And Error node to force the execution to fail so your error workflow catches it.

None of these internal tools can catch a workflow that never starts because someone deactivated it, a trigger failed to register, or the server was down at 3am. They also miss runs that finish green with zero items and can’t stop an alert storm when an upstream API goes down for an hour.

n8n’s built-in Insights feature gives you a 7-day summary banner on every plan and a full failure-rate dashboard on Cloud Pro, Cloud Enterprise, and self-hosted Business and Enterprise, though it remains a page you have to remember to check rather than an active alert. On n8n’s template library, template 13290 inspects schedule triggers via the n8n API and raises an error when a workflow is overdue, while template 20896 emails a daily liveness report and notes plainly that the watchdog can’t report its own absence. Either template is worth using if you only need a schedule check, whereas building stateful tables adds cooldowns, recovery notices, real-time crash alerts and a heartbeat check on one canvas.

What you need

  • n8n with Data Tables, either self-hosted (free Community Edition) or on a paid n8n Cloud plan, because the n8n public API isn’t available during the Cloud free trial.
  • An n8n API key, created under Settings > n8n API > Create an API key. If the dialog offers scopes, pick the Read only preset: it includes workflow:list, workflow:read, execution:list and execution:read, which is all the monitor uses. Without scopes the key has full access, which works too, because the template only reads. Either way, you should pick an expiration longer than the 30-day default so your monitor doesn’t lock itself out next month.
  • A Slack app with the chat:write bot scope (plus chat:write.public to post in public channels the bot hasn’t been invited to) and its bot token, or swap the Slack nodes for email, Telegram or Microsoft Teams.
  • Optional: an SMTP credential for the email fallback nodes, and a free Healthchecks.io account (20 checks on the $0 Hobbyist plan) for the external heartbeat URL. If you prefer Postgres over Data Tables, the GitHub repo also includes a Supabase version of this template.

n8n Create API key dialog with the Read only scopes preset selected

How it works

Reliable workflow monitoring needs to catch crashes as they happen, poll for missing or empty runs on a timer, and prove that the monitor itself is still alive:

  1. Setup runs once by hand to create three n8n Data Tables (monitor_failures, monitor_runs and monitor_watch) that give the monitor memory across runs.
  2. Catch acts as your instance’s error workflow, saving every failure row first before sending one Slack alert per workflow per cooldown window.
  3. Log a run is an optional sub-workflow trigger that any workflow can call at the end of a run to record how many items it processed, allowing the monitor to spot runs that finish green with nothing in them.
  4. Watch runs every 15 minutes, inspects every published workflow’s Schedule Trigger and recent executions through the n8n API, and alerts once when a workflow is stalled, failing in a burst or logging empty runs, then posts again when it recovers.
  5. Digest runs every morning at 08:00 to post either a summary of problems or an explicit all-clear, lists published workflows that aren’t covered yet, pings an external heartbeat URL, and prunes rows older than 30 days.
  6. Demo fails on purpose on a 5-minute schedule so you can test the entire alert and cooldown loop without breaking a production workflow, while watched workflows only need Settings > Error workflow pointed at this canvas.

Step 1: Create the monitoring tables

Alerts need state so the workflow knows whether it already paged you ten minutes ago, whether a broken schedule has recovered, and how many times an API failed overnight. In the Setup lane, add a Manual Trigger named Run once to set up and wire it to three Data table nodes in sequence: Create monitor_failures, Create monitor_runs and Create monitor_watch.

All three nodes use Resource: table, Operation: create, and turn Options > Reuse existing table on so re-running Setup never wipes your existing history. Set the table name and columns on Create monitor_failures to store one row per failed execution:

SettingValue
Resourcetable
Operationcreate
Table namemonitor_failures
Options > Reuse existing tableon
Column: workflow_idstring
Column: workflow_namestring
Column: execution_idstring
Column: execution_urlstring
Column: node_namestring
Column: error_messagestring
Column: modestring
Column: failed_atdate
Column: alertedboolean

Configure Create monitor_runs with Table name monitor_runs to hold item counts from workflows that opt into empty-run logging:

SettingValue
Column: workflow_idstring
Column: workflow_namestring
Column: itemsnumber
Column: notestring
Column: ran_atdate

Configure Create monitor_watch with Table name monitor_watch to hold each watched workflow’s inferred schedule, your manual overrides and its current alert state:

SettingValue
Column: workflow_idstring
Column: workflow_namestring
Column: schedulestring
Column: max_gap_minutesnumber
Column: override_gap_minutesnumber
Column: min_itemsnumber
Column: empty_runs_before_alertnumber
Column: enabledboolean
Column: alertingboolean
Column: alert_kindstring
Column: last_alert_atdate

Check: Click Execute workflow on Run once to set up and confirm that monitor_failures, monitor_runs and monitor_watch appear under Data tables in the left sidebar.

Step 2: Build the n8n error workflow

When an automatic execution fails, n8n passes the crash details to whichever workflow is selected as that workflow’s error handler. Start the Catch lane with an Error Trigger node named When a workflow fails (the n8n Error Trigger takes no parameters and fires only on automatic runs). Connect an Edit Fields (Set) node named Catch settings with Include other input fields turned on so your channel, email and 120-minute cooldown travel alongside the error payload:

SettingValue
Include other input fieldson
slack_channel (string)#n8n-alerts
alert_email (string)[email protected]
from_email (string)[email protected]
cooldown_minutes (number)120
timezone (string)UTC

Next, add a Code node named Read the failure. An error workflow receives two different payload shapes: a normal crash populates execution{} with an ID, URL and last node executed, whereas a trigger that failed to start populates trigger{} with no execution ID at all. Normalising both shapes and trimming whitespace in a single Code node saves wiring four or five Set and If nodes:

// Two payload shapes: a failed execution (execution.*) and a trigger that couldn't start (trigger.*, no execution id).
const j = $input.first().json;
const ex = j.execution || {};
const tr = j.trigger || {};
const wf = j.workflow || {};
const err = ex.error || tr.error || {};
let message = String(err.message || 'Unknown error').trim();
if (err.description && !message.includes(err.description)) message += ' ' + String(err.description).trim();
return [{ json: {
  workflow_id: String(wf.id || ''),
  workflow_name: wf.name || 'Unknown workflow',
  execution_id: ex.id ? String(ex.id) : '',
  execution_url: ex.url || '',
  node_name: ex.lastNodeExecuted || (err.node && err.node.name) || (tr.error && tr.error.node && tr.error.node.name) || '',
  error_message: message.replace(/\s+/g, ' ').slice(0, 2000),
  mode: ex.id ? (ex.mode || 'trigger') : 'activation',
  failed_at: $now.toISO(),
} }];

Connect a Data table node named Save the failure right after Read the failure. Saving the row before attempting any network call ensures that even if Slack and your mail server are both down, the failure is still recorded for Watch and the morning digest:

SettingValue
Resourcerow
Operationinsert
Data tablemonitor_failures
Column: workflow_id{{ $json.workflow_id }}
Column: workflow_name{{ $json.workflow_name }}
Column: execution_id{{ $json.execution_id }}
Column: execution_url{{ $json.execution_url }}
Column: node_name{{ $json.node_name }}
Column: error_message{{ $json.error_message }}
Column: mode{{ $json.mode }}
Column: failed_at{{ $json.failed_at }}
Column: alerted{{ false }}

To check whether this workflow has already alerted during the current outage, connect a Data table node named Alerted recently? (turn on Execute Once and Always Output Data in the node settings so a zero-row result still continues down the canvas):

SettingValue
Resourcerow
Operationget
Data tablemonitor_failures
Must matchAll conditions
Condition: workflow_ideq {{ $('Read the failure').first().json.workflow_id }}
Condition: alertedeq {{ true }}
Condition: failed_atgt {{ $now.minus({ minutes: $('Catch settings').first().json.cooldown_minutes }).toISO() }}
Return allon

Wire Alerted recently? to an If node named In cooldown? that checks whether any matching row came back:

SettingValue
Value 1{{ $input.all().some(i => i.json.id) }}
Operatorboolean true
Value 2“

Connect the true output (output 0) to a No Operation node named Saved, no alert. Connect the false output (output 1) to a Slack node named Post the alert. In the Slack node’s Settings tab, enable Retry On Fail (3 tries, 5000 ms apart) and set On Error to Continue (using error output) so a Slack outage routes to the email fallback instead of crashing the monitor:

SettingValue
ResourceMessage
OperationSend
Send Message ToChannel
Channel{{ $('Catch settings').first().json.slack_channel }} (By ID)
Message TypeBlocks
Notification Text{{ String('🔴 ' + $('Read the failure').first().json.workflow_name + ' failed').slice(0, 150) }}
Options > Include Link to Workflowoff
Options > Unfurl Links / Unfurl Mediaoff
CredentialSlack API (bot token)

Set the Blocks parameter to an expression that builds a Slack Block Kit header, a section with the stopping node, the error message trimmed to 600 characters and escaped for Slack markdown, the execution link, and a timestamped context footer:

{{ ({ blocks: [{ type: 'header', text: { type: 'plain_text', emoji: true, text: String('🔴 ' + $('Read the failure').first().json.workflow_name + ' failed').slice(0, 150) } }, { type: 'section', text: { type: 'mrkdwn', text: String((f => (f.node_name ? 'Stopped at *' + f.node_name + '*\n' : '') + '```' + f.error_message.slice(0, 600).replace(/```/g, "'''").replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') + '```' + (f.mode === 'activation' ? '\nThe trigger failed, so the workflow never started.' : '') + (f.execution_url ? '\n<' + f.execution_url + '|Open the execution>' : ''))($('Read the failure').first().json)).slice(0, 2900) } }, { type: 'context', elements: [{ type: 'mrkdwn', text: 'n8n workflow monitor · ' + $now.setZone($('Catch settings').first().json.timezone).toFormat('d LLL, h:mm a') }] }] }) }}

Connect the error output (output 1) of Post the alert to a Send Email node named Email the alert, with Retry On Fail set to 2 tries, 5000 ms apart. Leave Email the alert disabled until you add an SMTP credential, because n8n won’t let you publish a workflow while an enabled node has no credential configured:

SettingValue
From Email{{ $('Catch settings').first().json.from_email }}
To Email{{ $('Catch settings').first().json.alert_email }}
Subject{{ String('🔴 ' + $('Read the failure').first().json.workflow_name + ' failed').slice(0, 150) }}
Email FormatText
CredentialSMTP

Use this expression for the email Text field to strip Slack formatting and turn the link into plain text:

{{ String((f => (f.node_name ? 'Stopped at *' + f.node_name + '*\n' : '') + '```' + f.error_message.slice(0, 600).replace(/```/g, "'''").replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') + '```' + (f.mode === 'activation' ? '\nThe trigger failed, so the workflow never started.' : '') + (f.execution_url ? '\n<' + f.execution_url + '|Open the execution>' : ''))($('Read the failure').first().json)).replace(/<([^|>]+)\|([^>]+)>/g, '$2: $1').replace(/[*_]/g, '') }}

Wire both the main output (output 0) of Post the alert and the output of Email the alert into a Data table node named Mark alerted. Because a disabled node passes its input item straight through, a failed Slack post with Email the alert still disabled passes $json.error into Mark alerted, so {{ !$json.error }} records alerted = false and allows the next failure to try alerting again:

SettingValue
Resourcerow
Operationupdate
Data tablemonitor_failures
Must matchAll conditions
Condition: ideq {{ $('Save the failure').first().json.id }}
Column: alerted{{ !$json.error }}

To test the Catch lane without breaking a production workflow, build the Demo lane at the bottom of the canvas with a disabled Schedule Trigger named Every 5 minutes (demo) (Trigger interval: Minutes, Minutes between triggers: 5) and a Manual Trigger named Try it by hand, both wired to a Stop and Error node named Fail on purpose (Error Type: Error Message, Error Message: Demo failure: the supplier feed returned 0 rows). Add a red sticky note next to the demo reminding you to turn it off again after testing, since leaving a 5-minute demo trigger enabled creates 288 failed executions a day.

In every workflow you want to monitor, open Workflow Settings (the three dots menu > Settings) and set Error workflow to this monitoring workflow:

n8n Workflow settings with Error workflow set to the monitoring workflow

As n8n’s Error Trigger documentation notes, a workflow that contains an Error Trigger node uses itself as its error workflow by default, which means the Watch, Digest and Demo lanes on this canvas are automatically covered by Catch. If Catch itself crashes (for example, Slack fails and you enabled Email the alert with a broken SMTP server), n8n does not fire the Error Trigger a second time, preventing an infinite loop while leaving the failure safely recorded in monitor_failures and the Catch execution marked red.

Check: Click Execute workflow on Try it by hand and confirm that Fail on purpose turns red without triggering Catch, because manual runs never fire the Error Trigger. Next, enable Every 5 minutes (demo) and publish the workflow: the first automatic run posts a Slack alert and marks alerted = true in monitor_failures, while the second run 5 minutes later adds a second row with alerted = false and sends no message. Disable Every 5 minutes (demo) and publish the workflow again.

Step 3: Log runs that finish with nothing in them

An HTTP Request or FTP node that fetches a supplier file with zero rows finishes green, so n8n records a successful execution even when your catalogue hasn’t updated all week. Adding lightweight n8n logging for workflows where item count matters lets Watch catch consecutive empty runs.

In the Log lane, add an Execute Workflow Trigger node named Log a run. Keep this as the only Execute Workflow Trigger on the canvas, because n8n always starts a called sub-workflow at its first Execute Workflow Trigger:

SettingValue
Input data modeDefine using fields below
Input: workflow_idstring
Input: workflow_namestring
Input: itemsnumber
Input: notestring

Connect Log a run to a Data table node named Save the run:

SettingValue
Resourcerow
Operationinsert
Data tablemonitor_runs
Column: workflow_id{{ String($json.workflow_id || "") }}
Column: workflow_name{{ $json.workflow_name }}
Column: items{{ Number($json.items) || 0 }}
Column: note{{ $json.note || "" }}
Column: ran_at{{ $now.toISO() }}

Make sure your monitoring workflow is published first, because n8n won’t let you publish a caller workflow that references an unpublished sub-workflow. At the end of a watched workflow such as Sync inventory, add an Execute Workflow node with Wait for sub-workflow completion turned off so logging never slows down or fails the main run, and note that sub-workflow executions don’t count towards your plan’s monthly execution limit:

SettingValue
SourceDatabase
WorkflowThis monitoring workflow (By ID)
ModeRun once with all items
Wait for sub-workflow completionOff
workflow_id{{ $workflow.id }}
workflow_name{{ $workflow.name }}
items{{ $('Do the work').all().filter(i => i.json.sku).length }}
note(blank)

Execute Workflow node in Sync inventory calling Log a run with workflow_id, workflow_name and items

Once Step 4 populates monitor_watch, set min_items to 1 (and optionally empty_runs_before_alert, which defaults to 3) on the Sync inventory row in monitor_watch.

Check: Run Sync inventory once and verify that a new row appears in monitor_runs with its workflow_id, workflow_name and items count.

Step 4: Find every scheduled workflow and work out when it should run

External cron job monitoring tools require you to create a separate ping URL and add an HTTP node to the end of every scheduled job, which is tedious when you already have 40 workflows. Reading your workflows and their Schedule Triggers directly through the n8n node means watched workflows need no extra nodes at all.

Start the Watch lane with a Schedule Trigger named Every 15 minutes (Trigger interval: Minutes, Minutes between triggers: 15), wired to an Edit Fields (Set) node named Watch settings (with Include other input fields off):

SettingValue
slack_channel (string)#n8n-alerts
alert_email (string)[email protected]
from_email (string)[email protected]
n8n_url (string)https://your-n8n.example.com
only_tag (string)“
ignore_tag (string)no-monitor
grace_ratio (number)0.5
grace_min_minutes (number)30
failing_threshold (number)5
cooldown_minutes (number)120
timezone (string)UTC

Connect an n8n node named Get published workflows (with Execute Once on, and Retry On Fail set to 3 tries, 5000 ms apart) to fetch all active workflows, optionally filtered by only_tag:

SettingValue
ResourceWorkflow
OperationGet Many
Return Allon
Filters > Return Only Active Workflowson
Filters > Tags{{ $json.only_tag }}
Filters > Exclude Pinned Dataon
Credentialn8n API

Connect a Data table node named Get watch rows (Resource: row, Operation: get, Data table: monitor_watch, Return all: on, with Execute Once and Always Output Data enabled) so your existing alert state and manual overrides are available in memory. Next, add a Code node named Work out the schedules.

Because no built-in node parses n8n schedule rules or cron expressions, Work out the schedules inspects every enabled scheduleTrigger node on each workflow. For standard intervals (seconds, minutes, hours, days, weeks, months) it calculates the gap directly, and for cron expressions it simulates 62 days from a Monday to find the longest gap between any two consecutive runs (handling month and day names, ranges, steps, optional 6-field seconds, and cron’s rule when both day-of-month and day-of-week are restricted). It takes the shortest gap across a workflow’s triggers and adds a grace margin equal to the larger of grace_min_minutes (30) or gap * grace_ratio (0.5). That margin is why Publish new products (cron 0 9 * * 1-5, whose longest gap is Friday 09:00 to Monday 09:00, or 4,320 minutes) gets a limit of 6,480 minutes (4.5 days) and never false-alerts over the weekend, while an hourly workflow gets 90 minutes and a 2-hour workflow gets 180 minutes. The code skips this monitoring workflow itself, any workflow tagged no-monitor, rows with enabled = false, and webhook-only workflows that have no schedule trigger and no override_gap_minutes:

// Works out how often each published workflow should run, from its Schedule Trigger.
// Why Code: no node parses a schedule rule or a cron expression.
const s = $('Watch settings').first().json;
const self = $workflow.id;
const rows = {};
for (const { json: r } of $('Get watch rows').all()) if (r && r.workflow_id) rows[String(r.workflow_id)] = r;

const DAY = 1440;
const fmt = m => m >= DAY ? (m % DAY ? Math.round(m / 60) + ' h' : (m / DAY) + (m === DAY ? ' day' : ' days')) : m >= 60 ? (m % 60 ? Math.floor(m / 60) + ' h ' + (m % 60) + ' min' : (m / 60) + ' h') : m + ' min';

function expand(field, lo, hi, names = []) {
  const set = new Set();
  for (let part of String(field).toUpperCase().split(',')) {
    names.forEach((n, i) => { part = part.replace(new RegExp('\\b' + n + '\\b', 'g'), String(i + lo)); });
    let [range, step] = part.split('/');
    step = step ? Number(step) : 1;
    let a = lo, b = hi;
    if (range !== '*' && range !== '?') {
      const m = range.match(/^(\d+)(?:-(\d+))?$/);
      if (!m) return null;
      a = Number(m[1]); b = m[2] !== undefined ? Number(m[2]) : (step > 1 ? hi : a);
    }
    for (let v = a; v <= b; v += step) set.add(v);
  }
  return set;
}

function cronGap(expr) {
  let f = String(expr || '').trim().split(/\s+/);
  if (f.length === 6) f = f.slice(1);              // n8n allows a seconds field first
  if (f.length !== 5) return null;
  const min = expand(f[0], 0, 59), hour = expand(f[1], 0, 23), dom = expand(f[2], 1, 31);
  const mon = expand(f[3], 1, 12, ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC']);
  const dow = expand(f[4].replace(/\b7\b/g, '0'), 0, 6, ['SUN','MON','TUE','WED','THU','FRI','SAT']);
  if (!min || !hour || !dom || !mon || !dow) return null;
  const domAny = f[2] === '*' || f[2] === '?', dowAny = f[4] === '*' || f[4] === '?';
  const times = [];
  const start = Date.UTC(2026, 0, 5);                // any Monday; 62 days covers monthly schedules
  for (let d = 0; d < 62; d++) {
    const day = new Date(start + d * 864e5);
    if (!mon.has(day.getUTCMonth() + 1)) continue;
    const okDom = dom.has(day.getUTCDate()), okDow = dow.has(day.getUTCDay());
    if (!(domAny && dowAny ? true : domAny ? okDow : dowAny ? okDom : (okDom || okDow))) continue;
    for (const h of [...hour].sort((a, b) => a - b)) for (const m of [...min].sort((a, b) => a - b)) times.push(d * DAY + h * 60 + m);
  }
  if (times.length < 2) return null;
  let gap = 0;
  for (let i = 1; i < times.length; i++) gap = Math.max(gap, times[i] - times[i - 1]);
  return gap;
}

function ruleGap(iv) {
  const field = iv.field || 'days';
  if (field === 'cronExpression') return { gap: cronGap(iv.expression), text: 'cron ' + iv.expression };
  if (field === 'seconds') return { gap: 1, text: 'every ' + (iv.secondsInterval || 30) + ' s' };
  if (field === 'minutes') { const n = iv.minutesInterval || 5; return { gap: n, text: 'every ' + fmt(n) }; }
  if (field === 'hours') { const n = (iv.hoursInterval || 1) * 60; return { gap: n, text: 'every ' + fmt(n) }; }
  if (field === 'days') { const n = (iv.daysInterval || 1); return { gap: n * DAY, text: n === 1 ? 'daily' : 'every ' + n + ' days' }; }
  if (field === 'weeks') {
    const n = iv.weeksInterval || 1;
    const days = [...new Set((iv.triggerAtDay && iv.triggerAtDay.length ? iv.triggerAtDay : [0]).map(Number))].sort((a, b) => a - b);
    if (n > 1 || days.length === 1) return { gap: n * 7 * DAY, text: n > 1 ? 'every ' + n + ' weeks' : 'weekly' };
    let gap = 0;
    for (let i = 0; i < days.length; i++) gap = Math.max(gap, ((days[(i + 1) % days.length] - days[i] + 7) % 7 || 7) * DAY);
    return { gap, text: days.length === 5 && days.join() === '1,2,3,4,5' ? 'weekdays' : days.length + ' days a week' };
  }
  if (field === 'months') { const n = iv.monthsInterval || 1; return { gap: n * 31 * DAY, text: n === 1 ? 'monthly' : 'every ' + n + ' months' }; }
  return { gap: null, text: field };
}

const out = [];
for (const { json: w } of $('Get published workflows').all()) {
  if (!w || !w.id || w.id === self) continue;
  const tags = (w.tags || []).map(t => t.name);
  if (s.ignore_tag && tags.includes(s.ignore_tag)) continue;
  const row = rows[String(w.id)] || {};
  if (row.enabled === false) continue;
  const override = Number(row.override_gap_minutes) > 0 ? Number(row.override_gap_minutes) : null;
  const triggers = (w.nodes || []).filter(n => n.type === 'n8n-nodes-base.scheduleTrigger' && !n.disabled);
  if (!triggers.length && !override) continue;      // webhook-only: Catch still sees its failures
  let gap = null; const texts = [];
  for (const t of triggers) for (const iv of ((t.parameters.rule || {}).interval || [{}])) {
    const r = ruleGap(iv); texts.push(r.text);
    if (r.gap) gap = gap === null ? r.gap : Math.min(gap, r.gap);
  }
  const inferred = gap ? Math.round(gap + Math.max(s.grace_min_minutes, gap * s.grace_ratio)) : null;
  out.push({ json: {
    workflow_id: String(w.id), workflow_name: w.name, schedule: texts.join(', ') || 'manual gap',
    expected_minutes: override || gap, max_gap_minutes: inferred, limit_minutes: override || inferred,
    saves_success: !(w.settings && w.settings.saveDataSuccessExecution === 'none'),
    updated_at: w.updatedAt, row,
  } });
}
return out;

Check: Once you connect the rest of the Watch lane in Step 5 and run it once, open monitor_watch to see Sync inventory (every 1 h, max_gap_minutes: 90), Import supplier feed (every 2 h, max_gap_minutes: 180) and Publish new products (cron 0 9 * * 1-5, max_gap_minutes: 6480):

The monitor_watch data table with schedules and gaps of 90, 180 and 6,480 minutes

Step 5: Check for stalls, bursts and empty runs every 15 minutes

Reading execution history from the n8n API rather than relying solely on self-logged rows is what lets Watch detect a workflow that never started at all. Connect Work out the schedules to an n8n node named Get recent executions (turn on Always Output Data so a brand-new workflow with zero executions doesn’t halt the lane, and set Retry On Fail to 3 tries, 5000 ms apart):

SettingValue
ResourceExecution
OperationGet Many
Limit50
Filters > Workflow{{ $json.workflow_id }} (By ID)
Credentialn8n API

Connect a Data table node named Get recent runs (with Execute Once and Always Output Data on) to pull the last 7 days of item-count logs from monitor_runs:

SettingValue
Resourcerow
Operationget
Data tablemonitor_runs
Must matchAll conditions
Condition: ran_atgt {{ $now.minus({ days: 7 }).toISO() }}
Return allon

Next, add a Code node named Judge each workflow (with Execute Once on). For each scheduled workflow, it evaluates four conditions:

  • stalled: no non-manual execution has started within limit_minutes (if a workflow has never run, it measures from updated_at when the workflow was last saved, and skips the stall check if the workflow doesn’t save successful executions).
  • failing: at least failing_threshold (5) error or crashed executions occurred in the last hour, surfacing a burst that Catch kept quiet during its cooldown.
  • empty: the last empty_runs_before_alert (default 3) rows in monitor_runs all had fewer items than min_items.
  • recovered: the workflow had alerting = true on the previous check and now has no active issue, so your team knows at 09:00 whether the 03:00 alert is already resolved.

It respects cooldown_minutes before repeating an ongoing alert and outputs only workflows whose schedule metadata or alert state changed:

// Judges each workflow: stalled, failing, empty, or recovered. Outputs only rows whose state changed.
const s = $('Watch settings').first().json;
const now = Date.now();
const plan = $('Work out the schedules').all().map(i => i.json);
const execs = $('Get recent executions').all().map(i => i.json).filter(e => e && e.id && e.mode !== 'manual');
const runs = $('Get recent runs').all().map(i => i.json).filter(r => r && r.workflow_id);
const fmt = m => m >= 2880 ? Math.round(m / 1440) + ' days' : m >= 120 ? Math.round(m / 60) + ' h' : m >= 60 ? '1 h ' + (m - 60) + ' min' : m + ' min';
const link = id => s.n8n_url ? ' <' + s.n8n_url.replace(/\/$/, '') + '/workflow/' + id + '/executions|Executions>' : '';

const out = [];
for (const p of plan) {
  const row = p.row || {};
  const mine = execs.filter(e => String(e.workflowId) === p.workflow_id);
  const last = Math.max(0, ...mine.map(e => Date.parse(e.startedAt) || 0));
  const since = last || Date.parse(p.updated_at) || now;            // never ran: count from when it was last saved
  const idle = Math.floor((now - since) / 60000);
  const fails = mine.filter(e => ['error', 'crashed'].includes(e.status) && Date.parse(e.startedAt) > now - 3600e3).length;
  const minItems = Number(row.min_items) > 0 ? Number(row.min_items) : null;
  const k = Number(row.empty_runs_before_alert) > 0 ? Number(row.empty_runs_before_alert) : 3;
  const recent = runs.filter(r => String(r.workflow_id) === p.workflow_id || (!r.workflow_id && r.workflow_name === p.workflow_name))
                     .sort((a, b) => Date.parse(b.ran_at) - Date.parse(a.ran_at)).slice(0, k);

  let issue = '', line = '';
  if (p.saves_success && p.limit_minutes && idle > p.limit_minutes) {
    issue = 'stalled';
    line = last ? `no run for ${fmt(idle)} (expected every ${fmt(p.expected_minutes)})` : `no run in the ${fmt(idle)} since it was last saved`;
  } else if (fails >= s.failing_threshold) {
    issue = 'failing'; line = `${fails} failed runs in the last hour`;
  } else if (minItems && recent.length >= k && recent.every(r => Number(r.items) < minItems)) {
    issue = 'empty'; line = (k === 1 ? 'the last run had ' : `the last ${k} runs all had `) + (minItems === 1 ? 'no items' : `fewer than ${minItems} items (latest: ${Number(recent[0].items)})`);
  }

  const was = row.alerting === true;
  const cooled = !row.last_alert_at || now - Date.parse(row.last_alert_at) > s.cooldown_minutes * 60000;
  let say = '';
  if (issue && (!was || row.alert_kind !== issue || cooled)) say = issue;
  else if (!issue && was) say = 'recovered';

  const changed = !row.workflow_id || row.workflow_name !== p.workflow_name || row.schedule !== p.schedule
    || (Number(row.max_gap_minutes) || null) !== p.max_gap_minutes || say;
  if (!changed) continue;
  out.push({ json: {
    workflow_id: p.workflow_id, workflow_name: p.workflow_name, schedule: p.schedule, max_gap_minutes: p.max_gap_minutes,
    alerting: !!issue, alert_kind: issue,
    last_alert_at: say && say !== 'recovered' ? new Date(now).toISOString() : (row.last_alert_at || null),
    say, line: issue ? line + link(p.workflow_id) : '',
    repeat: !!(say && was && row.alert_kind === say),
  } });
}
return out;

Connect a Data table node named Save the state to upsert each changed row by workflow_id. Mapping only the seven system columns leaves your manual tuning columns (override_gap_minutes, min_items, empty_runs_before_alert and enabled) untouched:

SettingValue
Resourcerow
Operationupsert
Data tablemonitor_watch
Must matchAll conditions
Condition: workflow_ideq {{ $json.workflow_id }}
Column: workflow_id{{ $json.workflow_id }}
Column: workflow_name{{ $json.workflow_name }}
Column: schedule{{ $json.schedule }}
Column: max_gap_minutes{{ $json.max_gap_minutes }}
Column: alerting{{ $json.alerting }}
Column: alert_kind{{ $json.alert_kind }}
Column: last_alert_at{{ $json.last_alert_at }}

Connect a Code node named Build the message (with Execute Once on) to combine all findings from this 15-minute check into one Slack message, or return [] to stop the lane quietly when nothing needs reporting:

// One message for everything this run found. Nothing to say, no message.
const rows = $('Judge each workflow').all().map(i => i.json).filter(r => r.say);
if (!rows.length) return [];
const icon = { stalled: '🔴', failing: '🟠', empty: '🟡', recovered: '✅' };
const order = ['stalled', 'failing', 'empty', 'recovered'];
rows.sort((a, b) => order.indexOf(a.say) - order.indexOf(b.say));
const bad = rows.filter(r => r.say !== 'recovered');
const lines = rows.map(r => r.say === 'recovered'
  ? `${icon.recovered} *${r.workflow_name}* recovered`
  : `${icon[r.say]} *${r.workflow_name}* ${r.say}${r.repeat ? ' (still)' : ''}: ${r.line}`);
return [{ json: {
  title: bad.length ? `Workflow health: ${bad.length} issue${bad.length > 1 ? 's' : ''}` : 'Workflow health: recovered',
  body: lines.join('\n'),
} }];

Finish the lane with a Slack node named Post the health alert and a disabled Send Email node named Email the health alert wired to its error output. Both use the exact same settings, retry counts and error output configuration as Post the alert and Email the alert in Step 2, except they read Watch settings and send $json.title as the header/subject and $json.body as the section/email text:

{{ ({ blocks: [{ type: 'header', text: { type: 'plain_text', emoji: true, text: String($json.title).slice(0, 150) } }, { type: 'section', text: { type: 'mrkdwn', text: String($json.body).slice(0, 2900) } }, { type: 'context', elements: [{ type: 'mrkdwn', text: 'n8n workflow monitor · ' + $now.setZone($('Watch settings').first().json.timezone).toFormat('d LLL, h:mm a') }] }] }) }}

Check: In monitor_watch, set override_gap_minutes to 1 on Publish new products and run Every 15 minutes by hand to receive a stalled alert. Clear override_gap_minutes back to null and run the lane again to receive the recovered message.

Step 6: Post a daily all-clear

A monitor that only speaks when something breaks looks identical to a monitor that has silently died. Posting a daily n8n health check at 08:00 makes silence itself a signal that something is wrong, and pairing it with an outbound heartbeat ping covers the case where your entire n8n server goes offline.

Start the Digest lane with a Schedule Trigger named Every morning (Trigger interval: Days, Trigger at hour: 8), which runs at 08:00 in your workflow’s configured timezone. Connect an Edit Fields (Set) node named Digest settings (with Include other input fields off):

SettingValue
slack_channel (string)#n8n-alerts
alert_email (string)[email protected]
from_email (string)[email protected]
only_tag (string)“
ignore_tag (string)no-monitor
heartbeat_url (string)“
keep_days (number)30
timezone (string)UTC

Wire three data-gathering nodes in sequence, each with Execute Once enabled (and Always Output Data on the two Data table nodes):

  1. Data table Get the last 24 hours of failures: Resource row, Operation get, Data table monitor_failures, Must match All conditions, Condition failed_at gt {{ $now.minus({ hours: 24 }).toISO() }}, Return all on.
  2. Data table Get the watch list: Resource row, Operation get, Data table monitor_watch, Return all on.
  3. n8n Get workflows for the digest: Resource Workflow, Operation Get Many, Return All on, Filters > Return Only Active Workflows on, Filters > Tags {{ $('Digest settings').first().json.only_tag }}, Filters > Exclude Pinned Data on (with Retry On Fail set to 3 tries, 5000 ms apart).

Connect a Code node named Build the digest (with Execute Once on). When no watched workflow is alerting and no failures happened in the past 24 hours, it outputs a one-line all-clear. Otherwise, it lists currently alerting workflows, groups the last 24 hours of failures by workflow (showing count, latest failure time, failing node and clipped error message), and finishes with a Not covered section. Because n8n has no instance-wide default error workflow setting, that Not covered check inspects every published workflow via the API and lists any workflow missing errorWorkflow, any workflow with saveDataSuccessExecution set to none (where stalls can’t be detected), and any watched workflow whose schedule couldn’t be parsed:

// Posts every day. All clear is one line; otherwise what's wrong, the day's failures, and what isn't covered.
const s = $('Digest settings').first().json;
const self = $workflow.id;
const fails = $('Get the last 24 hours of failures').all().map(i => i.json).filter(f => f && f.workflow_id !== undefined && f.id);
const watch = $('Get the watch list').all().map(i => i.json).filter(r => r && r.workflow_id);
const wfs = $('Get workflows for the digest').all().map(i => i.json).filter(w => w && w.id && w.id !== self
  && !(s.ignore_tag && (w.tags || []).some(t => t.name === s.ignore_tag)));
const live = new Set(wfs.map(w => String(w.id)));
const watched = watch.filter(r => r.enabled !== false && live.has(String(r.workflow_id)));
const alerting = watched.filter(r => r.alerting);
const icon = { stalled: '🔴', failing: '🟠', empty: '🟡' };
const clip = (t, n) => { t = String(t || '').replace(/\s+/g, ' '); return t.length > n ? t.slice(0, n - 1) + '…' : t; };

const groups = {};
for (const f of fails) {
  const g = groups[f.workflow_name] = groups[f.workflow_name] || { n: 0, last: f };
  g.n++; if (Date.parse(f.failed_at) > Date.parse(g.last.failed_at)) g.last = f;
}
const names = Object.keys(groups).sort((a, b) => groups[b].n - groups[a].n);
const noHandler = wfs.filter(w => !w.settings || w.settings.errorWorkflow !== self).map(w => w.name);
const noSaves = wfs.filter(w => w.settings && w.settings.saveDataSuccessExecution === 'none').map(w => w.name);
const noGap = watched.filter(r => !(Number(r.max_gap_minutes) > 0) && !(Number(r.override_gap_minutes) > 0)).map(r => r.workflow_name);

const lines = [];
let status = 'ok';
if (!alerting.length && !fails.length) {
  lines.push(`✅ *All clear*: ${watched.length} workflow${watched.length === 1 ? '' : 's'} watched, no failures in the last 24 hours.`);
} else {
  for (const r of alerting) lines.push(`${icon[r.alert_kind] || '🔴'} *${r.workflow_name}* is ${r.alert_kind}`);
  if (names.length) {
    if (alerting.length) lines.push('');
    lines.push(`*Failures in the last 24 hours* (${fails.length})`);
    for (const n of names.slice(0, 10)) {
      const g = groups[n], t = DateTime.fromISO(g.last.failed_at).setZone(s.timezone).toFormat('h:mm a');
      lines.push(`• *${n}*: ${g.n}×, last at ${t}${g.last.node_name ? ' in `' + g.last.node_name + '`' : ''}`);
      lines.push(`    _${clip(g.last.error_message, 160)}_`);
    }
    if (names.length > 10) lines.push(`• and ${names.length - 10} more`);
  }
  lines.push('', `_${watched.length - alerting.length} of ${watched.length} watched workflows are fine._`);
  status = alerting.length || names.some(n => groups[n].n >= 3) ? 'error' : 'warn';
}
const gaps = [];
if (noHandler.length) gaps.push(`${noHandler.length} published workflow${noHandler.length === 1 ? ' has' : 's have'} no error workflow: ${clip(noHandler.join(', '), 300)}`);
if (noSaves.length) gaps.push(`not saving successful runs, so stalls can't be seen: ${clip(noSaves.join(', '), 200)}`);
if (noGap.length) gaps.push(`schedule not understood, set override_gap_minutes: ${clip(noGap.join(', '), 200)}`);
if (gaps.length) lines.push('', '*Not covered*', ...gaps.map(g => '• ' + g));
const head = { ok: '✅', warn: '🟡', error: '🔴' }[status];
return [{ json: { title: `${head} n8n daily health`, body: lines.join('\n').trim(), status } }];

Connect Build the digest to Post the digest (Slack) and Email the digest (Send Email, disabled), configured just like Watch’s alert nodes but reading Digest settings. Wire both the main output of Post the digest and the output of Email the digest to an If node named Heartbeat set? (with Execute Once on):

SettingValue
Value 1{{ !!$('Digest settings').first().json.heartbeat_url }}
Operatorboolean true
Value 2“

Connect the true output (output 0) to an HTTP Request node named Ping the heartbeat (Method: GET, URL: {{ $('Digest settings').first().json.heartbeat_url }}, Options > Timeout: 10000, with Retry On Fail set to 3 tries, 5000 ms apart and On Error: Continue). When you paste a check URL from Healthchecks.io into Digest settings, Healthchecks.io expects a ping every morning and emails you itself if your n8n container or VM goes down.

Finally, wire both Ping the heartbeat and the false output (output 1) of Heartbeat set? to a Data table node named Delete old failures, then connect it to Delete old runs (both with Execute Once and Always Output Data on) to keep your tables well inside n8n’s default 200 MiB Data Tables storage limit:

NodeData tableCondition
Delete old failuresmonitor_failures (Operation: deleteRows)failed_at lt {{ $now.minus({ days: $('Digest settings').first().json.keep_days }).toISO() }}
Delete old runsmonitor_runs (Operation: deleteRows)ran_at lt {{ $now.minus({ days: $('Digest settings').first().json.keep_days }).toISO() }}

Check: Run Every morning by hand and confirm that Slack receives your daily health post (including any workflows under Not covered that haven’t had their error workflow set yet).

What it costs

Watch uses one execution per run regardless of how many workflows it inspects (2,880 executions a month at 15-minute intervals, or 720 a month if you change Every 15 minutes to hourly). Digest uses 30 executions a month, Catch runs once per failure that gets past Retry On Fail, sub-workflow calls to Log a run don’t count towards Cloud execution limits, and n8n API calls don’t count as executions. Because the monitor shares your monthly execution allowance with the workflows it watches, here is how it fits across plans (checked November 2026):

ItemWatch every 15 minutesWatch hourly
n8n self-hostedFreeFree
n8n CloudPro, €50 a month (about 2,910 plus failures, of 10,000)Starter, €20 a month (about 750 plus failures, of 2,500)
SlackFree plan worksFree plan works
Email fallbackFree with an SMTP account you already haveSame
Heartbeat (Healthchecks.io)Free, 20 checksFree
Chat modelNone in this templateNone

To keep execution counts low on n8n Cloud, switch Every 15 minutes to run hourly, turn on Retry On Fail on your external API nodes so temporary blips never reach Catch, and add the Log a run call only to workflows where a zero-item run is a real problem.

Common mistakes

Alerting on every failure

Sending a message every time a 5-minute workflow hits a rate limit produces dozens of identical pings in an hour and trains your team to mute the channel. Save every failure to monitor_failures, alert once per cooldown in Catch, let Watch report the error burst, and send a recovery line when it clears.

Testing the error workflow with a manual run

Clicking Execute workflow in the editor never triggers n8n’s Error Trigger node, even when the run ends in red. Always test your error workflow by enabling a scheduled trigger such as Every 5 minutes (demo) and publishing the workflow.

Turning on the error output with nothing wired to it

Setting a node’s On Error option to Continue (using error output) without connecting a downstream node to that second output silently discards the error and marks the execution green. Either wire the error output to a handler or leave On Error set to Stop Workflow so Catch receives the crash.

Forgetting the error workflow setting on new workflows

New workflows don’t inherit an error workflow automatically, which means a newly published automation will fail in silence until someone links it. Check the Not covered section of your morning digest to spot published workflows that still need Settings > Error workflow set.

Not saving successful production executions or pruning too aggressively

If a watched workflow has Save successful production executions set to Do not save, or if your instance prunes execution history after 24 hours while a workflow runs once a week, the n8n API will see zero recent runs and flag the workflow as stalled. Keep execution retention longer than your longest schedule gap and check the digest’s Not covered list for workflows that don’t save successful runs.

Building a monitor that only speaks on failure

If your alert channel is completely quiet for a week, you have no way to tell whether every workflow is healthy or your Slack token expired on Monday. Post the daily 08:00 all-clear and set a heartbeat_url so silence is immediately obvious.

Alerting into the channel that’s down

If your error workflow tries to post to Slack before recording the failure and Slack’s API is down, you lose both the notification and the record of the crash. Always insert the row into monitor_failures first, and enable the three SMTP fallback nodes if you want email delivery when Slack fails.

Letting the n8n API key expire

New n8n API keys default to a 30-day expiration, and when the key expires both Watch and Digest will fail. Catch will alert you when Watch fails, so pick a longer expiration or no expiration when creating the key.

Make it your own

Actionable alerts

You can add Retry, Snooze and Mute buttons directly to the Slack alerts so you can re-run a failed execution or silence a noisy workflow without opening n8n. Follow the guide on Slack approvals with n8n and Block Kit to wire interactive Block Kit buttons back to an n8n webhook.

Other channels

If your team doesn’t use Slack, replace Post the alert, Post the health alert and Post the digest with the Microsoft Teams, Telegram, Discord or PagerDuty node, sending the same text. For a solo instance where you only want email, delete the three Slack nodes and enable the three Send Email nodes.

Watch a second instance

Point the n8n API credential on Get published workflows, Get recent executions and Get workflows for the digest at another n8n instance’s URL and API key. Running the monitor on a second instance means neither instance is responsible for watching itself.

Business checks

Your morning digest doesn’t have to stop at execution status. Query your database or store API before Build the digest to append a line checking whether orders were imported in the last 24 hours or whether today’s supplier feed file updated on disk.

FAQ

Does n8n have built-in error notifications?

n8n doesn’t send email or chat alerts out of the box when a workflow fails; instead, it provides the Error Trigger node so you can build your own notification workflow. Cloud Pro and Enterprise, and self-hosted Business and Enterprise, also include the Insights dashboard to view failure rates in the UI, and alerts are something you build, usually as an error workflow like the one in this guide, which is how most n8n monitoring works.

Can I set one error workflow for every workflow at once?

No, n8n sets the error workflow per workflow under Workflow Settings > Error workflow, and one error workflow can be selected by as many workflows as you like. To make sure you never miss one, the morning digest in this guide automatically lists any published workflow on your instance that doesn’t have its error workflow set yet.

Will this work on n8n Cloud?

Yes, this setup works on any paid n8n Cloud plan (Starter or Pro) as well as self-hosted Community Edition. It won’t run during the n8n Cloud free trial because access to the n8n public API is disabled on trial accounts.

What about webhook workflows that don’t run on a schedule?

Webhook workflows are still covered by the Catch lane whenever an execution fails, and Work out the schedules skips them by default so Watch doesn’t flag them as stalled when traffic is quiet. If you have a webhook workflow that should always fire at least once a day (such as a daily order webhook), add a row for its workflow_id in monitor_watch and set override_gap_minutes to 1440.

What if n8n itself is down?

If the n8n process or server goes offline, no workflow on the instance can run to send an alert. Pinging an external service such as Healthchecks.io from Ping the heartbeat at the end of every morning digest ensures an outside server emails you as soon as the daily ping fails to arrive.

Next steps

Import the template into your n8n instance, fill in your Slack channel and n8n_url in the three settings nodes, and run Run once to set up to create the Data Tables. Publish the monitor, set it as the Error workflow on one production workflow, and enable Every 5 minutes (demo) for ten minutes to watch the first alert and cooldown work end to end.

reganmcgregor.com.au/n8n-error-handling/