# 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.

URL: https://www.matthewswong.com/en/learn/system-design/api-design-for-beginners/

System design, step by step · Lesson 8 of 11 · Reviewed on 18 October 2026 · By Matthews Wong (https://www.matthewswong.com/en/about/)

> 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.

## 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.

1. **Meet the parts.** 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. **Read the contract first.** 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:* 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. **Old path, new headers.** 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. **Wrong method: 405.** 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:* 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. **Validation names the field.** 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. **Too many: 429.** 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 links home.** 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.

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.

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.

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.

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.

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.

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.

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.

## The real config behind this lesson

`lib/api/errors.ts:14-41`

```typescript
/** 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`

```typescript
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`

```typescript
      // 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`

```typescript
      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

- **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

|  | REST over HTTP | GraphQL | gRPC |
| --- | --- | --- | --- |
| The client sends | A method on a resource URL, such as POST /api/v1/contact/ | A query or mutation to one endpoint, usually /graphql | A call to a method defined in a .proto service |
| The contract | An OpenAPI description | The schema and its type system | Protocol buffer definitions, compiled by protoc |
| Errors | The HTTP status plus an error body | 2xx with an errors list when data is not null; 400 when the query is invalid | gRPC status codes, such as DEADLINE_EXCEEDED |
| From a browser | Directly, with fetch | POST for queries and mutations; GET for queries only | Through gRPC-Web and a proxy, Envoy by default; unary and server streaming only |
| Changing it | A new version in the path, with Deprecation and Sunset headers | Evolve one schema; deprecate a field, remove it when unused | Add fields; never change an existing field number |
| Pick it when the client is | Anyone: browsers, partners, scripts and agents | Several apps that need different shapes of the same data | Your own services, where you control both ends |

## Try it yourself

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).

   ```bash
   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.

   ```bash
   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.

   ```bash
   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.

   ```bash
   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.

   ```bash
   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.

   ```bash
   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.

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

## Quick quiz

1. **A client sends PUT /profile, and the connection drops before any response arrives. Can it send the same PUT again?**
   A. No: PUT changes state, so repeating it is never safe
   B. Yes: PUT is idempotent, so a repeat has the same intended effect
   C. Only if the first attempt returned 201
   Answer: B. RFC 9110 lists PUT as idempotent and gives this exact case: the client can retry because repeating it has the same intended effect, even if the first attempt succeeded. PUT is not safe, but safe and idempotent are different properties.

2. **Your API answers a missing order with status 200 and an error message in the body. What goes wrong?**
   A. Caches, monitors and generic clients treat it as a success
   B. Nothing, as long as the body says error
   C. Browsers refuse to display the response
   Answer: A. Software that does not know your body format reads only the status class, and 2xx means success. A 404 tells all of it the request failed, and your body can still carry the detail and a stable code.

3. **You deprecated an endpoint today and will remove it in six months. Which header tells clients the removal date?**
   A. Deprecation
   B. Retry-After
   C. Sunset
   Answer: C. Deprecation says when the endpoint was or will be deprecated and changes nothing about how it behaves. Sunset, from RFC 8594, gives the date it is likely to stop responding, and must not be earlier than the Deprecation date.

## Think it through

- **What is the difference between a safe method and an idempotent one?** 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.
- **Why does the error body need a code when the status already says 400?** 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.
- **What does adding a Deprecation header change about how an endpoint behaves?** 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.
- **When would you pick gRPC over REST, and GraphQL over both?** 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

- [REST vs GraphQL: Choosing the Right API Design](https://www.matthewswong.com/en/blog/api-design-rest-graphql/)
- [API Versioning Strategies: URL, Header, or No Version at All](https://www.matthewswong.com/en/blog/api-versioning-strategies-rest/)
- [Cursor vs Offset Pagination: The 17x Performance Gap at Scale](https://www.matthewswong.com/en/blog/cursor-vs-offset-pagination/)
- [gRPC vs REST for Internal Microservices APIs: A Practical Comparison](https://www.matthewswong.com/en/blog/microservices-grpc-vs-rest-internal/)

## Sources

- [RFC 9110: HTTP Semantics (methods, status codes, Allow, Retry-After)](https://www.rfc-editor.org/rfc/rfc9110.html)
- [RFC 5789: PATCH Method for HTTP](https://www.rfc-editor.org/rfc/rfc5789.html)
- [RFC 6585: Additional HTTP Status Codes (429)](https://www.rfc-editor.org/rfc/rfc6585.html)
- [RFC 9457: Problem Details for HTTP APIs](https://www.rfc-editor.org/rfc/rfc9457.html)
- [RFC 9745: The Deprecation HTTP Response Header Field](https://www.rfc-editor.org/rfc/rfc9745.html)
- [RFC 8594: The Sunset HTTP Header Field](https://www.rfc-editor.org/rfc/rfc8594.html)
- [RFC 8288: Web Linking (the Link header)](https://www.rfc-editor.org/rfc/rfc8288.html)
- [IANA: Link Relations registry (successor-version, deprecation, service-desc)](https://www.iana.org/assignments/link-relations/link-relations.xhtml)
- [IETF draft: RateLimit header fields for HTTP](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/)
- [OpenAPI Specification 3.1.0](https://spec.openapis.org/oas/v3.1.0.html)
- [graphql.org: Introduction to GraphQL](https://graphql.org/learn/introduction/)
- [graphql.org: Serving over HTTP](https://graphql.org/learn/serving-over-http/)
- [grpc.io: Introduction to gRPC](https://grpc.io/docs/what-is-grpc/introduction/)
- [grpc.io: Core concepts (streaming, deadlines)](https://grpc.io/docs/what-is-grpc/core-concepts/)
- [grpc.io: Status codes and their use in gRPC](https://grpc.io/docs/guides/status-codes/)
- [grpc-web README: the proxy and supported RPC modes](https://github.com/grpc/grpc-web)
- [Protocol Buffers: proto3 guide (updating a message type)](https://protobuf.dev/programming-guides/proto3/)
- [PostgreSQL docs: LIMIT and OFFSET](https://www.postgresql.org/docs/current/queries-limit.html)
- [Use The Index, Luke: paging without OFFSET](https://use-the-index-luke.com/no-offset)
- [GitHub docs: Using pagination in the REST API](https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api)

---

- HTML version: https://www.matthewswong.com/en/learn/system-design/api-design-for-beginners/
- Bahasa Indonesia: https://www.matthewswong.com/id/learn/system-design/api-design-for-beginners/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
