DEV Community

Cover image for 10 Common API Design Mistakes
devTalk
devTalk

Posted on Originally published at dev-talk.com

10 Common API Design Mistakes

Last updated: 9 November 2026. See theupdate logat the end.

Most API problems don't show up on launch day. They show up months later, when a mobile app can't be updated because a field changed type, when a retry double-charges a customer, when a support ticket turns out to be one user reading another user's order. The code worked. The design was the bug, and by then a hundred clients depend on it.

This article covers ten mistakes that I see repeated in REST-style JSON APIs, with a small demo server behind each one so the symptom is real output rather than a claim. Every mistake comes with the fix, and where the fix is written down in a standard (HTTP semantics, Problem Details, the deprecation headers) I checked the standard instead of quoting from memory. The demo servers are plain Node, so nothing here depends on a framework; the PHP/Laravel snippets show how the fixes map to the stack many readers use.

The ten at a glance

# Mistake The fix in one line
1 Returning 200 OK for errors Use the status code, let clients branch on it
2 A different error shape on every endpoint One format, ideally application/problem+json
3 Ignoring HTTP method semantics Match method to safe / idempotent behavior
4 Unbounded lists and offset pagination Always paginate; use cursors for changing data
5 Retries that create duplicates Idempotency keys for non-idempotent writes
6 Breaking changes with no warning Additive changes, plus Deprecation and Sunset headers
7 No object-level authorization Check ownership on every object access
8 Exposing and accepting whole models Explicit response shapes and allow-listed input
9 No rate limiting Limit, and answer 429 with Retry-After
10 Sloppy data conventions: money, dates, naming Integer minor units, RFC 3339 timestamps, one naming style

1. Returning 200 OK for errors

It looks harmless and it is one of the most expensive habits. The endpoint returns 200 and puts the failure in the body:

$ curl -si localhost:4040/bad/users/999
HTTP/1.1 200 OK
Content-Type: application/json

{"success":false,"error":"User not found"}
Enter fullscreen mode Exit fullscreen mode

Everything between your server and the client's code treats that as a success: HTTP clients that only throw on 4xx/5xx, retry logic, monitoring that counts error rates from status codes, caches, and the browser's network panel. Your dashboards say the API is 100% healthy while users see "not found". Every client team also has to learn your private convention for finding the real outcome.

$ curl -si localhost:4040/good/users/999
HTTP/1.1 404 Not Found
Content-Type: application/problem+json

{"type":"https://api.example.com/problems/user-not-found","title":"User not found","status":404,"detail":"No user with id 999.","instance":"/good/users/999"}
Enter fullscreen mode Exit fullscreen mode

The status codes you need most are all defined in RFC 9110: 201 Created for a successful create, 204 No Content for success without a body, 400 for a malformed request, 401 and 403 for authentication and permission problems, 404, 409 Conflict, and 422 for a well-formed request that fails validation. The spec's current name for 422 is "Unprocessable Content". (My Node server still prints the older phrase "Unprocessable Entity" in the status line, which is only a label; the number is what matters.) Rate limiting uses 429, which comes from RFC 6585, and gets its own section below.

2. A different error format on every endpoint

Even with correct status codes, clients suffer when one endpoint returns {"error": "..."}, another {"message": "...", "code": 12}, and a third a bare string. Pick one shape. You don't need to invent it: RFC 9457, "Problem Details for HTTP APIs", defines one, and it replaced RFC 7807.

Per the RFC, the media type is application/problem+json and the standard members are all optional:

  • type: a URI that identifies the kind of problem. If it is absent, clients assume about:blank, meaning "nothing beyond the HTTP status".
  • status: the HTTP status code. Generators must send the same code in the actual response; consumers treat the member as advisory, since a proxy could change the real status.
  • title: a short summary of the problem type that stays the same across occurrences.
  • detail: an explanation of this specific occurrence, meant to help the client fix the problem.
  • instance: a URI identifying this particular occurrence.

