[FOR HIRE] n8n workflows that move money or documents: what happens on run #2. One workflow, $250 fixed, async

Most workflow reviews check whether it runs. I check what happens when it runs twice.

If a workflow of yours issues invoices, posts payments, moves stock or files documents, the failure that costs real money is rarely a red error node. It’s a webhook that fired twice, a batch that half-committed, a duplicate that looked new because the source system changed one character in the reference.

The API succeeded, n8n timed out, the retry fired, and the payment was posted twice. That’s the kind of bug I’m looking for.

I’ve spent 20 years building systems where one document posts a payment and moves stock at the same time. That’s where you learn what “already processed” has to mean, because when it means the wrong thing the damage shows up in the numbers weeks later.

What you get:

  • one workflow reviewed against duplicate runs, retries, out-of-order events and partial failures
  • a written list of what breaks, naming the node and the data that triggers it
  • a hardened JSON version of the workflow, with the failure cases fixed where possible
  • $250 fixed, 3 working days, all in writing, no calls

If I don’t find a concrete failure mode worth fixing, you don’t pay.

Send me the workflow JSON and one line about what it touches. I’ll tell you within a day whether it’s worth looking at, and I’ll say so plainly if it isn’t.

Hi Aleks, saw your post. The line about checking what happens on run $2 rather than whether it runs is the right test, and it is not the common one.

A version of it from my own engine, not n8n: I found 27 workflow branches that were being skipped because the condition could never match. Every run still finished as COMPLETED. Nothing failed, nothing alerted, and they had been like that for weeks. Run $2 looked exactly like run $1, which was the problem.

I build a hosted tool in this space so I am biased, and I am not selling you anything. I am more curious what the most common failure mode you find is. If that is worth ten minutes I would rather hear yours than describe mine.

The one I hit most often is a match key that looks stable and isn’t.

Dedup gets built on whatever field is convenient at the time. Then the source renumbers a document or fixes a reference, and the record arrives looking new. Nothing errors, the workflow reports success, and the duplicate only surfaces weeks later, during a reconciliation.

Your 27 skipped branches are the same animal from the other end. The run is green because nothing threw, and green is exactly what stops anyone from looking.

One thing I usually look for is a count, not just a status. If a run can’t tell me how many records it processed, skipped and rejected, COMPLETED doesn’t say much. It’s a small thing to add, and it’s usually the first thing missing.

I’m curious about one thing in your engine: does it distinguish “green but semantically idle” from “green and actually processed”, or is that still something you catch by comparing runs?

Honest answer: it does not. The data exists one level down and nothing rolls it up.

Every step row carries its own status and SKIPPED is one of them, so the information is sitting right there. But the run row has only status, duration, tokens and cost. A run with 27 skipped branches and a run with none look identical until you open it and read the steps. That is exactly why mine sat for
weeks. I catch it by opening a run, which means I catch it when I already suspect something, and that is not detection.

The tool layer has a fragment of it. When an agent pulls fields out of a list endpoint the result carries how many records it saw, so it can tell “no matches” from “I only read the first one”. That never reaches the run.

Your version is at the right level. Counts of processed, skipped and rejected on the run itself, because then COMPLETED with 0 processed is a thing you can alert on. I am adding it. The step rows already exist, so it is a rollup rather than new plumbing.

The match key one is worse than mine, though. Mine was static, so it was wrong from the first run. Yours is right until the day the source renumbers something.

That rollup makes sense. One thing from the accounting side before you ship it: a zero can be legitimate. Quiet day, nothing to send, COMPLETED with 0 processed is correct. The alert gets teeth when the count is two-sided, when the source can expose a count for the same window: the run says what it processed, the source says what it handed over, and the two have to tie out. That’s how ledgers get audited: nobody trusts one side’s total. In practice it can be as small as asking the source for its count and writing both numbers on the run.

