Every API invents its own error envelope — { error: "…" }, { errors: [{ field, msg }] }, { message, details: { … } } — and every client grows a parser per endpoint, which is why server errors so often end up as a generic toast instead of on the field that caused them.

Problem Details, standardised in RFC 9457 (which obsoletes RFC 7807), gives HTTP APIs a common error format with type, title, status, detail and instance, plus extension members for anything specific. This page, part of server error reconciliation, defines a validation problem type with per-field errors as JSON Pointers, and a client that routes any problem response to the right place in the form.


Context and prerequisites

The standard members:

  • type — a URI identifying the problem type; the primary key for client logic. Defaults to about:blank.
  • title — a short, human-readable summary of the type (same for every occurrence).
  • status — the HTTP status code, duplicated for convenience.
  • detail — a human-readable explanation of this occurrence.
  • instance — a URI identifying this occurrence (useful for support logs).

The media type is application/problem+json. RFC 9457 adds guidance for multiple problems of the same type, and describes an example validation problem that lists errors, each with a detail and a pointer — a JSON Pointer (RFC 6901) into the request body, such as #/address/postcode or /items/2/qty. That pointer is exactly what a form needs to place an error on a field.

Problem Details members and how a form uses them The type URI identifies the problem kind and is what the client switches on. The title is a short summary of the type, usable as a banner heading. The status repeats the HTTP status. The detail explains this occurrence and suits a form-level message. The instance identifies the occurrence for support. An errors extension lists individual problems, each with a detail and a JSON Pointer into the request body that the client maps to a field. Member Meaning Form client uses it to type problem kind (URI) choose how to handle it title summary of the kind banner heading status HTTP status sanity check detail this occurrence form-level message instance occurrence id show a reference for support errors[] (extension) detail + pointer each place errors on fields

The core pattern: a validation problem type and a routing client

HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json

{
  "type": "https://api.example.com/problems/validation",
  "title": "Your request has invalid fields.",
  "status": 422,
  "detail": "2 fields need attention.",
  "instance": "/requests/9f2c1e",
  "errors": [
    { "pointer": "#/email", "detail": "That email is used by another account.", "code": "email_taken" },
    { "pointer": "#/items/2/qty", "detail": "Only 3 left in stock.", "code": "stock_limit" }
  ]
}
export interface Problem {
  type?: string; title?: string; status?: number; detail?: string; instance?: string;
  errors?: { pointer?: string; detail: string; code?: string }[];
  [ext: string]: unknown;
}

export type Routed =
  | { kind: "fields"; fields: { path: (string | number)[]; message: string; code?: string }[]; unplaced: string[] }
  | { kind: "conflict"; message: string }
  | { kind: "retryable"; message: string; retryAfterMs?: number; reference?: string }
  | { kind: "form"; message: string; reference?: string };

const T = {
  validation: "https://api.example.com/problems/validation",
  conflict: "https://api.example.com/problems/edit-conflict",
  rateLimited: "https://api.example.com/problems/rate-limited",
};

// "#/items/2/qty" or "/items/2/qty" → ["items", 2, "qty"]  (RFC 6901 unescaping)
export function pointerToPath(pointer: string): (string | number)[] {
  const p = pointer.startsWith("#") ? decodeURIComponent(pointer.slice(1)) : pointer;
  return p.split("/").slice(1).map((s) => s.replace(/~1/g, "/").replace(/~0/g, "~")).map((s) => (/^\d+$/.test(s) ? Number(s) : s));
}

export async function routeProblem(res: Response, knownPaths: Set<string>): Promise<Routed> {
  const isProblem = (res.headers.get("Content-Type") ?? "").includes("application/problem+json");
  const body: Problem = isProblem ? await res.json().catch(() => ({})) : {};
  const reference = body.instance;

  if (body.type === T.validation && body.errors) {
    const fields: { path: (string | number)[]; message: string; code?: string }[] = [];
    const unplaced: string[] = [];
    for (const e of body.errors) {
      const path = e.pointer ? pointerToPath(e.pointer) : [];
      // Only place errors on fields the form actually renders; the rest go to the summary.
      if (path.length && knownPaths.has(path.join("."))) fields.push({ path, message: e.detail, code: e.code });
      else unplaced.push(e.detail);
    }
    return { kind: "fields", fields, unplaced };
  }
  if (body.type === T.conflict || res.status === 409) {
    return { kind: "conflict", message: body.detail ?? "This record was changed by someone else." };
  }
  if (body.type === T.rateLimited || res.status === 429 || res.status >= 500) {
    const ra = Number(res.headers.get("Retry-After"));
    return { kind: "retryable", message: body.detail ?? "Something went wrong on our side. Please try again.",
             retryAfterMs: Number.isFinite(ra) ? ra * 1000 : undefined, reference };
  }
  return { kind: "form", message: body.detail ?? body.title ?? "We couldn't save your changes.", reference };
}

