Test your form's sad path without a backend β€” a mock API that returns real 422s

Your form has two paths. The happy one β€” valid input, green checkmark β€” gets tested every time you touch the page. The sad one β€” the 422, the per-field error messages, the red text under the input β€” usually can't be tested at all, because every free mock API accepts any payload. Send {"price": "cheap"} to JSONPlaceholder or DummyJSON and they cheerfully return 201 (checked July 2026); json-server stores whatever you send, by design. So your error-rendering code ships unexercised, and the first person to see it work β€” or not β€” is a real user hitting the real backend.

Mockbird writes can validate against the schema you already defined. Free, no signup, works on the public demo right now.

The 10-second version

# price is a number field β€” send a string, get a proper 422
curl -X POST 'https://mockbird.mockbird.workers.dev/m/demo/products?mock_validate=1' \
  -H 'content-type: application/json' \
  -d '{"name":"Widget","price":"cheap"}'
{
  "error": "validation_failed",
  "fields": {
    "price": "expected number, got string"
  }
}

HTTP status 422, one entry per bad field, human-readable reasons. Send several bad fields and you get all of them at once β€” exactly what your form needs to render every red message in one round-trip:

curl -X POST 'https://mockbird.mockbird.workers.dev/m/demo/products?mock_validate=1' \
  -H 'content-type: application/json' \
  -d '{"name":"Widget","price":"cheap","inStock":"yes","rating":"high"}'
# β†’ 422 {"error":"validation_failed","fields":{
#     "price":"expected number, got string",
#     "inStock":"expected boolean, got string",
#     "rating":"expected number, got string"}}

What gets checked

Validation runs against the field types of your resource schema β€” the same schema that seeds your fake data:

Field typeRuleExample failure
number, price, rating, percent, age, refId…must be a JSON number"expected number, got string"
booleanmust be true/false"expected boolean, got string"
emailwell-formed address"must be a valid email address"
uuidRFC-4122 shape"must be a valid UUID"
url, image, avatarparseable URL"must be a valid URL"
ipvalid IPv4"must be a valid IPv4 address"
date, pastDate, futureDateparseable date string"must be a parseable date string (ISO 8601 recommended)"
oneOf (enums)value must be in the list"must be one of: active, trial, churned"
other string typesmust be a string"expected string, got number"

Format checks work anywhere in the schema β€” the demo's customers resource has an email field:

curl -X POST 'https://mockbird.mockbird.workers.dev/m/demo/customers?mock_validate=1' \
  -H 'content-type: application/json' \
  -d '{"firstName":"Ada","email":"not-an-email"}'
# β†’ 422 {"error":"validation_failed","fields":{"email":"must be a valid email address"}}

Strict mode β€” required fields and unknown-field rejection

?mock_validate=1 type-checks the fields you send and ignores the rest. ?mock_validate=strict behaves like a picky backend: unknown fields are rejected, and every schema field is required on POST/PUT:

curl -X POST 'https://mockbird.mockbird.workers.dev/m/demo/products?mock_validate=strict' \
  -H 'content-type: application/json' \
  -d '{"name":"Widget","price":9.99,"color":"red"}'
# β†’ 422 {"error":"validation_failed","fields":{
#     "color":"unknown field (strict mode rejects fields not in the schema)",
#     "description":"required", "image":"required",
#     "category":"required", "inStock":"required", "rating":"required"}}

PATCH stays partial in both modes β€” only the fields you send are checked, nothing is "required". That matches how real PATCH endpoints behave, so your inline-edit UI can be tested honestly:

# partial PATCH with one valid field β†’ 200, merged
curl -X PATCH 'https://mockbird.mockbird.workers.dev/m/demo/products/1?mock_validate=strict' \
  -H 'content-type: application/json' -d '{"price":12.5}'

# partial PATCH with a bad type β†’ still 422
curl -X PATCH 'https://mockbird.mockbird.workers.dev/m/demo/products/1?mock_validate=1' \
  -H 'content-type: application/json' -d '{"price":"nope"}'

Make it the project default β€” zero URL changes in your app

Query params are great for curl experiments, but your app's fetch layer shouldn't need to know it's talking to a mock. Set validation once, project-wide:

curl -X PUT https://mockbird.mockbird.workers.dev/api/projects/PROJECT/settings \
  -H 'x-admin-key: KEY' -H 'content-type: application/json' \
  -d '{"validate":"strict"}'

From then on every plain POST/PUT/PATCH against the project validates β€” your normal form submits hit a rejecting API with no code changes. Override per request with ?mock_validate=off (handy for seeding scripts); clear the default with {"validate":"off"}. Also settable in the dashboard.

Build the error rendering against a stable shape

The response shape is fixed β€” error is always "validation_failed", fields maps field name β†’ reason β€” so you can write your form's error plumbing against it once:

const res = await fetch(`${API}/products`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify(formValues),
});
if (res.status === 422) {
  const { fields } = await res.json();
  for (const [name, message] of Object.entries(fields)) {
    setFieldError(name, message);   // your framework's per-field error setter
  }
  return;
}
if (!res.ok) return setFormError(`Something went wrong (${res.status})`);
celebrate(await res.json());

When the real backend arrives, keep the same plumbing and adjust the field-mapping line to its error format β€” the hard part (wiring errors to inputs, focus management, aria-invalid) is already built and tested.

The full sad-path matrix

Validation composes with the other simulation params, so one mock URL can exercise every failure your form will ever meet:

ScenarioHow
Bad payload β†’ 422 field errors?mock_validate=1 (this guide)
Server blows up β†’ 500?mock_status=500 (takes precedence over validation β€” a forced status wins)
Slow submit β†’ double-click protection?mock_delay=3000 β€” is your button disabled while pending?
Flaky network β†’ retry logic?mock_chaos=0.3 fails 30% of requests with 5xx/429
Rate limited β†’ 429 + Retry-After?mock_ratelimit=5 (5 requests/60s per client)
Session expired β†’ 401?mock_status=401, or real mock JWTs with 5-second expiry

More recipes in Testing loading and error states.

Honest notes

This is type and format validation, not business rules: there are no min/max ranges, no cross-field checks ("end date after start date"), no async uniqueness. It covers the shape of the sad path β€” which is what your form's error rendering needs β€” not your domain logic. A non-object body (a bare JSON array, or invalid JSON) is a 400 {"error":"invalid JSON body"}, not a 422. And credit where due: Prism does richer, OpenAPI-spec-driven request validation β€” but it runs on your machine and needs you to maintain a spec; Mockbird is a hosted URL and the schema is the one your resources already have. If you have a spec, import it and you get both.

Get your own (with your schema)

curl -s -X POST https://mockbird.mockbird.workers.dev/api/projects \
  -H 'content-type: application/json' \
  -d '{"name":"my-api","preset":"ecommerce"}'

…or the one-click dashboard link. Enum fields validate too β€” define a field with {"type":"oneOf","values":["active","trial","churned"]}, or import an OpenAPI spec and its enums are kept verbatim. 10,000 requests/day free, plus GraphQL, snapshots and a request inspector to see exactly what your form sent.