Module 3: Contracts Between Workflows
2. What a workflow contract is
Description
By the end of this lesson you'll be able to precisely define what a workflow contract is —not as a metaphor, but as an object with concrete parts— and you're going to be able to identify its four pieces in any sub-workflow you come across: the input fields with their types and their requiredness, the output shape when everything goes well, the output shape when something fails, and the effects the sub-workflow produces in the world. You're also going to understand why putting that contract in writing, even when the system already "works," is the difference between a safe change and a ticking time bomb.
This matters because the previous lesson made clear that the contract between two workflows always exists, whether you write it down or not. But "existing" isn't enough to protect it: a contract that only lives in your head and in the way you happened to wire the nodes can't be reviewed, can't be validated, and can't be versioned. To do any of those three things —which are lessons 5, 6, and 3's topics, respectively— you first need to know exactly what a contract is made of. This lesson gives the promise its anatomy.
Connection to the module: lesson 1 showed you that a multi-workflow system is a system of promises, and that those promises break silently. This lesson precisely defines the promise, so the following ones can operate on it. Lesson 3 takes this anatomy and turns it into a concrete schema you write next to the workflow. Lesson 4 shows you where the boundary this contract crosses physically lives. And everything you validate (lesson 5) and version (lesson 6) is, at bottom, this contract being defended. There's an important bridge outside the module: the concept of a "tool contract" the chatbots and agents guide teaches is a direct relative of what you see here —a particular case where the caller is an AI model instead of another workflow—, and lesson 7 is going to explicitly connect them.
The promise, precisely: a function signature
In the previous lesson we used a restaurant's menu as an image. It's a good image for intuition, but to work we need something more precise. A workflow contract's exact analogy is a function signature in programming.
Don't worry if you don't program: the idea is simpler than the word. A function is a box that receives some data, does something with it, and returns a result. Its signature is the line that says what it receives and what it returns, without saying how it does it internally. Think of a vending machine. Its signature is: "receives a coin and a product number; returns the product or gives you the coin back." That promise is enough for you to use it. You don't need to know how the spring mechanism works inside, or in what order the gears move. You put in the coin, you press B4, out comes the chocolate bar. The signature is the contract; the gears are the implementation.
A sub-workflow is exactly a vending machine. check-credit has a signature: "receives a customer_id, an order_id, and an amount; returns an approved and an available_credit." With that signature, order-triage can use it without knowing anything about its gears —without knowing whether it checks the credit in a database, a spreadsheet, or an API—. It puts in the coin (the three input fields), presses the product (calls the sub-workflow), and out comes the chocolate bar (the result).
Here's the definition we're going to use throughout the module, and it's worth reading slowly:
A workflow contract is the stable promise, between the caller and the responder, about which fields go in —with what types and which required—, what shape the output has when everything goes well, what shape the output has when something fails, and what effects the sub-workflow produces in the world.
Every word in that definition carries weight. "Stable," because a contract that changes all the time isn't a contract, it's a negotiation. "Between the caller and the responder," because a contract has two parties and both have obligations. "With what types and which required," because a field that's sometimes a number and sometimes text, or that's sometimes there and sometimes not, breaks whoever trusted it. And the two output shapes —success and failure— because a sub-workflow that only promises what it returns when everything goes right leaves its caller blind exactly when something goes wrong, which is when they need it most.
A contract's anatomy: its four pieces
Let's take the definition apart into the four concrete pieces you're going to look for in any sub-workflow. It's the same list you're going to fill in when you design your own in lesson 3.
Piece 1: the input fields. What data the sub-workflow needs to receive to do its job. Every field has three attributes: an exact name (customer_id, not customerId or cliente), a type (text, number, boolean, object, or list), and a required or optional flag. A required field is one without which the sub-workflow can't function; an optional one is one that, if it doesn't arrive, the sub-workflow knows how to manage —usually with a default value—.
Piece 2: the success output shape. What the sub-workflow returns when it does its job with no problems. Like the input, it's a set of fields with exact names and types. It's the part of the contract the caller reads to continue: order-triage reads approved from here.
Piece 3: the failure output shape. What the sub-workflow returns when it can't do its job —because the input was invalid, because the customer doesn't exist, because something went wrong—. This piece is the most forgotten and the most painful to forget. A sub-workflow that on failure sometimes returns an empty object, sometimes a cryptic error, and sometimes nothing, forces every caller to guess what happened. A serious contract promises a stable error shape: a field saying "this failed" and another saying why.
Piece 4: the declared effects. What the sub-workflow changes in the world when it runs. check-credit is a read: it queries the credit and responds, changing nothing —you can call it ten times and the world stays the same—. issue-refund is an effect: it issues a refund, moves money, and calling it twice produces two refunds if it isn't protected. Declaring this in the contract isn't decoration; it tells the caller whether it's safe to retry the call or whether it has to be handled with the care of something irreversible. This is where the contract shakes hands with module 2's idempotency.
A compact way to see all four pieces together, for check-credit:
CONTRACT — check-credit
INPUT (what the caller must send)
customer_id : string (required) — whose credit is being checked
order_id : string (required) — which order this query belongs to
amount : number (required) — order total to compare against credit
SUCCESS OUTPUT (what the sub-workflow promises to return if everything goes well)
customer_id : string — returned as-is, so the caller knows whose it is
approved : boolean — true if there is enough credit, false if not
available_credit : number — credit the customer has left after this order
FAILURE OUTPUT (what it promises to return if it cannot do its job)
error : true — flag that this did not go well
code : string — stable code: "INVALID_INPUT", "CUSTOMER_NOT_FOUND"
message : string — human-readable explanation
EFFECTS
none — check-credit only reads; calling it N times leaves the world the same
That's a contract. It isn't code, it isn't yet a formal n8n schema —that comes in lesson 3—; it's the promise written in language anyone on the team can read. And just by being written, it does something the invisible contract from lesson 1 couldn't: it can be reviewed before touching anything.
Worked example: reading a contract before calling
Let's stand on the caller's side to see why the written contract changes everything. Imagine you're the person about to connect order-triage to check-credit, and you've never seen check-credit's internals. All you have is the contract above.
With that contract in hand, you can answer every question you need to make the call, without opening the sub-workflow even once:
- What do I have to send it? Three fields:
customer_id,order_id,amount. All three required. If I'm missing one, I already know —without testing— the call is going to fail. - What type is each one?
customer_idandorder_idare text;amountis a number. That detail matters: if I sendamountas the text"1842.50"instead of the number1842.50, the credit comparison could give anything. The contract warned me before I made the mistake. - What's it going to return if everything goes well? A boolean
approved, which is what I'm going to read to decide. I already know the exact field name:approved. Notis_approved, notcredit_ok.approved. - What's it going to return if something goes wrong? An object with
error: true, acode, and amessage. So I can write my workflow's error-handling branch ahead of time, instead of discovering in production thatcheck-creditsometimes returns something weird.
What to expect. When you connect the two workflows guided by the contract, the first execution does exactly what you predicted: you send it the three fields, you get back { customer_id, approved, available_credit }, and your decision branch reads approved and works. And when you test sending it an order with no customer_id, you get back { error: true, code: "INVALID_INPUT", message: "..." } —the failure shape the contract promised—, and your error branch catches it cleanly. At no point did you have to open check-credit to discover how it behaves. The contract was all you needed.
Compare that with the no-contract world from lesson 1: there, to know which field to read, you had to open check-credit, follow its nodes to the end, see what the last one produced, and hope nobody changed it. With the contract, the vending machine has its label stuck on the front. You put in the coin, press B4, and you already know a chocolate bar comes out.
Contract vs. implementation: the window is sacred
There's a distinction this whole module rests on, and it's worth nailing down now: the contract and the implementation are two separate things, and only one of the two is a promise.
The implementation is how check-credit does its job internally: which nodes it uses, in what order, whether it queries a database or a spreadsheet, whether the available-credit calculation subtracts pending orders with a Code node or with three visual nodes. All of that is the vending machine's gears. And all of that —this is the liberating part— you can change it whenever you want without breaking anyone, as long as the signature stays the same. If tomorrow check-credit switches from querying a spreadsheet to querying a Postgres database, but keeps receiving the same three fields and returning the same three, no caller notices or cares. You changed the kitchen; the window stayed the same.
The contract is the signature: the two arrows from lesson 1's diagram, what comes in and what goes out through the boundary. That is a promise, and breaking it breaks everyone who trusted it. Renaming approved isn't changing the kitchen; it's moving the window, and on the other side there are waiters who can no longer find the dish.
From here comes this whole module's most useful operating rule, and we already named it in lesson 1: you can change the implementation freely; the contract only gets changed carefully and with versioning. Once Cumbre's team understands this separation, they're going to be able to improve their sub-workflows internally as much as they want —make them faster, cleaner, cheaper— without fear, because they'll know exactly which line isn't crossed without notice. That fear of touching anything "in case something breaks" that paralyzes teams with no contracts disappears once the boundary is written.
A contract has two sides, and both must comply
Let's return to the word "between" in the definition: the contract is between the caller and the responder. It isn't a one-sided list of obligations. It's an agreement with duties for both, and this is more than a formality —it changes how you design—.
The responder promises its output. check-credit commits that, if it receives a valid input, it's going to return approved, available_credit, and customer_id with those names and those types; and that, if something fails, it's going to return the promised error shape. It's never going to return an empty object with no explanation, nor a field with a different name depending on the day. That's its side of the deal.
The caller promises its input. order-triage commits to sending the three required fields, with the correct types. It can't send amount as text and then complain the comparison failed; the contract said number. It can't omit customer_id and expect check-credit to guess whose it is. That's its side.
This symmetry has a practical consequence you're going to use in lesson 5. Since the caller can fail to keep its side —by mistake, by a bug, by dirty data coming from a channel like rep_csv—, the responder can't blindly trust that the input arrived well-formed. It has to verify the input promise was kept before acting. That act of checking at the door —"did you really send me what the contract requires?"— is boundary validation, and it's important enough to get its own lesson. For now, hold on to the idea: a two-sided contract means the responder has the right —and the responsibility— to reject an input that doesn't comply, instead of trying to work with garbage and producing a nonsensical result.
Why write it down, if the system already works
It's a reasonable objection: if order-triage and check-credit already understand each other and everything runs, why the extra work of writing the contract? The system works with no document.
The answer is the document doesn't make the system work today —that's already happening—; it makes the system survive tomorrow's change. And automation systems live to change. Let's look at what the written contract concretely gains, beyond the theory.
It gains that the change becomes a conscious decision. With no written contract, the person who renamed approved had no way of knowing they were touching a promise; to them it was just a field with a poor name. With the contract written next to the workflow, that field shows up listed as part of the promised output, and renaming it stops being an oversight and becomes what it really is: breaking a contract, a decision made on purpose and with a migration plan (lesson 6), not in passing on a Tuesday afternoon.
It gains that anyone on the team can use the sub-workflow with no reverse engineering. Cumbre has twelve people. Whoever wrote check-credit isn't always going to be the one connecting it to a new workflow. Without a contract, every new caller has to open the sub-workflow, follow its nodes, and infer its signature —and every inference is a chance to get it wrong—. With a contract, the signature is written: it's read in thirty seconds and used correctly on the first try.
It gains that validation and versioning become possible. You can't validate against a contract that doesn't exist in writing —validate against what?—. You can't tell a compatible change apart from a breaking one with no reference contract to compare against. This module's two most powerful tools, lessons 5 and 6, rest on an explicit contract. The document isn't bureaucracy; it's the foundation everything after it stands on.
That said, honestly: writing the contract has a cost, and not every sub-workflow needs it with the same rigor. A trivial sub-workflow only you use and that you're never going to change can live with a lightweight contract —a two-line note—. The rigor is justified when the sub-workflow has more than one caller, when it handles an effect that matters, or when someone other than the person who wrote it is going to maintain it. check-credit, which decides whether an order continues or stops, falls squarely into that category. The rule isn't "heavy contract for everything"; it's "the explicit contract is proportional to how much it hurts when it breaks."
The word carrying all the weight: "stable"
Go back to the definition once more and notice an adjective that's easy to skim past: the promise is stable. It isn't a stylistic detail; it's what turns a data shape into a contract. Anyone can describe which fields go in and out of a sub-workflow today. What makes that description a contract is the commitment that it's going to stay the same tomorrow, and the day after, and three months from now when someone else connects it to a new workflow.
Think of a wall outlet. The reason you can buy any lamp and trust it's going to fit any socket in your house isn't that the lamp and the socket agreed on this this morning —it's that the outlet's shape has been the same for decades—. That stability is what lets a lamp manufacturer in one country and an electrician in another work with each other without ever talking: both trust a shape that doesn't change. If every manufacturer moved the prongs whenever they felt it looked "more elegant," the outlet would stop being a contract and go back to being a case-by-case negotiation.
A workflow contract is that outlet. order-triage and check-credit can be maintained by different people, in different weeks, with no coordination, exactly because the call's shape is stable. The moment that shape becomes shifty —today approved, next week credit_approved, depending on who thought it sounded clearer— it stops being a contract and goes back to being lesson 1's restaurant with no menu.
This has a design consequence worth having from now, even though lesson 6 develops it: a contract is designed to last, not for today's requirement. When you write check-credit's schema in the next lesson, you're not going to choose the names thinking only about what you need this week; you're going to choose them thinking they'll still be there a year from now. A vague name like data or value —which looks flexible— is actually fragile, because it tempts everyone to put different things there and the promise dissolves. A precise name like available_credit is rigid in the good sense: it says exactly what it is, and that rigidity is what makes it last. Stability isn't achieved by leaving the contract open; it's achieved by making it specific and committing to respect it.
Common mistakes
Documenting only the success output and forgetting the failure one (conceptual). What happens: someone writes check-credit's contract carefully listing approved and available_credit, and considers the contract complete; the day the input arrives invalid, the sub-workflow returns whatever —a half-built object, an internal n8n error— and the caller doesn't know how to interpret it. Why it happens: when you test a sub-workflow, you almost always test it with good data, so the success path is the only one you see; the failure one stays invisible until it happens in production. How to spot it: look at your contract and ask yourself "if I send it an invalid input, what does the contract say it's going to return?" If there's no written answer, the contract is half-done. How to fix it: define the error shape as part of the contract, always, with the same seriousness as the success one —an error flag, a stable code, and a readable message—. Lesson 5 uses exactly that shape to cleanly reject bad inputs.
Confusing the contract with the implementation (conceptual). What happens: someone documents check-credit's "contract" by describing its internal nodes —"first it queries sheet X, then subtracts with a Code node, then compares"— and calls that the contract. When they optimize those nodes internally, they think they changed the contract and panic looking for callers to update. Why it happens: it's natural to describe something by how it works instead of by what it promises; the signature is more abstract than the gears. How to spot it: review your contract and cross out every line that talks about how the sub-workflow works internally; what's left —what goes in and out through the boundary— is the real contract. How to fix it: write the contract only in terms of the boundary: input, success output, failure output, effects. Never mention an internal node. If your contract names a node, you're documenting the kitchen, not the window.
Treating an optional field as if it were required, or the other way around (practical). What happens: the contract marks currency as optional with a default value, but the sub-workflow internally assumes it always arrives and fails when it doesn't; or the reverse, it marks amount as optional when it actually can't function without it. Why it happens: a field's requiredness is easy to decide off the cuff and easy to get wrong; it sounds like a minor detail and it isn't. How to spot it: for every input field marked optional, verify the sub-workflow genuinely knows what to do when that field doesn't arrive —that it has a real default value, not an undefined that blows up three nodes later. For every required one, verify the sub-workflow truly can't function without it. How to fix it: the required/optional flag isn't decorative; it's a promise about behavior. An optional field obligates the sub-workflow to handle its absence; a required one entitles it to reject the call if it's missing. Lesson 3 covers default values in detail and lesson 5 covers rejection.
Exercises
Exercise 1 — Write issue-refund's contract. The issue-refund sub-workflow issues a refund for an order. It receives the order's order_id, the amount to refund, and a free-text reason. It returns, if everything goes well, a refund_id (the identifier of the created refund) and a status. If it fails, it returns the same error shape as check-credit. Unlike check-credit, issue-refund does produce an effect: it moves money. Write its complete contract with all four pieces.
See solution
CONTRACT — issue-refund
INPUT
order_id : string (required) — the order to refund
amount : number (required) — amount to refund in the order currency
reason : string (required) — refund reason, free text
SUCCESS OUTPUT
refund_id : string — identifier of the created refund
status : string — refund status, e.g. "completed" or "pending"
order_id : string — returned so the caller knows which order it belongs to
FAILURE OUTPUT
error : true
code : string — "INVALID_INPUT", "ORDER_NOT_FOUND", "ALREADY_REFUNDED"
message : string
EFFECTS
DOES produce an effect: issues a refund (moves money).
Calling it twice with the same order_id, unprotected, produces TWO refunds.
That is why calling it must be idempotent (Module 2) and its input, validated (Lesson 5).
Why this works: the contract has all four pieces, but what makes it correct is piece 4. Declaring that issue-refund moves money and that it isn't safe to call it twice isn't an optional comment: it's the information that tells the caller this call demands the care of an irreversible effect, combining this module's contract with the previous one's idempotency. A contract that omitted that line would be technically complete in form and dangerously incomplete in substance.
Exercise 2 — Kitchen or window. For each of these changes to check-credit, decide whether it touches the implementation (the kitchen, safe change) or the contract (the window, a change that can break callers), and justify in one sentence:
(a) Changing the available-credit calculation from a Code node to three visual nodes that do the same thing.
(b) Renaming the output field available_credit to remaining_credit.
(c) Changing the credit source from a Google Sheets sheet to a Postgres database, returning the same fields.
(d) Changing the input field amount's type from number to text.
See solution
(a) Kitchen (safe). How it's calculated internally changed, but the signature —what goes in and out— stays identical. No caller notices. You can do it with no announcement to anyone.
(b) Window (breaks). An output field changed name. Every caller that read available_credit now reads undefined. It's exactly lesson 1's silent break, and it demands versioning (lesson 6).
(c) Kitchen (safe). The data source is pure implementation; as long as the output returns the same fields with the same types, the window didn't move. You can migrate from Sheets to Postgres without touching a single caller.
(d) Window (breaks). An input field's type is part of the contract. A caller that sent amount as a number will keep sending it as a number, and now the sub-workflow expects text: the input promise changed. Changing a type is one of the classic breaking changes.
Why this works: the criterion isn't "how big the change sounds." Migrating from Sheets to Postgres sounds huge and is safe; renaming a field sounds trivial and breaks everything. The only question that matters is: did something that crosses the boundary change —a name, a type, a field's requiredness, the output shape? If yes, it's the window. If the change is purely internal, it's the kitchen.
Exercise 3 — The two-sided contract. order-triage started sending check-credit the amount field as the text "1842.50" instead of the number 1842.50, because of a bug in an earlier node. check-credit tried to compare that text against the available credit and produced a nonsensical result —it approved an order it should have rejected—. Who broke the contract, the caller or the responder? And what should the responder have done to protect itself?
See solution
The caller, order-triage, broke the contract: it said amount is a number, and it sent text. That's a violation of the input promise, not the output one.
But —and here's the point— that doesn't let check-credit off the hook. Since the contract has two sides and the caller can fail to keep its own (through a bug, through dirty data), the responder shouldn't blindly trust the input arrived well-formed. check-credit should have verified amount was truly a number before using it, and if it wasn't, rejected the call by returning the contract's error shape (error: true, code: "INVALID_INPUT") instead of trying to compare text against a number and producing an absurd result.
Why this works: this exercise previews lesson 5's heart. The two-sided contract means each side has its responsibility, but since the caller is fallible, the responder carries the duty of checking at the door. Trusting the input always complies is a bet a serious sub-workflow doesn't make: the nonsensical result that approved an order that should have been rejected is exactly what that blind trust produces. Rejecting early would have turned a silent error into a visible, contained one.
Summary and next step
In this lesson you gave precise anatomy to the promise lesson 1 left as intuition. You defined a workflow contract as a vending machine's signature: which coins it accepts and which product it dispenses, saying nothing about its gears. You saw its four pieces —the input fields with name, type, and requiredness; the success output shape; the failure output shape, the most forgotten one; and the effects the sub-workflow produces in the world— and wrote them out in full for check-credit. You separated the contract from the implementation with the rule this whole module rests on: the kitchen changes freely, the window is sacred. You understood the contract has two sides with their own duties —the responder promises its output, the caller promises its input—, and that since the caller is fallible, the responder has the duty to check at the door. And you saw why writing the contract, even when the system already works, is what lets it survive change: it makes every modification conscious, it lets anyone use the sub-workflow with no reverse engineering, and it's the foundation validation and versioning stand on.
Before moving on to lesson 3 you should be able to: name a contract's four pieces from memory; write the contract for a sub-workflow given to you; and decide, faced with a change, whether it touches the kitchen or the window.
What you have so far is the contract in human language —a note anyone can read—. That's enough to reason with, but it's not yet something n8n can use. Lesson 3 turns this anatomy into a concrete schema: how you express the names, the types, the required and optional fields, and their default values in a way that serves both to document next to the workflow and for n8n, later on, to recognize at the boundary. We move from "what a contract is" to "how you write one the machine understands too."
Resources
- Sub-workflows — n8n Docs — the official overview of how one workflow calls another and what data crosses between them; the context every workflow contract lives in.
- Execute Sub-workflow Trigger — n8n Docs — the node that receives the call and where, in lesson 3, you're going to declare the contract's input fields.
- Understand n8n's data structure — n8n Docs — the shape of the items that travel between nodes and workflows, the foundation for understanding what a contract field's types mean.
- How tools work — n8n Docs — how an agent sees a workflow or a node as a tool; the workflow contract's relative that lesson 7 is going to connect with what you saw here.