DEV Community

Samuel Owolabi
Samuel Owolabi

Posted on

Design Your API Like a Senior Engineer

Design Your API Like a Senior Engineer

I have had a few experiences integrating APIs, and an integration that should take minutes can end up taking days because of bad API design and poor documentation.

Great developer experience, or DX, means a developer can understand your API, integrate it quickly, and trust it, without friction or guesswork. Good API design is what creates that experience: clear names, predictable endpoints, honest errors, and consistent responses. This is also where a senior engineer stands apart from a junior. A junior focuses on making the endpoint work. A senior designs the whole experience around the people who will use it.

The heart of it is simple. An API is a promise to people who are not in the room: the machines that call it, the teammates on other teams, and whoever maintains it in two years. This is also why API design comes up so often at big companies and in system design interviews, which I will come back to at the end.

 

What this guide covers

By the end, you should be able to:

  1. Use the right HTTP verb
  2. Design resource-based URLs
  3. Return the correct HTTP status codes
  4. Keep your API consistent
  5. Return clear, structured errors
  6. Paginate large responses
  7. Version your API
  8. Secure your API

 

1. The right HTTP verb

Every HTTP request uses a method, and each method has a clear meaning:

GET     read data
QUERY   read using a request body (new, see the tip below)
POST    create something new
PUT     replace something entirely
PATCH   update part of something
DELETE  remove something
Enter fullscreen mode Exit fullscreen mode

The mistake is ignoring those meanings and sending everything through POST, usually with the action written into the URL.

// Bad ❌
POST /books/123/update
Enter fullscreen mode Exit fullscreen mode
// Good ✔️
PATCH /books/123
Enter fullscreen mode Exit fullscreen mode

Machines act on the method. Browsers, caches, crawlers, and security tools treat GET as safe and POST as a change, and they act on that without ever reading your response. There are real cases where a crawler followed a page full of delete links and wiped out records, because the deletes were hidden behind GET.

Idempotency decides what is safe to retry. Idempotency means calling the same request more than once has the same effect as calling it once. GET, PUT, and DELETE are idempotent, so clients and networks retry them safely after a timeout. POST is not. If POST charges a card and the network drops for a second, the retry can charge the customer twice.

AI agents assume GET is safe. An agent exploring your API calls GET endpoints freely, because they are meant to only read. If one of them quietly changes data, the agent can corrupt your system just by looking around.

Bonus tip: QUERY has just been added as a new HTTP Method

QUERY is a new HTTP method, standardized in 2026 and the first one added since PATCH in 2010. It is built for searches with many filters. Normally you reach for GET, but GET has no real body, so every filter gets crammed into the URL. You could use POST, but POST signals that you are changing data. QUERY fixes this: it carries a request body like POST, but stays safe to cache and retry like GET.

Bad: GET /products?category=shoes&min=50&max=200&color=black

Good: QUERY /products with the filters in the request body

It is still new, so check that your tools support it before you rely on it.

 

2. Resource-based URLs

In a good API, the URL names a thing and the method says what to do with it. The URL should be a noun, not an action.

// Bad ❌

POST /getUser
POST /createUser
POST /updateUser
POST /deleteUser
Enter fullscreen mode Exit fullscreen mode
// Good ✔️

GET    /users/{id}
POST   /users
PATCH  /users/{id}
DELETE /users/{id}
Enter fullscreen mode Exit fullscreen mode

Endpoints multiply forever. With resources, one path like /users covers everything. With actions in the URL, every new need becomes a new endpoint. You start with /getUser, then add /getUserById, then /deactivateUser, and soon you have forty endpoints for a handful of resources.

Nobody can guess your API. Once someone learns how /users works, they should be able to guess how /orders works. Action URLs remove that, because there is no way to know if deleting a user is /deleteUser, /removeUser, or /users/delete. Every call becomes a trip to the docs.

Names drift across a team. One engineer writes getUser, another fetchUser, another retrieveUser. Same action, three names, one messy API.

AI agents need predictable paths. An agent can work through a resource-based API on its own, because it can reason about /users and then guess /orders. It cannot guess a set of custom action names that only your docs explain.

 

3. HTTP status codes

Every response comes with a status code, and that code is meant to tell the caller what happened. The common mistake is returning 200 OK for everything, including failures, and hiding the real result in the body.

// Bad ❌

HTTP 200 OK
{ "success": false, "error": "User not found" }
Enter fullscreen mode Exit fullscreen mode
// Good ✔️

HTTP 404 Not Found
{ "error": "User not found" }
Enter fullscreen mode Exit fullscreen mode

The codes worth knowing:

200 OK                     success
201 Created                resource created
204 No Content             success, nothing to return
400 Bad Request            malformed request
401 Unauthorized           authentication required
403 Forbidden              authenticated but not allowed
404 Not Found              resource does not exist
422 Unprocessable Content  validation failed
429 Too Many Requests      rate limited
500 Internal Server Error  server failure
Enter fullscreen mode Exit fullscreen mode

