Schema Contracts Between Tools: The API Discipline Most Automations Skip
Validate JSON at every trust boundary. Reject nulls and type drift before any CRM write. Silent acceptance poisons those CRM records for weeks afterward.
William Spurlock Founder — Spurlock Studios Updated 21 MIN
Most automation failures are not dramatic. A field that was always a string arrives as null. The HTTP node still returns 200. The graph keeps running. Your CRM quietly fills with blank emails, empty company names, and numbers that used to be strings.
A schema contract is the agreement — enforced in code — about the shape of data as it crosses a trust boundary. Validate there. Reject or quarantine. Do not let silent nulls become CRM facts.
This is the discipline most graphs skip. It sits in the spine of the Production n8n handbook. When a vendor payload moves, pair it with when APIs change. Across 500+ automations, the graphs that stay boring are the ones that fail closed on identity fields.
The short answer
- Validate at the border — immediately after inbound webhooks, third-party HTTP responses, and model JSON. Before any CRM or money write.
- A contract is small — required fields, types, allowed enums, and what happens on violation. Not a 40-page spec for every internal mapping.
requiredis not enough — JSON Schemarequiredonly checks that the key exists.nullis a JSON value. Identity fields need atypethat excludes it.- Hard-fail identity — missing or null email, id, or amount goes to a review pile. Optional enrichment can soft-fail.
- One owner — a shared validator sub-workflow, a version name, and two fixtures. Duplicated IF chains drift inside a month.
What is a schema contract (and what is not)?
A contract states, for a given step:
| Clause | What you write down | Example |
|---|---|---|
| Required keys | Must be present | id, email, submittedAt |
| Types | Must match | id string, employees number |
| Enums | Allowed values | plan in starter, pro, enterprise |
| Null policy | Allowed or forbidden | email never null; linkedinUrl may be absent |
| On fail | Reject, quarantine, or default — and who gets notified | Identity fail → review queue + #auto-critical |
It is not an OpenAPI novel for every scratch field you invent between nodes. Start with the fields that, if wrong, create support tickets or bad money movement.
A one-screen contract people will keep:
Name: LeadInboundV2
Source: Typeform webhook
Required: id:string, email:email, submittedAt:iso8601
Optional: company:string, employees:number
Enums: plan in {starter, pro, enterprise} if present
Nulls: email/id forbidden; company may be absent, never null-as-string
On fail: quarantine errorClass=schema, notify #auto-critical
Owner: growth-ops
When marketing adds a field, bump the version and note why. Folklore in Slack is not a contract.
- Named (
LeadInboundV2), not “the Code node after HTTP” - Failure behavior written, not implied
- Owner named
- Sample good payload and sample bad payload next to the workflow export
Why do silent nulls poison a CRM?
JSON has a real null. RFC 8259 lists it with object, array, number, string, true, and false. A missing key and a key set to null are different values. Most CRM writers treat both as “I have nothing” — or worse, as “clear what was there.”
HubSpot’s own property guide is blunt: you clear a property by sending an empty string. Example: PATCH { "properties": { "firstname": "" } } (HubSpot: Update or clear a property). If your workflow maps a vendor null into "" and PATCHes, you did not “skip the field.” You wiped yesterday’s first name.
| Incoming JSON | What a naive mapper does | What the CRM stores |
|---|---|---|
| key absent | skip write | yesterday’s value (lucky) |
"email": null | write null / skip / coerce | blank, stale, or API error |
"email": "" | write empty string | HubSpot treats "" as clear |
"email": "null" | write the string | the literal word null |
"employees": "12" | write string into a number property | reject, coerce, or garbage |
The failure mode is a green execution. n8n’s HTTP Request node reports success on 2xx by default. A 200 with { "email": null } is not a contract pass. It is a successful delivery of poison.
What it costs: sales works a contact that cannot be emailed. Dedupes miss. Sequences skip. A week later you have a cleanup project and nobody trusts the row.
- Identity fields (
email,id, primary phone) rejectnull,"", and the string"null" - Optional fields that arrive as
nullare omitted from the CRM PATCH, not cleared - Numeric CRM properties never receive
"null"or""unless you intend to clear - Validator output names the field, the expected type, and the received value
Where do trust boundaries sit in n8n?
A trust boundary is any hop where you did not produce the JSON yourself. Put a validator immediately after:
| Boundary | Typical n8n node | Why it lies |
|---|---|---|
| Inbound event | Webhook | Callers send whatever they want. Auth is not a schema. |
| Third-party read | HTTP Request | 200 ≠ shape. Keys rename. Types flip. |
| Model output | Code / AI node | Models omit keys, rename them, wrap JSON in fences. |
| Join / merge | Merge, Code | Two “valid” objects produce a third shape nobody owns. |
Put a lighter check before irreversible writes: payments, customer email, bulk CRM updates.
You do not need to validate your own intermediate scratch fields on every node. You do need to validate anything you did not produce.
Two n8n settings people mistake for a contract:
- Webhook Only Run If — an expression that must return
trueor the workflow does not run. If the expression fails to evaluate, n8n logs a warning and lets the request through (Webhook node). That is the opposite of fail-closed. - HTTP Request Never Error — the node returns success regardless of status code (HTTP Request). Useful for branching on 404. Fatal if you then write the body to HubSpot with no shape check.
The Filter node is also not a contract. It omits items that miss a condition. No error class. No payload parked. The item vanishes. Vanishing is not validation.
- Validator sits on the first node after the untrusted hop
- Only Run If is extra gating, not the schema
- Never Error is paired with an explicit status + body check
- Filter is for “drop junk we already classified,” not for identity
How do I validate JSON in n8n?
n8n passes data as an array of items, each wrapping a json object (n8n data structure). Your contract runs against $json (the payload), not against the wrapper.
Practical options:
| Method | When it is enough | When it breaks |
|---|---|---|
| IF / Filter on 2–4 fields | Tiny webhook, one required email | Nested objects, type unions, more than ~5 fields |
| Code node + manual checks | Most production graphs | You copy-paste the node into five workflows |
| Code + Ajv / Zod | Self-hosted n8n with extra modules enabled | n8n Cloud cannot import npm modules — only crypto and moment ship (Code node) |
| Execute Sub-workflow | Same payload, many graphs | Trigger set to “Accept all data” with no inner check |
The Code node cannot make HTTP requests or touch the filesystem. Fetch with HTTP Request, then validate the body in Code (Code node: file system and HTTP). If you pass malformed JSON into HTTP Request, n8n fails with “JSON parameter need to be an valid JSON” — wrap expressions so the whole object is valid JSON (HTTP Request common issues). That error is about the request you send, not the response you received. Do not confuse the two.
Conceptual Code-node check (Cloud-safe, no extra libraries):
const email = $json.email;
const id = $json.id;
const company = $json.company;
const errors = [];
if (typeof id !== "string" || !id.trim()) errors.push("id");
if (typeof email !== "string" || !email.includes("@")) errors.push("email");
if (company != null && typeof company !== "string") errors.push("company");
if (errors.length) {
return [{
json: { ok: false, errors, raw: $json },
}];
}
return [{
json: {
ok: true,
contact: { id, email, company: company ?? "" },
},
}];
Branch on ok. Failures go to a review queue or an error workflow, not to HubSpot. Return an object under json — a json key pointing at an array is a documented Code-node failure (Code common issues).
Procedure:
- HTTP Request or Webhook produces items.
- Code (or sub-workflow) returns
{ ok, errors, value }. - IF:
ok === true→ map → write. - IF:
ok === false→ park payload, field list, execution id. - Do not write on the failure branch. Not “write what we have.”
What does JSON Schema actually check?
JSON Schema 2020-12 is the current dialect. The spec splits Core (what a schema is, $id, $ref) from Validation (assertions: type, required, enum, ranges).
type may be one string or an array of strings. The validation spec lists six primitives — null, boolean, object, array, number, string — plus integer (Validation §6.1.1). { "type": "string" } rejects null. { "type": ["string", "null"] } allows it. If you do not write the union, null is a type error. That is the whole point.
A contract you can keep next to the workflow:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/schemas/lead-inbound-v2.json",
"type": "object",
"required": ["id", "email", "submittedAt"],
"additionalProperties": true,
"properties": {
"id": { "type": "string", "minLength": 1 },
"email": { "type": "string", "minLength": 3 },
"submittedAt": { "type": "string", "minLength": 10 },
"company": { "type": "string", "minLength": 1 },
"employees": { "type": "integer", "minimum": 1 },
"plan": { "type": "string", "enum": ["starter", "pro", "enterprise"] }
}
}
company is listed under properties but not under required. Absent is fine. Present-and-null is not, because type is "string" only.
| Keyword | What it asserts | What it does not assert |
|---|---|---|
required | Those keys exist on the object | The values are non-null or non-empty |
type: "string" | The value is a JSON string | It is a usable email |
type: ["string", "null"] | String or JSON null | You wanted “optional” |
enum | Value equals one listed element | Business meaning of the label |
minLength | String length ≥ n | The string is not "null" |
format | Annotation by default | Assertion unless the validator turns it on (Validation §7) |
Do not rely on format: "email" as your only email check. Format is an annotation unless you enable format assertion. A minLength plus a includes("@") check in Code is louder and portable on n8n Cloud.
- Dialect pinned (
$schema→ 2020-12) - Identity properties use
typethat omits"null" - Optional properties are omitted from
required, not typed as null-unions unless you mean it -
additionalProperties: trueuntil you know extra keys are safe to drop
Why is required not enough?
The validation spec is one sentence: an object is valid against required if every listed name is the name of a property in the instance (Validation §6.5.3). Presence. Not “has a real value.”
These all satisfy "required": ["email"]:
| Instance | required | type: "string" | Usable as a login? |
|---|---|---|---|
{ "email": "ada@example.com" } | pass | pass | yes |
{ "email": null } | pass | fail | no |
{ "email": "" } | pass | pass | no |
{ "email": "null" } | pass | pass | no |
{ } | fail | n/a | no |
If you only check required, null walks in. That is the Tuesday outage: the key was there, the type was wrong, the CRM write still ran.
Decision list:
- Must the key exist? →
required - May the value be JSON
null? → only thentype: ["string", "null"] - May it be empty string? →
minLength: 1or a Code check - Is it identity or money? → hard-fail all of the above
- Is it enrichment? → absent is fine; present-and-null is a skip, not a clear
Treat the string "null" as a type error on identity fields. Vendors and spreadsheet exports produce it. JSON Schema will not catch it with type: "string" alone.
When should validation hard-fail?
Hard fail: required identity or money is missing, null, empty, or the wrong type. Stop. Park the item. Notify the owner. Do not write.
Soft fail: optional enrichment is missing or the wrong type. Continue. Log the skip. Maybe fill later. Do not invent a company name from an email domain and store it as fact.
| Field | Fail | Why |
|---|---|---|
email, id, CRM record id | Hard | Dedupe and login |
| Amount, currency, quantity | Hard | Money |
plan enum unexpected | Hard if it drives billing; soft if it drives a badge | Product call |
| LinkedIn URL absent | Soft | Nice to have |
employees null | Soft — omit from PATCH | Do not clear a good number |
Envelope items not an array | Hard | Batch is corrupt |
Do not hard-fail the whole lead-routing flow because LinkedIn URL was absent. Do hard-fail if email was absent and email is how you dedupe.
Wire the hard path to an error workflow (Error Trigger) or a dedicated review graph. The Stop and Error node can force the execution to fail under your rules so the error workflow actually runs.
- Identity hard-fail does not write a partial CRM row
- Soft-fail sets
enrichmentStatus=partial(or equivalent) on the item - Alert includes field name, expected type, received value, execution id
- Money fields never soft-fail into a default of
0
How do I version a contract people will keep?
When a vendor changes a payload, you want a loud break in staging or in the validator — not a quiet corruption in production. That is the same incident class as APIs changing under you.
Practices that survive:
| Practice | Do | Do not |
|---|---|---|
| Name | ContactInboundV1 in the sub-workflow title | “Validate” on five graphs |
| Bump | New shape → V2, date in the comment | Edit V1 in place with no note |
| Fixtures | lead.good.json, lead.null-email.json | “We tested it in the editor once” |
| Alert | Spike in validator failures | Only alert on HTTP 500 |
| Owner | One team, one Slack channel | “Whoever built the Typeform” |
HTTP 200 with a new null is a contract event, not an HTTP event. Alert on validator failure rate, not only on node exceptions.
2026-05-22 ContactInboundV1 → V2
Why: vendor now sends company as object { name, domain }
Action: accept object, map name; reject leftover string
That note next to the workflow export beats a wiki nobody opens.
What is safe to normalize?
Validation asks “is this acceptable?” Normalization asks “can we make it acceptable without guessing?”
| Safe | Unsafe |
|---|---|
| Trim whitespace | Invent company from email domain and store it as official |
| Lowercase emails | Default missing country to US |
Coerce "42" to number when the field is known numeric | Coerce null to 0 on money |
| Phone to E.164 when a library is confident | Drop unknown keys your downstream still needs |
| Omit null optional fields from a PATCH | Map null → "" on HubSpot identity properties |
Label inferred fields (companySource=inferred_from_domain). Never silently invent money or identity. HubSpot will happily store the invention. Sales will treat it as ground truth.
- Normalization runs after type checks, or is listed in the contract
- Inferred values get a sibling source field
- CRM PATCH omits keys you did not validate as present-and-typed
- Empty-string clears are explicit, never the default for
null
How do batches and lists change the contract?
Webhooks that deliver arrays need two layers:
- Envelope —
itemsis an array,batchIdis a string. - Per-item — the same object contract, inside a loop.
Fail the item, not always the whole batch — unless the envelope itself is corrupt. Partial success with per-item review rows is normal for migrations and sync jobs.
n8n nodes process each item in the incoming array (data structure). If you validate in “Run Once for All Items” mode, you must loop. If you validate in “Run Once for Each Item,” you must still return the { ok, errors, value } shape per item (Code node modes).
| Envelope | Item | Action |
|---|---|---|
items missing or not array | — | Hard-fail the execution |
items: [] | — | Success, zero writes, log it |
| Good envelope | One bad item | Park that item; write the rest |
| Good envelope | All bad | No CRM writes; one alert with counts |
- Envelope contract and item contract are named separately (
BatchV1/LeadInboundV2) - Loop does not abort the batch on the first item error unless you chose that
- Review rows include
batchIdand index - Empty batch is not treated as “vendor is down”
Why do model outputs need the same border?
If a model returns JSON for a content or CRM write, validate before use. Models omit fields, rename keys, and wrap objects in markdown fences. The contract is the border between “draft helper” and “system of record.”
Same pattern: decode → validate → (often) approve → write. Model confidence is not a schema.
Require:
| Check | Why |
|---|---|
| Strict JSON, or fenced text that decodes — else review | Fences are not objects |
| Allowlist keys | Extra keys become CRM properties by accident |
| Max string lengths | Prompt-stuffed novels in notes fields |
| Numeric ranges for scores | score: "high" is not a number |
| Same business contract as the webhook | The write path does not care who invented the JSON |
Do not run a looser contract on model output than you run on Typeform. The CRM cannot tell the difference. The cleanup looks the same.
- Decode failure is a hard-fail, not a retry-until-it-looks-right loop
- Allowlist drop is logged
- Scores have min/max
- Customer-facing text still has a human gate if you already require one
How do I share one validator across graphs?
When the same payload shape feeds multiple workflows, put validation in one sub-workflow. n8n’s Execute Sub-workflow plus Execute Sub-workflow Trigger is the official split (break workflows into smaller parts).
Input data mode matters:
| Mode | Use | Risk |
|---|---|---|
| Define fields below | Caller must supply named fields | Still not types — you validate inside |
| JSON example | Documents expected shape | Example is not enforcement |
| Accept all data | “This sub-workflow must handle any input inconsistencies” (n8n) | Easy to forget the inner contract |
Create validate-lead-inbound. Return { ok, errors, value }. All intake graphs call it. When the contract bumps to V3, you change one place. Duplicated Code nodes drift within a month — budget on it.
If three workflows consume “HubSpot contact upserted,” publish one shared contract for that event. Consumers can be stricter. They should not invent different required fields. Five conflicting IF chains is how you get five definitions of “email.”
- One sub-workflow per payload family
- Trigger is not “Accept all data” unless the first node is the validator
- Parent branches on
ok, never on “the sub-workflow did not throw” - Version in the sub-workflow name
How do I test a contract without a platform team?
Keep fixtures next to the workflow export:
lead.good.jsonlead.missing-email.jsonlead.null-company.jsonlead.email-string-null.json
Run them through the validator node in staging when you change anything. Ten seconds of fixture testing prevents a week of CRM cleanup.
When a production schema failure fires, save the payload (redacted) as a new fixture so the bug cannot return unnoticed.
| Fixture | Expect |
|---|---|
| Good | ok: true, mapped value |
| Missing email | ok: false, errors includes email |
email: null | ok: false — this is the one people forget |
company: null | soft path: ok: true, company omitted |
| Extra unknown key | pass if additionalProperties is true; logged |
n8n will not run a hidden test suite for you. Pin the fixtures in the same folder as the exported workflow JSON. If you cannot name the last fixture you added, you do not have tests. You have hope.
- At least one null-identity fixture
- At least one extra-key fixture
- Production failure → new fixture in the same change as the fix
- Staging replay before you unpause the write path
What do I send a vendor when the shape breaks?
When a SaaS partner breaks your contract, you want evidence, not a vibe.
Package:
- Timestamp (UTC)
- Expected contract name and version
- Received JSON (redacted)
- Validator errors (field, expected, actual)
- n8n execution id and URL
- Whether you paused the write path
That package shortens support tickets. Validators are not only defensive engineering. They are how you get vendors to take you seriously.
If a vendor changes a required field to optional and starts omitting it, you may reject until they fix, or accept with a new enrichment step that fills it. That is a product decision. Schema failures surface the decision; they do not make it for you. Bring growth or finance in when identity fields wobble.
| You have | You can ask |
|---|---|
| Execution id + payload | “This field was string, now null as of 14:02 UTC” |
| Failure rate spike | “X% of events since deploy Y” |
| Nothing but “it feels off” | A shrug and a changelog |
How do I tighten a messy inherited graph?
If you inherit a workflow that already writes, do not flip every field to hard-fail on day one. Sudden hard validation on a dirty historical path can stop revenue ops cold.
- Observe payloads for a week. Log would-be violations. Do not block yet.
- Turn on soft validation (warn + continue) for optional fields.
- Hard-fail identity fields (
email,id). - Expand hard-fail as data quality improves.
- Only then refuse vendor field changes that break money or login.
| Week | Control | Success signal |
|---|---|---|
| 1 | Log-only validator | You can name the top three violations |
| 2 | Soft-fail enrichment | CRM PATCHes omit bad optionals |
| 3 | Hard-fail identity | Zero null emails written |
| 4 | Shared sub-workflow | One version, many callers |
| 5 | Fixtures from real failures | Last incident cannot recur unnoticed |
Tighten with intent. The goal is a boring write path, not a perfect schema on Friday.
What does a healthy production path look like?
A healthy workflow:
- Rejects or quarantines malformed inbound events in seconds
- Never writes
nullinto required CRM properties - Never maps vendor
nullto HubSpot""on identity unless a human chose “clear” - Produces a review item that says which field failed
- Survives a vendor type change with an alert, not a weekend cleanup
| Check | Pass | Fail |
|---|---|---|
| Border | Validator on the first node after the hop | First node after HTTP is HubSpot |
| Identity | Null/empty email never written | Green run, blank email |
| Optional | Null omitted from PATCH | Null cleared a good value |
| Share | One named sub-workflow | Five copied Code nodes |
| Proof | Fixtures include a null case | “We clicked Test” |
That is API discipline without pretending you run a platform team of forty. The automation lane is the rest of the operating system this post sits in.
FAQ
What are API schema contracts in automation?
They are enforced rules for the shape of data crossing system boundaries — required fields, types, null policy, and failure behavior. In automation they usually live as validators right after webhooks and HTTP calls, not as a wiki page. The contract is the code that can reject an item before a CRM write.
How do I validate JSON in n8n?
Use a Code node or a shared sub-workflow to check required fields and types, return ok / errors / value, and branch failures to a review queue or error workflow. Plain JavaScript checks work on n8n Cloud. Ajv or Zod need self-hosted n8n with extra modules enabled. Do not treat Filter or Only Run If as the contract.
Should I validate every field?
No. Validate identity fields and anything that drives irreversible actions. Optional enrichment can soft-fail and be omitted from the CRM PATCH. Over-validation creates noise you will mute. Under-validation creates CRM poison you will clean by hand.
What happens when upstream changes a field type?
Your validator should fail loudly and park items for review. That is success. The failure mode to fear is silent acceptance of null where a string belonged, then a week of blank HubSpot properties. Treat the spike as the same class of incident as a vendor API change.
Do schema contracts replace integration tests?
No. They catch bad runtime data. You still want fixture payloads — especially a null-identity case — run when you change the workflow. Contracts are the seatbelt. Fixtures are the garage check. A production failure should become a new fixture in the same change as the fix.
How do contracts relate to retries and duplicate writes?
Validate before you treat an item as successfully processed. Garbage should not look like a completed event. If you retry a write after a schema fail, you will retry poison. Park first, fix the mapping, then replay the review pile on purpose.
CTA
If your automations trust every JSON blob they meet, they are one vendor deploy away from a CRM cleanup.
Add validators at trust boundaries, keep the handbook open, and use the automation lane or book an automation audit when you want contracts standardized across the stack.
What questions does this article answer?
- What are API schema contracts in automation?
- They are enforced rules for the shape of data crossing system boundaries — required fields, types, null policy, and failure behavior. In automation they usually live as validators right after webhooks and HTTP calls, not as a wiki page. The contract is the code that can reject an item before a CRM write.
- How do I validate JSON in n8n?
- Use a Code node or a shared sub-workflow to check required fields and types, return `ok` / `errors` / `value`, and branch failures to a review queue or error workflow. Plain JavaScript checks work on n8n Cloud. Ajv or Zod need self-hosted n8n with extra modules enabled. Do not treat Filter or Only Run If as the contract.
- Should I validate every field?
- No. Validate identity fields and anything that drives irreversible actions. Optional enrichment can soft-fail and be omitted from the CRM PATCH. Over-validation creates noise you will mute. Under-validation creates CRM poison you will clean by hand.
- What happens when upstream changes a field type?
- Your validator should fail loudly and park items for review. That is success. The failure mode to fear is silent acceptance of `null` where a string belonged, then a week of blank HubSpot properties. Treat the spike as the same class of incident as a vendor API change.
- Do schema contracts replace integration tests?
- No. They catch bad runtime data. You still want fixture payloads — especially a null-identity case — run when you change the workflow. Contracts are the seatbelt. Fixtures are the garage check. A production failure should become a new fixture in the same change as the fix.
- How do contracts relate to retries and duplicate writes?
- Validate before you treat an item as successfully processed. Garbage should not look like a completed event. If you retry a write after a schema fail, you will retry poison. Park first, fix the mapping, then replay the review pile on purpose.
Last reviewed
Automation
Automation After the show is not you at 1 a.m.
Post-show onboarding — thank-you, join path, merch nudge — belongs in a human-gated n8n rail, not your thumb at load-out.
Automation Paperwork that is not the plant
Invoice and PO matching, intake, and support triage in n8n with Metrc fences — the paperwork operators hate, not a menu widget.
Automation Saturday still books — the missed-call rail for trades
A missed-call text-back that routes zip and books a slot beats voicemail and Saturday desk coverage you cannot keep staffed. If a kid is cheaper, say so.
Automation Why doesn’t worker concurrency cap my n8n sub-workflows
Worker concurrency does not cap n8n sub-workflows. Each Execute Workflow child is a new execution the production limit skips, usually on the parent worker.
Will's Journal in your inbox.
What I learned this week building for shops, floors, and houses.
You're on the list.
Sign-up failed — try again.
By subscribing, you agree to the Privacy Policy.