Here’s the problem. You run a production n8n workflow that routes paying leads to partner companies. Since the day it went live, any test submission reaches a real partner: a real inbox, a real CRM record that can’t be deleted. You still need to change the workflow, sometimes within the hour. How do you change it safely?
This is how I do it for a South America travel company’s lead routing, which moved from Pabbly Connect to n8n on 28 September 2026. The routing workflow is a 39-node JSON file in git. A change is described, made in that file, tested against the file, diffed against the live copy, pushed, and then smoke-tested on a route no partner can see. On the day it went live, a gate that would have held back any enquiry with a blank last name was found, fixed, proven and live within an hour of the partner email that led to it, and the new tests were shown to fail on the old version first.
Stack, as of September 2026: n8n Cloud with its public REST API, Gravity Forms on WordPress, Salesforce REST API v62.0 into a partner’s org, Google Sheets, and Gmail.
Why couldn’t the old workflows be changed this way?
For years the routing lived in Pabbly Connect: 51 active workflows out of 92, roughly one per form, source and partner. A rule change meant editing many workflows by hand, with no undo. It isn’t a judgement on Pabbly as software. It’s a judgement on the shape of the problem.
As of October 2026, Pabbly Connect offers no API for listing, reading or editing workflows. Its MCP server exposes individual action steps you’ve already configured, not the workflows themselves. So the only way to read or change the routing was the web interface, one workflow at a time: expanding accordions, opening route panels, closing them again. Run history covered 15 days, so the failure evidence had to be captured before it expired.
n8n changed three things that matter here. A public REST API reads and writes workflows. Execution search answers “did you send us that lead?” with a lookup. And a workflow is plain JSON you can keep in your own repository. n8n sells a built-in git integration on a higher tier; I didn’t need it, because the JSON lives in the repo anyway.
What does changing a workflow “by describing it” actually mean?
One JSON file in the repository is the source of truth. Changes go into that file, they’re pushed through n8n’s API, and the server’s copy is saved back to git after every push. The workflow grew that way: 4 nodes at the end of August, 20, then 36, and 39 at cutover.
I don’t edit this workflow on the canvas. I describe the change in plain language to Claude Code, working in the repository alongside me. It edits the file and runs every check below. I read the diff, and nothing is pushed without my go. The design, the rules and the calls about what’s safe are mine; so is every mistake in what follows.
None of this is new. Plenty of teams keep automation in version control and review changes as diffs. The part worth recording is what sits between the description and production.
The AI is in how changes get made, not in how leads get routed. The routing itself is deterministic: region, paid or organic, and call tags, in a fixed order, with no model anywhere in the decision.
And the canvas stays readable for a person: a small number of well-named nodes, a note on nearly every node, and no logic hidden where a human wouldn’t look for it.
What has to pass before a change reaches production?
Five checks. Each one exists because of a specific bug it would have caught.
1. A local simulator that runs the workflow’s own expressions, in test mode and production mode. It exists because of a bug on 15 September. A Set node built a partner’s email address from a field set in the same node. In n8n, $json inside a Set node is the node’s input, not its own output, so in production every lead would have thrown, silently. Test mode short-circuited to a test inbox and never touched the broken branch. A clean shadow run would have gone green, and cutover would have dropped every lead.
The simulator doesn’t decide branches for itself. It evaluates the IF and Switch gates from the workflow’s own conditions, because an earlier hard-coded version had never exercised five of them. It throws on any operator it doesn’t implement, rather than guessing.
It builds test leads from each form’s exported field layout. That rule came from a plain question: “do all the PDF guide forms work?” At first, both the workflow’s guide-form normalizer and the simulator’s fake leads read all thirteen guide forms by one form’s field IDs, which is exactly why the test passed. Had it gone live, two destinations would have gone to the wrong partner, and other forms’ paid clicks would have looked organic. It never went live, because only the correctly mapped form was in shadow at the time.
It also counts its own work: 138 leads per mode, or the run fails. A bare return once let it exit successfully while failing. That one was mine.
2. A syntax check using n8n’s own expression pipeline. Plain JavaScript accepted 19 expressions that n8n rejects. The checker runs every expression through n8n’s own extendSyntax and @n8n/tournament (the versions it pins are n8n-workflow 2.16.0 and @n8n/tournament 1.11.1), and screens Code nodes for the words n8n refuses at runtime.
3. A mutation check: a new test must fail on the old workflow before its pass counts. This isn’t a one-off. It’s how each fix gets proven:
- breaking the escaping or the gate gave four failures;
- the syntax checker rejected the broken version with 19 errors and passed the fix with none;
- an injected throw gave three failures and a non-zero exit, and flipping one gate failed every lead;
- the phone workflow’s tests were proven the same way;
- and the fix below.
The rule behind all of them: every test reads the artifact it tests, never a copy of its logic. The phone workflow’s simulator says it in its header: “Nothing here is a copy of the logic.”
4. Diff live against git before every push. A person can still open the n8n editor, and a save there rewrites the JSON. On 17 September an exploratory save in the editor stripped default values from the live copy. The pre-push diff caught it, and the change went on top of live instead of overwriting it. After every push, the server’s copy must equal git’s.
5. A smoke test that reaches no partner. Since cutover, a form submission reaches a real partner. So the safe production smoke test is an enquiry for a destination the company handles in-house, which touches no partner, no sheet and no CRM. Salesforce needs a different probe, and that’s its own record.
How did a blank last name go from report to production fix in one sitting?
The engine went to production at 01:55 on 28 September. At 10:34 a partner replied to the cutover email to say they’d been missing leads. Reconciling every enquiry to find out why surfaced this defect along the way.
The defect. The gate every lead passes through, Valid Lead?, required a last name. A traveller had typed their full name into the first-name box; the trip builder’s last-name box is optional. During the shadow run that enquiry was held as invalid-lead. In production it would have reached no one. The old system had at least emailed it.
This one’s mine. Salesforce requires LastName on a Lead, so I built the gate to quarantine a nameless lead rather than half-deliver it. That put one destination’s rule on the gate every partner’s email passes through. And I’d seen this coming: ten days earlier, review found a contact form with an optional name field would be held by the same gate. I made that form’s name field required and left the gate as it was.
Scale: under 1% of entries since June, all on the trip builder. Rare, but every one was a real traveller with a real email address.
The described change, three nodes changed and nothing rewired:
- when the last-name box is blank and the first-name box holds two or more words, split them, in the one node every form passes through;
- the gate now needs a first or a last name, trimmed, plus an email, and it no longer accepts a last name that’s only whitespace;
- in Salesforce only, a single name gets the placeholder the partner’s own staff already use for a missing surname. A test fails if that placeholder ever appears in a partner’s email.
Tests: a new simulator section with six cases, and a rewritten gate table. The pre-fix engine fails five of the new checks; the fixed one passes everything. The syntax check covers 458 expressions. The routing rule passes 41 of 41 cases and 10 of 10 adversarial ones. The phone workflow passes 117.
Push and smoke test: live matched git before the push, and the server matched git after it. The workflow went live at 11:18. A minute later an in-house test enquiry, with a two-word test name in the first box and nothing in the last, came through split correctly, wasn’t held, and reached the company’s own inbox.
Not one of the first day’s later holds was for a missing name. The repair, for what it’s worth, lives in a node still called Resolve Paid, whose note now admits it also fixes names.
Which n8n expression traps got past plain JavaScript?
These are the ones that cost something, with the check that now catches each.
Optional computed access is “invalid syntax” in n8n. ?.[expr] is valid JavaScript, and n8n’s expression parser rejects it. It cost one broken push, restored within minutes, with no real submission in the window. Rewrite it as ((b, m) => b && m && m.x ? b[m.x] : '')(…). The syntax checker catches it now.
In a Set node, $json is the node’s input. One assignment can’t read another from the same node. That’s the bug that justified the simulator.
Further down a branch, $json is whatever fed the node. On cutover day a log column read $json.id after a Salesforce read-back node that spells the field Id, so it was blank on every phone lead. Name the node instead: $('SF Create Lead').first().json.id.
prototype, __proto__ and constructor parse fine and are refused at runtime, with Cannot access "prototype" due to security concerns. This one I caused. I shipped Object.prototype.hasOwnProperty.call(…) in an expression, and it quarantined a shadow lead on the first replay. Object.keys(o).indexOf(k) !== -1 does the same job. A static tripwire now fails the build on those words.
A node that throws with no error output loses the lead silently, because the webhook has already answered 200. Set onError to continueErrorOutput and wire that output to a quarantine. Those outputs went in with the same push as the prototype bug, and they’re the only reason that lead left a row and an alert.
A Google Sheets node on the main path replaces the item with the row it appended, and every $json field downstream goes undefined. Logic tests could never have caught this; only running the graph could. Keep sheet writes on side branches.
A webhook set to respond from the last node returns HTTP 500 No item to return was found when that last branch outputs nothing, even though the run succeeded. Respond immediately instead.
What can’t this chain prove?
- A live credential. Everything runs offline. On cutover day the whole suite was green while every Salesforce write failed. After any credential or endpoint change, prove it with one live write.
- The Salesforce placeholder path. That’s proven by the simulator only, because testing it live would write a permanent Lead into the partner’s org.
- Runtime refusals. A parser-only check can’t see them. It took a tripwire, and a quarantined lead, to learn that.
- Anything beyond the simulator’s operators. It refuses what it doesn’t implement, which is the right failure, but a new operator means extending it first.
- Holds during a shadow run. While the old system still delivers, a lead the new one holds costs nothing, so nobody feels it. The blank-last-name hold sat in the shadow log for days.
What would I carry to the next workflow?
- Keep the workflow as text, and make every test read that text, not a copy of its logic.
- Make a new test fail on the old version before you trust its pass.
- Diff live against git before every push. If someone saved in the editor, apply your change on top of live.
- Put a destination’s constraint in that destination’s payload, not on the gate every lead shares.
- Keep one route that reaches no partner, for smoke tests.
- During a shadow run, treat every hold as a lead you’ll lose at cutover.
Part of a series. Six System Debriefs on rebuilding the lead routing of a South America travel company. This is part 5.
- From 51 Pabbly Connect workflows to three in n8n — the rebuild this workflow came from.
- Every test green, every Salesforce write failing: scope parameter not supported — what the offline chain could not see on cutover day.
- Writing Leads into a Salesforce org you don’t own — why the placeholder path can’t be tested live.
- Lead source tracking that decides routing: gclid, call tags and live chat — how a lead’s source decides which partner gets it.
- Changing a live n8n workflow without testing on real partners (this post)
- Missing leads between web form and CRM: reconcile, don’t guess — finding which leads went missing, and where.
System Debriefs are drafted from working-session context by the harness that did the work, then reviewed and published by me.