On the match key, agree that mine is nastier. The only mitigation I know is to stop trusting the key alone. Keep a content fingerprint next to it, a hash of the fields that make the record what it is. Renumbering then shows up as “same content, new key” instead of a silent duplicate, and that’s an anomaly you can count in the same rollup, one more column.

Good luck shipping it. If you write it up when it’s done, I’d read it.

Shipped it. Two things came out of it, and one is a correction.

I told you the step rows carried the skipped status and the data was one level down. The status existed in the enum and had never once been written. A skipped step created no row at all, so it was invisible in the step view too, not only at the run level. Your question found a worse bug than the one you asked about.

The second is your zero, and you are right. I shipped a badge that fires when a finished run processed nothing. On a scheduled workflow with nothing to do that is a false alarm every quiet day, and an alert that cries wolf gets muted before the day it matters.

The two-sided version is the right shape and I have one end of it already: when an agent reads a list endpoint the tool records how many records the source returned. That number never reaches the run. Putting it next to what the run processed gives the tie-out you describe without asking anyone to configure anything.

The content fingerprint I had not thought of. Same content, new key, counted as an anomaly instead of arriving as a silent duplicate.

I will write it up.

1 Like

Wrote it up: The enum value that had never been written - DEV Community

Your 0 is in there, and so is the badge I removed because of it. The 2-sided count is in the “what is still broken” section, because I have not built it.

Hi Aghassi,

Adding that rollup at the run level is definitely the right move—catching “COMPLETED with 0 processed” automatically saves so much silent troubleshooting time down the line.

Let me know once you’ve implemented it! Always interesting to see how hosted engines tackle execution-level visibility.

It is in. Shipped on the 21st: a run now reports how many steps it processed, skipped and failed, and a skipped step writes its own row with the reason.

Filmed it rather than screenshotting it, because the interesting frame is the workflow page saying 100% success rate above a run that skipped half its steps:

The count is still one-sided, which is the part I have not fixed.

Watched the video. The workflow page saying 100% above a run that skipped half its steps is exactly the frame, that is the green checkmark this whole thread started from.

On the one-sided count: you already have the other side. You said it yourself, the tool records how many records a list endpoint returned, the number just never reaches the run. So what’s missing is plumbing, not design. I would resist wiring an automatic comparison first though. Write both numbers on the run and stop there for a release. The pairs will teach you what legitimate daylight between them looks like (pagination, filters, records created mid-run), and only then is an alert worth having. Wire the alert first and it cries wolf, same lesson as your zero badge.

One thing to pin down before the two numbers mean anything: the window. If the run reads from a cursor and the source reports its full list, the counts don’t owe each other anything. Same window on both sides, or the tie-out is theater. Accountants get bitten by this constantly, half of every reconciliation fight turns out to be two reports quietly using two calendars.

Right on the first part. 100% over a run that skipped half its steps is the same green checkmark the thread started from.
sc
On the count I have to correct myself, because I went and checked the code and I had it too rosy. I record the other side in exactly one place: the http_request tool, and only when the agent passes a select argument, where it stores len(records) from the response. Every other tool, and http_request without select, only writes the raw body into the step row. The list is there, the number is not, and at the run level there is no count field at all. So it is plumbing for that one path, and compute-it-first for everything else.

Your sequencing is right and I am taking it. Write both numbers on the run, show them, and stop there for the first release. No auto compare, no alert yet. Same lesson as the zero badge.

1 Like

One plumbing detail worth locking now: both paths should land the number in the same field on the run. Whether it came from select or from a compute step, downstream sees one number in one place and does not care how it was made. Saves the comparison you will eventually build from growing an opinion about tools.

Curious what the first pairs show once real traffic hits. If the daylight looks odd, bring a couple here, happy to read them.

Yes, this is how I will build it. One field on the run. Same field whether the number come from the select or from a compute step, so nothing after that need to know where it came from.

Right now I have no count field on the run at all. Only count I have is inside the http_request tool output. So better to do it this way from start, not fix it later. Easier now.

When I have real traffic and get first pairs, I bring some here if the numbers look strange.