Clients retry based on the code. A 5xx means the server failed, so retrying may help. A 4xx means the caller was wrong, so it will not. Return 200 on a real failure and nothing retries a request that could have worked. Return 500 on bad input and clients keep retrying something that will never succeed.

Monitoring is built on codes. Error rates, dashboards, and alerts all read status codes. If failures come back as 200, your error rate looks like zero while things are broken, and no alert fires.

The exact code guides the caller. 401 means log in or refresh a token. 403 means you are not allowed, so do not retry. 429 means slow down. Automated clients and AI agents branch on these too, so the code has to tell the truth.

 

4. API consistency

Consistency means picking one way to do something and using it everywhere. The mistake is small choices that drift apart, like mixing naming styles or response shapes.

// Bad ❌

GET /book/123          // (singular here)
GET /authors           // (plural there)

{ "created_at": "..." }    // on one object
{ "createdDate": "..." }   // on another
Enter fullscreen mode Exit fullscreen mode
// Good ✔️

GET /books/123         // (plural everywhere)
GET /authors

{ "created_at": "..." }    // the same style everywhere
Enter fullscreen mode Exit fullscreen mode

The same goes for response shapes. Pick one format and use it every time.

// Bad ❌

{ "status": "ok",  "payload": { } }    // in one place
{ "success": true, "data":    { } }    // somewhere else
Enter fullscreen mode Exit fullscreen mode
// Good ✔️

{ "success": true, "data": { }, "error": null } // the same shape every time
Enter fullscreen mode Exit fullscreen mode

Every difference is a lookup. If it is userId in one place and user_id in another, developers can never trust their memory, so they open the docs for every field.

One shape means one handler. With a consistent response shape, a client writes a single piece of handling code. With mixed shapes, they write a special case for every endpoint.

Drift creates silent bugs. If a date is created_at on one object and createdDate on another, some code reads the wrong field, gets nothing back, and fails in a way that is hard to trace.

The specific choice barely matters. Plural or singular, snake_case or camelCase, either is fine. What matters is picking one and sticking to it. Juniors argue over which is right. Seniors pick one, write it down, and move on.

 

5. Structured errors

Errors are part of your API, not an afterthought. Developers spend a lot of their time dealing with errors, so this is where clear design helps most. The mistake is a vague message that does not tell the caller what to do.

// Bad ❌

{ "error": "Invalid request" }
Enter fullscreen mode Exit fullscreen mode
// Good ✔️

{
  "error": {
    "type": "validation_error",
    "message": "The 'email' field must be a valid email address.",
    "field": "email",
    "docs": "https://docs.example.com/errors/validation"
  }
}
Enter fullscreen mode Exit fullscreen mode

Vague errors waste time. "Invalid request" does not say how, which field, or what was expected. The developer has to dig through your docs or guess to answer a question the server already knew.

A stable type lets them handle it in code. A consistent error shape with a type field means developers can write one error handler and branch on the type, instead of parsing messages by hand.

 

6. Pagination

Some endpoints return lists that can grow huge, like posts or orders or messages. You cannot send all of them at once without overloading the server, the client, and the network. So you send one page at a time.

// Bad ❌

GET /posts       // returns all 2,000,000 posts at once
Enter fullscreen mode Exit fullscreen mode
// Good ✔️

GET /posts?limit=10&cursor=eyJpZCI6MTAwfQ   // one page at a time
Enter fullscreen mode Exit fullscreen mode

There are three common ways to paginate, and a senior picks the one that fits.

Offset Pagination

Looks like ?limit=10&offset=20. It skips some rows and takes the next few, which maps neatly to page numbers. The catch is speed. To skip rows, the database cannot jump straight to them. It reads from the start and counts every row until it reaches the offset. So offset=20 is cheap, but offset=500000 makes it count through half a million rows first, which is close to a full table scan on every call. Good for small datasets and admin dashboards.

-- GET /posts?limit=10&offset=20
SELECT * FROM posts
ORDER BY created_at DESC
LIMIT 10 OFFSET 20;
Enter fullscreen mode Exit fullscreen mode

The response can include the total, so the client can show page numbers and work out the next offset:

{
  "data": [ ... ],
  "pagination": { "limit": 10, "offset": 20, "total": 2000000 }
}
Enter fullscreen mode Exit fullscreen mode

Cursor Pagination

Looks like ?limit=10&cursor=abc. Instead of counting, you hand the client a pointer to the last item it saw, usually its id or timestamp. The next request asks for the rows after that point. Because that column is indexed, the database jumps straight to the spot instead of counting up to it, so it stays fast whether you are on page 1 or page 100,000. You give up random page jumps, but it stays correct as new data arrives. This is what social feeds and infinite scroll use.

-- GET /posts?limit=10&cursor=eyJpZCI6MTAwfQ
-- the cursor decodes to the last id you saw, here 100
SELECT * FROM posts
WHERE id < 100
ORDER BY id DESC
LIMIT 10;
Enter fullscreen mode Exit fullscreen mode

