Spurlock Studios
Contact
Share LinkedIn X
Nested brass frames. Thesis: SCHEMA CONTRACTS BETWEEN TOOLS API.

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.
  • required is not enough — JSON Schema required only checks that the key exists. null is a JSON value. Identity fields need a type that 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:

ClauseWhat you write downExample
Required keysMust be presentid, email, submittedAt
TypesMust matchid string, employees number
EnumsAllowed valuesplan in starter, pro, enterprise
Null policyAllowed or forbiddenemail never null; linkedinUrl may be absent
On failReject, quarantine, or default — and who gets notifiedIdentity 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 JSONWhat a naive mapper doesWhat the CRM stores
key absentskip writeyesterday’s value (lucky)
"email": nullwrite null / skip / coerceblank, stale, or API error
"email": ""write empty stringHubSpot treats "" as clear
"email": "null"write the stringthe literal word null
"employees": "12"write string into a number propertyreject, 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) reject null, "", and the string "null"
  • Optional fields that arrive as null are 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:

BoundaryTypical n8n nodeWhy it lies
Inbound eventWebhookCallers send whatever they want. Auth is not a schema.
Third-party readHTTP Request200 ≠ shape. Keys rename. Types flip.
Model outputCode / AI nodeModels omit keys, rename them, wrap JSON in fences.
Join / mergeMerge, CodeTwo “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:

  1. Webhook Only Run If — an expression that must return true or 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.
  2. 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:

MethodWhen it is enoughWhen it breaks
IF / Filter on 2–4 fieldsTiny webhook, one required emailNested objects, type unions, more than ~5 fields
Code node + manual checksMost production graphsYou copy-paste the node into five workflows
Code + Ajv / ZodSelf-hosted n8n with extra modules enabledn8n Cloud cannot import npm modules — only crypto and moment ship (Code node)
Execute Sub-workflowSame payload, many graphsTrigger 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:

  1. HTTP Request or Webhook produces items.
  2. Code (or sub-workflow) returns { ok, errors, value }.
  3. IF: ok === true → map → write.
  4. IF: ok === false → park payload, field list, execution id.
  5. 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.

KeywordWhat it assertsWhat it does not assert
requiredThose keys exist on the objectThe values are non-null or non-empty
type: "string"The value is a JSON stringIt is a usable email
type: ["string", "null"]String or JSON nullYou wanted “optional”
enumValue equals one listed elementBusiness meaning of the label
minLengthString length ≥ nThe string is not "null"
formatAnnotation by defaultAssertion 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 type that omits "null"
  • Optional properties are omitted from required, not typed as null-unions unless you mean it
  • additionalProperties: true until 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"]:

Instancerequiredtype: "string"Usable as a login?
{ "email": "ada@example.com" }passpassyes
{ "email": null }passfailno
{ "email": "" }passpassno
{ "email": "null" }passpassno
{ }failn/ano

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:

  1. Must the key exist? → required
  2. May the value be JSON null? → only then type: ["string", "null"]
  3. May it be empty string? → minLength: 1 or a Code check
  4. Is it identity or money? → hard-fail all of the above
  5. 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.

FieldFailWhy
email, id, CRM record idHardDedupe and login
Amount, currency, quantityHardMoney
plan enum unexpectedHard if it drives billing; soft if it drives a badgeProduct call
LinkedIn URL absentSoftNice to have
employees nullSoft — omit from PATCHDo not clear a good number
Envelope items not an arrayHardBatch 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:

PracticeDoDo not
NameContactInboundV1 in the sub-workflow title“Validate” on five graphs
BumpNew shape → V2, date in the commentEdit V1 in place with no note
Fixtureslead.good.json, lead.null-email.json“We tested it in the editor once”
AlertSpike in validator failuresOnly alert on HTTP 500
OwnerOne 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?”

SafeUnsafe
Trim whitespaceInvent company from email domain and store it as official
Lowercase emailsDefault missing country to US
Coerce "42" to number when the field is known numericCoerce null to 0 on money
Phone to E.164 when a library is confidentDrop unknown keys your downstream still needs
Omit null optional fields from a PATCHMap 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:

  1. Envelope — items is an array, batchId is a string.
  2. 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).

EnvelopeItemAction
items missing or not array—Hard-fail the execution
items: []—Success, zero writes, log it
Good envelopeOne bad itemPark that item; write the rest
Good envelopeAll badNo 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 batchId and 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:

CheckWhy
Strict JSON, or fenced text that decodes — else reviewFences are not objects
Allowlist keysExtra keys become CRM properties by accident
Max string lengthsPrompt-stuffed novels in notes fields
Numeric ranges for scoresscore: "high" is not a number
Same business contract as the webhookThe 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:

ModeUseRisk
Define fields belowCaller must supply named fieldsStill not types — you validate inside
JSON exampleDocuments expected shapeExample 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.json
  • lead.missing-email.json
  • lead.null-company.json
  • lead.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.

FixtureExpect
Goodok: true, mapped value
Missing emailok: false, errors includes email
email: nullok: false — this is the one people forget
company: nullsoft path: ok: true, company omitted
Extra unknown keypass 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:

  1. Timestamp (UTC)
  2. Expected contract name and version
  3. Received JSON (redacted)
  4. Validator errors (field, expected, actual)
  5. n8n execution id and URL
  6. 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 haveYou 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.

  1. Observe payloads for a week. Log would-be violations. Do not block yet.
  2. Turn on soft validation (warn + continue) for optional fields.
  3. Hard-fail identity fields (email, id).
  4. Expand hard-fail as data quality improves.
  5. Only then refuse vendor field changes that break money or login.
WeekControlSuccess signal
1Log-only validatorYou can name the top three violations
2Soft-fail enrichmentCRM PATCHes omit bad optionals
3Hard-fail identityZero null emails written
4Shared sub-workflowOne version, many callers
5Fixtures from real failuresLast 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 null into required CRM properties
  • Never maps vendor null to 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
CheckPassFail
BorderValidator on the first node after the hopFirst node after HTTP is HubSpot
IdentityNull/empty email never writtenGreen run, blank email
OptionalNull omitted from PATCHNull cleared a good value
ShareOne named sub-workflowFive copied Code nodes
ProofFixtures 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.

FAQ

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.
Sources

Last reviewed

More from this lane

Automation

All →
Book the audit