Module 3: Contracts Between Workflows

5. Validating inputs at the boundary

Description

By the end of this lesson you'll be able to make a sub-workflow reject, at its own door and before doing anything, any input that doesn't comply with its contract: missing required fields, wrong types, violated business rules. You're going to know how to validate both with native nodes and with a Code node that checks the item's fields, return a clear error in the shape the contract promises, and route the flow so bad data never reaches the effect. And you're going to understand why "fail early with a useful message" is one of the decisions that most protects a system.

This matters because in the previous lesson you put the doorman at the door, but the doorman still doesn't check anything in depth. Declaring the fields in the trigger told n8n the expected names and types, and that helps callers —but it doesn't stop a negative amount, an empty customer_id, or a number that arrived as text from slipping through and reaching the sub-workflow's heart—. If that sub-workflow only reads, bad data produces a nonsensical result. If the sub-workflow moves money, like issue-refund, bad data produces an irreversible effect on wrong data. Boundary validation is the difference between the two.

Connection to the module: lesson 4 built the boundary and declared the schema in the trigger. This lesson gives the doorman real work: verifying the input complies with the contract you designed in lesson 3, and rejecting it with the error shape you defined. This is where lesson 2's two-sided contract gets enforced —the responder exercises its right to reject an input that doesn't comply—. And this is where this module shakes hands with Module 2's idempotency: validating before the effect is what prevents malformed data from triggering a wrong refund. Lesson 8 integrates this validation into the complete sub-workflow.

Fail early: the checkpoint at the gate, not inside the plane

Think of how airport security works. The check happens at the checkpoint, before you board the plane. Not in the aisle, not at your seat, not mid-flight. The reason is obvious once you say it out loud: the further in you let a problem slip, the more expensive and dangerous it is to remove it. Catching a prohibited object at the checkpoint is a thirty-second formality; catching it once the plane has taken off is an emergency. Same object, same problem —what changed is when you found it—.

Invalid data in a sub-workflow is that prohibited object. If you catch it at the door —at the first node, the moment it crosses the boundary— rejecting it costs nothing: you return a clear error and nothing bad happened. If you let it through and catch it three nodes in, once it's already been used in a calculation, the damage is half-done and untangling it is hard. And if you let it reach the effect —the node that issues the refund, calls the API, writes to the CRM— then the problem is no longer bad data: it's a refund issued on the wrong order, a call made with garbage data, a corrupted record. Bad data at the door is a nuisance; the same bad data past the effect is an incident.

This is the lesson's principle, and it fits in one sentence: validate at the gate, not inside the plane. Reject whatever doesn't comply with the contract as close to the entrance as possible, before it touches anything. In lesson 4's analogy, it's the doorman finally doing their job: not just standing at reception, but actually checking who's carrying what the contract requires before letting them into the building.

Why the trigger's schema isn't enough

You might think: "but in lesson 4 I already declared the fields in the trigger, doesn't that validate?" It's worth being precise about what the schema declaration does and doesn't do, because confusing it leaves dangerous holes.

Declaring the fields in the Execute Sub-workflow Trigger does two useful things: it tells n8n which names and types the sub-workflow expects, and it shows those fields as a guide to callers. It's executable documentation, and it helps. But it isn't a strict customs checkpoint. It doesn't guarantee the amount that arrived is truly a number —a caller with a bug can send text—, it doesn't check amount is positive, it doesn't verify customer_id corresponds to a customer that exists, nor that currency is one Cumbre handles. The trigger describes the expected shape; it doesn't reject what doesn't comply.

Think of it this way: declaring the schema is like putting a sign on the door that says "photo ID required." The sign informs, orients, and most people show up with their ID from having read it. But the sign doesn't check anyone. To actually check —"let's see, show me that ID; no, this one's expired; no, this photo isn't you"— you need a doorman who looks at each case and decides. This lesson's validation is that doorman. The sign (the trigger's schema) and the doorman (validation) work together: the sign reduces honest mistakes, and the doorman catches the ones that get through anyway.

What gets validated: the three layers

A complete boundary validation checks three things, from the most basic to the most specific. It's worth keeping them separate because they're different contract promises.

Layer 1: presence of required fields. Did every field the contract marks as required arrive? For check-credit: is customer_id there? is order_id there? is amount there? A missing required field is the most common violation and the easiest to detect. Here you also apply the optionals' default values: if currency didn't arrive, it isn't an error —the contract allows it—, but it has to be filled in with "MXN" so the rest of the sub-workflow doesn't work with a gap.

