Complete REST
API Design
REST isn't a technology you install — it's a set of agreements that let millions of clients and servers talk without coordinating. This chapter takes the standard apart, rebuilds it from the scalability crisis that created it, and turns every fuzzy question (plural or singular? PUT or PATCH? which status code?) into a rule you never have to guess about again.
What REST Actually Is
In 1990 Tim Berners-Lee started a project to share knowledge across the world — the World Wide Web. Within about a year he invented essentially everything we still use: the URI (uniform resource identifier), the HTTP protocol, HTML, the first web server, the first browser, and the first WYSIWYG HTML editor built into that browser. Remarkable for one person in twelve months.
Then the problem arrived: scale. The web grew exponentially, far past anything Berners-Lee had planned for. The original design simply could not absorb the user base it was acquiring every day. Around 1993, Roy Fielding — co-founder of the Apache HTTP server project — got concerned about exactly this. To make the web scalable he proposed a set of architectural constraints. He and Berners-Lee then co-wrote the specification for HTTP/1.1, the first standardized version of the protocol. Finally, in 2000, Fielding named and described the whole architectural style in his PhD dissertation: REST — REpresentational State Transfer.
Fielding's dissertation is the original source document for REST. Search "Roy Fielding REST dissertation" — it's a genuinely worthwhile read for any backend engineer, because it explains why these patterns exist, not just what they are.
The name decomposes into three ideas, and each one is a real concept you use every day:
Representational
A resource (a piece of data — a user, a book, a cart) can be represented in different formats depending on who's asking. The same user record might be sent as JSON to an API client (server-to-server), or as HTML to a browser, or as XML to some legacy consumer. The underlying resource is one thing; its representations are many.
State
State is the current condition of a resource — its present attributes. Think of an Amazon shopping cart: its state is the set of items in it, their quantities, and the total price right now. That state lives on the server and is what gets moved around.
Transfer
Because we have a client and a server, the whole point is moving these representations between them. The transfer happens over the common standard — HTTP — using its methods (GET, POST, PUT, PATCH, DELETE, …). When your browser asks for a page with GET, it's transferring a representation from server to client.
REST = resources that have multiple representations, whose state can be transferred between client and server over HTTP, all within a set of constraints that keep the system scalable. You don't need to memorize the theory — but knowing the three words tells you what every API call is really doing: moving a representation of some resource's current state.
The Six Constraints
To be truly "RESTful" — and to get the scalability Fielding was after — a system follows six constraints. Five are required; the sixth is optional. These aren't bureaucracy; each one buys a concrete scaling property.
| Constraint | What it says | What it buys |
|---|---|---|
| 1 · Client–Server | Strict separation of concerns: client owns UI/UX, server owns data & business logic. | Each side evolves independently. |
| 2 · Uniform Interface | One standardized way for all components to talk. (Four sub-constraints below.) | Consistency across every service. |
| 3 · Layered System | Hierarchical layers; a layer only sees the one immediately below it. | Drop in load balancers / proxies without touching core logic. |
| 4 · Cache | Responses must label themselves cacheable or not. | Less server load, faster responses. |
| 5 · Stateless | Server keeps no memory of past requests; each request carries everything it needs. | Any server can handle any request → horizontal scaling. |
| 6 · Code on Demand (optional) | Server may ship executable code (e.g. JavaScript) to extend the client. | Flexible client behavior when needed. |
The two that matter most in practice
Stateless is the heavy hitter. The server does not remember your previous request — every request must contain all the information needed to understand and process it. This is what makes scaling possible: put a load balancer in front of three identical servers, and because no server holds your session in memory, any of them can handle any request via round-robin. Statelessness is the reason you can add servers and they "just work."
Layered System is what statelessness enables. Because the client only ever talks to the layer immediately in front of it, you can slip in CDNs, reverse proxies, load balancers, and API gateways between client and origin — and the client never knows or cares. This is the entire foundation of how a small app grows into one serving millions.
Because no server stores your session, the load balancer can send your requests to server 1, 2, or 3 interchangeably — each one carries everything it needs inside the request. Add a fourth server and it works instantly, no coordination required. That's horizontal scaling, and statelessness is what makes it free.
The four sub-constraints of Uniform Interface
- Resource identification — every resource is addressable by a URI.
- Manipulation through representations — you act on a resource by sending/receiving its representation (the JSON you PUT is how you change it).
- Self-descriptive messages — each message carries enough metadata (headers, content-type, method) to be understood on its own.
- HATEOAS — Hypermedia As The Engine of Application State: responses can include links telling the client what it can do next. (The most aspirational sub-constraint; few APIs implement it fully.)
Why REST Confuses Us Now
Here's the honest backstory the transcript leans on: people still get tripped up by REST — plural or singular path? PUT or PATCH? which status code for a custom action? — and the reason is historical. When these standards were forming, the web ran on MPAs (multi-page applications): every interaction was a full-page request to the server, which rendered HTML and sent it back.
Today we mostly build SPAs (single-page applications): the first request downloads a big bundle of JavaScript, and from then on the browser does its own routing on the client side, fetching data (JSON) from APIs rather than whole pages. The client got heavy; the server became a pure data API. The old standards never anticipated this shift, which is why some of their rules feel ambiguous against modern usage.
Not to invent new rules — the standards already exist — but to extract clear, practical guidelines from them and commit to one consistent style. Once you've internalized the conventions, you stop re-litigating "is this RESTful?" on every endpoint and get to spend your energy on business logic instead.
Anatomy of a Route
Start with a normal website URL and its parts:
https, the encrypted transport.api. subdomain.v1, API versioning via path./ means hierarchy.The industry-standard shape of an API route (a convention, not a hard rule) layers these together: https:// + an api. subdomain + a version like /v1 + the resource path.
Rule 1 — always plural nouns
The resource in the path is always plural, even when fetching a single item. List all books → /books. Fetch one book → /books/123, not /book/123. The resource type ("books") is a collection; the ID just narrows into it. This is the single most common beginner mistake.
Rule 2 — no spaces or underscores; use slugs
URLs travel across many server/client/OS environments, so keep them clean. Never put spaces or underscores in a path. To put a human-readable name in a URL, build a slug:
Harry Potterharry potterharry-potter → /books/harry-potterRule 3 — the slash means hierarchy
A forward slash expresses a hierarchical relationship between resources. /organizations/123/projects reads as "the projects that belong specifically to organization 123." First level: the collection. Next level: a specific member. Next: that member's sub-collection. Design your paths so the nesting tells the story.
Methods & Idempotency
Idempotency is the concept that unlocks "which method should I use?" An operation is idempotent if performing it many times has the same effect on the server as performing it once. The key subtlety: idempotency is about the side effect you cause on the server, not about whether the response bytes are identical.
| Method | Purpose | Idempotent? | Has body? |
|---|---|---|---|
GET | Read / fetch a representation | yes + safe | No |
POST | Create a resource / custom action | no | Yes |
PUT | Replace a resource entirely | yes | Yes (full) |
PATCH | Update part of a resource | usually | Yes (partial) |
DELETE | Remove a resource | yes | No |
HEAD (fetch only headers) and OPTIONS (used in the CORS pre-flight to ask "is this origin allowed?") exist too, but these five carry the real data work.
GET — idempotent & safe
Fetches data; changes nothing on the server. Call it once or a thousand times — the server state is identical. "But what if someone else creates a book between my calls and the response changes?" That doesn't break idempotency: idempotency asks what side effect your call causes, and a GET causes none. Because it's safe, it's freely retryable and cacheable.
On the wire, a GET is just a request line, headers, and a blank line — no body. All input rides in the URL (path + query string), which is why you never put secrets in a GET: the whole URL lands in browser history, server logs, and CDN logs.
| Code | On a GET, this means |
|---|---|
200 OK | Found it; the body is the representation. |
304 Not Modified | Your cached copy is still fresh (you sent If-None-Match); no body returned — saves the payload. |
301 / 302 | Moved; follow the Location header. |
400 Bad Request | Your query string is malformed. |
401 / 403 | Not authenticated / authenticated but not allowed. |
404 Not Found | No such single resource. |
429 Too Many Requests | Rate-limited; check Retry-After. |
PUT & PATCH — idempotent updates
Both update. The difference is scope:
- PATCH — partial update. Send only the fields you want to change (e.g. just
status). Best fit for SPA-era JSON apps, where you almost always update a few fields, not the whole record. - PUT — full replacement. You must send the entire representation; the server replaces what it has with your payload. Omit a field and you've wiped it.
Why idempotent? Say a user's name is A and you PATCH it to B. First call: A → B. Second identical call: B → B. Thousandth call: still B. The end state never changes after the first call, so repeating the same update payload is idempotent.
Developers often use them interchangeably, and internally that's mostly fine. But for a public API, stick to the semantics: PATCH for partial, PUT for full replacement. Consumers assume you follow the standard — if you use PUT where you mean PATCH, you hand them a confusing, wrong assumption. In practice most modern (SPA-driven) APIs lean on PATCH, because you rarely want to replace an entire entity.
On the wire — the body is the whole difference
PUT replaces. If the stored user has name, email, and city, but you PUT only {"city":"Bangalore"}, you've just blanked the name and email. Fields you forget to send are gone. If you only have some of the record in hand, use PATCH — never PUT. This is the #1 way people accidentally destroy data through an API.
Concurrent edits — the lost-update problem
Two clients GET the same record. A changes the email and saves. B (who never saw A's change) saves the city using B's stale copy — and B's write silently overwrites A's email. The fix is optimistic concurrency with ETag + If-Match: the server hands out a version fingerprint, and only applies your write if that version is still current.
func UpdateOrganization(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
org := store.Get(id)
if org == nil {
writeJSON(w, 404, errBody("organization not found"))
return
}
// optimistic concurrency: reject stale writes
if m := r.Header.Get("If-Match"); m != "" && m != org.ETag {
writeJSON(w, 412, errBody("resource changed; re-fetch and retry"))
return
}
var patch map[string]any
json.NewDecoder(r.Body).Decode(&patch) // only the fields to change
updated := store.Patch(id, patch) // merge, don't replace
w.Header().Set("ETag", updated.ETag)
writeJSON(w, 200, updated) // 200 + updated entity
}
| Code | On a PUT / PATCH, this means |
|---|---|
200 OK | Updated; body holds the new state. |
201 Created | PUT only — created the resource at a client-chosen URL (upsert). PATCH never creates. |
204 No Content | Updated; nothing to return. |
400 Bad Request | Body malformed. |
404 Not Found | Resource doesn't exist (PATCH); PUT without upsert. |
409 Conflict | Change collides with current state (e.g. unique-field clash). |
412 Precondition Failed | Stale If-Match — somebody edited it first. |
415 Unsupported Media Type | Wrong Content-Type (esp. PATCH's merge-patch vs json-patch). |
422 Unprocessable Entity | Well-formed JSON, but fails a business rule (e.g. age: -5). |
DELETE — idempotent
Delete user 1. First call: the user is removed (a real side effect). Second call: the server checks, finds no such user, and returns 404 — but nothing changed on the server. No new side effect occurred. You only changed state on the first call; every call after that is a no-op that simply reports "not found." Hence DELETE is idempotent.
POST — the only non-idempotent one
POST creates. Send a "create book" payload once → one book with ID 1. Send the exact same payload again → a second book with ID 2 (IDs are generated server/DB-side, and names usually needn't be unique). Ten identical POSTs → ten distinct books. The side effect changes every time, so POST is non-idempotent.
On the wire, POST carries a body (declared by Content-Type), and a successful create returns 201 with a Location header pointing at the new resource:
Say you POST /payments {amount: 5000} and the network drops after the server charged the card but before the response reached you. You don't know if it worked, so you retry — and now you've charged ₹10,000. The standard fix is an idempotency key: the client sends a unique ID, the server records it with the result, and any retry carrying the same key returns the original result instead of charging again. This is how Stripe and every serious payment API make POST safe to retry.
func CreatePayment(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("Idempotency-Key") // client-generated UUID
// already processed this exact request? return the SAME result, don't re-charge
if prior, ok := idemStore.Get(key); ok {
writeJSON(w, prior.Status, prior.Body)
return
}
var in struct{ Amount int `json:"amount"` }
json.NewDecoder(r.Body).Decode(&in)
payment := charge(in.Amount) // the real, non-idempotent side effect
idemStore.Save(key, 201, payment) // remember it, keyed by the idempotency key
writeJSON(w, 201, payment)
}
| Code | On a POST, this means |
|---|---|
200 OK | Action ran; no new resource created (e.g. "send email", custom actions). |
201 Created | A new resource was created; Location points to it. |
202 Accepted | Queued for async processing — "I'll do it later" (reports, heavy jobs). |
400 Bad Request | Body malformed or missing required fields. |
409 Conflict | Collides with existing state (e.g. "username already taken"). |
413 Payload Too Large | Body exceeds the server's size limit. |
415 Unsupported Media Type | Server doesn't accept the Content-Type you sent. |
422 Unprocessable Entity | Valid JSON, but fails validation at the business-rule level. |
429 Too Many Requests | Rate-limited. |
Custom Actions — beyond CRUD
Sometimes an action doesn't fit Create/Read/Update/Delete. "Clone a project," "archive an organization," "send an email" — these trigger a web of background work beyond a simple database write. The REST spec makes POST the open-ended method for exactly these cases.
The rule & the route shape
When an action doesn't map to a standard method, make it a POST and append the action as a verb at the end of a specific resource path:
This keeps the hierarchy honest: all organizations → one specific organization → the action to run on it.
On the surface, archiving looks like setting status = "archived" — so why not PATCH? Because archiving an organization may trigger far more than a field write: deleting all its projects and their tasks, emailing the owner, revoking access, queuing cleanup jobs. The status flip is a side effect of the action, not the action itself. When the real operation is bigger than the data change, it's a custom action → POST.
The "send email" example
Consider POST /emails with body {"target": "someone@example.com"}. Is sending an email a fetch? No. A create? Not really. An update or delete? No. It's a custom action — so it lives under POST. And note: a custom action POST often returns 200 OK (the action ran), not 201 Created, because nothing new was necessarily created. Never assume "POST ⇒ 201."
List APIs: Page · Sort · Filter
A list endpoint can't just dump every row. Three features make it robust, and all three ride on query parameters.
Pagination — why & how
Returning a thousand records at once is expensive on both ends: JSON serialization/deserialization is heavy work, the network bottlenecks, and the user perceives a multi-second delay — even though on screen they only see the first 10–20 items before scrolling. So the server returns the data in chunks (pages).
A paginated response includes four fields:
data— the array of items for this page.total— the absolute count of items in the DB (so the UI can say "showing 10 of 50").page— which page this response represents.totalPages— the maximum number of pages (the frontend uses this to know when to stop fetching on infinite scroll: whenpage === totalPages, stop).
The client controls it with two params: limit (how many per page) and page (which chunk). And — a sane-defaults moment — if the client sends neither, the server should default page to 1 and limit to something like 10 or 20, never crash.
Sorting
Two params: sortBy (which field) and sortOrder (ascending / descending). Crucial default: even with no sort params, the server must sort by something — otherwise the DB returns rows in arbitrary order and the same call yields different orderings each time. The natural default is sortBy=createdAt, sortOrder=descending (newest first).
Filtering
Pass resource fields as query params to narrow the list: ?status=active, or combine them: ?status=archived&name=org. The server applies each as a filter on the result set.
// GET /v1/organizations?status=active&sortBy=name&sortOrder=ascending&page=1&limit=10
func ListOrganizations(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
// --- sane defaults: never crash if the client omits params ---
page := atoiDefault(q.Get("page"), 1)
limit := atoiDefault(q.Get("limit"), 10)
sortBy := defaultStr(q.Get("sortBy"), "createdAt")
sortOrder := defaultStr(q.Get("sortOrder"), "descending")
filters := map[string]string{}
if s := q.Get("status"); s != "" {
filters["status"] = s // ?status=active
}
rows, total := store.Query(filters, sortBy, sortOrder, page, limit)
totalPages := (total + limit - 1) / limit // ceil division
writeJSON(w, 200, map[string]any{
"data": rows,
"total": total,
"page": page,
"totalPages": totalPages,
})
}
Status Codes — done right
| Code | Meaning | Use it when |
|---|---|---|
200 OK | Success | Fetch, update (PATCH/PUT), or a custom action that ran. |
201 Created | Created | A POST successfully created a new entity. Return the new entity in the body. |
204 No Content | Success, empty body | A successful DELETE — nothing to send back. |
404 Not Found | Resource missing | Client asked for one specific ID that doesn't exist. |
The 404 rule — the one people get wrong
Return 404 only when a client requests a specific single resource that doesn't exist (e.g. GET /users/999). If a client hits a list API and nothing matches (e.g. "all users named Zack," or a filter that matches nothing), do not return 404. Return 200 OK with an empty array []. A list that found nothing still succeeded — it just found nothing.
The five families — read the first digit
Every status code's first digit tells you almost everything. This is the fastest debugging instinct you can build.
| Family | Class | Whose problem |
|---|---|---|
1xx | Informational | Rare; you'll basically never handle these directly. |
2xx | Success | It worked. |
3xx | Redirection | Go look elsewhere / use your cache. |
4xx | Client error | You sent something wrong — fix the request. |
5xx | Server error | The server broke — not your fault; retry / alert ops. |
When a call fails: 4xx → fix your request (check body, auth, URL). 5xx → not your fault; retry with backoff or page the server team. When it succeeds: 20x → done, 30x → follow the redirect. That single split resolves most "what do I do with this response?" questions instantly.
The 4xx codes people confuse
401 vs 403
401 Unauthorized means "I don't know who you are" — no credentials, or they're invalid/expired. The fix is to log in or refresh the token. 403 Forbidden means "I know exactly who you are, and you're not allowed" — valid credentials, insufficient permission. Logging in again won't help. (The names are historically swapped — 401 is really about authentication, 403 about authorization.)
409 Conflict
The request collides with the current state of the resource: creating something that already exists, a unique-field clash, or deleting something other records still depend on ("can't delete a user with active orders").
422 vs 400
400 Bad Request = the request is malformed at the syntax level (broken JSON, a string where a number was required). 422 Unprocessable Entity = the syntax is perfect, but it fails a semantic/business rule (a negative age, an email that isn't an email, an end-date before the start-date). Use 422 when you parsed the body fine but the values don't make sense.
429 Too Many Requests
Rate-limited. Respect the Retry-After header (seconds to wait) instead of hammering, or you'll just stay throttled.
The codes you'll meet 90% of the time
| Code | Plain meaning |
|---|---|
200 | worked |
201 | created (POST success) |
204 | worked, no body (DELETE success) |
301 / 302 | redirect |
304 | your cache is still good |
400 | your request is broken (syntax) |
401 | log in |
403 | logged in, but not allowed |
404 | that one thing doesn't exist |
409 | conflict with current state |
422 | data fails validation (semantics) |
429 | slow down |
500 | they crashed |
503 | they're overloaded / in maintenance |
Worked Example — a PM platform
Following the transcript's build: a project-management product (think Jira / Linear). From the wireframes we extract the nouns → resources: organizations, projects, tasks (also users, tags). Those become tables, then endpoints. Here's the full endpoint set for one resource — the same pattern repeats for every other resource.
| Action | Method + Route | Success |
|---|---|---|
| List organizations | GET /v1/organizations | 200 · paginated |
| Create organization | POST /v1/organizations | 201 · new entity |
| Get one | GET /v1/organizations/:id | 200 / 404 |
| Update (partial) | PATCH /v1/organizations/:id | 200 · updated entity |
| Delete | DELETE /v1/organizations/:id | 204 · empty |
| Archive (custom) | POST /v1/organizations/:id/archive | 200 · archived entity |
Notice the symmetry: list and create share the collection URL (/organizations) and differ only by method — the server routes GET to the list handler and POST to the create handler. Likewise get-one / update / delete share /organizations/:id and differ only by method. The custom action appends a verb. projects would mirror this exactly, with clone as its custom action.
func RegisterOrgRoutes(mux *http.ServeMux) {
// list + create share the collection URL, split by method
mux.HandleFunc("GET /v1/organizations", ListOrganizations)
mux.HandleFunc("POST /v1/organizations", CreateOrganization)
// get-one / update / delete share /:id, split by method
mux.HandleFunc("GET /v1/organizations/{id}", GetOrganization)
mux.HandleFunc("PATCH /v1/organizations/{id}", UpdateOrganization)
mux.HandleFunc("DELETE /v1/organizations/{id}", DeleteOrganization)
// custom action: verb at the end of a specific resource
mux.HandleFunc("POST /v1/organizations/{id}/archive", ArchiveOrganization)
}
func CreateOrganization(w http.ResponseWriter, r *http.Request) {
var in struct {
Name string `json:"name"`
Status string `json:"status"`
Description string `json:"description"`
}
json.NewDecoder(r.Body).Decode(&in)
if in.Status == "" {
in.Status = "active" // sane default — don't force the client to send the obvious
}
org := store.Insert(in.Name, in.Status, in.Description) // id, createdAt set server-side
writeJSON(w, 201, org) // 201 Created + the new entity
}
func DeleteOrganization(w http.ResponseWriter, r *http.Request) {
store.Delete(r.PathValue("id"))
w.WriteHeader(204) // No Content
}
func ArchiveOrganization(w http.ResponseWriter, r *http.Request) {
org := store.Archive(r.PathValue("id")) // flips status + cascades: projects, tasks, emails...
writeJSON(w, 200, org) // custom action → 200, NOT 201
}
Golden Rules
Extract nouns from the UI first
Before writing a line of business logic, look at the wireframes (Figma) or talk to the product people. The nouns users interact with — projects, users, tasks, tags — are your resources. Looking at how the end-user touches data tells you how the lowest layer (the DB) relates to the top layer (the screen), and that's the right place to start designing the interface.
Design the interface before coding
A REST API is designed, not programmed-first. Lay out routes, payloads, and responses in a tool like Insomnia or Postman before touching Go, Python, or any framework. The whole point is a delightful, intuitive, unambiguous interface — so the consumer never has to read your source code or guess your behavior by trial and error.
Provide sane defaults
Never crash because the client omitted something obvious. No page → default 1. No limit → default 10. No sortBy → createdAt descending. No status on create → active. Require only the information you genuinely cannot infer.
Be ruthlessly consistent
- JSON in
camelCase, always — both payloads and responses. - No abbreviations — if a field is
description, never call itdescin another endpoint. You have context the consumer doesn't; an abbreviation they can't decode costs them a failed call and a docs hunt. - Same shapes across resources — if
organizationsare plural and paginated with{data,total,page,totalPages}, thenprojectsandtasksare too. Consumers integrate one endpoint, then assume the rest follow the same style. Reward that assumption.
Following a shared standard removes guesswork, assumptions, and human error from integration. If a consumer can assume your API is ~80% standard-compliant, their integration time drops sharply — fewer bugs, fewer "how does this endpoint behave?" calls. Consistency of style is one of the clearest marks of a good backend engineer.
Ship interactive docs
Generate an interactive playground with Swagger / OpenAPI from the start. It doubles as documentation for frontend engineers integrating you, and as a testing ground for you. How consistently you maintain your OpenAPI spec genuinely sets you apart.
Pull resources (plural nouns) from your UI, give each the CRUD set on a clean hierarchical URL, pick the method by intent (GET read · POST create/action · PUT replace · PATCH partial · DELETE remove), let idempotency decide retryability, return the honest status code (201 created · 204 deleted · 404 only for a missing single resource), make lists page/sort/filter with sane defaults, and stay ruthlessly consistent — so you can stop arguing about REST and get back to your business logic.
Versioning — not breaking your consumers
The moment another team or another company integrates your API, you can no longer change it freely — a field you rename or a response you reshape breaks their code in production. Versioning is how you evolve without breaking what's already deployed. Three common strategies:
| Strategy | Looks like | Trade-off |
|---|---|---|
| URL path (most common) | /v1/books → /v2/books | Dead obvious, trivial to debug, easy to route. Slightly "impure" REST (the version isn't part of the resource's identity). The pragmatic default. |
| Header | Accept: application/vnd.example.v2+json | Keeps URLs clean and "pure," but invisible in a browser and harder to test/debug. Used by GitHub for years. |
| Query param | /books?version=2 | Simple but clutters every URL and muddies caching. Least recommended. |
When to bump the version
Only on a breaking change — something that would break an existing consumer:
- Removing or renaming a field; changing its type or meaning.
- Removing an endpoint, or changing its method/route.
- Making a previously-optional field required, or changing default behavior.
Additive changes are NOT breaking — and don't need a new version. Adding a new optional field, a new endpoint, or a new optional query param leaves old clients working untouched (they just ignore what they don't know about). Bumping the version for additive changes is needless churn.
When you must retire a version, don't yank it. Announce a timeline, run v1 and v2 in parallel during a migration window, and send a Deprecation / Sunset response header on the old version so integrators get a programmatic heads-up. Breaking people silently is how you lose their trust.
Error Responses — the other half of the contract
People design the happy path carefully and then return raw stack traces or bare strings on failure. But the error shape is part of your API contract too — the consumer's error-handling code depends on it being consistent and machine-readable. A status code alone isn't enough; the body should explain what went wrong in a predictable structure.
A consistent error envelope
Pick one error shape and use it on every error, across every endpoint. A solid, minimal envelope:
Why each piece earns its place:
code— a stable string the client canswitchon. Never make them parse the human message; messages get reworded, codes don't.message— for humans/logs. Safe to show or surface.details— per-field errors so a form can highlight exactly which inputs failed (pairs naturally with422).requestId— the single most useful field in production: the consumer quotes it in a bug report and you find the exact request in your logs in seconds.
Don't return stack traces, SQL errors, file paths, or framework exception dumps to clients — they're useless to the consumer and a gift to attackers (they reveal your stack, schema, and structure). Log the gory details server-side against the requestId; return the clean envelope to the client. A 500 body should say "something went wrong, here's your request id" — nothing more.
type FieldError struct {
Field string `json:"field"`
Issue string `json:"issue"`
}
func writeError(w http.ResponseWriter, status int, code, msg string, details ...FieldError) {
body := map[string]any{"error": map[string]any{
"code": code,
"message": msg,
"details": details,
"requestId": requestIDFromCtx(),
}}
writeJSON(w, status, body) // SAME shape for every error in the whole API
}
// usage
writeError(w, 422, "validation_failed", "Some fields are invalid.",
FieldError{"email", "must be a valid email"},
FieldError{"age", "must be >= 0"},
)
If you'd rather adopt a spec than invent an envelope, RFC 9457 (Problem Details for HTTP APIs) defines a standard application/problem+json body with type, title, status, detail, and instance fields. Either approach is fine — the only wrong move is being inconsistent about error shape across your endpoints.
Return the honest status code, wrap every failure in one consistent JSON envelope with a machine-readable code + human message + per-field details + a requestId, and never leak internals — so a consumer can handle your errors as reliably as your successes.