I have six scheduled and form-driven n8n workflows on a VPS. They run every few minutes with nobody watching. Without alerts, a failure only shows up when I notice that something did not happen.
The fix is one small error workflow. It has four nodes, and every other workflow points at it. When any of them fails, I get an email with the workflow name, the node that failed, the error message and a link to the execution.
The alert workflow was the easy part. Most of this post is about the second half: shaping failures in the other workflows so that the email is worth reading.
The error workflow
Four nodes in a line:
- Error Trigger fires when a workflow that points here fails.
- Who am I asks the Gmail API for the address of the connected account.
- Build email is a Code node that builds the message.
- Send posts it to the Gmail API.
I generate my workflows from a Python script, so here is the definition as it is in that script. http() and code() are helpers that return an HTTP Request node and a Code node:
def alerts():
nodes = [
{"id": nid(), "name": "Error Trigger", "type": "n8n-nodes-base.errorTrigger", "typeVersion": 1,
"position": [0, 0], "parameters": {}},
http("Who am I", [220, 0], "GET", "https://gmail.googleapis.com/gmail/v1/users/me/profile", "gmail"),
code("Build email", [440, 0], BUILD_EMAIL_JS),
http("Send", [660, 0], "POST", "https://gmail.googleapis.com/gmail/v1/users/me/messages/send", "gmail",
json_body="={{ JSON.stringify({raw: $json.raw}) }}", retryOnFail=True, maxTries=3, waitBetweenTries=10000),
]
conns = connect(("Error Trigger", "Who am I"), ("Who am I", "Build email"), ("Build email", "Send"))
return workflow("ClipStack - Error alerts", nodes, conns)
(In the real file the JavaScript is inline. I named it BUILD_EMAIL_JS here to show it separately below.)
Both HTTP Request nodes use “Predefined Credential Type” authentication with a Gmail OAuth2 credential. If you build this in the editor, those are the two settings to pick on each node.
Why “Who am I” exists
I did not want an email address written into a workflow file that lives in git. The Gmail profile endpoint returns the address of whichever account the credential belongs to, as emailAddress. The alert goes to that address. If I reconnect the credential to another account, the alerts follow it with no edits.
Building the message
The Gmail API messages/send endpoint takes one field, raw. It is a complete RFC 822 message, base64url encoded. The Code node does that by hand:
const e = $("Error Trigger").first().json;
const to = $json.emailAddress;
const wf = (e.workflow || {}).name || "unknown workflow";
const ex = e.execution || {};
const err = ex.error || (e.trigger || {}).error || {};
const subject = `[ClipStack] ${wf} failed`;
const body = [
`Workflow: ${wf}`,
`Failed node: ${ex.lastNodeExecuted || "?"}`,
`Error: ${err.message || "unknown"}`,
"",
ex.url ? `Execution: ${ex.url}` : "",
].join("\n");
const mime = [
`To: ${to}`,
`Subject: =?UTF-8?B?${Buffer.from(subject).toString("base64")}?=`,
"Content-Type: text/plain; charset=UTF-8",
"",
body,
].join("\r\n");
return [
{
json: {
raw: Buffer.from(mime)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, ""),
},
},
];
Some details that are easy to get wrong:
- The error can be in two places. For a normal failure it is at
execution.error. The code also checkstrigger.error, which is where the error sits when the failing workflow’s own trigger is what broke. In that case there is no execution to report on. - Every field is read defensively.
(e.workflow || {}).name,ex.lastNodeExecuted || '?'. An alert workflow that throws on a missing field is worse than no alert workflow. - The subject is encoded. The
=?UTF-8?B?...?=wrapper keeps a workflow name with non-ASCII characters from breaking the header. - Header lines are joined with
\r\n. A blank line separates headers from the body. - base64url, not base64.
+becomes-,/becomes_, and the=padding is removed. ex.urlis a link to the failed execution. It is only added to the body when it is present.
Pointing the other workflows at it
A workflow opts in through its settings. In the editor it is the “Error Workflow” dropdown in the workflow settings. In JSON it is one key:
settings = {"executionOrder": "v1", "saveDataErrorExecution": "all", "saveDataSuccessExecution": "all"}
if error_wf:
settings["errorWorkflow"] = error_wf
The value is the ID of the error workflow. That creates an ordering rule for a scripted deploy: create the error workflow first, read its ID from the API response, then build and create the others with that ID.
The alert workflow itself has no errorWorkflow. If sending the alert fails, there is nobody left to tell. That is why the Send node is the one with retryOnFail, three tries, ten seconds apart.
The error workflow only fires for real runs started by a trigger. A manual test run in the editor that fails does not call it. See the n8n error handling docs.
Make the failure say something
Out of the box, the alert for a failed HTTP call is a generic HTTP error. It does not tell me which file was being handled or what state things were left in. I use three patterns to fix that.
Clean up first, then fail on purpose
One workflow sends a file to an internal API for processing. If that call fails, I want the file moved to a “Failed” folder, with the reason saved on it, and I still want the alert.
The risky node gets onError="continueErrorOutput". That gives it a second output for errors. The error branch does the cleanup and ends in a Stop and Error node:
def stop(name, pos, message):
return {"id": nid(), "name": name, "type": "n8n-nodes-base.stopAndError", "typeVersion": 1,
"position": pos, "parameters": {"errorMessage": message}}
stop("Import failed", [1760, 120],
"=AutoClip import failed for {{ $('Next video').first().json.name }} (moved to ClipStack/Inbox/Failed): {{ ($('Import to AutoClip').first().json.error || {}).message }}"),
Wired like this:
("Import to AutoClip", 0, "Create clip folder"), ("Create clip folder", "Mark processing"),
("Import to AutoClip", 1, "Move to Failed"), ("Move to Failed", "Import failed"))
Output 0 is the success path. Output 1 is the error path. On the error path the original error is available on the item as $json.error.message, so the Stop and Error message can include the file name, what was done about it, and the original error text.
The Stop and Error node fails the execution, which triggers the error workflow. The email then reads like a sentence a person wrote, and it tells me where the file went.
Retry only what is safe to repeat
Reads and small metadata updates get retries:
retryOnFail=True, maxTries=3, waitBetweenTries=5000
One node never does. It uploads a finished video file to an external API. The comment in my script explains it:
# Never retried: a timeout after YouTube accepted the bytes would post the clip twice.
http("Upload to YouTube", [1100, 0], "PUT", "={{ $('Start YouTube upload').first().json.headers.location }}", "youtube",
binary_body=True, timeout=1800000, onError="continueErrorOutput"),
A timeout does not prove the other side did nothing. So this node also uses the error output. The failed item is moved to a “Needs review” folder, and the Stop and Error message tells me to check by hand before I queue it again. The alert email is how I find out that a check is needed.
The rule I follow: if running the node twice could create two of something, do not retry it. Fail, park the item and send the alert.
Let unimportant steps fail quietly
Some steps should never produce an alert. A cleanup call at the end of a run is one. A lookup where “not found” is a normal answer is another. Those get onError="continueRegularOutput":
http("Get workspace", [660, 0], "GET", f"={AUTOCLIP}/studio/{{{{ $json.pid }}}}", timeout=60000,
onError="continueRegularOutput"),
The node passes an item with an error field down the normal output. The next Code node looks at it and decides what it means:
if (w.error) {
const msg = String(w.error.message || w.error);
if (msg.includes('404')) return [{json: {...p, state: 'failed', reason: 'project no longer exists in AutoClip'}}];
continue; // AutoClip unreachable; try again next run
}
A 404 is a real failure with a clear reason. Anything else is treated as “the service is down right now”, and the workflow tries again on its next run five minutes later. Without this, a short restart of that internal service would send me an email every five minutes.
“Nothing to do” is not an error
The scheduled workflows poll. Most runs find nothing. In a Code node, returning an empty array ends that branch without an error:
const file = ($json.files || [])[0];
if (!file) return [];
No items means the later nodes do not run, the execution is a success, and no alert goes out. I use that everywhere a workflow checks for work.
What it looks like in practice
The subject line is [ClipStack] <workflow name> failed, which makes a mail filter easy. The body is four lines: workflow, failed node, error, execution link.
The whole setup is one four-node workflow and one setting on each of the others. The part that took thought was deciding, node by node, which failures should retry, which should be ignored, and which should stop the run with a message I can act on.