Skip to main content
System design, step by step · Lesson 8 of 11 · 15 min

API Design for Beginners: Resources, Errors and Versioning

API design for beginners: resources and methods, honest status codes, one error shape, versioning and deprecation, rate limits, pagination and OpenAPI.

By Matthews WongReviewed on

Key takeaway

API design is deciding the contract between a server and the programs that call it: which resources exist, which methods act on them, what each status code means and what an error looks like. A good first API names resources with nouns, uses methods for their HTTP meaning, returns one error shape with a stable code, versions in the path and publishes the whole contract as OpenAPI.

You will be able to

  • Model an endpoint as a resource plus a method, and pick the status code that tells any client what happened.
  • Design one error envelope with a stable code and a hint, and compare it with RFC 9457 problem details.
  • Retire an endpoint with Deprecation, Link and Sunset headers, and publish the contract as OpenAPI.
Watch it work

Step through the diagram

Press Play, or step with the arrows. Every step is written out below the diagram.

One client meets the API's contract

A client reads the contract at /openapi.json, calls an old unversioned path that still works but answers with Deprecation and Link headers, then meets the v1 endpoint's method check, rate limit and validation, where every failure leaves in the same error envelope that points back at the contract.

Step 1 of 7. A client, the API's contract at /openapi.json, an old unversioned path and v1. Requests to v1 meet a rate limit, then validation, and every failure leaves in one error envelope.

Keys: ← → step · Home / End · K play or pause

  1. A client, the API's contract at /openapi.json, an old unversioned path and v1. Requests to v1 meet a rate limit, then validation, and every failure leaves in one error envelope.

  2. The client starts with the contract. GET /openapi.json returns every path, its method, the required fields and the error schema, built from the same constants the routes enforce.

    openapi: "3.1.0",Predict the next step An old client still calls /api/contact/, the path without a version. What does it get? (404, because the old path is gone · The usual answer, plus a Deprecation header · A redirect to /api/v1/contact/)
  3. The old path still answers through the same handler: deprecation changes no behaviour. Every response adds Deprecation, the date it happened, and a Link whose successor-version points at v1.

    deprecation: @1791417600 rel="successor-version"
  4. A GET on v1 gets 405 Method Not Allowed. RFC 9110 requires an Allow header listing the methods that do work, here POST, and the body is the same error envelope.

    status: 405, code: "method_not_allowed",Predict the next step Next it POSTs an empty JSON object. How should the client's code tell this failure from a rate limit? (By the stable code field · By parsing the error message)
  5. An empty JSON object passes the rate limit, then fails validation: 400, code invalid_request, and a hint naming the field. Programs branch on code; people read error and hint.

    code: "invalid_request", hint: "`name` must be 2 to 100 characters.",
  6. After five POSTs in a minute, the sixth stops at the rate limit: 429, a Retry-After header in seconds, and code rate_limited. Same envelope, no second parser.

    status: 429, code: "rate_limited", "Retry-After": String(resetIn),
  7. Every error carries docs and a Link header with rel service-desc, both pointing at /openapi.json. A client that only ever sees errors can still find the whole contract.

    docs: API_DOCS_URL,

What is API design?

API design is deciding the contract between a server and the programs that call it, before anyone writes a client. The contract names the resources, the methods each one accepts, the shape of every request and response, the status codes and what an error looks like. A caller cannot read your source code, so the contract is all it has, and anything you publish is something a caller may start to depend on.

Read more (1)

Design from the caller's side, starting with who the caller is: a browser, a mobile app, a partner's server, your own service or an AI agent. This site's API is small, three POST endpoints for its chat assistant and contact form, but it is public, so its callers are strangers. That is why its effort goes into a predictable error shape, a version in every path and a published description at /openapi.json.

How do resources and HTTP methods fit together?

In a REST-style API the URL names a thing and the method says what to do with it. Use nouns for resources, /orders for the collection and /orders/42 for one order, and let the method carry the verb. RFC 9110 defines what each method means. GET, HEAD, OPTIONS and TRACE are safe: the client does not request any state change. PUT, DELETE and the safe methods are idempotent: sending the same request twice has the same intended effect as sending it once.