You can add your own members, such as an errors array for per-field validation failures. Clients must ignore extension members they don't recognize, so adding them later doesn't break anyone. Keep detail useful to the caller and free of stack traces and SQL; the RFC says it is meant to help the client fix the problem rather than to provide debugging information.

Whatever your framework returns by default for a 404 or a validation failure, call two or three endpoints with curl -i and look at the shapes. If they differ, that is the work.

3. Ignoring HTTP method semantics

Two properties from RFC 9110 matter in practice. A safe method is essentially read-only: of the methods defined there, GET, HEAD, OPTIONS and TRACE are safe. An idempotent method has the same intended effect whether it is sent once or many times: PUT, DELETE and the safe methods are idempotent. POST is not on that list.

Why you care: clients, proxies and browsers act on these properties. A client may retry an idempotent request automatically after a connection failure, and a crawler or link prefetcher can follow a GET without anyone clicking. So these designs go wrong:

  • GET /orders/42/cancel or GET /users/7/delete: a state change behind a safe method. Anything that fetches the URL, including a prefetcher or a monitoring probe, triggers it.
  • POST /getUser, POST /updateUser, POST /deleteUser: everything funneled through POST, so no caching of reads and no automatic retry of anything.
  • A PUT that appends instead of replacing, so a retry applies the change twice and the "idempotent" label lies.

The straightforward mapping: GET /orders/42 reads, POST /orders creates, PUT /orders/42 replaces, DELETE /orders/42 removes. For actions that aren't CRUD, a sub-resource noun usually reads better than a verb in the path, for example POST /orders/42/cancellation. Don't fight over naming purity; the rule that matters is that the method's promised behavior is true.

4. Unbounded lists and offset pagination

The first version of GET /items returns everything. It works in development with 50 rows and takes the server down with 5 million. Always cap and paginate, even if you only have one client today.

The second trap is the pagination style. Offset pagination (?limit=3&offset=3) breaks when data changes between page requests. I fetched page one, deleted item 2, then fetched page two:

$ curl -s "localhost:4040/items?limit=3&offset=0"
{"data":[{"id":1,"name":"item1"},{"id":2,"name":"item2"},{"id":3,"name":"item3"}]}
$ curl -s -X DELETE localhost:4040/items/2        # 204
$ curl -s "localhost:4040/items?limit=3&offset=3"
{"data":[{"id":5,"name":"item5"},{"id":6,"name":"item6"},{"id":7,"name":"item7"}]}
Enter fullscreen mode Exit fullscreen mode

Item 4 never appeared. Deleting a row on an earlier page shifted everything up by one, and the next offset jumped over it. An insert does the opposite and shows a duplicate. Cursor (keyset) pagination asks for "the items after the last one I saw" and doesn't care what moved:

$ curl -s "localhost:4040/items?limit=3&after=0"
{"data":[{"id":1,...},{"id":2,...},{"id":3,...}],"next_cursor":3}
$ curl -s -X DELETE localhost:4040/items/2        # 204
$ curl -s "localhost:4040/items?limit=3&after=3"
{"data":[{"id":4,"name":"item4"},{"id":5,"name":"item5"},{"id":6,"name":"item6"}],"next_cursor":6}
Enter fullscreen mode Exit fullscreen mode