Layer 2: correct types. Is each field the type the contract promises? Is amount truly a number, or did it arrive as the text "1842.50"? This layer catches the module's most silent error: the number that looks like a number but is text, and that makes comparisons give absurd results. A field that's present but the wrong type is just as invalid as one that's absent.

Layer 3: business rules. Does the value make sense for the business, beyond its type? An amount can be a perfectly well-formed number and still be invalid if it's -500 —there's no such thing as a negative-amount order—. currency can be valid text and still be invalid if it's "XYZ", a currency Cumbre doesn't handle. This layer is what the trigger's schema could never cover, because it depends on business knowledge, not just the data's shape.

All three layers together answer the full question: "does this input truly comply with the contract, in form and in substance?" A validation that only does layer 1 lets text disguised as a number through; one that does 1 and 2 but not 3 lets a negative amount through. A serious doorman checks all three.

What to validate with: native nodes or a Code node

You have two tools for building the validation, and it's worth knowing when to use each.

Native nodes (If, Filter, Switch). You can check conditions with visual nodes. An If node asking "is customer_id empty?" routes the ones that fail toward the error output. It's readable and needs no code. Its limit: native nodes are comfortable for checking presence and comparing values ("is amount greater than zero?"), but they get clumsy for precisely verifying types ("is amount a number or text that looks like a number?") and for combining several errors into a single message. For simple validations, they're enough and clear.

A Code node that checks the item's fields. When the validation involves types, several rules, or you want to return a message listing every problem at once, a Code node is cleaner. And here it's worth being explicit about what a Code node can do in n8n 2.0, because it's exactly what we need: read and check the fields of the item that came in. That's allowed with no restriction whatsoever —the code only inspects data it already has—.

What a Code node can't do in n8n 2.0 doesn't get in our way here, but it's worth remembering so you don't design an impossible validation: from a Code node you can't make HTTP requests (no fetch or axios), you can't access the file system, you can't require anything except crypto and moment, and on n8n Cloud those two are the only modules available; you also can't read environment variables with $env or use helpers like this.helpers or this.getCredentials. All of that got locked down by the separate-process isolation version 2.0 brought. But notice what this means for us: validating the input never needs any of that. Validation only looks at the fields that already arrived and decides whether they comply —a purely local operation—. That's why n8n 2.0's restriction isn't an obstacle for this lesson: validating a contract is exactly the kind of work a Code node can do.

There's an exception worth keeping in mind: layer 3, business rules, sometimes needs to compare against external data —"does customer_id exist in the customer database?"—. That specific check does not fit in the Code node, because it would require querying a database, and that's what native nodes are for (a database node that looks up the customer). Shape and local-rule validation goes in the Code node; validation requiring an external system query goes in native nodes before or after it. In this lesson we focus on layers 1, 2, and the local layer-3 rules; validation against external systems leans on the corresponding integration nodes.

Worked example: validating check-credit's input

Let's build check-credit's validation with a Code node, covering all three layers, and route the result so bad data never gets through. The node goes right after the Execute Sub-workflow Trigger —the first thing that happens when something crosses the boundary—.

// Node: Code — "Validate input"
// Mode: Run Once for Each Item
// Goes right after check-credit Execute Sub-workflow Trigger.
// Only READS the item fields to check the contract: no HTTP, no touching
// files, so it respects the Code node restrictions in n8n 2.0.

const input = $input.item.json;   // the item that came in through the boundary
const errors = [];                // I collect every problem found here

// --- Layer 1: required fields are present ---
if (input.customer_id === undefined || input.customer_id === null || input.customer_id === '') {
  errors.push('customer_id is required');
}
if (input.order_id === undefined || input.order_id === null || input.order_id === '') {
  errors.push('order_id is required');
}
if (input.amount === undefined || input.amount === null) {
  errors.push('amount is required');
}

// --- Layer 2: types match what the contract promises ---
// typeof tells me what a value is made of: 'string', 'number', 'boolean'...
if (input.customer_id !== undefined && typeof input.customer_id !== 'string') {
  errors.push('customer_id must be text');
}
if (input.amount !== undefined && typeof input.amount !== 'number') {
  errors.push('amount must be a number'); // "1842.50" as text falls here: not a number
}

// --- Layer 3: business rules (the ones checkable without leaving the node) ---
if (typeof input.amount === 'number' && input.amount <= 0) {
  errors.push('amount must be greater than zero'); // no such thing as a negative-amount order
}

