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

> n8n error handling beyond the error workflow: alert once per outage, catch stalled schedules and empty runs, and post a daily all-clear. 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.

<TemplateCallout
  repo="https://github.com/reganmcgregor/n8n-ecommerce-templates/tree/main/n8n-workflow-monitoring"
  title="n8n workflow monitoring template"
>

- Catches failed executions, stalled schedules, error bursts and empty runs across your instance on one canvas.
- Sends one Slack alert per outage (with an optional email fallback), tells you when the workflow recovers, and posts a daily 08:00 summary.
- Lists published workflows that don't have an error workflow set and pings an optional external heartbeat so you know if n8n itself goes down.

</TemplateCallout>

## 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](./_attachments/n8n-error-handling/slack-error-alert.png "One alert per outage, with the node, the error and a link to the execution.")

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](./_attachments/n8n-error-handling/watch-health-messages.png "Watch reports bursts, empty runs, stalls and recoveries.")

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](./_attachments/n8n-error-handling/daily-digest-problems.png "The morning digest when something is wrong.")

![n8n daily health digest: All clear, 3 workflows watched, no failures in the last 24 hours](./_attachments/n8n-error-handling/daily-digest-all-clear.png "The morning all-clear.")

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](./_attachments/n8n-error-handling/n8n-template-canvas.png "The template: six lanes on one canvas.")

## 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](https://docs.n8n.io/workflows/components/nodes/) 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](https://docs.n8n.io/flow-logic/error-handling/) 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](https://docs.n8n.io/insights/) 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](https://n8n.io/workflows/13290) inspects schedule triggers via the n8n API and raises an error when a workflow is overdue, while [template 20896](https://n8n.io/workflows/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](https://n8n.io/pricing/), because the [n8n public API isn't available during the Cloud free trial](https://docs.n8n.io/api/authentication/).
- **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](https://docs.slack.dev/reference/scopes/chat.write/) (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](https://healthchecks.io/pricing/) 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](./_attachments/n8n-error-handling/n8n-create-api-key-read-only.png "A Read only key is all the template needs.")

## 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:

| Setting | Value |
|---|---|
| Resource | table |
| Operation | create |
| Table name | monitor_failures |
| Options > Reuse existing table | on |
| Column: workflow_id | string |
| Column: workflow_name | string |
| Column: execution_id | string |
| Column: execution_url | string |
| Column: node_name | string |
| Column: error_message | string |
| Column: mode | string |
| Column: failed_at | date |
| Column: alerted | boolean |

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

| Setting | Value |
|---|---|
| Column: workflow_id | string |
| Column: workflow_name | string |
| Column: items | number |
| Column: note | string |
| Column: ran_at | date |

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:

| Setting | Value |
|---|---|
| Column: workflow_id | string |
| Column: workflow_name | string |
| Column: schedule | string |
| Column: max_gap_minutes | number |
| Column: override_gap_minutes | number |
| Column: min_items | number |
| Column: empty_runs_before_alert | number |
| Column: enabled | boolean |
| Column: alerting | boolean |
| Column: alert_kind | string |
| Column: last_alert_at | date |

**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](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.errortrigger/) 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:

| Setting | Value |
|---|---|
| Include other input fields | on |
| slack_channel (string) | `#n8n-alerts` |
| alert_email (string) | `you@example.com` |
| from_email (string) | `n8n@example.com` |
| cooldown_minutes (number) | `120` |
| timezone (string) | `UTC` |

