{"id":410,"date":"2026-07-02T13:00:37","date_gmt":"2026-07-02T13:00:37","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/rest-api-design-2026-the-rules-i-actually-keep-on-real-backends\/"},"modified":"2026-07-02T13:00:37","modified_gmt":"2026-07-02T13:00:37","slug":"rest-api-design-2026-the-rules-i-actually-keep-on-real-backends","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/rest-api-design-2026-the-rules-i-actually-keep-on-real-backends\/","title":{"rendered":"REST API Design 2026: The Rules I Actually Keep on Real Backends"},"content":{"rendered":"<p>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 &ldquo;great work.&rdquo; More like &ldquo;why is this a POST?&rdquo; and &ldquo;where does pagination live?&rdquo; I&rsquo;ve written most of the same mistakes myself in earlier projects, so this isn&rsquo;t a smug review. It&rsquo;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.<\/p>\n<p>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&rsquo;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.<\/p>\n<h2 id=\"pick-the-versioning-story-on-day-one-and-dont-change-it-later\">Pick the versioning story on day one (and don&rsquo;t change it later)<\/h2>\n<p>Versioning is the call that&rsquo;s cheapest to make at hour one and most painful to fix at month twelve. I default to URL path versioning, like <code>\/v1\/invoices<\/code>, because every client tool, every cURL example, every Postman collection on the team&rsquo;s wiki survives without anyone setting a special header. The <a href=\"https:\/\/github.com\/microsoft\/api-guidelines\/blob\/vNext\/Guidelines.md#12-versioning\" rel=\"nofollow noopener\" target=\"_blank\">Microsoft REST API guidelines<\/a> write this up more thoroughly than I will, and they end up in roughly the same place.<\/p>\n<pre><code class=\"language-go\">\/\/ Go, using chi. I version at the route level so middleware can branch.\nr.Route(&quot;\/v1&quot;, func(v1 chi.Router) {\n    v1.Get(&quot;\/invoices\/{id}&quot;, h.GetInvoice)\n    v1.Post(&quot;\/invoices&quot;, h.CreateInvoice)\n})\n\nr.Route(&quot;\/v2&quot;, func(v2 chi.Router) {\n    v2.Get(&quot;\/invoices\/{id}&quot;, h.GetInvoiceV2) \/\/ new shape\n})\n<\/code><\/pre>\n<p>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 <code>\/v1<\/code> because &ldquo;nobody is using that part yet.&rdquo; Somebody is. I just haven&rsquo;t talked to them.<\/p>\n<p>Header versioning works too, but tooling support is worse and the failure mode when the header is missing is usually silent and weird. I&rsquo;d rather have a URL that&rsquo;s a little ugly than a debugging session that starts with &ldquo;why is this client still on the old shape?&rdquo;<\/p>\n<h2 id=\"naming-nouns-for-resources-and-a-quiet-rule-for-the-things-that-arent\">Naming: nouns for resources, and a quiet rule for the things that aren&rsquo;t<\/h2>\n<p>The thing that fights me most in API reviews is verbs in URLs. <code>\/getInvoice<\/code>, <code>\/updateUser<\/code>, <code>\/cancelSubscription<\/code>. 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.<\/p>\n<p>The rule I keep is the boring one from the <a href=\"https:\/\/cloud.google.com\/apis\/design\/resource_names\" rel=\"nofollow noopener\" target=\"_blank\">Google API Design Guide<\/a>: resources are nouns, collections are plural, and the HTTP method tells you what&rsquo;s happening to them.<\/p>\n<pre><code>GET    \/v1\/invoices              # list\nGET    \/v1\/invoices\/{id}         # read one\nPOST   \/v1\/invoices              # create\nPATCH  \/v1\/invoices\/{id}         # partial update\nDELETE \/v1\/invoices\/{id}         # delete\n<\/code><\/pre>\n<p>The interesting cases are the things that aren&rsquo;t really CRUD. &ldquo;Send this invoice.&rdquo; &ldquo;Cancel this subscription.&rdquo; &ldquo;Reissue this token.&rdquo; 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:<\/p>\n<pre><code>POST \/v1\/invoices\/{id}\/send\nPOST \/v1\/subscriptions\/{id}\/cancel\nPOST \/v1\/tokens\/{id}\/reissue\n<\/code><\/pre>\n<p>It&rsquo;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 <code>:send<\/code> colon convention. Pick one, write it down somewhere, and stop arguing about it.<\/p>\n<h2 id=\"pagination-is-the-thing-pr-reviewers-get-wrong-most\">Pagination is the thing PR reviewers get wrong most<\/h2>\n<p>Every endpoint that returns a list will eventually return more than you expected. I now treat &ldquo;is this paginated?&rdquo; as a default question on any list endpoint, not a special case. And I almost always reach for cursor-based pagination over offset.<\/p>\n<p>Offset pagination, the <code>?page=3&amp;size=20<\/code> 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.<\/p>\n<p>Cursor pagination side-steps both. The server hands the client an opaque token that points at &ldquo;where you were&rdquo;, and the next request says &ldquo;give me the page after this token.&rdquo;<\/p>\n<pre><code class=\"language-json\">GET \/v1\/invoices?limit=50\n\n{\n  &quot;data&quot;: [ ...50 invoices... ],\n  &quot;next_cursor&quot;: &quot;eyJpZCI6Imludl8wMDAxMjMifQ&quot;,\n  &quot;has_more&quot;: true\n}\n<\/code><\/pre>\n<pre><code class=\"language-go\">\/\/ Reading a cursor in Go. The cursor is just a base64'd struct.\ntype cursor struct {\n    LastID    string    `json:&quot;id&quot;`\n    CreatedAt time.Time `json:&quot;created_at,omitempty&quot;`\n}\n\nfunc decodeCursor(s string) (cursor, error) {\n    b, err := base64.URLEncoding.DecodeString(s)\n    if err != nil { return cursor{}, err }\n    var c cursor\n    return c, json.Unmarshal(b, &amp;c)\n}\n<\/code><\/pre>\n<p>The cursor encodes whatever you need to keep the SQL fast, usually the last ID and any sort key. Don&rsquo;t expose the schema in it. Treat it as opaque from the client&rsquo;s side and you can change the contents later without breaking anyone.<\/p>\n<h2 id=\"errors-that-tell-the-caller-what-to-do-next\">Errors that tell the caller what to do next<\/h2>\n<p>Status codes alone aren&rsquo;t an error contract. <code>400<\/code> could mean &ldquo;your JSON is malformed&rdquo;, &ldquo;your invoice is already paid&rdquo;, or &ldquo;we don&rsquo;t ship to that country.&rdquo; The client needs to handle these three very differently.<\/p>\n<p>I pick a body shape on day one and use it for every error path. <a href=\"https:\/\/www.rfc-editor.org\/rfc\/rfc9457.html\" rel=\"nofollow noopener\" target=\"_blank\">RFC 9457 Problem Details<\/a> gives you one off the shelf:<\/p>\n<pre><code class=\"language-json\">{\n  &quot;type&quot;: &quot;https:\/\/api.example.com\/errors\/invoice-not-paid&quot;,\n  &quot;title&quot;: &quot;Invoice not paid&quot;,\n  &quot;status&quot;: 422,\n  &quot;detail&quot;: &quot;Invoice inv_123 has an outstanding balance of $48.50&quot;,\n  &quot;invoice_id&quot;: &quot;inv_123&quot;,\n  &quot;outstanding_amount_cents&quot;: 4850\n}\n<\/code><\/pre>\n<p>Three properties matter to me. The <code>type<\/code> is a stable, unique URI per error. The client can switch on it without parsing English. The <code>status<\/code> 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&rsquo;s the first thing I want when I&rsquo;m staring at production logs at midnight.<\/p>\n<p>What I don&rsquo;t do: leak stack traces, raw SQL errors, or internal queue names. The error body is part of your API. If you wouldn&rsquo;t show it to a stranger, don&rsquo;t return it.<\/p>\n<h2 id=\"idempotency-the-cheap-insurance-my-retry-loop-needs\">Idempotency: the cheap insurance my retry loop needs<\/h2>\n<p>Every non-GET request needs to be safe to retry. The network drops, the client&rsquo;s pod gets rescheduled, the upstream load balancer 502s. If your <code>POST \/payments<\/code> charges twice when a client retries, that&rsquo;s a bug that will eventually be a customer complaint.<\/p>\n<p>The pattern I copy from Stripe is the <code>Idempotency-Key<\/code> 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. <a href=\"https:\/\/stripe.com\/blog\/idempotency\" rel=\"nofollow noopener\" target=\"_blank\">Stripe&rsquo;s writeup on idempotency<\/a> is still the clearest version I&rsquo;ve read.<\/p>\n<pre><code class=\"language-go\">func (h *PaymentHandler) Create(w http.ResponseWriter, r *http.Request) {\n    key := r.Header.Get(&quot;Idempotency-Key&quot;)\n    if key == &quot;&quot; {\n        http.Error(w, &quot;Idempotency-Key required&quot;, http.StatusBadRequest)\n        return\n    }\n    if cached, ok := h.cache.Get(key); ok {\n        w.WriteHeader(cached.Status)\n        w.Write(cached.Body)\n        return\n    }\n    \/\/ process payment, then cache (key -&gt; response) for 24h\n}\n<\/code><\/pre>\n<p>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&rsquo;s responses, which is the kind of bug you only notice in production.<\/p>\n<p>I went through a related version of this calculus in my <a href=\"https:\/\/abrarqasim.com\/blog\/grpc-vs-rest-2026-decide-by-who-calls-the-service\/\" rel=\"noopener\">post on picking between gRPC and REST<\/a>. Once you commit to either, the retry story is the next thing to nail.<\/p>\n<h2 id=\"the-two-checks-i-run-before-i-call-an-api-done\">The two checks I run before I call an API done<\/h2>\n<p>Before I ship a new endpoint, I do two boring things that catch most of the mistakes I&rsquo;d otherwise hear about from a client team.<\/p>\n<p>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&rsquo;t disagree.<\/p>\n<p>Second, I call the endpoint from a different language than the server is written in. Most of the &ldquo;the API is fine, your client is broken&rdquo; arguments I&rsquo;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 <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">the projects I share on my portfolio<\/a>.<\/p>\n<h2 id=\"what-to-do-this-week\">What to do this week<\/h2>\n<p>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 &ldquo;kind of&rdquo; is your weekend project. None of these rules are new. They&rsquo;re just the ones I wish I&rsquo;d written down on day one of the last three APIs I worked on.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>REST API design rules I still lean on in 2026: versioning, naming, pagination, errors, idempotency. Real Go code, plus the rituals I quietly skip.<\/p>\n","protected":false},"author":2,"featured_media":409,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"REST API design rules I still lean on in 2026: versioning, naming, pagination, errors, idempotency. Real Go code, plus the rituals I quietly skip.","rank_math_focus_keyword":"rest api design best practices","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[147],"tags":[100,49,46,321,445,447,446,320],"class_list":["post-410","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-backend","tag-api-design","tag-backend","tag-go","tag-http","tag-idempotency","tag-openapi","tag-pagination","tag-rest-api"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/410","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=410"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/410\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/409"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=410"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=410"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=410"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}