The response returns the next cursor, which the client sends back to get the following page. There is no total, since counting every row is the cost we are avoiding:

{
  "data": [ ... ],
  "pagination": { "next_cursor": "eyJpZCI6OTB9", "has_more": true }
}
Enter fullscreen mode Exit fullscreen mode

Keyset Pagination

Looks like ?limit=50&before=2024-03-15T00:00:00Z. It is the same idea as a cursor, but you page directly on a sorted, indexed column like a timestamp. Very fast, and ideal for logs, chat, and other time-based data.

-- GET /messages?limit=50&before=2024-03-15T00:00:00Z
SELECT * FROM messages
WHERE created_at < '2024-03-15T00:00:00Z'
ORDER BY created_at DESC
LIMIT 50;
Enter fullscreen mode Exit fullscreen mode

The response hands back the timestamp of the last item, which the client passes as before on the next request:

{
  "data": [ ... ],
  "pagination": { "next_before": "2024-03-14T22:15:00Z", "has_more": true }
}
Enter fullscreen mode Exit fullscreen mode

Notice how each response hands back exactly what the client needs to request the next page. The senior move is picking the technique that still works at a hundred times the data.

If you want to go deeper on pagination, including page-based, time-based, and combined approaches, Merge has a thorough guide: A guide to REST API pagination.

 

7. API Versioning

Your API will change over time. The only question is whether you planned for it. The mistake is shipping with no version, so the day you change something, everyone breaks at once.

// Bad ❌

GET /users/{id}       // no version, so a breaking change hits everyone
Enter fullscreen mode Exit fullscreen mode
// Good ✔️

GET /v1/users/{id}    // versioned from the start, so v2 can change safely
Enter fullscreen mode Exit fullscreen mode

Adding fields is safe. Good clients ignore fields they do not recognize, so you can add new ones any time without breaking anyone.

Removing or renaming breaks people. That is a breaking change, so it belongs in a new version. Keep the old version running for a while and give people time to move over, instead of deleting it with no warning.

A published API is a promise other people build on. Versioning is how you keep improving without pulling the floor out from under them.

 

8. API Security

Everything above is about the shape of your API. The other half is deciding who can call it and what they can do. This is not a full security guide, but these are the basics every API needs, starting with two ideas that are easy to mix up.

Authentication: who are you?

Authentication proves who is making the request. The common tools are API keys for simple app-to-app access, sessions for browser logins, and tokens like JWTs for stateless auth across services.

One quick senior tip: never put the key in the URL, where it leaks into server logs, browser history, and analytics. Put it in a header instead.

// Bad ❌

GET /v1/orders?api_key=sk_live_abc123
Enter fullscreen mode Exit fullscreen mode
// Good ✔️

GET /v1/orders
Authorization: Bearer sk_live_abc123
Enter fullscreen mode Exit fullscreen mode

Authorization: what are you allowed to do?

Authorization decides what an authenticated caller is allowed to touch. Two common patterns are roles, where a role like admin or viewer bundles a set of permissions, and scopes, where a token is limited to specific actions like read:orders.

The simplest way to remember the difference: authentication checks who you are, authorization checks what you can do. You need both, and confusing them is a common source of security holes.

A couple more basics

Hide your server details. By default, many servers add headers like Server: nginx/1.18.0 or X-Powered-By: Express. These quietly tell an attacker your exact stack and version, which makes it easy to look up known vulnerabilities for it. Strip these headers so your responses do not advertise what you run.

Get CORS right. CORS controls which websites are allowed to call your API from a browser. The lazy setup allows every origin with a wildcard, which lets any site make requests on behalf of a logged-in user. Instead, list only the origins you trust, and never pair a wildcard origin with credentials.

None of this is advanced. It is the baseline that is easy to skip when you are rushing to ship, which is exactly why senior engineers make a point of it.

 

Quick recap

Good API design comes down to a handful of habits:

  • Use the right HTTP verb and let the method carry the action
  • Name URLs after resources, not actions
  • Return honest status codes and clear, structured errors
  • Stay consistent, paginate large lists, and version from day one
  • Control who can call your API and what they can do

Together these give you good developer experience: an API people understand quickly, integrate without friction, and trust. That is what makes people adopt it and keep building on it. At a big company, dozens of teams may depend on that same API for years, which is why senior engineers treat API design as an architecture decision, not a detail. It is also exactly what a system design interviewer is looking for when they ask you to design an API.

So before you finish an endpoint, think about everyone on the other side. The cache, the retry, the AI agent, the teammate on another team, and the person maintaining this next year. Design for them, not just for the request working today.


I just built and launched xendr, a lightweight, embeddable API tester for developer docs. Developers can run your endpoints, inspect the success and error responses, and copy ready-to-use code snippets, all directly inside your documentation, without copying cURL into a separate tool like Postman or Swagger.

Top comments (0)