My n8n lead qualifier sent a "🔥 Hot lead (85)" alert to Slack for a form whose message field was four emojis.
It did the same for a message that was the literal string "undefined", for one that was only <script>alert(1)</script>, for a number, for null, and for an empty string. 15 fake hot-lead pings in total. Every run was green, and the Error Trigger never fired once.
I've written before about silent failures in n8n AI workflows. That post was about the model's output. This one is about what happens before the model: the stuff people actually POST to your webhook. Forms with a missing field, a Zapier step that sends null, an OCR job that returns 300 KB of noise, a Typeform that renames a field to Review.
So I started a series where I try to break my own workflows. Episode 1: 30 malformed inputs, 5 workflows, one Error Trigger, and a count of how many runs fail where I can see it vs how many fail silently.
The setup
- n8n 1.123.84 self-hosted, fresh instance, 5 of my own workflows unchanged except for the model URL: lead qualifier (with Slack ping for score ≥ 70, pointed at a local mock), support FAQ, invoice extractor, meeting notes → action items, review reply.
-
Qwen 1.8B (Q4_K_M) in Ollama on CPU through its OpenAI-compatible
/v1endpoint, temperature 0.2,response_format: json_object, Ollama's default 4096-token context. - An Error Trigger workflow set as the error workflow on all 5. It posts every failure to a local receiver, so "loud" means "I'd have been alerted".
-
30 payloads per workflow, built from one valid payload each:
- 22 unusable ones that should never reach the model: empty body,
{},[],null, truncated JSON, main field missing /""/ whitespace /null/ number / boolean / array / nested object, the strings"undefined"and"[object Object]", emoji only, zero-width characters only, ~300 KB of text, one 50,000-character token, the field name in the wrong case (Review), the payload double-wrapped in{"body": ...}, and a<script>tag with nothing else. - 1 edge case: valid text sent as
text/plain. - 7 weird but valid ones that should still work: Arabic (RTL), Hindi, emoji mixed into real text, real text wrapped in HTML with a
<script>, zero-width and control characters inside words, 300 junk extra fields, and{{ $env.HOME }} ${process.env.PATH}pasted into the text.
- 22 unusable ones that should never reach the model: empty body,
That's 150 runs per version, one round each. Every run got one of three labels:
- Handled: unusable input rejected with an error the caller can see, or valid input processed correctly (I checked the actual answer: invoice total = 11,800, FAQ gives the 14-day refund rule, the meeting has its action items, and so on).
- Loud: the execution failed and the Error Trigger fired.
- Silent: green execution, but the output was built from garbage, or was wrong for a valid input.
The raw results: 102 of 150 silent
| Raw workflows | Runs |
|---|---|
| Handled correctly | 37 |
| Errored loudly (Error Trigger fired) | 11 |
| Failed silently (green) | 102 |
The 22 unusable inputs are where it really shows: 95 of those 110 runs went green. n8n itself rejected exactly one kind of input, truncated JSON (HTTP 422 "Failed to parse request body", 5 runs). Every other kind of broken payload went straight to the LLM.
By workflow, it was the same story everywhere:
| Workflow | Silent | Handled | Loud |
|---|---|---|---|
| Lead qualifier | 19 | 8 | 3 |
| Support FAQ | 22 | 6 | 2 |
| Invoice extractor | 20 | 8 | 2 |
| Meeting notes | 21 | 7 | 2 |
| Review reply | 20 | 8 | 2 |
What "silent" looked like
Not all silent failures are equally bad, so I split them. 46 of the 102 produced confident-looking output. The other 56 produced empty or "escalate" output, which looks harmless but is still a green run nobody gets told about.
The confident ones:
-
Lead qualifier, 19/19 silent runs confident. With the message missing, the model scored the rest of the form (company size, budget) and invented intent. For an empty message: "Priya Nair is a SaaS founder with 42 employees... Her interest in budgeting ($3,000/month) suggests she is looking for solutions." Score 85. Slack ping. For the emoji-only message it listed "The lead uses strong emojis in the message" as a reason. A completely empty body scored 45, and
{}scored 50 ("warm"). -
Review reply, 20/20 confident. It wrote a polite public reply to every non-review. Emoji only: sentiment positive, rating_guess 5, "Thank you for your kind review! We're thrilled to hear that you enjoyed your experience at Cafe Mocha." The
<script>review got topics["script injection", "malicious code"]and a reply promising to "take steps to ensure similar issues do not occur in the future". If that goes to a Google review queue, it gets posted. -
Meeting notes, 3. The transcript
"undefined"produced invented action items: "Asha: Review and finalize the project timeline, due next week." Nobody said that. -
Invoice extractor, 2. The worst single output in the test: invoice text that was only whitespace came back as a complete invoice. Vendor "ABC Corp", invoice INV-12345, dated 2023-01-15, subtotal 1,500, tax 300, total 1,800 USD, two line items, a GSTIN. None of it exists. The other one: the number
4521sent as the invoice text came back asinvoice_number: "4521". - Support FAQ, 2. Both were valid questions, and the answers were right, but the JSON was broken in a way nothing notices (more on that below).
The empty ones are the sneaky kind. The FAQ bot answered "I am not sure, a teammate will follow up shortly" with escalate: true on 19 unusable inputs, so a human gets a ticket with no question in it. The invoice extractor returned an all-null row 18 times, the kind of row that ends up in a Google Sheet. The meeting workflow returned "No summary provided" or a generic "Discussion on project progress and upcoming tasks." 18 times. None of them are dangerous on their own, but none of them told anyone the input was broken.
The 11 loud runs weren't as loud as I thought
The Error Trigger fired 11 times, always for the same reason: the prompt was bigger than the model's context window. The ~300 KB text and the 50k-character token (5 workflows each), plus the lead qualifier with 300 junk fields, because its Build Prompt does JSON.stringify(body) and sends every field to the model. Ollama answered 400 Bad request, the LLM node failed, and the alert arrived.
The webhook caller got HTTP 200 with an empty body in all 11.
That's because these workflows use "Respond to Webhook" and the execution died before reaching that node. So I got an alert, but whatever posted the form got a success code. If your form tool or Zapier step only checks the status code, it thinks everything's fine.
One bug I didn't plan to find
The FAQ checks were failing on valid questions too, even the baseline. The reason: in 8 of 31 raw FAQ runs, the 1.8B model returned the key " escalate", with a leading space. JSON-wise that's fine, so Parse Result passes it on, but $json.result.escalate is undefined. Any IF node that checks "escalate is true" will quietly treat those as "no handoff needed".
The fix: an Input Guard before the model
The rule I ended up with: the LLM should never be the first thing that looks at user input. A Code node goes right after the Webhook, checks the payload, cleans the text, and either passes a clean body on or routes to a "Reject 422" Respond node with the reasons.
Here's the Code node. The only thing that changes per workflow is SPEC (this one's for the review reply):
// Input Guard: runs right after the Webhook, before any LLM call.
const SPEC = { field: 'review', min: 3, max: 3000, optional: ['business', 'policy'] };
const problems = [];
const body = $json.body;
const ct = String(($json.headers || {})['content-type'] || '');
if (!ct.includes('application/json')) problems.push(`content-type must be application/json (got "${ct || 'none'}")`);
const isObj = body !== null && typeof body === 'object' && !Array.isArray(body);
if (!isObj) problems.push(`body must be a JSON object (got ${Array.isArray(body) ? 'array' : body === null ? 'null' : typeof body})`);
const b = isObj ? body : {};
// strip HTML, invisible/control characters, template syntax; collapse whitespace
const clean = s => String(s).normalize('NFC')
.replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, ' ')
.replace(/<br\s*\/?>|<\/p>|<\/div>/gi, '\n')
.replace(/<[^>]+>/g, ' ')
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u00AD\u200B-\u200F\u202A-\u202E\u2060-\u2064\uFEFF]/g, '')
.replace(/\{\{|\}\}|\$\{/g, '')
.replace(/[ \t]+/g, ' ').replace(/\n\s*\n+/g, '\n').trim();
const out = {};
const v = b[SPEC.field];
if (v === undefined) {
const near = Object.keys(b).find(k => k.toLowerCase() === SPEC.field.toLowerCase());
problems.push(`missing field "${SPEC.field}"` + (near ? ` (found "${near}", field names are case-sensitive)` : ''));
} else if (typeof v !== 'string') {
problems.push(`"${SPEC.field}" must be a string (got ${Array.isArray(v) ? 'array' : v === null ? 'null' : typeof v})`);
} else {
const t = clean(v);
const meaningful = (t.match(/[\p{L}\p{N}]/gu) || []).length; // letters/digits in ANY script
if (/^(undefined|null|nan|\[object object\])$/i.test(t)) problems.push(`"${SPEC.field}" is the literal string "${t}"`);
else if (meaningful < SPEC.min) problems.push(`"${SPEC.field}" has ${meaningful} letters/digits after cleaning (min ${SPEC.min})`);
if (t.length > SPEC.max) problems.push(`"${SPEC.field}" is ${t.length} chars after cleaning (max ${SPEC.max})`);
if (/(\S{60,})/.test(t) && !/https?:\/\//.test(t)) problems.push(`"${SPEC.field}" contains a 60+ char unbroken token`);
out[SPEC.field] = t;
}
// keep only the fields this workflow knows about, as short strings
for (const k of SPEC.optional) {
const o = b[k];
if (o === undefined || o === null) continue;
if (typeof o !== 'string' && typeof o !== 'number') { problems.push(`"${k}" must be a string or number`); continue; }
out[k] = clean(o).slice(0, 300);
}
const dropped = Object.keys(b).filter(k => k !== SPEC.field && !SPEC.optional.includes(k));
return [{ json: { body: out, guard: { ok: problems.length === 0, problems, dropped_fields: dropped.length } }, pairedItem: 0 }];
A few choices that mattered:
-
\p{L}\p{N}instead of[a-zA-Z]. Counting "meaningful characters" with a Latin regex would reject every Arabic and Hindi message. The Unicode classes count letters in any script, and they don't count emoji or zero-width spaces. -
Whitelist, don't pass through. The output
bodyonly has the fields the workflow knows about. That's what fixed the lead qualifier's 300-junk-fields crash, since its prompt stringifies the whole body. - Limits are per workflow and based on the model: 1,000 characters for an FAQ question, 3,000 for a review, 4,000 for a lead message, 6,000 for invoices and transcripts. A 4096-token local model can't read 300 KB anyway, so it's better to say so with a 422 than with a context-overflow crash.
-
The fields stay named the same (
$json.body.review), so the existing Build Prompt node works unchanged.
Then an IF node on {{ $json.guard.ok }} (boolean, is true). The true branch goes to the old Config node; the false branch goes to a Respond to Webhook node with Response Code 422 and this body:
{{ JSON.stringify({ rejected: true, problems: $json.guard.problems }) }}
That gives the caller a useful error instead of a fake success:
{"rejected":true,"problems":["missing field \"review\" (found \"Review\", field names are case-sensitive)"]}
I deliberately didn't send rejections to the Error Trigger. A bad form submission isn't an outage, and 22 types of junk paging me at 2 a.m. would make me mute the channel. The 422 goes back to whatever sent it.
And a strict parser after the model
I also replaced Parse Result. The original catches a JSON error and passes {error: ...} downstream as a normal item, which is a silent failure by design. The new one throws, so the execution fails and the Error Trigger fires:
// Strict parse: a bad model answer fails the execution, so the Error Trigger fires
const raw = ($json.choices?.[0]?.message?.content || '').trim()
.replace(/^```
{% endraw %}
(?:json)?\s*/i, '').replace(/\s*
{% raw %}
```$/, '');
let o; try { o = JSON.parse(raw); } catch (e) { throw new Error('LLM returned non-JSON: ' + raw.slice(0, 200)); }
// per-workflow schema check, this one is the FAQ bot's
if (!(typeof o.answer === 'string' && o.answer.trim().length > 0 && typeof o.escalate === 'boolean'))
throw new Error('LLM output failed schema check: ' + raw.slice(0, 200));
if (/<script|\{\{|\$\{/i.test(raw)) throw new Error('LLM output contains markup/template syntax');
return [{ json: { result: o }, pairedItem: 0 }];
The re-run: 145 of 150 handled
Same 30 inputs, same 5 workflows, same model:
| Raw | Guarded | |
|---|---|---|
| Handled correctly | 37 | 145 |
| Errored loudly (Error Trigger fired) | 11 | 1 |
| Failed silently | 102 | 4 |
| ...with confident-looking output | 46 | 3 |
| Unusable inputs that went green | 95/110 | 0/110 |
| Fake "Hot lead" Slack pings | 15 | 0 |
| Weird-but-valid inputs wrongly rejected | n/a | 0/35 |
- All 110 unusable runs got a 422 with the reason: 105 from the guard and 5 from n8n's own JSON parser. Rejections took about 0.1 s and no LLM call. That's 105 model calls I don't pay for, on a hosted model.
- All 35 weird-but-valid runs (Arabic, Hindi, emoji, HTML-wrapped, control characters, junk fields, template strings) got through the guard. The HTML-wrapped review got a normal reply with no
<script>in it. - The
text/plainrequest is rejected now. That's a choice: in the raw version, the lead qualifier happened to read the plain text, and the other four never saw it: three returned nothing useful, and the review bot replied to a review about "website speed" that nobody wrote. - The 1 loud run is the strict parser doing its job: the FAQ bot returned
" escalate"again on the Hindi question, and this time I got an alert instead of a missing field.
The 4 it didn't fix
All 4 remaining silent runs are the meeting-notes workflow returning action_items: [] for a valid transcript (Hindi, HTML-wrapped, junk fields, template strings). My first thought was that the guard's cleaning broke the transcript. It didn't: I checked the execution data and the prompts were byte-for-byte identical to the raw version's.
So I sent the same valid transcript 6 more times to each version. Raw: empty action items in 1 of 6 runs. Guarded: 2 of 6. That's the 1.8B model, not the input. An input guard can't catch it, and my strict parser allows an empty array because some meetings really do have no action items. If I want that caught, it's an output check ("the transcript has 'I'll ... by Thursday' but action_items is empty"), and that's a different post.
What I'd tell anyone with a webhook-triggered AI workflow
- Your Error Trigger only sees crashes, and LLMs almost never crash. Give a model garbage and it writes a fluent answer. 95 of my 110 garbage runs were green. If you're counting on the Error Trigger as your safety net, it's covering about 10% of the problem.
- Guard the input before the model, in code. Type checks, a "meaningful characters" count that works for every script, length limits that match your model's context, and a field whitelist. It's one Code node and one IF.
- Reject loudly to the caller, not to yourself. A 422 with a reason fixes bad integrations faster than an alert channel nobody reads.
-
Don't trust a 200. In
Respond to Webhookmode, my workflows answered 200 with an empty body when they crashed. Make whatever calls the webhook treat an empty body as a failure. I haven't tested adding an error output on the LLM node to return a proper 5xx yet. -
Make your parser throw.
catch (e) { out = { error } }turns every bad model answer into a silent failure.
The base lead qualifier and review reply workflows are in the free repo: github.com/arkintof/n8n-ai-agent-workflows (workflows/01-lead-qualifier.json and workflows/07-review-reply-agent.json). The guarded versions from this test are not in the repo yet, and neither are the FAQ, invoice and meeting workflows. Everything you need to add the guard is the code above: one Code node after the Webhook, an IF on guard.ok, a Respond node with code 422, and the stricter Parse Result.
Next in the series I go after the lead qualifier on purpose: fake budgets, injected scores, spam dressed up as a real lead.
What's the weirdest payload that's ever hit one of your webhooks, and did your workflow notice?
Written with AI assistance; workflow built and tested by me.
Top comments (0)