Validation Error Payloads

Part of the Error Contracts & Resilience Mapping reference. Validation is the most frequently hit error path in most APIs and the least designed. This section covers the contract that turns a rejection into something a client can act on: the right status code, a precise field address, a stable rule code, and every violation reported at once.

Problem framing

The default validation response in most frameworks is a single sentence — "Invalid request" — with a 400 and nothing else. A form consuming that endpoint cannot highlight the offending field, cannot say what is wrong, and cannot know whether fixing the first problem reveals a second. So the client re-implements the server’s rules to give useful feedback, which means the rules now live in two places and disagree within a release.

The alternative is a validation contract: a 422 (or a documented 400) carrying an array of violations, each with an RFC 6901 pointer identifying the field, a stable code identifying the rule, and structured parameters the client can render into its own message. The client renders errors it did not have to predict, and adding a rule server-side improves every client without a release. This builds directly on the shared error envelope from RFC 7807 Problem+JSON Implementation — validation is one problem type, not a separate error system.

Malformed versus invalid

The first design decision is where the boundary sits, because it determines the status code and how much of the request has been processed.

Where 400 ends and 422 begins A request passes through transport parsing, JSON parsing, schema validation and business rule evaluation. Failures in the first two stages produce 400 because the request is malformed. Failures in the last two produce 422 because the request parsed but violates a rule. 1 · transport content type, encoding 2 · parse JSON syntax 3 · schema types, required,format 4 · business rules cross-field, state 400 Bad Request — malformed 422 Unprocessable Content — invalid no field pointers possible — the body could not be read field pointers available — report every violation at once a business rule failure is still 422, not 409, unless it is astate conflict

The practical consequence is that a 400 usually cannot carry field-level detail — the parser failed before there were fields — while a 422 always can. If your API returns 400 for everything, you have collapsed a useful distinction and clients cannot tell “I sent broken JSON” from “the end date is before the start date”.

Spec definition

# openapi.yaml (OpenAPI 3.1.0) — the validation problem contract
components:
  schemas:
    Violation:
      type: object
      required: [pointer, code, message]
      properties:
        pointer:
          type: string
          description: RFC 6901 JSON Pointer to the offending value.
          examples: ["/items/2/quantity", "/customer/email"]
        code:
          type: string
          description: Stable, machine-readable rule identifier.
          examples: ["required", "min_value", "date_order", "unknown_currency"]
        message:
          type: string
          description: English fallback text; clients should localise from `code`.
        params:
          type: object
          description: Rule parameters for client-side message rendering.
          additionalProperties: true
          examples: [{ "min": 1 }, { "before_field": "/starts_at" }]
    ValidationProblem:
      allOf:
        - $ref: "#/components/schemas/Problem"
        - type: object
          required: [violations]
          properties:
            type: { const: "urn:api:errors:v1:validation-failed" }
            violations:
              type: array
              minItems: 1
              items: { $ref: "#/components/schemas/Violation" }
  responses:
    ValidationFailed:
      description: The request parsed but violated one or more rules.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/ValidationProblem" }

A concrete response:

{
  "type": "urn:api:errors:v1:validation-failed",
  "title": "Request validation failed",
  "status": 422,
  "detail": "3 fields failed validation.",
  "instance": "/orders",
  "violations": [
    { "pointer": "/customer/email", "code": "format",    "message": "Must be a valid email address.", "params": { "format": "email" } },
    { "pointer": "/items/2/quantity", "code": "min_value", "message": "Must be at least 1.",           "params": { "min": 1 } },
    { "pointer": "/ends_at",         "code": "date_order", "message": "Must be after starts_at.",      "params": { "after_field": "/starts_at" } }
  ]
}

Standard alignment

Element Standard Clause Note
400 Bad Request RFC 9110 §15.5.1 Client error the server cannot process
422 Unprocessable Content RFC 9110 §15.5.21 Syntax fine, semantics wrong
409 Conflict RFC 9110 §15.5.10 State conflict, not input validation
JSON Pointer RFC 6901 §3–5 / separator, ~0/~1 escapes
Problem body RFC 9457 §3 Envelope with extension members
Extension members RFC 9457 §3.2 Where violations lives

422 moved into core HTTP semantics with RFC 9110 (it originated in WebDAV), which removes the historical objection that it was a WebDAV-only code. The escaping rules in RFC 6901 matter more than they look: a field literally named a/b is addressed as /a~1b, and clients that split on / without unescaping will mis-attribute the error.

Implementation: collecting every violation

The single most valuable property of a validation contract is completeness — one round trip, all problems:

// validate-order.ts — collect violations rather than throwing on the first
import Ajv2020, { type ErrorObject } from "ajv/dist/2020";
import addFormats from "ajv-formats";
import type { Request, Response, NextFunction } from "express";
import orderSchema from "./schemas/order.json" assert { type: "json" };

