AutomationFlowsGeneral › Validate JSON Payloads Against a Schema

Validate JSON Payloads Against a Schema

Original n8n title: Validate JSON Payloads Against a Schema with Detailed Error Messages (no Ai)

ByLiam McGarrigle @liammcgarrigle on n8n.io

A modular schema checker that returns detailed error messages on validation failure.

Event trigger★★★★☆ complexity16 nodesExecute Workflow Trigger
General Trigger: Event Nodes: 16 Complexity: ★★★★☆ Added:

This workflow corresponds to n8n.io template #14208 — we link there as the canonical source.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "nodes": [
    {
      "id": "0bff1b8c-ed35-4673-835a-47f413847ef2",
      "name": "When Executed by Another Workflow",
      "type": "n8n-nodes-base.executeWorkflowTrigger",
      "position": [
        1328,
        352
      ],
      "parameters": {
        "workflowInputs": {
          "values": [
            {
              "name": "requiredSchema",
              "type": "object"
            },
            {
              "name": "paramsToValidate",
              "type": "object"
            }
          ]
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "d639a119-f215-41b5-9669-85306a3146bf",
      "name": "Schema Validation",
      "type": "n8n-nodes-base.code",
      "position": [
        1664,
        352
      ],
      "parameters": {
        "jsCode": "/**\n * Lightweight JSON Schema validator with human-readable error messages.\n *\n * Supports: type, const, enum, required, properties, additionalProperties,\n * minLength, maxLength, pattern, minimum, maximum, minItems, maxItems,\n * items, oneOf, anyOf, allOf, not.\n */\n\n// \u2500\u2500\u2500 Schema & Input \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst requiredSchema = $input.first().json.requiredSchema;\nconst jsonToValidate = $input.first().json.paramsToValidate;\n\n// \u2500\u2500\u2500 Helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Creates an error object with a path, message, and optional description.\n * Keeps internal metadata separate from user-facing fields via _meta.\n */\nfunction makeError(path, message, schema = {}, meta = {}) {\n  const error = { path, message };\n  if (schema.description) error.description = schema.description;\n  if (Object.keys(meta).length) error._meta = meta;\n  return error;\n}\n\n/**\n * Returns the human-readable type name for a value.\n * Distinguishes arrays, null, and NaN from generic typeof results.\n */\nfunction getTypeName(value) {\n  if (value === null) return \"null\";\n  if (Array.isArray(value)) return \"array\";\n  if (typeof value === \"number\" && isNaN(value)) return \"NaN\";\n  return typeof value;\n}\n\n/**\n * Checks if a value matches JSON Schema's \"integer\" type.\n * Must be a finite number with no fractional part. NaN fails.\n */\nfunction isInteger(value) {\n  return typeof value === \"number\" && Number.isFinite(value) && Math.floor(value) === value;\n}\n\n/**\n * Checks if a value is a valid, finite number.\n * Rejects NaN, Infinity, and -Infinity \u2014 none are meaningful\n * in the context of JSON Schema numeric constraints.\n */\nfunction isValidNumber(value) {\n  return typeof value === \"number\" && Number.isFinite(value);\n}\n\n/**\n * Checks whether the given value is a plain object (not null, not an array).\n * Used for schema shape validation and internal type guards.\n */\nfunction isPlainObject(value) {\n  return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n/**\n * Deep equality comparison for JSON-compatible values.\n * Handles primitives, arrays, and plain objects recursively.\n * Used by checkEnum so objects/arrays are compared by structure, not reference.\n */\nfunction deepEqual(a, b) {\n  if (a === b) return true;\n  if (a === null || b === null) return false;\n  if (typeof a !== typeof b) return false;\n\n  if (Array.isArray(a)) {\n    if (!Array.isArray(b) || a.length !== b.length) return false;\n    return a.every((val, i) => deepEqual(val, b[i]));\n  }\n\n  if (typeof a === \"object\") {\n    if (Array.isArray(b)) return false;\n    const keysA = Object.keys(a);\n    const keysB = Object.keys(b);\n    if (keysA.length !== keysB.length) return false;\n    return keysA.every((key) => Object.prototype.hasOwnProperty.call(b, key) && deepEqual(a[key], b[key]));\n  }\n\n  return false;\n}\n\n/**\n * Safely tests a string against a regex pattern.\n * Returns { match: boolean, error?: string }.\n * Catches malformed regex patterns instead of crashing.\n */\nfunction safeRegexTest(pattern, value) {\n  try {\n    return { match: new RegExp(pattern).test(value) };\n  } catch (e) {\n    return { match: false, error: `Invalid regex pattern: ${pattern}` };\n  }\n}\n\n/**\n * Pluralizes \"Field\"/\"is\" based on count for error messages.\n * e.g. formatFieldList([\"a\"]) -> 'Field \"a\" is'\n *      formatFieldList([\"a\",\"b\"]) -> 'Fields \"a\", \"b\" are'\n */\nfunction formatFieldList(fields) {\n  const quoted = fields.map((f) => `\"${f}\"`).join(\", \");\n  return fields.length === 1\n    ? `Field ${quoted} is`\n    : `Fields ${quoted} are`;\n}\n\n/**\n * Extracts const-based context from a oneOf variant schema.\n * Returns a \"when x is y\" clause for enriching error messages.\n *\n * e.g. { properties: { type: { const: \"free_product\" } } }\n *   -> ' when type is \"free_product\"'\n */\nfunction buildWhenClause(variantSchema) {\n  const pairs = [];\n  for (const [key, propSchema] of Object.entries(variantSchema.properties || {})) {\n    if (propSchema.const !== undefined) {\n      pairs.push(`${key} is \"${propSchema.const}\"`);\n    }\n  }\n  return pairs.length ? ` when ${pairs.join(\" and \")}` : \"\";\n}\n\n/**\n * Checks whether a value is explicitly nullable in the schema.\n * Handles both enum and const allowing null.\n */\nfunction isNullAllowed(schema) {\n  if (schema.const === null) return true;\n  if (Array.isArray(schema.enum) && schema.enum.some((v) => deepEqual(v, null))) return true;\n  return false;\n}\n\n/**\n * Builds a child path by appending a property key to a parent path.\n * Handles the root case (empty parent) so we get \"name\" instead of \".name\".\n */\nfunction joinPath(parent, key) {\n  return parent ? `${parent}.${key}` : key;\n}\n\n/**\n * Builds a child path by appending an array index to a parent path.\n * Handles the root case (empty parent) so we get \"[0]\" instead of \".[0]\".\n */\nfunction indexPath(parent, index) {\n  return `${parent}[${index}]`;\n}\n\n// \u2500\u2500\u2500 Type Validators \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Validates that the data's type matches the schema's declared type.\n * Handles the \"integer\" type that JSON Schema supports but JS typeof doesn't.\n * Rejects NaN and Infinity as invalid numbers.\n */\nfunction checkType(schema, data, at) {\n  if (!schema.type) return [];\n\n  const actual = getTypeName(data);\n\n  // \"integer\" is a special case \u2014 typeof returns \"number\" for both ints and floats\n  if (schema.type === \"integer\") {\n    if (!isInteger(data)) {\n      const display = actual === \"number\" ? \"non-integer number\" : actual;\n      return [makeError(at, `Expected type \"integer\" but got \"${display}\"`, schema)];\n    }\n    return [];\n  }\n\n  // NaN technically has typeof \"number\" but is never a valid number\n  if (schema.type === \"number\" && actual === \"NaN\") {\n    return [makeError(at, `Expected type \"number\" but got NaN`, schema)];\n  }\n\n  // Infinity/-Infinity are typeof \"number\" but not finite.\n  // Reject them at the type level so they don't silently pass through.\n  if (schema.type === \"number\" && !Number.isFinite(data)) {\n    return [makeError(at, `Expected a finite number but got ${data}`, schema)];\n  }\n\n  if (actual !== schema.type) {\n    return [makeError(at, `Expected type \"${schema.type}\" but got \"${actual}\"`, schema)];\n  }\n  return [];\n}\n\n/**\n * Validates string-specific constraints: minLength, maxLength, pattern.\n * Falls back to description for pattern errors when available.\n * Catches invalid regex patterns gracefully.\n */\nfunction checkString(schema, data, at) {\n  if (typeof data !== \"string\") return [];\n  const errors = [];\n\n  if (schema.minLength !== undefined && data.length < schema.minLength) {\n    const msg = schema.minLength === 1\n      ? \"Must not be empty\"\n      : `Must be at least ${schema.minLength} character(s) long, got ${data.length}`;\n    errors.push(makeError(at, msg, schema));\n  }\n\n  if (schema.maxLength !== undefined && data.length > schema.maxLength) {\n    errors.push(makeError(at, `Must be at most ${schema.maxLength} character(s) long, got ${data.length}`, schema));\n  }\n\n  // Pattern: use safeRegexTest to avoid crashing on malformed patterns\n  if (schema.pattern) {\n    const result = safeRegexTest(schema.pattern, data);\n    if (result.error) {\n      // Malformed regex in schema \u2014 report as a schema error\n      errors.push(makeError(at, result.error));\n    } else if (!result.match) {\n      // Prefer description over raw regex in the message\n      const msg = schema.description\n        ? `\"${data}\" is not valid \u2014 expected: ${schema.description}`\n        : `\"${data}\" does not match required pattern: ${schema.pattern}`;\n      errors.push(makeError(at, msg));\n    }\n  }\n\n  return errors;\n}\n\n/**\n * Validates number-specific constraints: minimum, maximum,\n * exclusiveMinimum, exclusiveMaximum.\n * Runs for both \"number\" and \"integer\" schema types.\n */\nfunction checkNumber(schema, data, at) {\n  if (!isValidNumber(data)) return [];\n  const errors = [];\n\n  if (schema.minimum !== undefined && data < schema.minimum)\n    errors.push(makeError(at, `Must be at least ${schema.minimum}, got ${data}`, schema));\n  if (schema.maximum !== undefined && data > schema.maximum)\n    errors.push(makeError(at, `Must be at most ${schema.maximum}, got ${data}`, schema));\n  if (schema.exclusiveMinimum !== undefined && data <= schema.exclusiveMinimum)\n    errors.push(makeError(at, `Must be greater than ${schema.exclusiveMinimum}, got ${data}`, schema));\n  if (schema.exclusiveMaximum !== undefined && data >= schema.exclusiveMaximum)\n    errors.push(makeError(at, `Must be less than ${schema.exclusiveMaximum}, got ${data}`, schema));\n\n  return errors;\n}\n\n/**\n * Validates enum constraint \u2014 value must be one of the listed options.\n * Uses deepEqual so objects and arrays are compared by structure, not reference.\n */\nfunction checkEnum(schema, data, at) {\n  if (!schema.enum) return [];\n  if (schema.enum.some((allowed) => deepEqual(allowed, data))) return [];\n  return [makeError(at, `\"${data}\" is not an allowed value. Must be one of: ${schema.enum.join(\", \")}`, schema)];\n}\n\n/**\n * Validates const constraint \u2014 value must exactly equal the specified constant.\n */\nfunction checkConst(schema, data, at) {\n  if (schema.const === undefined) return [];\n  if (data === schema.const) return [];\n  return [makeError(at, `Must be \"${schema.const}\" but got \"${data}\"`, schema)];\n}\n\n// \u2500\u2500\u2500 Composite Validators \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Validates array-specific constraints: minItems, maxItems, and\n * recursively validates each item against the items schema.\n * Attaches the raw value to leaf-level errors for context.\n *\n * Container-level errors (minItems, maxItems) use `label` so root-level\n * arrays get a readable path instead of an empty string.\n * Child item paths still use raw `at` for clean indexing (e.g. \"[0]\").\n */\nfunction checkArray(schema, data, at) {\n  if (!Array.isArray(data)) return [];\n  const errors = [];\n  const label = at || \"root\";\n\n  if (schema.minItems !== undefined && data.length < schema.minItems)\n    errors.push(makeError(label, `Must have at least ${schema.minItems} item(s), got ${data.length}`, schema));\n  if (schema.maxItems !== undefined && data.length > schema.maxItems)\n    errors.push(makeError(label, `Must have at most ${schema.maxItems} item(s), got ${data.length}`, schema));\n\n  if (schema.items) {\n    data.forEach((item, i) => {\n      const itemErrors = validate(schema.items, item, indexPath(at, i));\n      // Attach raw value for non-object items so the final message shows what was received\n      itemErrors.forEach((e) => {\n        if (e.value === undefined && typeof item !== \"object\") e.value = item;\n      });\n      errors.push(...itemErrors);\n    });\n  }\n\n  return errors;\n}\n\n/**\n * Validates object-specific constraints: required fields, property schemas,\n * and additionalProperties.\n *\n * When additionalProperties is false with no properties defined,\n * treats the allowed set as empty (rejects all keys).\n *\n * Validates that schema.required is an array before iterating. A bare string\n * like \"name\" would be iterated character-by-character by for..of, producing\n * nonsensical \"missing field 'n'\" errors.\n */\nfunction checkObject(schema, data, at) {\n  if (typeof data !== \"object\" || data === null || Array.isArray(data)) return [];\n  if (!schema.properties && !schema.required && schema.additionalProperties === undefined) return [];\n\n  const errors = [];\n\n  // Guard against non-array required (e.g. required: \"name\")\n  if (schema.required !== undefined && !Array.isArray(schema.required)) {\n    errors.push(makeError(at || \"root\", `Schema error: \"required\" must be an array, got ${getTypeName(schema.required)}`));\n  } else {\n    // Required fields\n    for (const key of schema.required || []) {\n      if (!(key in data)) {\n        const propSchema = schema.properties?.[key] || {};\n        errors.push(makeError(joinPath(at, key), `Missing required field \"${key}\"`, propSchema));\n      }\n    }\n  }\n\n  // Validate each declared property that exists on the data\n  for (const [key, propSchema] of Object.entries(schema.properties || {})) {\n    if (key in data) {\n      errors.push(...validate(propSchema, data[key], joinPath(at, key)));\n    }\n  }\n\n  // Reject undeclared properties when additionalProperties is false.\n  // If properties is not defined, treat allowed set as empty \u2014 all keys rejected.\n  if (schema.additionalProperties === false) {\n    const allowed = new Set(Object.keys(schema.properties || {}));\n    for (const key of Object.keys(data)) {\n      if (!allowed.has(key)) {\n        errors.push(makeError(joinPath(at, key), `Unknown field \"${key}\" is not allowed`));\n      }\n    }\n  }\n\n  return errors;\n}\n\n// \u2500\u2500\u2500 Logical Combinators \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Validates the \"not\" keyword.\n * The data is valid only if it FAILS the inner schema.\n * Reports which forbidden fields were found.\n * Handles generic \"not\" schemas beyond just \"not.required\".\n */\nfunction checkNot(schema, data, at) {\n  if (!schema.not) return [];\n\n  const innerErrors = validate(schema.not, data, at);\n  const label = at || \"root\";\n\n  // If inner schema produced errors, the \"not\" passes (data doesn't match the forbidden schema)\n  if (innerErrors.length > 0) return [];\n\n  // Data matched the forbidden schema \u2014 figure out what to report\n  const errors = [];\n\n  // Case 1: not.required \u2014 specific forbidden fields present in the data\n  if (Array.isArray(schema.not.required) && typeof data === \"object\" && data !== null) {\n    const present = schema.not.required.filter((k) => k in data);\n    if (present.length) {\n      errors.push(makeError(label, `${formatFieldList(present)} not allowed here`, {}, { forbidden: present }));\n    }\n  }\n\n  // Case 2: generic not (no required, or no forbidden fields found) \u2014 general message\n  if (errors.length === 0) {\n    errors.push(makeError(label, \"Value must not match the excluded schema\"));\n  }\n\n  return errors;\n}\n\n/**\n * Validates the \"oneOf\" keyword using best-match strategy.\n *\n * 1. If exactly one variant matches perfectly, pass.\n * 2. If multiple match, report ambiguity.\n * 3. If none match, pick the closest variant (fewest errors, with a\n *    tiebreaker favoring variants where const fields match the data)\n *    and surface only its errors, enriched with \"when\" context.\n *\n * Handles empty oneOf arrays gracefully.\n */\nfunction checkOneOf(schema, data, at) {\n  if (!schema.oneOf) return [];\n\n  const label = at || \"root\";\n\n  // Edge case: empty oneOf means nothing can match\n  if (schema.oneOf.length === 0) {\n    return [makeError(label, \"No variants defined in oneOf \u2014 nothing can match\")];\n  }\n\n  const results = schema.oneOf.map((sub) => ({\n    schema: sub,\n    errors: validate(sub, data, at),\n  }));\n\n  const perfect = results.filter((r) => r.errors.length === 0);\n\n  // Exactly one match \u2014 valid\n  if (perfect.length === 1) return [];\n\n  // Multiple matches \u2014 ambiguous\n  if (perfect.length > 1) {\n    return [makeError(label, \"Ambiguous \u2014 matches multiple variants when only one is allowed\")];\n  }\n\n  // No match \u2014 find best fit using const-match tiebreaker.\n  // Const matches heavily reduce score so the variant matching\n  // the user's intent always wins ties.\n  const scored = results.map((r) => {\n    let constMatches = 0;\n    for (const [key, propSchema] of Object.entries(r.schema.properties || {})) {\n      if (\n        propSchema.const !== undefined &&\n        typeof data === \"object\" &&\n        data !== null &&\n        data[key] === propSchema.const\n      ) {\n        constMatches++;\n      }\n    }\n    return { ...r, score: r.errors.length - constMatches * (results.length + 1) };\n  });\n  scored.sort((a, b) => a.score - b.score);\n\n  const best = scored[0];\n  const whenClause = buildWhenClause(best.schema);\n\n  // Enrich errors with context and filter out noise (const mismatch errors)\n  const enriched = best.errors\n    .filter((e) => !e.message.startsWith('Must be \"'))\n    .map((e) => {\n      const forbidden = e._meta?.forbidden;\n\n      if (forbidden && whenClause) {\n        // Preserve description from original error if present\n        const enrichedSchema = e.description ? { description: e.description } : {};\n        return makeError(e.path, `${formatFieldList(forbidden)} not allowed${whenClause}`, enrichedSchema);\n      }\n      if (e.message.includes(\"Missing required field\") && whenClause) {\n        const enrichedSchema = e.description ? { description: e.description } : {};\n        return makeError(e.path, `${e.message}${whenClause}`, enrichedSchema);\n      }\n      return e;\n    });\n\n  if (enriched.length) return enriched;\n\n  // Fallback: couldn't isolate clear errors from best match\n  const summary = results.map((r) => {\n    const constEntry = Object.entries(r.schema.properties || {}).find(([, v]) => v.const !== undefined);\n    const name = constEntry ? constEntry[1].const : \"unknown\";\n    return `\"${name}\": ${r.errors.map((e) => e.message).join(\"; \")}`;\n  });\n  return [makeError(label, `Does not match any allowed variant: ${summary.join(\" | \")}`)];\n}\n\n/**\n * Validates \"anyOf\" \u2014 at least one subschema must match.\n */\nfunction checkAnyOf(schema, data, at) {\n  if (!schema.anyOf) return [];\n  const anyMatch = schema.anyOf.some((sub) => validate(sub, data, at).length === 0);\n  if (anyMatch) return [];\n  return [makeError(at || \"root\", \"Does not match any allowed option\", schema)];\n}\n\n/**\n * Validates \"allOf\" \u2014 every subschema must match.\n */\nfunction checkAllOf(schema, data, at) {\n  if (!schema.allOf) return [];\n  return schema.allOf.flatMap((sub) => validate(sub, data, at));\n}\n\n// \u2500\u2500\u2500 Main Validator \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Recursively validates data against a JSON Schema.\n * Returns an array of error objects. Empty array = valid.\n *\n * Path starts as \"\" (root). Child paths are built via joinPath/indexPath\n * so top-level fields appear as \"name\" not \"root.name\", while nested\n * fields read naturally as \"address.street\" or \"tags[0]\".\n * Only true root-level errors (e.g. wrong top-level type) show as \"root\".\n *\n * Handles null/undefined before type checking because typeof null === \"object\" in JS.\n * Allows null/undefined when explicitly permitted by const or enum.\n *\n * Rejects non-plain-object schemas up front instead of silently\n * passing everything or crashing on null.\n *\n * When schema.const is defined, enum is skipped. const is strictly\n * more specific (single value) and subsumes enum.\n */\nfunction validate(schema, data, path = \"\") {\n  const at = path;\n\n  // Schema shape guard \u2014 reject anything that isn't a plain object.\n  // Catches null, undefined, strings, numbers, booleans, and arrays, all of\n  // which would either crash or silently produce zero errors.\n  if (!isPlainObject(schema)) {\n    return [makeError(at || \"root\", `Invalid schema: expected a plain object, got ${getTypeName(schema)}`)];\n  }\n\n  // Null/undefined: allow if schema explicitly permits it via const or enum,\n  // otherwise reject early before type check (typeof null === \"object\" in JS)\n  if (data === null || data === undefined) {\n    if (isNullAllowed(schema)) {\n      // null is explicitly valid here \u2014 still check const/enum but skip everything else\n      const errors = [...checkConst(schema, data, at || \"root\")];\n      // Skip enum when const is defined \u2014 const subsumes enum\n      if (schema.const === undefined) errors.push(...checkEnum(schema, data, at || \"root\"));\n      return errors;\n    }\n    return [makeError(at || \"root\", \"Field is missing or null\", schema)];\n  }\n\n  // Type check (gates further validation \u2014 wrong type means deeper checks are meaningless)\n  const typeErrors = checkType(schema, data, at || \"root\");\n  if (typeErrors.length) return typeErrors;\n\n  // When const is defined, it's the authoritative value check \u2014\n  // enum is redundant and potentially contradictory. Skip it.\n  const enumErrors = schema.const !== undefined ? [] : checkEnum(schema, data, at || \"root\");\n\n  // Run all validators and collect errors\n  return [\n    ...checkConst(schema, data, at || \"root\"),\n    ...checkString(schema, data, at || \"root\"),\n    ...checkNumber(schema, data, at || \"root\"),\n    ...enumErrors,\n    ...checkArray(schema, data, at),\n    ...checkObject(schema, data, at),\n    ...checkNot(schema, data, at),\n    ...checkOneOf(schema, data, at),\n    ...checkAnyOf(schema, data, at),\n    ...checkAllOf(schema, data, at),\n  ];\n}\n\n// \u2500\u2500\u2500 Format & Output \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Formats a single error object into a readable bullet line.\n * Includes the raw value if present, and appends description when\n * it adds context beyond what's already in the message.\n */\nfunction formatError(error) {\n  let line = `\u2022 ${error.path}: ${error.message}`;\n  if (error.value !== undefined) line += ` (got: \"${error.value}\")`;\n  if (error.description && !error.message.includes(error.description)) line += ` \u2014 ${error.description}`;\n  return line;\n}\n\n/**\n * Strips internal metadata (_meta) from error objects\n * so it never leaks into the output.\n */\nfunction cleanErrors(errors) {\n  return errors.map(({ _meta, ...rest }) => rest);\n}\n\n// \u2500\u2500\u2500 Run \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nlet output;\n\ntry {\n  const errors = validate(requiredSchema, jsonToValidate);\n\n  if (errors.length) {\n    const msg = cleanErrors(errors).map(formatError).join(\"\\n\");\n\n    output = {\n      valid: false,\n      validationError: `Validation failed (${errors.length} issue${errors.length > 1 ? \"s\" : \"\"}):\\n${msg}`,\n      requiredSchema: JSON.parse(JSON.stringify(requiredSchema)),\n    };\n  } else {\n    output = {\n      valid: true,\n      validationError: null,\n    };\n  }\n} catch (error) {\n  output = {\n    valid: false,\n    validationError: `Validator error: ${error.message}`,\n  };\n}\n\nreturn output;"
      },
      "typeVersion": 2
    },
    {
      "id": "00618ad0-367e-4ca0-9032-8357d3cc87dd",
      "name": "PLACEHOLDER: Source of your data",
      "type": "n8n-nodes-base.set",
      "position": [
        2000,
        560
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "33c2bdce-dcba-49d5-8964-044eedf7f58a",
              "name": "body",
              "type": "object",
              "value": "{\n  \"email\": \"user@example.com\",\n  \"plan\": \"cloud\",\n  \"seat_count\": 25\n}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "8287706f-d600-42fd-b843-2b13bd106d14",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1920,
        432
      ],
      "parameters": {
        "color": 7,
        "width": 528,
        "height": 320,
        "content": "## Copy and paste this sticky with the nodes inside of it into your AI prompt\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n_these nodes are just here to give the llm extra context_\n\n## JSON Schema Generator\n\nThe nodes below are part of a reusable n8n subworkflow that validates JSON objects against a JSON Schema. I need you to generate a schema for my use case.\n\nThe schema gets passed into the `requiredSchema` param and the JSON to validate goes into `paramsToValidate`. Both are wrapped in `={{ }}` so n8n treats them as objects.\n\n### What the Validator Supports\n\nYou can use any of these keywords in the schema you generate:\n\n- **Types:** `string`, `number`, `integer`, `boolean`, `array`, `object`, `null`\n- **String:** `minLength`, `maxLength`, `pattern` (regex)\n- **Number:** `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`\n- **Value restrictions:** `enum` (list of allowed values), `const` (exact match)\n- **Arrays:** `minItems`, `maxItems`, `items` (validates each element recursively)\n- **Objects:** `required`, `properties`, `additionalProperties`\n- **Conditional logic:** `oneOf`, `anyOf`, `allOf`, `not`\n- **`description`** \u2014 this is important: the validator pulls descriptions into error messages, so every field needs one\n\n### Rules for Generating the Schema\n\n- Every field MUST have a `description` in plain language \u2014 they appear in validation errors\n- Use `integer` instead of `number` for whole numbers\n- Use `enum` for fields with a fixed set of allowed values\n- Use `oneOf` with `const` and `not: { required: [...] }` for conditional required/forbidden fields\n- Use `additionalProperties: false` if extra fields should be rejected\n- Use `pattern` with regex for formatted strings (emails, URLs, codes, etc.)\n- Use `minimum` / `maximum` for numeric bounds\n- Use `minLength: 1` for strings that must not be empty\n- Use `minItems` for arrays that need at least one entry\n\n### Before Generating\n\nAsk me follow-up questions first to make sure you understand:\n\n- Which fields are required vs optional\n- Any conditional logic (e.g. \"field X is required only when field Y is a certain value\")\n- Valid value ranges, enums, or patterns for each field\n- Whether unknown/extra fields should be rejected\n- Any fields that conflict with each other (e.g. \"don't send A and B together\")\n\n### Output Format\n\nAsk the user whether they want:\n\n1. **Full n8n nodes** (default) \u2014 the complete set of ready-to-paste n8n nodes: the Execute Sub-Workflow node (with the schema already embedded), the If node to check `valid`, and the Respond to Webhook node for returning 400 errors.\n2. **Schema only** \u2014 just the raw JSON Schema object.\n\nOn the **first response**, default to returning the full nodes unless the user says otherwise. On **subsequent changes or revisions**, only return the updated schema \u2014 the user already has the nodes and just needs to swap the schema value.\n\n### How to Embed the Schema in n8n\n\nWhen generating the Execute Sub-Workflow node, the schema must be set correctly in the `requiredSchema` parameter:\n\n- The value must be wrapped in `={{ }}` so n8n evaluates it as a JavaScript expression that returns an object \u2014 not a string.\n- The schema JSON goes directly between `={{ ` and ` }}` with no quotes around the outer object.\n- All regex backslashes inside `pattern` values must be **double-escaped** (`\\\\\\\\`) because the expression is evaluated as a JS string first. For example, the regex `^\\S+@\\S+\\.\\S+$` becomes `\"^\\\\\\\\S+@\\\\\\\\S+\\\\\\\\.\\\\\\\\S+$\"` in the node parameter.\n- The `paramsToValidate` param should be set to `={{ $json.body }}` or wherever the incoming data lives. Remind the user to update this path to match their actual data location.\n- The Execute Sub-Workflow node must point to the ID of the Param Schema Validation workflow. Remind the user to re-select the workflow from the dropdown after importing since workflow IDs differ per instance.\n\n### Generating the Full Nodes\n\nWhen returning full nodes, use the following node structure as a base and only modify the `requiredSchema` value with the generated schema. Keep all other fields, positions, and wiring intact.\n\nThe nodes to include are:\n\n1. **Execute Sub-Workflow node** (`n8n-nodes-base.executeWorkflow`) \u2014 calls the validator with `requiredSchema` and `paramsToValidate`\n\n2. **If node** (`n8n-nodes-base.if`) \u2014 checks `{{ $json.valid }}` is true\n\n3. **Respond to Webhook node** (`n8n-nodes-base.respondToWebhook`) \u2014 returns a 400 with the validation error and schema in the response body\n\nThe Respond to Webhook node MUST use this exact response body format:\n\n```\n\n{\n\n  \"error\": {{ $json.validationError.toJsonString() }},\n\n  \"requestSchema\": {{ $json.requiredSchema.toJsonString() }}\n\n}\n\n```\n\nSet the response code to `400`. This format ensures the caller gets both the human-readable error string and the full schema they violated, which is especially useful for LLM-based callers that can self-correct.\n\nInclude the `connections` object so the nodes are wired: Execute Sub-Workflow \u2192 If \u2192 (true) continues, (false) \u2192 Respond to Webhook with 400.\n\nDo NOT include the Webhook trigger node \u2014 the user will already have their own trigger.\n\n### My JSON\n\nThe user will provide an example of their JSON request separately. If they do not, ask them for it."
      },
      "typeVersion": 1
    },
    {
      "id": "23b82931-8571-4d0f-b4c7-bbfd3dbd6ed2",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        480,
        224
      ],
      "parameters": {
        "width": 704,
        "height": 816,
        "content": "# JSON Schema Validator\n\n## How it works\n\nCall this via an **Execute Sub-Workflow** node with two params:\n\n- **`requiredSchema`** \u2014 your JSON Schema (wrapped in `={{ }}`)\n- **`paramsToValidate`** \u2014 the data to check, e.g. `={{ $json.body }}`\n\nReturns `{ valid: true }` on success. On failure, returns `valid: false` with a `validationError` string and the full `requiredSchema` \u2014 useful for error responses or feeding back into an LLM to self-correct.\n\n## Example error output\n\nValidation failed (3 issues):\n- name: Missing required field \"name\" \u2014 Customer full name\n- email: \"not-an-email\" is not valid \u2014 expected: Contact email address\n- plan: \"premium\" is not an allowed value. Must be one of: starter, pro, enterprise\n\n## Don't know JSON Schema?\n\nCopy the **prompt template sticky note** and its two nodes, paste into any LLM chat with an example of your data, and it'll generate a schema for you.\n\n## Quick start\n\n1. Add an **Execute Sub-Workflow** node pointing to this workflow\n2. Set `requiredSchema` to `={{ your_schema_here }}`\n3. Set `paramsToValidate` to `={{ $json.body }}`\n4. Route on `valid` \u2014 `true` continues, `false` handles the error"
      },
      "typeVersion": 1
    },
    {
      "id": "10948397-947f-4006-95dd-fb9db33f3cf0",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1920,
        224
      ],
      "parameters": {
        "color": 4,
        "width": 528,
        "height": 208,
        "content": "# Schema Creation\nAIs are great at making schemas. Copy and paste the two nodes below with the entire sticky into your agent of choice with an example of your request.\n\n\n_Out of view in the sticky is a prompt that will help the agent know how to make the schema_"
      },
      "typeVersion": 1
    },
    {
      "id": "d2af7f8f-c367-4fc9-a794-ed697ed6e7cd",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2496,
        224
      ],
      "parameters": {
        "color": 6,
        "width": 1248,
        "height": 624,
        "content": "# Usage Example\nThis is an example showing how to wire the validator into a webhook endpoint"
      },
      "typeVersion": 1
    },
    {
      "id": "1375dac8-c6bf-4b76-bb26-8e935e1540bf",
      "name": "Webhook",
      "type": "n8n-nodes-base.webhook",
      "position": [
        2576,
        368
      ],
      "parameters": {
        "path": "19b37d89-d47e-4f90-a945-8a92d61d8d8b",
        "options": {},
        "responseMode": "responseNode"
      },
      "typeVersion": 2.1
    },
    {
      "id": "38928989-fd9a-4fac-873f-12dfa4d7dcfd",
      "name": "If Params Valid",
      "type": "n8n-nodes-base.if",
      "position": [
        2992,
        368
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "12bd4ddc-000b-4e4d-aae0-4a4b0074b027",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.valid }}",
              "rightValue": false
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "1aef7e3c-1e25-417d-9ea9-368a2e85c49e",
      "name": "Return 400 param error",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        2992,
        592
      ],
      "parameters": {
        "options": {
          "responseCode": 400
        },
        "respondWith": "json",
        "responseBody": "={\n  \"error\": {{ $json.validationError.toJsonString() }},\n  \"requestSchema\": {{ $json.requiredSchema.toJsonString() }}\n} "
      },
      "typeVersion": 1.5
    },
    {
      "id": "698fff1b-c266-47e1-81cb-ad252a448889",
      "name": "Your workflow logic here",
      "type": "n8n-nodes-base.noOp",
      "position": [
        3248,
        368
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "93d5b54b-cbc2-4db3-a486-81a8d3e695f3",
      "name": "Return Success Response",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        3456,
        368
      ],
      "parameters": {
        "options": {},
        "respondWith": "json",
        "responseBody": "={\n  \"success\": true\n} "
      },
      "typeVersion": 1.5
    },
    {
      "id": "ccac00e9-f3fd-4345-992f-cee6858c1ebb",
      "name": "Validate Schema",
      "type": "n8n-nodes-base.executeWorkflow",
      "position": [
        2784,
        368
      ],
      "parameters": {
        "options": {},
        "workflowId": {
          "__rl": true,
          "mode": "list",
          "value": "O1tkgab3t4olf3AJ",
          "cachedResultUrl": "/workflow/O1tkgab3t4olf3AJ",
          "cachedResultName": "Param Schema Validation Template"
        },
        "workflowInputs": {
          "value": {
            "requiredSchema": "={{ \n{\n  \"type\": \"object\",\n  \"properties\": {\n    \"name\": {\n      \"type\": \"string\",\n      \"minLength\": 1,\n      \"description\": \"Customer full name\"\n    },\n    \"email\": {\n      \"type\": \"string\",\n      \"pattern\": \"^\\\\S+@\\\\S+\\\\.\\\\S+$\",\n      \"description\": \"Contact email address\"\n    },\n    \"plan\": {\n      \"type\": \"string\",\n      \"enum\": [\"starter\", \"pro\", \"enterprise\"],\n      \"description\": \"Subscription plan\"\n    },\n    \"seat_count\": {\n      \"type\": \"integer\",\n      \"minimum\": 1,\n      \"maximum\": 500,\n      \"description\": \"Number of licensed seats\"\n    },\n    \"tags\": {\n      \"type\": \"array\",\n      \"minItems\": 1,\n      \"items\": {\n        \"type\": \"string\",\n        \"minLength\": 1,\n        \"description\": \"A tag label\"\n      },\n      \"description\": \"At least one tag for categorization\"\n    }\n  },\n  \"required\": [\"name\", \"email\", \"plan\", \"seat_count\"],\n  \"additionalProperties\": false\n} \n}}",
            "paramsToValidate": "={{ $json.body }}"
          },
          "schema": [
            {
              "id": "requiredSchema",
              "type": "object",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "requiredSchema",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "paramsToValidate",
              "type": "object",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "paramsToValidate",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": true
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "17f5acfd-bb81-4078-bac2-ce995514f128",
      "name": "Call 'Param Schema Validation Template'",
      "type": "n8n-nodes-base.executeWorkflow",
      "position": [
        2256,
        560
      ],
      "parameters": {
        "options": {},
        "workflowId": {
          "__rl": true,
          "mode": "list",
          "value": "O1tkgab3t4olf3AJ",
          "cachedResultUrl": "/workflow/O1tkgab3t4olf3AJ",
          "cachedResultName": "Param Schema Validation Template"
        },
        "workflowInputs": {
          "value": {
            "requiredSchema": "={{ {\n  \"type\": \"object\",\n  \"properties\": {\n    \"email\": {\n      \"type\": \"string\",\n      \"pattern\": \"^\\\\S+@\\\\S+\\\\.\\\\S+$\",\n      \"description\": \"Contact email address\"\n    },\n    \"plan\": {\n      \"type\": \"string\",\n      \"enum\": [\"community\", \"cloud\", \"enterprise\"],\n      \"description\": \"Subscription tier\"\n    },\n    \"seat_count\": {\n      \"type\": \"integer\",\n      \"minimum\": 1,\n      \"maximum\": 500,\n      \"description\": \"Number of licensed seats\"\n    }\n  },\n  \"required\": [\"email\", \"plan\"],\n  \"additionalProperties\": false\n} }}",
            "paramsToValidate": "={{ $json.body }}"
          },
          "schema": [
            {
              "id": "requiredSchema",
              "type": "object",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "requiredSchema",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "paramsToValidate",
              "type": "object",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "paramsToValidate",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": true
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "e6e699de-7f68-41cd-a201-5974877bdc5a",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3232,
        560
      ],
      "parameters": {
        "color": 7,
        "width": 512,
        "height": 288,
        "content": "Then the `error` field gives a detailed readable description of all of the errors:\n\nValidation failed (6 issues):\n\u2022 name: Missing required field \\\"name\\\" \u2014 Customer full name\n\u2022 email: \\\"not-an-email\\\" is not valid \u2014 expected: Contact email address\n\u2022 plan: \\\"premium\\\" is not an allowed value. Must be one of: starter, pro, enterprise \u2014 Subscription plan\n\u2022 seat_count: Expected type \\\"integer\\\" but got \\\"non-integer number\\\" \u2014 Number of licensed seats\n\u2022 tags: Must have at least 1 item(s), got 0 \u2014 At least one tag for categorization\n\u2022 referral_code: Unknown field \\\"referral_code\\\" is not allowed"
      },
      "typeVersion": 1
    },
    {
      "id": "5b24fc13-03da-46ec-809c-887c2ad72e86",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1232,
        224
      ],
      "parameters": {
        "color": 7,
        "width": 640,
        "height": 304,
        "content": "### The Actual Workflow\nThese two nodes are the actual functional part of the template, all of the other nodes on the canvas are just for demonstration. \n\n"
      },
      "typeVersion": 1
    }
  ],
  "connections": {
    "Webhook": {
      "main": [
        [
          {
            "node": "Validate Schema",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If Params Valid": {
      "main": [
        [
          {
            "node": "Your workflow logic here",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Return 400 param error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate Schema": {
      "main": [
        [
          {
            "node": "If Params Valid",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Your workflow logic here": {
      "main": [
        [
          {
            "node": "Return Success Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "PLACEHOLDER: Source of your data": {
      "main": [
        [
          {
            "node": "Call 'Param Schema Validation Template'",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When Executed by Another Workflow": {
      "main": [
        [
          {
            "node": "Schema Validation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}
Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

A modular schema checker that returns detailed error messages on validation failure.

Source: https://n8n.io/workflows/14208/ — original creator credit. Request a take-down →

More General workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

General

This workflow contains community nodes that are only compatible with the self-hosted version of n8n.

Read Binary File, Write Binary File, Execute Command
General

Validate Totp Token Without Creating A Credential. Uses manualTrigger, stickyNote. Event-driven trigger; 5 nodes.

General

📄 Extract Key Value from JSON - n8n Workflow. Event-driven trigger; 5 nodes.

General

v1 helper - Find params with affected expressions. Uses manualTrigger, n8n, stickyNote. Event-driven trigger; 4 nodes.

n8n
General

ℹ️ This workflow is to be run after upgrading to n8n v1.

n8n