Same sequence, nothing skipped. (I shortened the first page's items with ... for width.) The Laravel documentation makes the same two points: offset pagination "may skip records or show duplicates" on data with frequent writes, and the offset clause scans through all previously matched rows, so cursor pagination performs better on large tables when the ordering columns are indexed.

// Laravel: cursor pagination (the ordering must include a unique column)
return Order::query()
    ->orderBy('id')
    ->cursorPaginate(25);
Enter fullscreen mode Exit fullscreen mode

The documented limits of cursor pagination: it only supports next/previous navigation, not "jump to page 40", and it requires ordering on at least one unique column, with no null values in the ordering columns. If users truly need page numbers (an admin table, say), offset is fine; just know you are trading consistency for convenience. Also set a maximum page size on the server so ?limit=100000 is not an option.

5. Retries that create duplicates

A client sends POST /orders and the connection drops before the response arrives. Did the order go through? The client can't know, so it retries, and now there may be two orders. Mobile networks, load balancers and impatient users make this routine, not rare. Without protection, the demo server does exactly what you fear:

$ curl -s -X POST localhost:4040/orders -d '{"sku":"A1","qty":1}'
{"id":1,"sku":"A1","qty":1}  [201]
$ curl -s -X POST localhost:4040/orders -d '{"sku":"A1","qty":1}'
{"id":2,"sku":"A1","qty":1}  [201]
$ curl -s localhost:4040/orders
{"count":2}
Enter fullscreen mode Exit fullscreen mode

The standard answer is an idempotency key: the client generates a unique value per logical operation and sends it with every retry, and the server remembers the result for that key. The IETF has an Internet-Draft for this, draft-ietf-httpapi-idempotency-key-header. It is a draft, not an RFC; revision 08 was the latest when I checked, and details may still change. What it currently describes:

  • The header is Idempotency-Key, a structured-field String, and the draft recommends a UUID or similar random value.
  • It targets non-idempotent methods, named as POST and PATCH.
  • A request that reuses a key with a different payload should get 422.
  • A retry that arrives while the first request is still processing should get 409.
  • A missing key, for an operation that requires one, should get 400.
  • Expiry is left to the service, which should document its policy.

My demo follows that behavior:

$ curl -si -X POST localhost:4040/orders -H 'Idempotency-Key: "k-123"' -d '{"sku":"A1","qty":1}'
HTTP/1.1 201 Created
{"id":1,"sku":"A1","qty":1}
$ curl -si -X POST localhost:4040/orders -H 'Idempotency-Key: "k-123"' -d '{"sku":"A1","qty":1}'
HTTP/1.1 201 Created
Idempotent-Replayed: true
{"id":1,"sku":"A1","qty":1}
$ curl -s localhost:4040/orders
{"count":1}

$ curl -si -X POST localhost:4040/orders -H 'Idempotency-Key: "k-123"' -d '{"sku":"B2","qty":5}'
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json

{"type":"about:blank","title":"Idempotency key reused with a different payload","status":422}
Enter fullscreen mode Exit fullscreen mode

(Idempotent-Replayed is my own demo header, not part of the draft.) The retry returned the original order and the count stayed at one. A real implementation needs more than my in-memory map:

  • Store the key, a hash of the request body, and the response in the database, with a unique constraint on the key (scoped to the user or API client, so one customer can't collide with another).
  • Insert the key before doing the work, so two simultaneous retries can't both pass the check. The loser gets the 409.
  • Write the key and the business change in the same transaction, or you can record a key for work that rolled back.
  • Expire old keys on a schedule you document.

I did not write a Laravel implementation for this article, so there is no copy-paste middleware here; the four rules above are the part that makes it correct, and the storage can be a plain table.

6. Breaking changes with no warning

Clients you don't control (mobile apps in particular) can't be updated the day you ship. What counts as breaking is mostly a matter of reasoning about what a client has to assume. Adding a new optional response field or a new endpoint is normally safe, as long as clients ignore unknown fields. Removing or renaming a field, changing its type (a number to a string, an object to an array), tightening validation, or changing what a status code means will break someone.

When you must break something, say so in the response itself. Two standards cover this. RFC 9745 defines a Deprecation response header whose value is a date written as a structured-field Date, for example @1688169599, and it can be in the past (already deprecated) or the future (planned). The older RFC 8594 defines Sunset, the time the resource is expected to stop responding, and RFC 9745 says the Sunset time must not be earlier than the Deprecation date. A Link header with rel="deprecation" points to the migration documentation. A response from the demo:

$ curl -si localhost:4042/v1/report
HTTP/1.1 200 OK
Deprecation: @1788912000
Sunset: Thu, 30 Sep 2027 23:59:59 GMT
Link: <https://api.example.com/docs/migrate-v2>; rel="deprecation"; type="text/html"
Enter fullscreen mode Exit fullscreen mode

Note the two date formats: Deprecation is a Unix timestamp prefixed with @ (here 1788912000 is 9 September 2026, 00:00 UTC), while Sunset uses the classic HTTP date. RFC 9745 itself points out that they differ for historical reasons. I got the weekday wrong on my first draft of that header, which is a good reason to generate these values with code instead of typing them.

The headers only help if someone reads them. Log which clients still call deprecated endpoints (by API key or user agent) so you can contact them before the sunset date, and keep the old version running until the numbers say it is safe. How you expose versions (a /v1/ path prefix, a header, a media type) is a trade-off with no universally right answer; a path prefix is the simplest to route, cache and debug, so it is a reasonable default.

7. No object-level authorization

Authentication tells you who is calling. Authorization has to check, on every request for a specific object, whether this caller may touch it. The classic mistake is checking only that the token is valid, then loading /orders/{id} for whatever ID the client sent. This is Broken Object Level Authorization, and it is API1 in the OWASP API Security Top 10 (2023 edition). In the demo, Ann's token reads Bob's order:

$ curl -s -H 'Authorization: Bearer tok-ann' localhost:4041/bad/orders/11
{"id":11,"owner":2,"total":9900} [200]

$ curl -s -H 'Authorization: Bearer tok-ann' localhost:4041/good/orders/11
{"title":"Not found"} [404]

$ curl -s -H 'Authorization: Bearer tok-ann' localhost:4041/good/orders/999
{"title":"Not found"} [404]
Enter fullscreen mode Exit fullscreen mode

The fixed version returns the same 404 for "exists but isn't yours" and "doesn't exist", so the response doesn't confirm which IDs are real. Whether to use 404 or 403 is a design decision: 403 is more honest and easier to debug but tells an attacker the object exists; 404 hides that. Pick one on purpose and apply it consistently.

Using sequential integer IDs makes the bug easy to exploit, because the attacker just counts upward, but switching to UUIDs doesn't fix the missing check; it only makes guessing harder. The fix is the check, and the cheapest way to make it hard to forget is to query through the owner:

// Scope the lookup to the authenticated user; a foreign id behaves like a missing one
$order = $request->user()->orders()->findOrFail($id);

// Or, if you prefer explicit policies (this returns 403 on denial):
$order = Order::findOrFail($id);
$this->authorize('view', $order);
Enter fullscreen mode Exit fullscreen mode

The same applies to functions, not only objects: an endpoint like DELETE /admin/users/5 needs its own role check (OWASP lists that as API5, Broken Function Level Authorization). For a wider checklist of what to lock down in a Laravel app.

8. Exposing and accepting whole models

Two mirror-image mistakes share one cause: the API shape is whatever the database model happens to look like.

On the way out, returning the model directly leaks columns nobody meant to publish. In the demo, GET /bad/me serializes the whole user record:

$ curl -s -H 'Authorization: Bearer tok-ann' localhost:4041/bad/me
{"id":1,"name":"Ann","email":"ann@example.test","is_admin":false,"password_hash":"$2y$secret"}

$ curl -s -H 'Authorization: Bearer tok-ann' localhost:4041/good/me
{"id":1,"name":"Ann","email":"ann@example.test"}
Enter fullscreen mode Exit fullscreen mode

On the way in, copying the request body onto the model is mass assignment. Ann sends one extra field:

$ curl -s -X PATCH -H 'Authorization: Bearer tok-ann' -d '{"name":"Ann","is_admin":true}' localhost:4041/good/me
{"ok":true,"is_admin":false}

$ curl -s -X PATCH -H 'Authorization: Bearer tok-ann' -d '{"name":"Ann","is_admin":true}' localhost:4041/bad/me
{"ok":true,"is_admin":true}
Enter fullscreen mode Exit fullscreen mode

On the unsafe endpoint she promoted herself to admin. OWASP groups both under API3:2023, Broken Object Property Level Authorization. The fix is the same in both directions: write the shape down. In Laravel that means a Form Request with explicit rules, using only validated() input, and an API Resource for output:

// app/Http/Requests/UpdateProfileRequest.php
class UpdateProfileRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'name'  => ['sometimes', 'string', 'max:100'],
            'email' => ['sometimes', 'email', 'max:255'],
        ];
    }
}

