Last week I sat down with a teammate to review a new internal REST API and we both immediately said the same thing about three of the endpoints. Not “great work.” More like “why is this a POST?” and “where does pagination live?” I’ve written most of the same mistakes myself in earlier projects, so this isn’t a smug review. It’s a list of the small calls I now make on every API, and the ones I argue for in PRs even when nobody asks me to.
Short version for the impatient: pick a versioning story on day one, treat resources as nouns, page everything that could grow, return errors that tell the client what to do next, and make non-GET requests safe to retry. That’s the whole thing. The rest of this post is why those five keep showing up, and what they look like when I actually write them.
Pick the versioning story on day one (and don’t change it later)
Versioning is the call that’s cheapest to make at hour one and most painful to fix at month twelve. I default to URL path versioning, like /v1/invoices, because every client tool, every cURL example, every Postman collection on the team’s wiki survives without anyone setting a special header. The Microsoft REST API guidelines write this up more thoroughly than I will, and they end up in roughly the same place.
// Go, using chi. I version at the route level so middleware can branch.
r.Route("/v1", func(v1 chi.Router) {
v1.Get("/invoices/{id}", h.GetInvoice)
v1.Post("/invoices", h.CreateInvoice)
})
r.Route("/v2", func(v2 chi.Router) {
v2.Get("/invoices/{id}", h.GetInvoiceV2) // new shape
})
Two rules I follow. First, only major versions go in the path. Minor and patch changes happen inside the response. Adding a field is fine. Removing one is a breaking change and gets a new path. Second, I never sneak a behavior change into /v1 because “nobody is using that part yet.” Somebody is. I just haven’t talked to them.
Header versioning works too, but tooling support is worse and the failure mode when the header is missing is usually silent and weird. I’d rather have a URL that’s a little ugly than a debugging session that starts with “why is this client still on the old shape?”
Naming: nouns for resources, and a quiet rule for the things that aren’t
The thing that fights me most in API reviews is verbs in URLs. /getInvoice, /updateUser, /cancelSubscription. Each one is a fine method on a Java class, and a small disaster in an HTTP API, because the verb is supposed to live in the HTTP method.
The rule I keep is the boring one from the Google API Design Guide: resources are nouns, collections are plural, and the HTTP method tells you what’s happening to them.
GET /v1/invoices # list
GET /v1/invoices/{id} # read one
POST /v1/invoices # create
PATCH /v1/invoices/{id} # partial update
DELETE /v1/invoices/{id} # delete
The interesting cases are the things that aren’t really CRUD. “Send this invoice.” “Cancel this subscription.” “Reissue this token.” I used to invent verbs for these and feel a little guilty. Now I just pick a convention and live with it. My current default is a sub-action under the resource:
POST /v1/invoices/{id}/send
POST /v1/subscriptions/{id}/cancel
POST /v1/tokens/{id}/reissue
It’s not REST-pure. It is, however, easy for the next person to read, and it keeps the verb out of the resource path. Google uses the :send colon convention. Pick one, write it down somewhere, and stop arguing about it.
Pagination is the thing PR reviewers get wrong most
Every endpoint that returns a list will eventually return more than you expected. I now treat “is this paginated?” as a default question on any list endpoint, not a special case. And I almost always reach for cursor-based pagination over offset.
Offset pagination, the ?page=3&size=20 kind, looks simpler and has a way of falling apart on real data. Items move between pages when new rows are inserted. The query gets slower as the offset grows. Two clients hitting page 3 at the same time can see different rows.
Cursor pagination side-steps both. The server hands the client an opaque token that points at “where you were”, and the next request says “give me the page after this token.”
GET /v1/invoices?limit=50
{
"data": [ ...50 invoices... ],
"next_cursor": "eyJpZCI6Imludl8wMDAxMjMifQ",
"has_more": true
}
// Reading a cursor in Go. The cursor is just a base64'd struct.
type cursor struct {
LastID string `json:"id"`
CreatedAt time.Time `json:"created_at,omitempty"`
}
func decodeCursor(s string) (cursor, error) {
b, err := base64.URLEncoding.DecodeString(s)
if err != nil { return cursor{}, err }
var c cursor
return c, json.Unmarshal(b, &c)
}
The cursor encodes whatever you need to keep the SQL fast, usually the last ID and any sort key. Don’t expose the schema in it. Treat it as opaque from the client’s side and you can change the contents later without breaking anyone.
Errors that tell the caller what to do next
Status codes alone aren’t an error contract. 400 could mean “your JSON is malformed”, “your invoice is already paid”, or “we don’t ship to that country.” The client needs to handle these three very differently.
I pick a body shape on day one and use it for every error path. RFC 9457 Problem Details gives you one off the shelf:
{
"type": "https://api.example.com/errors/invoice-not-paid",
"title": "Invoice not paid",
"status": 422,
"detail": "Invoice inv_123 has an outstanding balance of $48.50",
"invoice_id": "inv_123",
"outstanding_amount_cents": 4850
}
Three properties matter to me. The type is a stable, unique URI per error. The client can switch on it without parsing English. The status is the HTTP status, repeated so it survives middleware that swallows headers. And I always include the entity ID that caused the error, because that’s the first thing I want when I’m staring at production logs at midnight.
What I don’t do: leak stack traces, raw SQL errors, or internal queue names. The error body is part of your API. If you wouldn’t show it to a stranger, don’t return it.
Idempotency: the cheap insurance my retry loop needs
Every non-GET request needs to be safe to retry. The network drops, the client’s pod gets rescheduled, the upstream load balancer 502s. If your POST /payments charges twice when a client retries, that’s a bug that will eventually be a customer complaint.
The pattern I copy from Stripe is the Idempotency-Key header. The client picks a random UUID and sends it on the first attempt. If the server has seen that key before, it returns the same response. Stripe’s writeup on idempotency is still the clearest version I’ve read.
func (h *PaymentHandler) Create(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("Idempotency-Key")
if key == "" {
http.Error(w, "Idempotency-Key required", http.StatusBadRequest)
return
}
if cached, ok := h.cache.Get(key); ok {
w.WriteHeader(cached.Status)
w.Write(cached.Body)
return
}
// process payment, then cache (key -> response) for 24h
}
A couple of practical notes. The cache TTL needs to be long enough that a client retrying after a 30-second backoff still hits it. Twenty-four hours is the number I default to. And the cache key needs to include the user identity, not just the header. Otherwise two clients picking the same UUID would see each other’s responses, which is the kind of bug you only notice in production.
I went through a related version of this calculus in my post on picking between gRPC and REST. Once you commit to either, the retry story is the next thing to nail.
The two checks I run before I call an API done
Before I ship a new endpoint, I do two boring things that catch most of the mistakes I’d otherwise hear about from a client team.
First, I generate an OpenAPI spec from the code and check the diff. Hand-written specs drift the moment the code changes. If the spec comes out of the same source as the handlers, the docs and the implementation can’t disagree.
Second, I call the endpoint from a different language than the server is written in. Most of the “the API is fine, your client is broken” arguments I’ve had came from cases where the server author only ever tested with their own SDK. A five-minute cURL session catches a lot. If your team is small, this is the cheapest review you can do. I cover this kind of pragmatic backend work in the projects I share on my portfolio.
What to do this week
Pick an API you maintain. Open the list of endpoints. For each one, ask three questions: does the URL tell a stranger exactly what resource it touches, does the response shape tell them what to do on an error, and is it safe to call twice. Anywhere the answer is “kind of” is your weekend project. None of these rules are new. They’re just the ones I wish I’d written down on day one of the last three APIs I worked on.