JSON:API — Why We Picked a Standard Instead of Rolling Our Own
Every API needs a response format. The question is whether you design your own or adopt an existing standard.
The temptation to design your own is strong. You know your domain, you have opinions about how responses should be structured, and it’s genuinely fun to design a clean API envelope. We felt all of those things. We adopted JSON:API anyway.
The Custom Format Trap
Here’s how custom API formats usually evolve:
Version 1: a minimal wrapper. { "data": { ... } }. Clean. Simple. Everyone’s happy.
Version 2: you need error responses. { "error": { "message": "...", "code": "..." } }. OK, but now data and error are mutually exclusive, and clients need to check which one is present.
Version 3: you need pagination. { "data": [...], "page": 1, "total": 47 }. But the pagination format is different from the single-resource format. And what about cursor-based pagination?
Version 4: you need related resources. A request for a user should include their account. Do you nest it? { "data": { "account": { ... } } }. Or do you side-load it? { "data": { ... }, "included": { ... } }. What about circular references?
By version 4, you’ve reinvented a subset of JSON:API — except yours isn’t documented anywhere, doesn’t have client libraries in every language, and has subtle inconsistencies between endpoints because different developers made different choices.
What JSON:API Gives You
JSON:API is a published specification (v1.1) that answers all of these questions upfront:
Resource representation. Every resource has a type, id, and attributes object. Relationships are explicit, not nested. This sounds verbose until you realize it eliminates the ambiguity of “is this field a property of the resource or a related entity?”
Error format. Errors are always an array of error objects, each with status, title, detail, and optional source.pointer for field-level validation errors. Clients parse errors with a single code path regardless of what went wrong.
Relationship handling. Related resources are referenced by type and id, with optional included sideloading. No nested objects, no circular reference problems, no ambiguity about what’s the primary resource and what’s related.
Content negotiation. The content type is application/vnd.api+json. This signals to clients that the response follows the JSON:API format, enabling generic JSON:API client libraries.
We don’t use every feature — sparse fieldsets, compound documents, and relationship manipulation are deferred until we need them. But the foundation is there, and it’s consistent.
PUT-Only Updates
JSON:API supports both PUT (full replacement) and PATCH (partial update with specific merge semantics). We chose PUT only.
Supporting both doubles the surface area for every endpoint. PUT semantics are simple: the client sends the complete resource, the server replaces it. PATCH semantics are complicated: what does a null field mean? What about omitted fields? What about nested objects?
The GET-modify-PUT round-trip is straightforward: fetch the resource, change the fields you want, send the whole thing back. The server doesn’t need to distinguish between “this field was omitted” and “this field should be set to null” — both of which are genuine ambiguities in PATCH semantics.
PATCH can be introduced later for specific resources where partial updates provide a meaningful developer experience improvement. For now, PUT keeps things simple.
The Error Robustness Invariant
We added two invariants that go beyond what JSON:API requires:
Client input must never produce a 500. Any request a client can construct — missing body, malformed JSON, invalid fields, wrong content type — must result in a 4xx response. If client input causes a 500, that’s a bug in our service. Period.
Every error response has a JSON:API body. No empty responses, no HTML error pages, no plain text messages. Every error — including 500s — returns a JSON response conforming to the JSON:API errors format.
These invariants mean client code can always parse the error response using a single code path. No checking Content-Type to decide how to parse. No special handling for empty bodies. The response is always JSON:API, whether the request succeeded or failed.
400 Over 422 for Validation Errors
FastAPI defaults to returning 422 Unprocessable Entity for request validation errors. We override this to return 400 Bad Request.
422 is an HTTP extension from the WebDAV specification that most API consumers don’t expect. Major API platforms — Stripe, GitHub, Twilio — use 400 for validation errors. JSON:API’s error format handles validation detail via source.pointer regardless of status code. A single “your request is wrong” status code (400) is simpler for client error handling than distinguishing 400 from 422.
Postel’s Law on Inbound Requests
Services silently ignore read-only fields, immutable fields, and unrecognized fields on inbound requests. We don’t reject requests that include extra fields.
This is Postel’s Law applied to API design: be liberal in what you accept, conservative in what you send. The practical motivation is the GET-modify-PUT round-trip. A client that fetches a resource gets back fields like created_at and id (read-only) and key (immutable after creation). When the client sends the full resource back on PUT, those fields are present in the payload. Rejecting them would break the most natural client workflow.
Strict field validation sounds like good API hygiene until you try to use the API. Then it’s just friction.
The Actions Pattern
Some operations don’t map to CRUD: rotating an API key, toggling a feature flag, triggering a job run. Rather than overloading PUT with side effects, we use a dedicated actions sub-path:
POST /api/v1/api-keys/{id}/actions/rotate
POST /api/v1/feature-flags/{id}/actions/toggle
The action name is a verb. The method is always POST. This is the same pattern Stripe uses for POST /charges/{id}/capture and GitHub uses for POST /repos/{owner}/{repo}/actions.
It’s explicit, consistent, and doesn’t pollute the CRUD semantics of the main resource endpoints.
Was It Worth It?
Adopting JSON:API added some verbosity to our responses — the type/id/attributes structure is more bytes than a flat JSON object. In exchange, we got a response format that’s documented, tooling-compatible, and consistent across every endpoint in every service.
The time we would have spent designing and documenting a custom format, then explaining it to every developer who uses our API, then maintaining backward compatibility as it evolves — that time is now zero. We point to the JSON:API spec and focus on the parts that are unique to smplkit.
smplkit APIs follow the JSON:API specification. Try them out at docs.smplkit.com/api-reference.