// --- Optional with a default: if currency did not arrive, the contract says assume MXN ---
const currency = input.currency ?? 'MXN'; // ?? uses 'MXN' only if currency is null/undefined

if (errors.length > 0) {
  // Does not comply with the contract. Return the contract FAILURE shape and do NOT
  // let bad data through toward the effect. join('; ') combines every error into one message.
  return {
    json: {
      ok: false,
      error: {
        code: 'INVALID_INPUT',
        message: errors.join('; '),
      },
    },
  };
}

// Complies with the contract. Pass the item forward, already with the default applied
// and with ok: true, so the next If node knows it can proceed to the effect.
return {
  json: {
    ok: true,
    customer_id: input.customer_id,
    order_id: input.order_id,
    amount: input.amount,
    currency: currency,
  },
};

After this node, you put an If node checking {{ $json.ok }}:

  • If ok is true, the flow continues toward the credit logic (checking the balance, comparing, deciding) and ends up producing the success response.
  • If ok is false, the item already has the contract's failure shape ({ ok: false, error: { code, message } }), so that branch goes straight to the sub-workflow's output. Remember lesson 4: what the caller receives is whatever the path's last node produces; on the failure branch, that last node carries the contract's error.
[Execute Sub-workflow Trigger]
        │
[Code: Validate input]
        │
     [If: ok?]
     ├─ true  → [credit logic] → [success response]  → output
     └─ false → (the error already has the contract shape)   → output

What to expect. With a good input —{ customer_id: "CUST-118", order_id: "ORD-2041", amount: 1842.50 }—, the Code node produces { ok: true, customer_id: "CUST-118", order_id: "ORD-2041", amount: 1842.50, currency: "MXN" } (notice the currency: "MXN" that filled in on its own), the If takes the true branch, and the sub-workflow proceeds to check the credit. With a bad input —say { customer_id: "CUST-118", order_id: "ORD-2041", amount: "-50" }, with the amount as negative text—, the Code node collects two problems: amount isn't a number (it's text) and, even if it were, it would be less than zero. It produces { ok: false, error: { code: "INVALID_INPUT", message: "amount must be a number; amount must be greater than zero" } }, the If takes the false branch, and the sub-workflow returns that error without having touched the credit logic. The bad data never got past the filter. And on order-triage's side, the response that comes back is the failure shape its contract expected, ready for its error branch to handle.

Notice the message lists every problem at once —"it's text AND it's negative"—, not just the first. This is a courtesy to whoever's debugging: a message stating all three errors saves three round trips to discover them one by one.

The error message is part of the contract

It's tempting to treat the error message as a minor detail —"whatever, if it fails, it fails"—. But the message is what turns a rejection into useful information, and it deserves the same care as the rest of the contract.

Compare two ways of rejecting the same input. The first: the sub-workflow simply crashes with an internal n8n error, something like "Cannot read property of undefined." Whoever receives it doesn't know which field was missing, or why, or what to fix; they have to open the sub-workflow and reverse-engineer the failure. The second: the sub-workflow returns { ok: false, error: { code: "INVALID_INPUT", message: "amount is required and must be a number" } }. Whoever receives it knows exactly what happened and what to fix, without opening anything. The same rejection; an enormous difference in how much it costs to understand.

Two pieces make an error message useful. The code is a stable, short label from a known list (INVALID_INPUT, CUSTOMER_NOT_FOUND, ALREADY_REFUNDED), meant for a machine to read and decide with: order-triage can have a different branch depending on the code. The message is the readable explanation, meant for a human reading the log to understand at a glance. Both matter: the code so the system reacts, the message so the person diagnoses. An error with message but no code forces callers to read text to decide —fragile—; one with code but no message leaves the human blind. The failure contract you designed in lesson 3 has both for this reason.

Where validation goes: a single chokepoint

One last design decision, and it's the one that ties everything together with lesson 4. Validation goes in a single place: right after the Execute Sub-workflow Trigger, before anything else. Not spread across loose checks throughout the sub-workflow, not midway through. A single chokepoint every input passes through.

The reason is the same as lesson 4's single door. If validation is concentrated at one point, there's a single place to check what gets validated, a single place to update when the contract changes, and a clear guarantee: if something passed this node, it complies with the contract. If instead you spread the checks out —a bit here, a bit more three nodes ahead— you lose that guarantee: you never know for sure whether a piece of data was fully validated or only halfway, and updating the validation becomes a hunt across the whole canvas. The doorman is at the door, not scattered through the hallways. An input gets validated once, in full, on the way in; from there on, the rest of the sub-workflow can trust it's working with data that complies with the contract.

