Okum

Why n8n says a workflow succeeded when it didn't

31 July 2026 · Ashim Doley

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.

What "success" actually means in n8n

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.

Where the error actually lives

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:

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.

The harder half: whose fault was it?

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.

Two mistakes worth stealing

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.

Say "unclear" more often than you want to

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.

The part that doesn't have a happy ending

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.

The short version