JSON:API — why we picked a standard instead of rolling our own
Every API needs a response format, and the temptation to design your own is real. You know your domain, you have opinions, and designing a clean envelope is genuinely fun. We felt all of that. We adopted JSON:API anyway, and the reason is what happens to every custom format ever shipped.
The custom format trap
Version 1: a minimal wrapper. { "data": { ... } }. Clean. Everyone’s happy.
Version 2: you need errors. { "error": { "message": "...", "code": "..." } }. Fine, except now data and error are mutually exclusive and every client checks which one showed up.
Version 3: pagination. { "data": [...], "page": 1, "total": 47 } — which doesn’t match the single-resource shape, and someone is already asking about cursors.
Version 4: related resources. Does the account nest inside the user object, or side-load next to it? What about circular references?
By version 4 you’ve reinvented a subset of JSON:API — except yours isn’t documented anywhere, has no client libraries, and disagrees with itself between endpoints because different developers made different calls.
What the standard buys
JSON:API v1.1 answers all of it upfront. Resources have type, id, and attributes; relationships are explicit references rather than mystery nesting, with optional included side-loading. Errors are always an array of objects with status, title, detail, and an optional source.pointer for field-level validation, so clients parse every failure with one code path. The application/vnd.api+json content type tells generic tooling what it’s looking at.
We don’t use everything — sparse fieldsets, compound documents, and relationship manipulation wait until we need them. But every question the custom format would have answered ad hoc, the spec has already answered once, in writing.
What we layered on top
The spec leaves room for house rules. Ours:
PUT only, no PATCH. Supporting both doubles every endpoint’s surface. PUT is simple: send the complete resource, the server replaces it. PATCH is a semantics minefield — what does null mean, what does omitted mean, what happens in nested objects? The GET-modify-PUT round-trip needs none of those answers: fetch, change what you want, send it all back. PATCH can arrive later for a specific resource that truly earns it.
Client input never produces a 500. Any request a client can physically construct — missing body, malformed JSON, wrong content type — gets a 4xx. If client input produces a 500, that’s our bug, full stop.
Every error has a JSON:API body. Including 500s. No empty responses, no HTML error pages, no plain text. A client never checks Content-Type to decide how to parse a failure.
400, not 422, for validation. FastAPI defaults to 422, which comes from the WebDAV spec and surprises people. Stripe, GitHub, and Twilio all use 400 for validation errors; JSON:API carries the field-level detail in source.pointer regardless of the status code; and one “your request is wrong” code is easier to handle than two.
Postel’s law inbound. Services silently ignore read-only, immutable, and unrecognized fields on requests instead of rejecting them. The motivation is the same round-trip: a fetched resource includes id, created_at, and other read-only fields, and a client PUTting the resource back shouldn’t be punished for including what we sent. Strict field validation sounds like good API hygiene until you try to use the API. Then it’s just friction.
Actions for non-CRUD verbs. Rotating a key or triggering a job run doesn’t map to CRUD, so instead of overloading PUT with side effects: POST /api/v1/api-keys/{id}/actions/rotate. Verb in the URL, method always POST — the same move as Stripe’s /charges/{id}/capture.
Was it worth it?
The type/id/attributes structure costs bytes; a flat object would be smaller. What we got in exchange is a format that’s documented by someone else, consistent across every endpoint in every service, and compatible with existing tooling. The time we would have spent designing a custom format, documenting it, explaining it to every API consumer, and maintaining its backward compatibility as it grew appendages — that time is now zero. We point at the spec and spend the attention on the parts of smplkit that are actually ours.