Module 3: Contracts Between Workflows
3. Designing the input and output schema
Description
By the end of this lesson you'll be able to take the contract you wrote in human language in the previous lesson and turn it into a precise schema: a list of fields, each with its exact name, its type, its required-or-optional flag, and, when it applies, its default value. You're going to know how to design the response shape —both the success one and the error one— using the same consistent "envelope" that makes life easier for any caller. And you're going to know where and how to document that schema so it lives glued to the workflow, not lost in the head of whoever wrote it.
This matters because a contract in prose —"receives the customer's ID and the amount"— works for conversation, but not for building. The moment you want n8n to recognize the fields at the boundary (lesson 4), reject an invalid input (lesson 5), or warn you a change breaks the callers (lesson 6), you need the contract written with a schema's precision: no ambiguity over whether amount is a number or text, no doubt over whether currency can be missing. This lesson is the step from intention to blueprint.
Connection to the module: lesson 2 gave you the contract's four pieces in the abstract. This one grounds them into a concrete schema you can write today. The decisions you make here —what type each field has, which are optional, what shape the response has— are the ones lesson 4 is going to declare in the Execute Sub-workflow Trigger node, the ones lesson 5 is going to validate, and the ones lesson 6 is going to version. Designing the schema well here saves you pain in the three lessons that follow. A sloppy schema gets paid for later.
The schema as a well-made form
You already know what a contract is: the vending machine's signature. Now we have to write that signature with enough precision for a machine —and another person on the team— to read it with no interpretation needed. For that, the useful image is a paper form, the kind you fill out at some office.
A good form doesn't let you guess. Every box has a label that says exactly what goes there: "Name (as it appears on your ID)." Required fields carry a red asterisk; ones you can leave blank don't. Some come with a value already filled in that you can change or keep: "Country: Mexico." And the form tells you what shape each piece of data has: "Date (DD/MM/YYYY)," so you don't write the month where the day goes. A poorly made form —vague labels, no marking of what's required, no format— produces garbage answers, and whoever processes them afterward suffers. A well-made form produces clean data because it left no room for ambiguity.
An input schema is that form, but for a workflow instead of a person. Designing it well is exactly the same craft: precise labels, clear types, marked required fields, default values where they help. And designing it poorly produces the same disaster: a sub-workflow that receives inconsistent data and produces inconsistent results.
A schema, then, has four decisions per field. Let's go one by one, because each is a different promise.
Decision 1: the exact name
A field's name isn't a friendly label; it's the key that lets the caller and the responder find each other. order-triage writes amount and check-credit reads amount: if one writes amount and the other reads Amount with a capital, they don't find each other. Names are literal and sensitive to every character.
Three practical rules for naming, which come from the whole ecosystem's convention and from the real market:
Names go in English. customer_id, not id_cliente. available_credit, not credito_disponible. It's the convention throughout the guide and on any tech team in the region: code, field names, and data speak English, even though the prose and comments are in Spanish, or English here.
Choose a style and don't mix it. In this ecosystem we use snake_case —lowercase words joined by underscores— for data fields: customer_id, order_id, available_credit. What matters isn't so much which style you choose, but that you don't mix it: a contract with customer_id and orderId at the same time is a contract that invites mistakes, because nobody remembers which field used which convention.
Prefer the specific over the flexible. Lesson 2 already previewed this: a vague name like data, value, or input looks flexible and is fragile, because it tempts everyone to put different things there. A specific name like available_credit is rigid in the good sense: it says what it is, and that's why it lasts. If you catch yourself wanting to name a field data, it's almost always a sign that field should really be two or three fields with proper names.
Decision 2: the type
A field's type says what its value is made of, and in n8n the types you're going to use in an input schema are four. It's worth knowing them along with their behavior, because choosing the wrong type is this module's most silent error source.
| Type | What it stores | Example at Cumbre |
|---|---|---|
string | Text | customer_id, order_id, reason |
number | A number, with or without decimals | amount, available_credit |
boolean | True or false, nothing else | approved |
json | An object or a list (nested data) | line_items (an order's list of products) |
The distinction that's hardest and matters most is between string and number. An amount of 1842.50 as a number is one thing; the text "1842.50" is a completely different thing, even though they look almost the same on screen. With the number you can compare, add, subtract. With the text, "1842.50" is greater or less than another text alphabetically, not by value —"1842.50" compared with "900.00" gives that the first is less, because "1" comes before "9" in the alphabet—. An amount that arrives as text where the contract asked for a number is the classic cause of check-credit approving an order it should have rejected. That's why the type isn't an administrative detail: it's a promise about which operations are safe with that value.
The json type is for data with internal structure: a list of things, or an object with its own fields. If a sub-workflow needs to receive the full order with its line_items array, that field is json, because it carries more structure inside than a single value.
Decision 3: required or optional (and the default value)
Every field carries a flag: does the caller have to send it, or can it be omitted?
A required field is one without which the sub-workflow can't do its job. customer_id in check-credit is required: without knowing whose credit you're talking about, there's nothing to check. Marking a field as required gives the sub-workflow the right to reject the call if it's missing —the right you're going to exercise in lesson 5—.
An optional field is one the sub-workflow knows how to substitute if it doesn't arrive. And here comes a key design piece: the default value. An optional field almost never should end up simply "empty" when it's missing; it should have a sensible default value the sub-workflow uses in its place. currency in check-credit can be optional with a default value of "MXN": if the caller doesn't send the currency, the sub-workflow assumes Mexican pesos and keeps working, instead of stopping or —worse— continuing with a blank currency.
The default value is what makes "optional" mean something concrete instead of being a dangerous hole. Think of it with the paper form: the "Country" box that already comes filled in with "Mexico" is an optional field with a default value. If you leave it blank, it doesn't leave a gap that breaks the process; it stays "Mexico." An optional field with no default is a blank box someone else downstream is going to have to guess how to fill in —and guessing is exactly what a contract exists to prevent—.
A practical guide for deciding requiredness: ask yourself "if this field doesn't arrive, can the sub-workflow produce a correct result?" If the answer is no, it's required. If the answer is "yes, using such-and-such reasonable value," it's optional with that value as the default. If the answer is "yes, but I don't know with what value"... then you haven't finished designing it yet: either it's required, or it's missing a decided default.
Decision 4: the response shape (the envelope)
The previous three decisions design the input. The fourth designs the output, and it has a nuance of its own worth handling carefully, because it's where most teams improvise.
The problem: a sub-workflow can finish in two very different ways —success or failure— and the caller needs to know, at a glance, which of the two it's in before trying to read anything. If check-credit sometimes returns { approved, available_credit } and sometimes returns { error, code, message }, the caller has to guess which of the two shapes it got this time. Guessing, again, is what we want to eliminate.
The solution is a consistent envelope: a common wrapper for every response, with a fixed field that says right away whether this was a success or a failure, and inside, the content matching each case. Think of it as a mail envelope with a corner box that's always there: "DELIVERY" or "RETURN." Before opening the envelope, that box already told you what to expect inside. You don't have to read the whole letter to know whether your package arrived or was sent back.
For check-credit, the envelope looks like this:
// SUCCESS response — the "ok" box says true
{
"ok": true,
"customer_id": "CUST-118",
"approved": true,
"available_credit": 5157.50
}
// FAILURE response — the "ok" box says false
{
"ok": false,
"error": {
"code": "INVALID_INPUT",
"message": "The amount field is required and must be a number."
}
}
Notice what the caller gains: the first thing order-triage does when it receives the response is read ok. If it's true, it knows it can read approved with confidence. If it's false, it knows it should read error.code and error.message and take its failure branch. It never has to guess which case it's in: the envelope's box told it before opening it. This shape —a fixed discriminator field, plus the content for each case— is one of the design decisions that most eases the life of everyone who's going to call your sub-workflow.
There's no single "correct" envelope shape; there are consistent shapes and improvised shapes. You could use ok: true/false, or status: "success"/"error", or success: true/false. What matters —just like with the naming convention— is that you pick one and use it in every one of your sub-workflows, so a caller who already used one knows how to read the rest with no relearning. At Cumbre they chose ok, and that's how it stays throughout the guide.
Worked example: designing check-credit's complete schema
Let's put the four decisions together into check-credit's finished schema, field by field, saying each decision out loud so you see the reasoning and not just the result.
Input. We start with what the sub-workflow needs to receive.
customer_id— what's it made of? A customer identifier, text:string. Required? Without it there's no credit to check: required. No default.order_id— also text:string. Required? Yes, because we want to be able to trace which order each credit check belonged to: required.amount— the order's total, a number:number. Required, and here the type is critical:number, neverstring, because we're going to compare it against the credit.currency— the amount's currency, text:string. Required? No: if it doesn't arrive, we can assume Cumbre's base currency. Optional, with a default value of"MXN".
Success output. What it promises to return if everything goes well, inside the ok: true envelope.
customer_id— returned as-is, so the caller knows whose response this is without having to remember what it sent.approved— true or false:boolean. It's the fieldorder-triagereads to decide.available_credit— how much credit the customer has left after this order:number.
Failure output. Inside the ok: false envelope, an error object with:
code— a stable code from a short, known list:"INVALID_INPUT","CUSTOMER_NOT_FOUND". Text, but from a closed set, not free-form.message— the readable explanation for a human reading the log.
The finished schema, ready to document:
SCHEMA — check-credit
INPUT
customer_id : string required
order_id : string required
amount : number required
currency : string optional (default: "MXN")
OUTPUT (envelope with "ok" discriminator)
SUCCESS → { ok: true, customer_id: string, approved: boolean, available_credit: number }
FAILURE → { ok: false, error: { code: string, message: string } }
possible codes: "INVALID_INPUT", "CUSTOMER_NOT_FOUND"
EFFECTS
none (read-only)
What to expect. With this schema in hand, when in lesson 4 you declare the input fields in the Execute Sub-workflow Trigger node, you're going to transcribe exactly this list: four fields, three required of type string/number, one optional with a default. When in lesson 5 you validate, you're going to check exactly these four conditions. And when in lesson 6 you version, you're going to compare any change against exactly this document. The schema is the single source of truth for the three lessons that follow; writing it well once serves you three times.
Where the schema lives: glued to the workflow
A schema that lives in a separate document, in a folder nobody opens, goes stale on day one. The golden rule is that the contract lives as close as possible to the workflow it describes, so whoever edits the workflow sees the contract without having to look for it.
In n8n, the natural place is a Sticky Note —an adhesive note you put directly on the sub-workflow's canvas, next to its first node—. It's free text that executes nothing; it's just there so anyone who opens the workflow reads the contract before touching anything. You put the complete schema in a Sticky Note glued to the Execute Sub-workflow Trigger, and now the contract is impossible to ignore: it's on the same screen where someone would go to change something.
There's a second documentation layer lesson 4 is going to build, worth telling apart from this one. The Sticky Note is documentation for humans: a person reads it. The fields you declare inside the Execute Sub-workflow Trigger node are documentation for n8n: the machine reads it, and with them it knows what the sub-workflow expects and helps the caller send it correctly. Both layers describe the same contract; one in prose for the team, another in fields for the engine. A well-documented contract has both, and both say the same thing —when they diverge, that's when problems start—.
A concrete example is worth a thousand types
There's a third documentation piece, humbler than the other two and surprisingly valuable: a real example of an input and an output, with actual values. The schema says amount : number required; the example says "amount": 1842.50. Both describe the same thing, but the example does it in a way the brain grasps at a glance, with no translating types into values.
The reason is the one you already know from the worked example of Cumbre's canonical order throughout the guide: a type table tells you the shape; a filled-in object shows you the shape working. When someone is going to call check-credit for the first time, an input example saves them half the doubts —"ah, customer_id looks like this: CUST-118; amount goes with decimals"— that no type table resolves that fast.
That's why it's worth having the contract's Sticky Note include, besides the schema, a couple of concrete examples:
// Valid INPUT example
{
"customer_id": "CUST-118",
"order_id": "ORD-2041",
"amount": 1842.50,
"currency": "MXN"
}
// Success OUTPUT example
{
"ok": true,
"customer_id": "CUST-118",
"approved": true,
"available_credit": 5157.50
}
// Failure OUTPUT example (amount was missing)
{
"ok": false,
"error": {
"code": "INVALID_INPUT",
"message": "The amount field is required and must be a number."
}
}
This example isn't just for reading. In lesson 4 you're going to see that the Execute Sub-workflow Trigger node offers a mode called "Define using JSON example," where you paste an object like the one above and n8n infers the schema from it. In other words: the concrete example you write for a human to understand is, in n8n 2.0, also a way of declaring the schema to the machine. A single well-chosen object serves both documentation layers at once. When you get to that lesson, the example you build here doesn't go to waste: it gets pasted and turns into an executable contract.
Less is more: every field is a promise you're going to have to keep
One temptation when designing a schema is adding fields "just in case": a generic metadata, an extra_info that might come in handy someday, three output fields no caller is asking for yet. Resist that temptation, and the reason is the very definition of a contract.
Every field you put in the schema is a promise you're committing to keep forever —or at least until you version—. An output field you added "just in case" is a field some caller might start reading, and from that moment on you can't remove it without breaking them. A contract with twenty fields is a contract with twenty promises to maintain, most of which nobody asked you for. The stability we talked about in lesson 2 is easier to sustain over a small, precise contract than over a big, speculative one.
The discipline is the opposite of "just in case": include only the fields a real caller needs today, with the most precise names and types you can. If a new need comes up tomorrow, adding an optional field is a compatible, safe change (lesson 6). Starting small and growing carefully is sustainable; starting big and having to prune is painful, because pruning a contract is breaking it. check-credit has four input fields and three useful output fields, and with that it does everything Cumbre needs. Not one is extra.
Common mistakes
Using string for everything, including what's really a number (practical). What happens: someone designs the schema and marks amount as string because "it's text that looks like a number anyway," and later check-credit compares that text against the credit and gets absurd results —it approves orders it should reject—. Why it happens: on many forms and in some input channels numbers arrive as text (Cumbre's rep_csv channel is famous for this), and it's tempting to reflect that dirty reality in the contract instead of demanding the correct type. How to spot it: for every schema field, ask yourself "am I going to compare, add, or subtract this value?" If yes, it has to be number; if it's string, that's the mistake. How to fix it: the contract demands the correct type, number for everything arithmetic. That a channel sends the number as text isn't the contract's problem, it's the caller's: it's on them to convert it before calling, or the sub-workflow is going to reject it in lesson 5's validation. The contract defines how data should arrive, not how it arrives when it's dirty.
Optionals with no default value (conceptual). What happens: currency gets marked optional but no default is defined for it; when a caller doesn't send it, the sub-workflow continues with currency blank and produces a calculation with an empty currency, or fails several nodes later. Why it happens: marking "optional" feels complete, and it's easy to forget that "optional" with no default doesn't say what to do when the field is missing —it only says it can be missing—. How to spot it: walk through every optional field in the schema and verify it has a default value written next to it; if any doesn't, the design is half-done. How to fix it: every optional field carries an explicit, sensible default, and the sub-workflow uses that default when the field doesn't arrive. If you can't find a reasonable default for a field, it's a strong signal that field was really required.
A different output shape for success and for failure, with no discriminator (practical). What happens: the sub-workflow returns { approved, available_credit } when it goes well and { error, message } when it goes badly, with no common field saying which is which; the caller ends up checking "does the approved field exist? then it was a success" —a fragile heuristic that breaks the moment you add a field—. Why it happens: each shape gets designed separately, at the moment it's needed, without thinking that the caller is going to receive them on the same wire and needs to tell them apart quickly. How to spot it: look at your success response and your failure response side by side; if they don't share a fixed field saying up front which is which, the discriminator is missing. How to fix it: wrap both in the same envelope with a discriminator field (ok: true/false), so the caller reads that field first and knows unambiguously which branch to take. It's a design line that saves every caller, present and future, a headache.
Exercises
Exercise 1 — Design issue-refund's schema. In lesson 2 you wrote issue-refund's contract in prose. Now turn it into a schema with the four decisions per field. It receives order_id, amount, and reason; it returns refund_id and status on success. Decide each field's type, which are required, whether any deserves to be optional with a default, and write the output using the same ok envelope as check-credit.
See solution
SCHEMA — issue-refund
INPUT
order_id : string required — the order to refund
amount : number required — amount to refund (number, it is money: compared and validated)
reason : string required — reason, free text
OUTPUT (envelope with "ok" discriminator)
SUCCESS → { ok: true, order_id: string, refund_id: string, status: string }
FAILURE → { ok: false, error: { code: string, message: string } }
possible codes: "INVALID_INPUT", "ORDER_NOT_FOUND", "ALREADY_REFUNDED"
EFFECTS
DOES produce an effect: issues a refund. The call must be idempotent (Module 2).
All three inputs are required: there's no refund without knowing which order, for how much, and why. amount is number, not string, for the same reason as in check-credit: it's money, it gets compared and validated. No input deserves to be optional here —unlike currency in check-credit, there's no field where you can assume a sensible default with no risk, because all three are essential for an irreversible effect—. The output uses the same ok envelope, with a failure code that includes "ALREADY_REFUNDED", specific to an effect that can't be repeated.
Why this works: reusing check-credit's ok envelope isn't laziness, it's design: anyone who already knows how to read one Cumbre sub-workflow's response knows how to read all of them. And noticing that no issue-refund field deserves to be optional teaches you that requiredness depends on the sub-workflow, not on a fixed rule: a sensible default is a luxury only some fields, in some sub-workflows, can afford.
Exercise 2 — Fix a sloppy schema. You're handed this input schema for an apply-discount sub-workflow that applies a discount to an order. Find at least three design problems and propose the fix:
INPUT
data : json required — the discount info
amount : string required — how much to discount
pct : number optional — percentage
See solution
Problem 1: data is a vague name. A field called data of type json doesn't say what it contains, and tempts every caller to put different things there. Fix: replace it with specific fields —probably order_id: string and whatever "the discount info" actually means, with proper names—.
Problem 2: amount is string but it's a money amount. It's going to be compared or calculated, so it has to be number. As it stands, it invites the silent error of comparing text as if it were a number. Fix: amount : number.
Problem 3: pct is optional but has no default value. If a caller doesn't send it, the sub-workflow doesn't know what percentage to apply. On top of that, the name pct is an unclear abbreviation. Fix: rename it to discount_pct : number, and decide its default —for example 0, meaning "no discount" if unspecified— or, if there's no sensible default, make it required.
Bonus: the schema defines no output or error shape at all. A half-done contract. Fix: add the success and failure output with the ok envelope.
Why this works: the three problems are this lesson's three common mistakes together in a single schema —vague name, wrong type, optional with no default—. A sloppy schema almost never fails from a single thing; it fails from an accumulation of decisions that "seemed enough" at the time and that together produce a contract you can't validate or version with confidence.
Exercise 3 — Required or optional. For a send-order-confirmation sub-workflow that sends the customer a confirmation email, decide for each field whether it's required or optional, and if optional, what its default value would be. Justify each one with the question "if this field doesn't arrive, can the sub-workflow produce a correct result?"
(a) customer_email — who to send the email to.
(b) order_id — which order is being confirmed.
(c) language — the email's language ("es" or "en").
(d) include_invoice — whether to attach the invoice or not.
See solution
(a) customer_email — required, no default. Without the address there's nobody to send the email to; there's no sensible default (send it to whom?). If it doesn't arrive, the sub-workflow can't produce a correct result.
(b) order_id — required, no default. Without knowing which order is being confirmed, the email wouldn't have meaningful content. There's no "default" order.
(c) language — optional, default "es". If it doesn't arrive, the sub-workflow can produce a perfectly correct result using Cumbre's base language. It's the textbook case of an optional with a default.
(d) include_invoice — optional, default false. If it doesn't arrive, the safe, sensible choice is not attaching the invoice; the email still goes out fine. An optional boolean almost always has the more conservative option as its default.
Why this works: the question "can it produce a correct result without this field?" cleanly separates the two cases. For customer_email and order_id the answer is no —they're the heart of the task—; for language and include_invoice the answer is "yes, using this reasonable value," which is exactly the definition of an optional with a default. Notice the difficulty isn't technical: it's business. Deciding include_invoice's default is deciding what the safe behavior is when nobody specified, and that's a design decision, not a syntax one.
Summary and next step
In this lesson you turned lesson 2's prose contract into a precise schema, treating it like a well-made form: no ambiguity, clear labels, required fields marked. You saw the four decisions you make per field —the exact name (in English, with a consistent style, specific rather than flexible), the type (with the gap between string and number as the detail that hurts most to get wrong), the requiredness, and the default value that gives "optional" concrete meaning—. You designed the response shape with a consistent envelope and a discriminator field (ok: true/false) that tells the caller up front whether it was success or failure, with no guessing needed. You wrote check-credit's complete schema, which is going to be the source of truth for the three lessons that follow. You learned the schema lives glued to the workflow —a Sticky Note for humans, and in lesson 4, the trigger's fields for the machine—. And you adopted the "less is more" discipline: every field is a promise you're going to have to keep, so you only include the ones a real caller needs today.
Before moving on to lesson 4 you should be able to: take a prose contract and write its schema with the four decisions per field; choose the correct type, telling apart what's really a number from what only looks like one; and design a response with a discriminator envelope.
Up to here the schema lives on paper and in a Sticky Note. Lesson 4 takes it to the place where the call actually happens: the Execute Sub-workflow node's boundary. You're going to see how order-triage invokes check-credit, how your schema's input fields get declared inside the Execute Sub-workflow Trigger node so n8n recognizes them, and why that boundary —a single entry point— is what makes the whole contract governable.
Resources
- Execute Sub-workflow Trigger — n8n Docs — the node where you're going to declare the input fields of the schema you designed here, with its input-data definition modes.
- Data structure — n8n Docs — how types (
string,number,boolean, objects, and lists) are represented in the items traveling between nodes. - Sticky notes — n8n Docs — how to put a note on the canvas to document the contract glued to the workflow it describes.
- Data mapping in the UI — n8n Docs — how fields get connected between nodes, useful for understanding why the schema's exact names matter so much.