The failure that costs you a client is never the loud one. A workflow that crashes shows up red in n8n, fires your error workflow, and gets fixed the same morning.
The expensive one looks like this: the execution is green, the run
finished, the status says success — and the client's report
went out empty. Or didn't go out at all. Nobody finds out until somebody
on their side notices, and by then it has been three weeks.
This is what people mean by a silent failure, and it happens because of a gap between two things that sound identical and aren't.
An execution's status describes whether the run completed. It does
not describe whether the work happened. Those come apart the
moment a node is set to carry on after an error — the "Continue" setting
on a node's error handling, stored as continueOnFail or, in
newer versions, onError: "continueRegularOutput".
That setting is usually added for a good reason. You don't want one missing record to kill a batch of two hundred. But it also means the node can fail, the run can carry on, and the execution can finish clean. n8n is not lying to you. It is answering a narrower question than the one you were asking.
A run finishing without an error only proves the call didn't error. Not that the intended outcome actually happened.
That framing came out of a conversation on the n8n community forum, and it is the cleanest statement of the problem I have seen. Same pattern with wrong-record writes, wrong-amount payments, and garbage data that completes cleanly.
If you are pulling executions from the n8n API to monitor them, the
instinct is to read execution.status and stop there. That
misses the entire silent class.
The useful part is deeper. Ask for the execution data explicitly:
GET /api/v1/executions?limit=50&includeData=true
Now each execution carries a data object. There are two
separate places an error can appear inside it:
data.resultData.error — the run-level error. This is the
one that turns the execution red. If you only read status, this is
effectively all you ever see.
data.resultData.runData — an object keyed by node name.
Each value is an array of that node's runs, and any of those runs can
carry its own error. This is where the silent ones
hide.
So you walk every node, not just the top level:
function collectErrors(execution) {
const out = [];
const result = execution.data?.resultData ?? {};
// The run-level error — the one that turns the execution red
if (result.error) out.push({ node: null, error: result.error });
// Every node's own runs — where a "successful" execution hides its dead node
const runData = result.runData ?? {};
for (const nodeName of Object.keys(runData)) {
for (const run of runData[nodeName] ?? []) {
if (run?.error) out.push({ node: nodeName, error: run.error });
}
}
return out;
}
If collectErrors returns something on an execution whose
status is success, you have found a silent failure. In my own
history, the first time I ran this over past executions, it found runs I
had considered fine for weeks.
Finding the failure is the easy part. The alert still doesn't tell you what to do about it.
An expired Google OAuth credential, a client renaming a column in their sheet, and a bug in your own expression all arrive looking identical — "workflow failed." But two of those are a message to the client and one is you opening your laptop. Working that out by hand, every time, is the part that actually eats the evening.
The error object usually carries enough to separate them, but not in
error.message alone. The real signature tends to sit in
error.description, error.code,
error.httpCode, or nested inside error.cause,
which often holds the raw response body from the API that rejected you.
Flatten all of them into one string before you try to match anything.
I got both of these wrong first, and each took a while to notice because the failure mode was quiet.
1. Keying on HTTP status codes misses dead Google credentials.
invalid_grant and a 401 are reliable signals.
But a broken Google OAuth credential frequently surfaces as
Unable to sign without access token — with no HTTP code at
all, because the request was never sent. n8n failed at the signing step,
before it ever reached the network. My first classifier keyed on status
codes and dropped that entire class into "unclear." It sat there for
days before I worked out why nothing was matching it.
2. Matching on the word "token" is a trap.
It feels like a safe auth signal. It isn't. OpenAI's context-length errors are full of the word and have nothing to do with authentication. "Maximum context length is 4096 tokens" is not an expired credential, and telling someone it is will cost you more trust than saying nothing. The word only counts when it sits next to a death word — expired, revoked, invalid, refresh, denied.
The instinct when building this is to classify everything, because an unclassified bucket feels like failure. It isn't.
A confidently wrong blame label is worse than no label. Tell someone "not your bug" when it is in fact their bug, and they stop believing every label you produce after that — including the correct ones. The bucket you should be protecting is not the unclassified one. It is the trustworthy one.
A 403 is a good example. It could be a permission or scope
problem, which points at the credential. It could be a quota problem,
which points at the provider. Unless the message says which, the honest
answer is that you don't know.
Text matching gets you a long way and then stops. Every integration fails in its own shape, and that set is not stable.
Someone in the n8n community put the structural problem better than I had: reconstruction from error text is indexed to error shapes, and that set grows with every new integration and every provider that decides to change its error format. It refills from a source you do not control. Verifying an action's own receipt — the message ID the API handed back — is indexed to the actions you choose, and that set stops growing.
Which means: if you are building this for yourself, expect to keep maintaining the classifier. It does not converge. The realistic plan is reconstruction everywhere as the cheap default, plus receipt checks on the handful of actions per client that genuinely cannot be undone — money, external sends, deletes.
runData per node, not just resultData.error.message, description, code, httpCode and cause before matching.