Read more (2)

POST asks the resource to process what you send by its own rules, such as creating an order the server will name; when it creates one, RFC 9110 says to answer 201 Created with a Location header pointing at it. PUT creates or replaces the whole state of a resource. PATCH, from RFC 5789, applies a set of changes and is neither safe nor idempotent unless you design the request to be. This site's contact endpoint is a POST because sending a message is processing, not storing a resource you could GET back.

Never hide an action behind GET. RFC 9110 uses page?do=delete as its example and says the resource owner must disable an unsafe action when it is reached with a safe method, because automated tools GET every link they find to check it, prefetch it or index it.

Which HTTP status code should an API return?

The status code is the part every piece of HTTP software understands. RFC 9110 sorts codes by their first digit: 1xx informational, 2xx successful, 3xx redirection, 4xx client error and 5xx server error. A client must understand the class even when it does not know the exact code, and treat an unknown 471 as a 400. So the class has to be honest: an error sent inside a 200 looks like a success to any cache, monitor or retry policy that reads only the status.

Read more (2)

A small set covers most APIs. 200 OK, and 201 Created with a Location. 400 Bad Request for a request the server cannot or will not process. 401 when credentials are missing or invalid, which must come with a WWW-Authenticate header, and 403 when the server understood the request and refuses it. 404 when nothing is at that URL. 405 for a method the resource does not support, with an Allow header. 409 for a conflict with the resource's current state. 429 Too Many Requests, from RFC 6585. And 5xx only when the server failed a request that looked valid.

Some choices are a matter of convention, so pick one and write it down. RFC 9110 defines 422 Unprocessable Content for content that parses but cannot be processed; this site answers a failed field check with 400 and code invalid_request instead, and its OpenAPI description says so. It also answers a path without its trailing slash with 308 Permanent Redirect to the slashed path, so a client should call the exact path the contract lists.

What should an API error response look like?

One shape for every failure, so a client writes one parser. This site's envelope has success set to false, error for a person, code for a program, hint on how to recover, docs pointing at /openapi.json, and retryAfter on a rate limit. The status gives the category and the code gives the detail: three different 400s, invalid_json, invalid_request and rejected_input, call for three different reactions, and only the code tells them apart.

Read more (2)

RFC 9457, Problem Details for HTTP APIs, is the standard form of the same idea, served as application/problem+json. Its members are type, a URI that is the problem's primary identifier; title, a short summary of that type; status, which must equal the real status code; detail, an explanation of this occurrence that clients should not parse; and instance, a URI for this occurrence. You may add extension members, and clients must ignore the ones they do not recognise. For a new public API, start from RFC 9457 and carry a code and a hint as extensions.

Treat codes as part of the contract. Add new ones freely, but never rename or reuse one, because client code branches on it. Keep messages useful and free of internals: name the field and the range it must fall in, never a stack trace or an upstream provider's raw error.

How do you version an API and retire an old endpoint?

A change is safe when every existing client keeps working, such as adding an optional field or a new endpoint. Removing a field, renaming it, changing its type or making an optional field required breaks someone. This site's policy, written once in lib/api/versioning.ts, puts the version in the path, allows only additions within a version, ships a breaking change as /api/v2/, and keeps a deprecated version working for at least 180 days.

Read more (2)

Tell clients at runtime, not only in a changelog. RFC 9745's Deprecation header carries the date as an @ and Unix seconds, in the past or the future, and the RFC is explicit that deprecation changes nothing about how the resource behaves. A Link header, defined in RFC 8288, points at the replacement with rel successor-version and at human documentation with rel deprecation. Once a removal date exists, RFC 8594's Sunset header states it as an HTTP date, and RFC 9745 says it must not be earlier than the Deprecation date. This site promises Sunset at least 90 days ahead.

GraphQL takes the other road. Its documentation describes evolving one schema without the overhead of managing API versions: a field is deprecated, and removed once nothing uses it.

How do rate limits and pagination protect an API?