This connects directly to the effect. Since validation comes before everything, and since bad data takes the failure branch and exits without touching the logic, the effect never sees invalid data. In check-credit, which only reads, this prevents a nonsensical result. In issue-refund, which moves money, this prevents something much worse: a refund issued on garbage data. This is where this module's validation and Module 2's idempotency embrace each other —validation guarantees the effect receives correct data; idempotency guarantees the effect doesn't get applied twice—. Both protect the same sensitive spot from different angles.

Common mistakes

Validating after the effect instead of before (conceptual). What happens: someone puts the "is amount valid?" check after the node that already checked the credit or —worse— after the one that already issued the refund; by the time the error is detected, the effect has already happened. Why it happens: when building the sub-workflow top to bottom, it's easy to put the main logic first and "add the validations later," and "later" ends up meaning later in the flow, not just later in build time. How to spot it: look at where your validation node is relative to your effect; if the effect can run before validation has rejected a bad input, it's in the wrong place. How to fix it: validation goes immediately after the trigger, before any effect, no exceptions. The airport principle: it's checked at the checkpoint, not at the seat.

Trusting the trigger's schema as if it validated (practical). What happens: someone declares the fields in the Execute Sub-workflow Trigger, sees them show up as a guide in the caller, and concludes "it's already validated"; in production, a caller with a bug sends amount as text and the sub-workflow processes it as if nothing were wrong. Why it happens: the schema declaration feels like a barrier because it shows types and helps callers, but it's an informational sign, not a doorman. How to spot it: ask yourself "if a caller ignores the schema and sends garbage, does anything reject it?" If the only answer is "the trigger declared it," there's no real rejection. How to fix it: add explicit validation after the trigger. The schema orients and reduces honest mistakes; validation is what actually rejects. Both are needed.

A useless error message (practical). What happens: the validation correctly rejects, but returns something like { ok: false, error: "invalid" } or simply lets the sub-workflow crash with an internal n8n error; whoever receives it doesn't know which field failed or why. Why it happens: while writing the validation, you have the context fresh in your head and don't feel the lack of detail; the problem shows up weeks later, when someone else receives the error without that context. How to spot it: read your error message as if you knew nothing about the sub-workflow; if it doesn't tell you which field to fix, it's useless. How to fix it: always return a stable code (for the machine to decide with) and a message naming the field and the concrete problem ("amount must be a number"), ideally listing every problem at once. A good error message is the difference between a thirty-second fix and an afternoon of debugging.

Exercises

Exercise 1 — Classify each check by layer. For issue-refund (receives order_id, amount, reason), classify each of these checks by its layer: presence of required fields (1), correct type (2), or local business rule (3). Also mark which one would not fit in a Code node and why.

(a) order_id isn't empty. (b) amount is a number, not text. (c) amount is greater than zero. (d) order_id corresponds to an order that exists in the database.

See solution

(a) Layer 1 (presence). Only checks the required field arrived. Fits in a Code node with no problem.

(b) Layer 2 (type). Checks amount is the type the contract promises. Fits in a Code node with typeof.

(c) Layer 3 (local business rule). A negative or zero refund amount makes no sense. It's a business rule, but it's local —it's decided by only looking at the value—, so it fits in a Code node.

(d) Layer 3 (business rule), and does NOT fit in a Code node. Checking the order exists requires querying the database, and from a Code node in n8n 2.0 you can't make requests or query external systems (no fetch, axios, or credential access). This check goes with a native database node, before or after the Code node, not inside it.

Why this works: the exercise separates local business rules (which only look at the value that arrived, like "amount > 0") from ones that need to query the world (like "does this order exist"). The first fit in the Code node; the second demand a native node because of n8n 2.0's restriction. Knowing where each rule falls is what keeps you from designing an impossible validation —trying to query the database from the Code node and running into the isolation—.

Exercise 2 — Find the gap. This Code node tries to validate check-credit's input, but it lets a bad case through. Find it and fix it.

// Mode: Run Once for Each Item
const input = $input.item.json;
const errors = [];

if (!input.customer_id) errors.push('customer_id is required');
if (!input.order_id) errors.push('order_id is required');
if (!input.amount) errors.push('amount is required');

if (errors.length > 0) {
  return { json: { ok: false, error: { code: 'INVALID_INPUT', message: errors.join('; ') } } };
}
return { json: { ok: true, ...input } };
See solution

