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.
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.
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.
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.
Related
- Error Contracts & Resilience Mapping — up-link: the parent reference for status mapping, retries and fallbacks
- Choosing Between 400 and 422 for Validation Errors — the status code decision in depth
- Field-Level Error Paths with JSON Pointer — addressing nested and array fields correctly
- Mapping JSON Schema Errors to Problem Details — turning validator output into this contract
- RFC 7807 Problem+JSON Implementation — the sibling section defining the envelope this extends
- HTTP Status Code Mapping — where validation sits among the other client-error codes