A public API needs a limit per client, or one script can starve everyone else. RFC 6585 defines 429 Too Many Requests: the response should explain the condition, may carry Retry-After with how long to wait, and must not be stored by a cache. It leaves open how you identify the client and count requests. This site counts per client address per minute, 20 for chat, 30 for follow-ups and 5 for contact, and answers with Retry-After plus X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset.

Read more (2)

Those X-RateLimit names are a common convention, not a standard. The IETF HTTPAPI working group has a draft that defines standard RateLimit and RateLimit-Policy fields; on 2026-10-18 it was at revision 11 and not yet an RFC, so check its status before you adopt it.

Any list endpoint needs pagination from its first release, because adding it later changes the response shape. Offset pagination, where page 3 at 20 per page is offset 40, is easy, but PostgreSQL's documentation warns that skipped rows still have to be computed, so a large offset is slow, and results are inconsistent without an ORDER BY that gives a unique order. Cursor, or keyset, pagination asks for the rows after the last one seen, which avoids the duplicates offset gives when rows are inserted between pages. GitHub's REST API sends page links in a Link header with rel next, prev, first and last.

Why publish the contract as OpenAPI?

The OpenAPI Specification is a standard, language-agnostic description of an HTTP API that lets people and computers understand a service without its source code. From one document, tools generate documentation, client and server code, and tests. Version 3.1 describes request and response bodies with a superset of JSON Schema 2020-12, keeps reusable parts under components, and marks an operation deprecated with a single boolean.

Read more (1)

A description is only useful while it is true. This site builds /openapi.json from the same constants the routes use, the paths, limits and error codes, and a test fails if a documented endpoint has no route file or the documented error codes differ from the real list. Whether you write the description first and generate code, or generate the description from code, keep one source of truth and test the other side against it.

How this site does it

The real config behind this lesson

Excerpts from the repository that builds and deploys this website, quoted as they are.

lib/api/errors.ts:14-41
/** Stable error codes; also the enum in the OpenAPI `Error` schema. */
export const API_ERROR_CODES = [
  "invalid_json",
  "invalid_request",
  "rejected_input",
  "rate_limited",
  "not_found",
  "method_not_allowed",
  "service_unavailable",
  "upstream_error",
  "internal_error",
] as const;

export type ApiErrorCode = (typeof API_ERROR_CODES)[number];

export interface ApiErrorBody {
  success: false;
  /** Human-readable message, safe to show to a person. */
  error: string;
  /** Stable machine-readable code. */
  code: ApiErrorCode;
  /** What the caller can do about it. */
  hint: string;
  /** The OpenAPI description of this API. */
  docs: string;
  /** Seconds until the caller may retry, for rate limits. */
  retryAfter?: number;
}

The envelope as a type. API_ERROR_CODES is the one list of codes, and the OpenAPI Error schema spreads it into its enum, so the contract cannot promise a code the API never returns. ApiErrorBody keeps the two audiences apart: error and hint are for a person, code is for a program, docs points at the contract, and retryAfter appears only on a rate limit.

lib/api/versioning.ts:83-98
export function deprecationHeaders(
  successor: string,
  deprecatedAt: number = ALIASES_DEPRECATED_AT,
): HttpHeader[] {
  return [
    { key: "Deprecation", value: `@${deprecatedAt}` },
    {
      key: "Link",
      value: [
        `<${BASE_URL}${successor}>; rel="successor-version"`,
        `<${API_POLICY_URL}>; rel="deprecation"; type="text/html"`,
        `<${OPENAPI_URL}>; rel="service-desc"`,
      ].join(", "),
    },
  ];
}