The gap is in the type check: there isn't one. This code checks layer 1 (presence) but skips layer 2 (type) and layer 3 (rules). An amount that arrives as the text "1842.50" passes the check —it isn't empty— and continues toward the credit logic disguised as a number. That's exactly the silent error validation was supposed to catch.

There's also a subtle bug in if (!input.amount): in JavaScript, !0 is true, so an amount of 0 —which is also invalid, but by business rule— would get reported as "missing required," a misleading message. Worse, if some field could legitimately be 0 someday, !input.amount would wrongly reject it.

Fix: add the type and rule checks, and check amount's presence in a way that doesn't confuse 0 with "absent":

if (input.amount === undefined || input.amount === null) {
  errors.push('amount is required');
}
if (input.amount !== undefined && typeof input.amount !== 'number') {
  errors.push('amount must be a number'); // catches "1842.50" as text
}
if (typeof input.amount === 'number' && input.amount <= 0) {
  errors.push('amount must be greater than zero'); // catches 0 and negatives, by rule
}

Why this works: presence-only validation is the most common trap, because it looks complete —it checks the fields are there— and lets through exactly the error that hurts most: the wrong type. And the !input.amount detail shows even layer 1 has subtleties: checking presence with a simple negation confuses "absent" with "zero" or "empty string." Validating seriously means checking all three layers, carefully in each.

Exercise 3 — Design the error message. A caller sends check-credit this input: { order_id: "ORD-2041", amount: "zero" }customer_id is missing, and amount is the text "zero"—. Write the complete response object validation should produce, with code and message, following the lesson's pattern.

See solution
{
  "ok": false,
  "error": {
    "code": "INVALID_INPUT",
    "message": "customer_id is required; amount must be a number"
  }
}

The input has two problems and the message lists both: customer_id is missing (layer 1) and amount is text, not a number (layer 2). The code is the stable INVALID_INPUT, so order-triage knows it was an input problem without parsing text. Notice we don't report "amount must be greater than zero," because "zero" isn't even a number —it fails earlier, at the type layer—; reporting the business rule on a value that isn't a number would be confusing.

Why this works: a good error message does two things right —it lists every problem in one pass, so whoever's debugging doesn't discover the errors one at a time; and it doesn't report rules that don't apply, like "greater than zero" on a text value—. The order matters: presence and type get checked first, and only if the value is already a sensible number do the business rules get applied to it. That discipline makes the message precise instead of noisy.

Summary and next step

In this lesson you gave the doorman real work. You adopted the airport principle —validate at the gate, not inside the plane—: reject whatever doesn't comply with the contract as close to the entrance as possible, before it touches anything, because bad data at the door is a nuisance and the same data past the effect is an incident. You saw why the trigger's schema isn't enough —it's a sign that informs, not a doorman that checks— and why both are needed. You separated validation's three layers: presence of required fields, correct types (where the number disguised as text gets caught), and local business rules (like amount > 0). You chose the tool based on the case —native nodes for the simple stuff, a Code node for types and several rules— and confirmed that validating is exactly the work a Code node can do in n8n 2.0, because it only reads the item's fields, needing no HTTP, files, or credentials. You built check-credit's complete validation, with all three layers and an If that guarantees bad data takes the failure branch without touching the logic. You cared for the error message as part of the contract —a stable code for the machine and a readable message for the human, listing every problem at once—. And you put validation in a single chokepoint, right after the trigger, so the effect never sees invalid input.

Before moving on to lesson 6 you should be able to: write a three-layer validation for a sub-workflow's contract; decide which rules fit in a Code node and which need a native node; and design an error message with a useful code and message.

Up to here you have a contract designed, declared at the boundary, and validated. But contracts aren't eternal: the day comes when you need to change one —add a field, change a type, adjust the output— and that day, if you're not careful, you repeat lesson 1's silent break at a bigger scale. Lesson 6 is about how to change a contract without breaking whoever already calls it: telling a compatible change apart from a breaking one, running two versions side by side, and migrating callers with no crashes. It's what the contract is missing to be complete: not just defined and validated, but capable of evolving.

Resources

  • Code node — n8n Docs — the docs page for the Code node you used to validate, with its two execution modes and what you can read from the item.
  • Using the Code node — n8n Docs — what you can and can't do inside the Code node in n8n 2.0, including HTTP, file, and module restrictions.
  • If node — n8n Docs — the node that routes the flow based on the validation result (the ok: true branch versus ok: false).
  • Filter node — n8n Docs — a native alternative for discarding items that don't meet a simple presence or value condition.
  • Data structure — n8n Docs — how types are represented in an item, the foundation for understanding layer 2's type checking.