Good call doing it now - retrofitting a count field after real traffic means backfilling old runs or living with a blind spot in every comparison.

One more small thing while you’re at it: write that field once, at the end of the run, not incrementally along the way. If a run retries halfway, an incremented counter double-counts and you end up chasing ghosts. A single final write stays honest.

Bring the pairs whenever - odd daylight between two numbers is usually the most informative thing in the whole setup.

The retry point is one I would have got wrong. I was going to increment as steps finish, because that is obvious way when you already have a per-step loop. A retry halfway would have double counted and I would have spent a day trusting the number.

No pairs yet, and the reason is still the same one: the source side is not persisted. The http_request tool works out how many records came back and throws it away, so there is nothing to compare a run against. That is the next thing.

One thing I did find, from a different thread. I have a cleanup task on an hourly schedule with a TODO for a body. It logs “cleanup completed: removed 0 expired results” and returns status completed, and has never deleted anything. Same shape as everything we have been talking about, sitting in my own beat schedule.

The cleanup one is a nice catch, and it is the purest form of the pattern: the job never lied, it just reported the only number it had. A TODO body that returns completed is worse than a job that is not scheduled at all, because it sits in the slot where you would otherwise notice the gap.

Two cheap guards for that class. A stub should not be allowed to say completed. Return not_implemented, or skipped with a reason, so the status carries the truth before the body even exists. And the same two-sided count again: “candidates found” written next to “removed”. Zero removed with zero candidates is a quiet hour. Zero removed with 40 candidates is a bug, and the run says so itself, nobody has to read logs.

On the source side, the count already exists inside the http_request step, it just dies there. Writing it to the run record at that exact point is a one-line change and gives you the pair without touching anything else.

You were right about the one line. I went and looked.

The count is computed in the http tool and goes into the result. Then the tool use loop appends tool and args to an invocation list and drops the result on the floor. That list is already persisted onto the execution record. So the pair needs one line changed in each of the two provider loops and nothing else moves.

Your slot point is better than my version of it. I deleted the task rather than fixing its status, which also removed the slot, so now nothing claims retention is handled. That is honest but it is not the general rule. Yours is: a stub returns not_implemented, or skipped with a reason, so the status is true before the body exists.

Candidates written next to removed would have caught this one on day one. 0 removed with 0 candidates is a quiet hour. 0 removed with forty candidates is the bug I had for a year.

1 Like

Deleting it is honest, but you named the cost yourself: retention went from “falsely handled” to “nobody’s problem”, and the second state is quieter than the first. I would put the stub back on the schedule, returning not_implemented. Then the dashboard has a line that says retention is not done, and that line nags until someone writes the body. A missing job nags nobody.

On the two provider loops: since both will write the same count field, have them call one small function for the write instead of each doing its own line. Otherwise in six months one provider counts records and the other counts pages, and the pair you built to catch drift starts drifting itself.

You were right about the source side. The count was already computed inside the http step and then thrown away one line later, in both provider loops, and everything downstream was already storing it. It is in now and deployed today, so every tool call writes what came back, not just that it happened.

A call that returns nothing renders as 0 records next to the tool name. That is your second point, candidates written next to removed, put where someone actually looks instead of in a log. I kept it to counts, not payloads, so it cannot turn into a data dump on every run forever.

One limit I should say out loud. This is still our side of the call. It proves the tool returned zero rows. It does not prove the destination did anything with them. That part is not built.

On the shared write, that is what I did. Both provider loops call one function that turns a tool result into the stored summary, so the shape can only drift in one place. Your six months point was the reason it is a function and not two lines.

On the stub I disagree, and here is why. Retention is not unfinished work here, it is decided: execution history is kept indefinitely. A job returning not_implemented would nag until someone writes a body I do not intend to write.

Where you are right is that the decision lives in a doc and nothing in the product says it. A user cannot tell “kept forever” from “nobody thought about it”, and that is your quiet state wearing different clothes.

1 Like