Step-by-step walkthrough

  1. Define problem types as URIs you own. …/problems/validation, …/problems/edit-conflict, …/problems/rate-limited. Ideally they resolve to documentation for developers.
  2. Return validation failures with a stable errors extension. Each entry has a JSON Pointer to the offending input and a user-facing detail, plus a machine code for translation.
  3. Set application/problem+json. Clients detect the format by media type, not by guessing at body shape.
  4. Switch on type in the client. Validation problems go to fields; conflicts go to reconciliation (see handling 409 conflicts on form submit); rate limits and 5xx become retryable banners; anything else is a form-level message.
  5. Convert pointers to form paths and check they exist. Errors for fields the form does not render go to the summary instead of disappearing.
  6. Show instance as a reference on unexpected errors. “Reference 9f2c1e” lets support find the server log.

Why a standard format pays off in the form layer

The value of Problem Details is not the specific member names; it is that every endpoint returns errors the same way, so one client function handles them all. Without that, each form’s submit handler grows bespoke parsing for its endpoint’s quirks, and the handling of rarer cases — conflicts, rate limits, unexpected 500s — is inconsistent or missing. With a standard envelope and a small set of problem types, the routing logic lives in one tested module, and every form gets correct field placement, conflict handling and retry banners for free.

Routing a problem response If the type is validation, map each error's JSON pointer to a field and send unknown pointers to the summary. If the type is edit conflict or the status is 409, start conflict reconciliation. If the type is rate limited or the status is 429 or 5xx, show a retryable banner honouring Retry-After. Otherwise show a form-level message with the instance as a reference. type = validation? Place errors on fields yes no type = edit-conflict or 409? Conflict reconciliation yes no rate-limited, 429 or 5xx? Retryable banner yes no Form-level message detail or title; show instance as reference.

Failure modes and edge cases

1. Parsing HTML error pages as problems

Proxies and load balancers return HTML for 502 and 504. Check the Content-Type before parsing, and treat non-problem responses by status alone.

2. Pointers to renamed fields

If the API names a field postal_code and the form uses postcode, pointers will not match. Map API paths to form paths in one place — the normalisation described in normalizing nested field error paths — rather than in each form.

3. Indices after reorder

A pointer like #/items/2/qty refers to the order that was sent. Translate with a snapshot of that order, as in keeping array errors aligned after reorder and delete.

4. Leaking internals in detail

detail is shown to users; never put stack traces, SQL or internal ids in it. Log those server-side against the instance.

5. Localisation

title and detail are human-readable and should follow the request’s Accept-Language, or the client should translate from code. Mixed-language screens — English server messages in a German form — are a common symptom of skipping this.

One submit, three kinds of problem The form submits and receives a validation problem with two errors; the client places them on the email field and the third line item. The user fixes both and submits again; the server responds with a rate-limited problem and Retry-After, and the client shows a retryable banner while keeping all input. After the wait, the retry succeeds. Form Problem router API POST /orders 422 validation: #/email, #/items/2/qty errors on email and item 3 POST /orders (fixed) 429 rate-limited, Retry-After: 5 retryable banner; input kept

Verification checklist


Frequently Asked Questions

Is 422 or 400 the right status for validation errors?

Both are used. 422 Unprocessable Content signals that the request was well-formed but semantically invalid, which fits form validation well; 400 is common for malformed bodies. Pick one convention for validation and keep 400 for requests the server could not parse at all.

Is the errors array part of the standard?

It is an extension member. RFC 9457 shows a validation example with an errors array of objects carrying detail and pointer, but extension names are up to the API. Document yours alongside the problem type URI.

Do framework error formats map onto this?

Many do or can: ASP.NET Core produces Problem Details natively (with a errors dictionary keyed by field name), Spring supports ProblemDetail, and other frameworks have middleware. Normalise their field keys to your pointer format in the client router.


Related

← Server Error Reconciliation