const ajv = addFormats(new Ajv2020({ allErrors: true, strict: false }));
const validate = ajv.compile(orderSchema);

const CODE_BY_KEYWORD: Record<string, string> = {
  required: "required", type: "type", format: "format",
  minimum: "min_value", maximum: "max_value",
  minLength: "min_length", maxLength: "max_length",
  enum: "not_allowed", pattern: "pattern",
};

function toViolation(e: ErrorObject) {
  // ajv reports `required` against the *parent*; point at the missing child.
  const pointer = e.keyword === "required"
    ? `${e.instancePath}/${(e.params as { missingProperty: string }).missingProperty}`
    : e.instancePath || "/";
  return {
    pointer,
    code: CODE_BY_KEYWORD[e.keyword] ?? e.keyword,
    message: e.message ?? "Invalid value.",
    params: e.params,
  };
}

export function validateOrder(req: Request, res: Response, next: NextFunction) {
  const violations = validate(req.body) ? [] : (validate.errors ?? []).map(toViolation);

  // Cross-field business rules run in the same pass, not a later one.
  if (req.body?.starts_at && req.body?.ends_at && req.body.ends_at <= req.body.starts_at) {
    violations.push({
      pointer: "/ends_at", code: "date_order",
      message: "Must be after starts_at.",
      params: { after_field: "/starts_at" },
    });
  }

  if (violations.length === 0) return next();

  res.status(422).type("application/problem+json").json({
    type: "urn:api:errors:v1:validation-failed",
    title: "Request validation failed",
    status: 422,
    detail: `${violations.length} field(s) failed validation.`,
    instance: req.originalUrl,
    violations,
  });
}

allErrors: true is the setting that makes this work; ajv defaults to stopping at the first error for speed, which produces exactly the drip-feed experience the contract is meant to eliminate.

Implementation: rendering on the client

// form-errors.ts — map violations onto form fields without server-side text
type Violation = { pointer: string; code: string; message: string; params?: Record<string, unknown> };

const MESSAGES: Record<string, (p: Record<string, any>) => string> = {
  required:    () => "This field is required.",
  format:      p => p.format === "email" ? "Enter a valid email address." : "Invalid format.",
  min_value:   p => `Must be at least ${p.min}.`,
  date_order:  p => `Must be after ${label(p.after_field)}.`,
};

function unescape(token: string) {
  return token.replace(/~1/g, "/").replace(/~0/g, "~");   // RFC 6901 §3
}

export function fieldErrors(violations: Violation[]) {
  const byField = new Map<string, string[]>();
  for (const v of violations) {
    const path = v.pointer.split("/").slice(1).map(unescape).join(".");
    const render = MESSAGES[v.code];
    const text = render ? render(v.params ?? {}) : v.message;  // fall back to server text
    byField.set(path, [...(byField.get(path) ?? []), text]);
  }
  return byField;   // e.g. "items.2.quantity" → ["Must be at least 1."]
}

The ?? v.message fallback matters: when the server adds a rule the client has no template for, the user still sees a sensible English sentence rather than a blank error. New rules degrade gracefully instead of breaking the form.

Edge-case handling

Bulk operations. A batch of 500 items with 3 bad ones should not fail wholesale unless the batch is atomic. Either return 422 with pointers into the array (/items/312/sku), or return 207-style per-item results — but document which, because the two require completely different client code.

Unknown fields. Rejecting unknown properties makes forward compatibility impossible: an old server rejects payloads from a client built against a newer spec. Prefer ignoring unknown fields and reporting them as warnings, unless the endpoint handles money or permissions, where silent ignoring is the greater risk.

Rules that depend on server state. “This SKU is discontinued” is not schema validation; it is a business rule whose truth changes over time. Keep it in the same response shape but consider whether the outcome is really a 409 Conflict — the distinction is covered in 409 Conflict vs 412 Precondition Failed.

Secrets in violations. Never echo the submitted value for password, token or card fields. Some frameworks include the failing value in the error object by default; strip it centrally rather than per endpoint.

Drip-fed errors versus a complete violation list On the left, a client submits four times, each time learning about one more invalid field. On the right, one submission returns three violations and the second submission succeeds. First-error-only All violations at once submit → 422 "email invalid" submit → 422 "quantity too low" submit → 422 "ends_at before starts_at" submit → 201 Created 4 round trips · user frustration compounds submit → 422 violations[3] /customer/email · /items/2/quantity /ends_at submit → 201 Created 2 round trips · every field highlighted at once

Validation and testing patterns

// validation-contract.test.ts — the properties that matter, asserted directly
import { describe, it, expect } from "vitest";