Deprecation as data. The value is an @ followed by Unix seconds, the RFC 9745 date format; for the old paths it is 1791417600, midnight UTC on 2026-10-08. The Link value joins three RFC 8288 links: the successor (the successor-version relation from RFC 5829), the human policy page (RFC 9745's deprecation relation) and the OpenAPI description. Its doc comment explains why service-desc is repeated: a config header replaces the route's own Link instead of joining it.

next.config.ts:151-157
      // The unversioned API paths still answer, as deprecated aliases of
      // /api/v1/. Only the slashed form is ever served: the bare one gets
      // the trailingSlash 308 first.
      ...DEPRECATED_API_ALIASES.map(({ path, successor }) => ({
        source: `${path}/`,
        headers: deprecationHeaders(successor),
      })),

Where the headers are attached. Each deprecated alias gets them from the site config, so the handler code stays identical for the old and new paths, which is what RFC 9745 means when it says deprecation changes no behaviour. The source is the slashed path because a request without the slash is answered with a 308 to the slashed one before any of this runs.

lib/api/openapi.ts:416-434
      responses: {
        BadRequest: {
          description:
            "The body is not JSON (`invalid_json`), fails validation (`invalid_request`) or was refused (`rejected_input`).",
          content: { "application/json": { schema: ref("Error") } },
        },
        MethodNotAllowed: {
          description: "Only POST is accepted (`method_not_allowed`).",
          headers: { Allow: header("Allow") },
          content: { "application/json": { schema: ref("Error") } },
        },
        RateLimited: {
          description: "Too many requests from this client (`rate_limited`).",
          headers: {
            "Retry-After": header("RetryAfter"),
            ...RATE_LIMIT_HEADERS,
          },
          content: { "application/json": { schema: ref("Error") } },
        },

The error responses as the contract states them. Each one names the codes it can carry, the 405 documents its Allow header, the 429 documents Retry-After and the X-RateLimit headers, and all of them point at one Error schema. tests/agent-readiness.test.mjs fails if that schema's codes and the real list differ, or if a documented endpoint has no route file.

What breaks

Mistakes that cost real time

  • An error inside a 200

    The body says error, the status says OK, so caches, monitoring and retry logic all count a success. Send the status that matches and keep the detail in the body. RFC 9457 requires its status member to equal the real status code for exactly this reason: generic HTTP software must still behave correctly.

  • Clients that parse the message

    A client that matches on the error text breaks the day you fix a typo in it. Give every failure a stable code, document the list, and treat renaming a code as a breaking change. RFC 9457 makes the same point: consumers should not parse its detail member.

  • A breaking change inside v1

    Renaming a field, changing its type or making it required breaks clients that never changed a line. Within a version, only add. Ship anything else as v2 and keep v1 running behind a Deprecation header for the window you promised.

  • A missing trailing slash

    This site's paths end in a slash, and /api/v1/contact without it answers 308 to /api/v1/contact/. curl without -L, or any client set not to follow redirects, stops at the 308 and never reaches the endpoint. Call the exact path the contract lists.

  • Deep offset pages on a live table

    A large offset makes the database compute and throw away every earlier row, and rows inserted between two requests shift items across pages, so readers see duplicates. Use a cursor for long or live lists, and always sort on a unique key.

  • What I'd change here: plain JSON errors

    This site's errors are application/json with its own field names, because the chatbot and the contact form already read success and error. A v2 could answer with application/problem+json and keep code and hint as extension members, so tools that know RFC 9457 could read the errors without custom code.

REST, GraphQL or gRPC: decided by who the client is
The client sendsA method on a resource URL, such as POST /api/v1/contact/A query or mutation to one endpoint, usually /graphqlA call to a method defined in a .proto service
The contractAn OpenAPI descriptionThe schema and its type systemProtocol buffer definitions, compiled by protoc
ErrorsThe HTTP status plus an error body2xx with an errors list when data is not null; 400 when the query is invalidgRPC status codes, such as DEADLINE_EXCEEDED
From a browserDirectly, with fetchPOST for queries and mutations; GET for queries onlyThrough gRPC-Web and a proxy, Envoy by default; unary and server streaming only
Changing itA new version in the path, with Deprecation and Sunset headersEvolve one schema; deprecate a field, remove it when unusedAdd fields; never change an existing field number
Pick it when the client isAnyone: browsers, partners, scripts and agentsSeveral apps that need different shapes of the same dataYour own services, where you control both ends
Try it yourself

Do it on your own machine

You need curl, and jq to pick fields out of JSON (drop the pipe to see the raw body). Every request here is read-only or rejected before the API does any work: none of them sends a message.

  1. Read the v1 index. It prints the version and the three endpoints, and its Link header points at the contract (service-desc) and the developer guide (service-doc).

    curl -s https://www.matthewswong.com/api/v1/ | jq '.version, .endpoints'
    curl -s -D - -o /dev/null https://www.matthewswong.com/api/v1/ | grep -i '^link'
  2. Read the contract: the OpenAPI version, every documented path (the v1 ones and the deprecated aliases), and the fields a contact request must carry.

    curl -s https://www.matthewswong.com/openapi.json | jq '.openapi, (.paths | keys)'
    curl -s https://www.matthewswong.com/openapi.json | jq '.components.schemas.ContactRequest.required'
  3. Use the wrong method: curl sends GET by default. Expect status 405, an allow: POST header among the response headers, and the envelope with code method_not_allowed.

    curl -si https://www.matthewswong.com/api/v1/contact/
  4. Send an invalid body. An empty JSON object fails validation before anything is sent: expect code invalid_request and a hint naming the name field. It counts toward the limit of 5 per minute, so do not loop it.

    curl -s -X POST https://www.matthewswong.com/api/v1/contact/ \
      -H 'Content-Type: application/json' -d '{}' | jq
  5. Ask for a path that does not exist. You get a JSON 404 with code not_found and a hint that lists the real endpoints, not an HTML error page.

    curl -s https://www.matthewswong.com/api/v1/orders/ | jq
  6. Call the deprecated path and read its headers: deprecation: @1791417600 and a link with rel successor-version pointing at /api/v1/contact/. Then turn the Unix seconds into a date: 2026-10-08T00:00:00.000Z.

    curl -s -D - -o /dev/null https://www.matthewswong.com/api/contact/ | grep -iE '^(deprecation|link)'
    node -e 'console.log(new Date(1791417600 * 1000).toISOString())'
  7. Read the rate-limit response from the contract instead of triggering it: the 429 documents Retry-After and the three X-RateLimit headers.

    curl -s https://www.matthewswong.com/openapi.json | jq '.components.responses.RateLimited.headers | keys'
Quick quiz

Three questions before you move on

Pick an answer to see why it is right or wrong. Nothing is graded, so guess freely; a miss is kept in this browser so the course can ask it again later.

  1. 1.A client sends PUT /profile, and the connection drops before any response arrives. Can it send the same PUT again?
  2. 2.Your API answers a missing order with status 200 and an error message in the body. What goes wrong?
  3. 3.You deprecated an endpoint today and will remove it in six months. Which header tells clients the removal date?
From earlier lessons

Two questions from earlier lessons

Recalling an older lesson after a gap is what makes it stick. Answer from memory before you look anything up.

  1. 1.A browser and then an AI agent request the same URL. A cache in front stored the browser's HTML without Vary: Accept. What does the agent get?
  2. 2.After a deploy, the container's health check fails three times in a row. The service has restart: unless-stopped. What does Docker do?

From: 7. System Design Case Study Example: Designing This Website · 5. Reliability Patterns Explained: Retries to Circuit Breakers

Think it through

Answer in your head, then check

  1. 1.What is the difference between a safe method and an idempotent one?Show the answer

    Answer:A safe method requests no state change at all: GET, HEAD, OPTIONS and TRACE. An idempotent method may change state, but sending it twice has the same intended effect as once: PUT and DELETE, plus every safe method. POST and PATCH are neither by default.

  2. 2.Why does the error body need a code when the status already says 400?Show the answer

    Answer:One status covers several failures. This site returns 400 for invalid_json, invalid_request and rejected_input, and a client reacts to each differently. The code is the stable detail a program branches on; error and hint are for the person reading it.

  3. 3.What does adding a Deprecation header change about how an endpoint behaves?Show the answer

    Answer:Nothing. RFC 9745 says deprecation only informs clients. The endpoint keeps working until its sunset date, while the header and the Link to its successor tell clients it is time to move.

  4. 4.When would you pick gRPC over REST, and GraphQL over both?Show the answer

    Answer:gRPC when you control both ends, such as your own internal services, and want generated clients and streaming. GraphQL when several clients need different shapes of the same data. REST when the client could be anyone, from a browser to a partner's script running curl.

Go deeper

Related deep dives on this site