// app/Http/Resources/UserResource.php
class UserResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'    => $this->id,
            'name'  => $this->name,
            'email' => $this->email,
        ];
    }
}

// controller
public function update(UpdateProfileRequest $request)
{
    $request->user()->update($request->validated());

    return new UserResource($request->user()->refresh());
}
Enter fullscreen mode Exit fullscreen mode

Because validated() returns only the keys that have rules, an is_admin field in the body is dropped rather than saved. Treat the model's $fillable list as a second line of defense, not the first. The explicit resource also gives you a stable contract: adding a column to the table doesn't silently add a field to the public API.

9. No rate limiting

Without limits, one buggy client in a retry loop, one scraper or one credential-stuffing script can eat the capacity every other user shares. OWASP calls the general problem Unrestricted Resource Consumption (API4:2023). Limits also protect your database and your bill.

RFC 6585 defines 429 Too Many Requests: the client sent too many requests in a given time. The response should describe the problem and may include a Retry-After header saying how long to wait. Giving well-behaved clients a number is what turns a limit from a wall into a protocol:

$ for i in 1 2 3 4; do curl -s -o /dev/null -w "req $i -> %{http_code}\n" localhost:4042/search; done
req 1 -> 200
req 2 -> 200
req 3 -> 200
req 4 -> 429