describe("validation contract", () => {
  it("reports every violation in one response", async () => {
    const res = await post("/orders", {
      customer: { email: "nope" },
      items: [{ sku: "A", quantity: 1 }, { sku: "B", quantity: 1 }, { sku: "C", quantity: 0 }],
      starts_at: "2026-08-01T00:00:00Z",
      ends_at: "2026-07-01T00:00:00Z",
    });

    expect(res.status).toBe(422);
    const body = await res.json();
    const pointers = body.violations.map((v: any) => v.pointer).sort();
    expect(pointers).toEqual(["/customer/email", "/ends_at", "/items/2/quantity"]);
  });

  it("uses stable codes, not message text, as the branch key", async () => {
    const res = await post("/orders", {});
    const body = await res.json();
    for (const v of body.violations) {
      expect(v.code).toMatch(/^[a-z][a-z0-9_]*$/);   // codes are identifiers
      expect(v.pointer.startsWith("/")).toBe(true);   // pointers are RFC 6901
    }
  });

  it("never echoes secret values", async () => {
    const res = await post("/sessions", { password: "hunter2" });
    expect(JSON.stringify(await res.json())).not.toContain("hunter2");
  });
});

SDK generation impact

  // Generated TypeScript
- // Undocumented validation body: a generic error with an opaque message
- catch (e) { showToast((e as Error).message); }
+ // Documented ValidationProblem: typed violations the UI can bind to fields
+ catch (e) {
+   if (e instanceof ValidationFailedError) {
+     for (const v of e.problem.violations) {
+       form.setFieldError(pointerToPath(v.pointer), renderMessage(v));
+     }
+   }
+ }

Because violations is a documented array of a named component, every language gets a real type for it — Violation[], list[Violation], []Violation — and the field-binding code above becomes type-checked rather than defensive. The naming and composition rules that make that type readable are in OpenAPI Schema Composition.

Anti-patterns quick-reference

Anti-pattern Recommended approach
One error at a time Collect and return every violation in one response
400 for every failure 400 for malformed, 422 for semantically invalid
Dotted field paths RFC 6901 JSON Pointers with proper escaping
Clients branching on message text Branch on a stable code; messages are for humans
Server-side localisation Send code + params; let the client render
Echoing secret values in errors Strip sensitive fields centrally
Bespoke error shape per service One shared ValidationProblem across the estate
Undocumented error response Declare it so clients get typed violations

Who owns the message text

A validation contract has to decide where human-readable wording comes from, and the choice shapes every consumer. Server-side wording is convenient for a single first-party client and becomes a liability the moment there are several: the server must know each user’s locale, must ship translations in each consumer’s tone, and must be redeployed whenever a phrase changes.

The alternative costs one extra field. Send a stable code plus structured params, keep an English message as a fallback, and let each client render its own sentence. A web form says “Enter a valid email address”, a batch importer logs “row 412: field customer.email failed format”, and neither needed a server change. The fallback matters as much as the code: when the server adds a rule a client has no template for, the user still sees a sensible sentence rather than a blank error.

Where each part of a violation is consumed The code is consumed by client branching logic, the params by message rendering, the pointer by form field binding and the message by logs and unfamiliar codes. one violation, four consumers code stable identifier, never reworded client branching logic params structured rule parameters localised message rendering pointer RFC 6901 path to the value form field highlighting message English fallback text logs, and codes the client lacks the server owns meaning; the client owns wording

FAQ

Should a validation failure return 400 or 422?

Split them by where the failure happened. 400 is for a request the server could not process at all — unparseable JSON, the wrong content type, a missing path parameter — where no field-level detail is even available. 422 is for a request that parsed cleanly but broke a rule: a malformed email, a quantity below the minimum, an end date before a start date. The distinction is worth keeping because it tells the client whether the problem is in how it built the request or in what the user typed. Whichever convention you choose, document it once and apply it everywhere, since clients branch on the status code before they read the body.

Why use JSON Pointer instead of a dotted field path?

Because it is specified and unambiguous. RFC 6901 defines the separator, the array-index syntax and the escaping rules for keys containing / or ~, so /items/2/quantity has exactly one meaning and a field named a/b is addressable as /a~1b. A dotted path has none of that: a.b could be a nested field or a top-level field with a dot in its name, and array positions are conventionally a[0] or a.0 depending on who wrote the serializer. Every client then needs bespoke parsing, and the bespoke parsers disagree.

Should validation messages be localised by the server?

Send a stable code plus structured params, and let clients localise. Server-side localisation requires the server to know each user’s locale, to ship translations for every consumer’s tone and terminology, and to redeploy whenever a translation changes. A code-and-parameters contract inverts that: the API guarantees the meaning of a failure, and each client renders it in its own language and voice. Keep the English message as a fallback so an unrecognised code still produces something readable rather than an empty error.

Should the response echo the invalid values back to the client?

Usually not. The client already holds what it sent, so echoing adds payload size without adding information, and it creates a real hazard: validation errors are logged, forwarded to error trackers and screenshotted into tickets, so an echoed password or token leaks into all three. Return the pointer and the rule that failed. The exceptions are narrow — an interactive query language, or a value that was transformed server-side before validation — and there you should echo a truncated, explicitly redacted representation rather than the raw input.