Next, add a **Code** node named `Read the failure`. An [error workflow receives two different payload shapes](https://docs.n8n.io/flow-logic/error-handling/): 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:

```javascript
// 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:

| Setting | Value |
|---|---|
| Resource | row |
| Operation | insert |
| Data table | monitor_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):

| Setting | Value |
|---|---|
| Resource | row |
| Operation | get |
| Data table | monitor_failures |
| Must match | All conditions |
| Condition: workflow_id | eq `{{ $('Read the failure').first().json.workflow_id }}` |
| Condition: alerted | eq `{{ true }}` |
| Condition: failed_at | gt `{{ $now.minus({ minutes: $('Catch settings').first().json.cooldown_minutes }).toISO() }}` |
| Return all | on |

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

| Setting | Value |
|---|---|
| Value 1 | `{{ $input.all().some(i => i.json.id) }}` |
| Operator | boolean 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:

| Setting | Value |
|---|---|
| Resource | Message |
| Operation | Send |
| Send Message To | Channel |
| Channel | `{{ $('Catch settings').first().json.slack_channel }}` (By ID) |
| Message Type | Blocks |
| Notification Text | `{{ String('🔴 ' + $('Read the failure').first().json.workflow_name + ' failed').slice(0, 150) }}` |
| Options > Include Link to Workflow | off |
| Options > Unfurl Links / Unfurl Media | off |
| Credential | Slack API (bot token) |

Set the **Blocks** parameter to an expression that builds a [Slack Block Kit](https://docs.slack.dev/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:

```javascript
{{ ({ 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:

| Setting | Value |
|---|---|
| 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 Format | Text |
| Credential | SMTP |

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

```javascript
{{ 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:

| Setting | Value |
|---|---|
| Resource | row |
| Operation | update |
| Data table | monitor_failures |
| Must match | All conditions |
| Condition: id | eq `{{ $('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**](https://docs.n8n.io/workflows/settings/) to this monitoring workflow:

![n8n Workflow settings with Error workflow set to the monitoring workflow](./_attachments/n8n-error-handling/workflow-settings-error-workflow.png "The one setting each watched workflow needs.")

As n8n's Error Trigger documentation notes, a workflow that contains an Error Trigger node [uses itself as its error workflow by default](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.errortrigger/), 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](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.errortrigger/). 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:

| Setting | Value |
|---|---|
| Input data mode | Define using fields below |
| Input: workflow_id | string |
| Input: workflow_name | string |
| Input: items | number |
| Input: note | string |

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

| Setting | Value |
|---|---|
| Resource | row |
| Operation | insert |
| Data table | monitor_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](https://docs.n8n.io/flow-logic/subworkflows/):

| Setting | Value |
|---|---|
| Source | Database |
| Workflow | This monitoring workflow (By ID) |
| Mode | Run once with all items |
| Wait for sub-workflow completion | Off |
| `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](./_attachments/n8n-error-handling/execute-workflow-log-a-run.png "Sync inventory logs how many items it handled.")

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](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.n8n/) 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):

| Setting | Value |
|---|---|
| slack_channel (string) | `#n8n-alerts` |
| alert_email (string) | `you@example.com` |
| from_email (string) | `n8n@example.com` |
| 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`:

| Setting | Value |
|---|---|
| Resource | Workflow |
| Operation | Get Many |
| Return All | on |
| Filters > Return Only Active Workflows | on |
| Filters > Tags | `{{ $json.only_tag }}` |
| Filters > Exclude Pinned Data | on |
| Credential | n8n 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`:

```javascript
// 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](./_attachments/n8n-error-handling/monitor-watch-table.png "Watch works out each workflow's expected gap from its schedule.")

## 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):

| Setting | Value |
|---|---|
| Resource | Execution |
| Operation | Get Many |
| Limit | 50 |
| Filters > Workflow | `{{ $json.workflow_id }}` (By ID) |
| Credential | n8n 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`:

| Setting | Value |
|---|---|
| Resource | row |
| Operation | get |
| Data table | monitor_runs |
| Must match | All conditions |
| Condition: ran_at | gt `{{ $now.minus({ days: 7 }).toISO() }}` |
| Return all | on |

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:

```javascript
// 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:

| Setting | Value |
|---|---|
| Resource | row |
| Operation | upsert |
| Data table | monitor_watch |
| Must match | All conditions |
| Condition: workflow_id | eq `{{ $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:

```javascript
// 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:

```javascript
{{ ({ 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):

| Setting | Value |
|---|---|
| slack_channel (string) | `#n8n-alerts` |
| alert_email (string) | `you@example.com` |
| from_email (string) | `n8n@example.com` |
| 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:

```javascript
// 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):

| Setting | Value |
|---|---|
| Value 1 | `{{ !!$('Digest settings').first().json.heartbeat_url }}` |
| Operator | boolean 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](https://healthchecks.io/pricing/) 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:

| Node | Data table | Condition |
|---|---|---|
| `Delete old failures` | `monitor_failures` (Operation: `deleteRows`) | `failed_at` lt `{{ $now.minus({ days: $('Digest settings').first().json.keep_days }).toISO() }}` |
| `Delete old runs` | `monitor_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](https://docs.n8n.io/flow-logic/subworkflows/), 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):

| Item | Watch every 15 minutes | Watch hourly |
|---|---|---|
| n8n self-hosted | Free | Free |
| n8n Cloud | [Pro, €50 a month](https://n8n.io/pricing/) (about 2,910 plus failures, of 10,000) | [Starter, €20 a month](https://n8n.io/pricing/) (about 750 plus failures, of 2,500) |
| Slack | Free plan works | Free plan works |
| Email fallback | Free with an SMTP account you already have | Same |
| Heartbeat ([Healthchecks.io](https://healthchecks.io/pricing/)) | Free, 20 checks | Free |
| Chat model | None in this template | None |

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](/slack-approval-workflow-n8n/) 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](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.errortrigger/) so you can build your own notification workflow. Cloud Pro and Enterprise, and self-hosted Business and Enterprise, also include the [Insights dashboard](https://docs.n8n.io/insights/) 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](https://docs.n8n.io/flow-logic/error-handling/) 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](https://n8n.io/pricing/) (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](https://docs.n8n.io/api/authentication/).

### 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](https://healthchecks.io/pricing/) 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.

<TemplateCallout
  repo="https://github.com/reganmcgregor/n8n-ecommerce-templates/tree/main/n8n-workflow-monitoring"
>

- Catches failed executions, stalled schedules, error bursts and empty runs across your instance on one canvas.
- Sends one Slack alert per outage (with an optional email fallback), tells you when the workflow recovers, and posts a daily 08:00 summary.
- Lists published workflows that don't have an error workflow set and pings an optional external heartbeat so you know if n8n itself goes down.

</TemplateCallout>