$ curl -si localhost:4042/search
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 30

{"title":"Too Many Requests","status":429,"detail":"Limit is 3 requests per minute."}
Enter fullscreen mode Exit fullscreen mode

In Laravel, define a named limiter and attach it with the throttle middleware. This is the example from the framework documentation:

// app/Providers/AppServiceProvider.php, in boot()
RateLimiter::for('api', function (Request $request) {
    return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
Enter fullscreen mode Exit fullscreen mode

The docs state that Laravel returns a 429 automatically when the limit is exceeded, but they don't list which headers it sends, so run curl -i against your own app and confirm that Retry-After is present before you tell client developers they can rely on it. Think about what you key the limit on: per-IP limits punish everyone behind one office or mobile carrier address, while per-user limits don't stop anonymous floods, so most APIs need both. Stricter limits belong on login and password-reset endpoints, and the limit counters should live in a shared store like Redis when you run more than one server, otherwise each server counts separately.

10. Sloppy data conventions: money, dates and naming

These are small decisions that are very hard to change later.

Money as floating point. JSON numbers are doubles in many clients, and binary floating point can't represent most decimal amounts exactly:

$ node -e 'console.log(19.99*100); console.log(Math.round(19.99*100))'
1998.9999999999998
1999

$ php -r 'var_dump((int)(19.99*100));'
int(1998)
Enter fullscreen mode Exit fullscreen mode

That PHP line is the dangerous one: a cast truncates, and a customer is charged 1998 cents instead of 1999. Send money as an integer in the smallest currency unit plus an explicit currency, {"amount": 1999, "currency": "USD"}, or as a decimal string ("19.99") that the client parses with a decimal library. Be careful with currencies that don't have two minor digits; this is why the currency should travel with the amount.

Dates without a time zone. "2026-11-09 22:03" means nothing without an offset. Use RFC 3339 / ISO 8601 timestamps with an explicit offset or Z, and store UTC. The same instant in both forms, from Node:

2026-11-09T22:03:00+05:30      (what the user saw in Colombo)
2026-11-09T16:33:00.000Z       (the same instant in UTC)
Enter fullscreen mode Exit fullscreen mode

Mixed naming. userId in one response, user_id in another, UserID in a third. Pick one convention (snake_case or camelCase), and apply it to field names, query parameters and error members alike. Same for booleans (is_active everywhere, not active here and enabled there), plural collection names, and ID types. A lint step in CI over your OpenAPI file will catch most drift, and having that file at all is the other half of this mistake: an API without a machine-readable description gets documented by whoever last read the code.

Related Dev Talk articles


Originally published on DEV Talk.

Top comments (0)