The first API review It evers ran was a disaster. It sats down with a team to go over their public-facing endpoints before launch. Forty-five minutes in, we'd counted seventeen different ways they were returning errors. Some returned {"error": "message"}, others returned {"error": {"code": "...", "message": "..."}}, others returned {"errors": [...]}. One endpoint returned a 500 for a missing field. Another returned 200 with an empty body for a successful delete. By the time we finished, the launch was postponed six weeks and the team was rebuilding half the API. They eventually shipped — but they shipped with the handwritten list of mistakes taped to the office wall. This article is that list, refined over the years.
Why This Matters
REST API mistakes are expensive in three ways. First, they create technical debt that compounds — every inconsistent endpoint makes the next one easier to do wrong. Second, they break integrations you don't control, and you find out about those breakages from angry emails or churned customers. Third, they make on-call painful — when everything looks the same, debugging is archaeology.
The article has watched teams spend entire quarters fixing API debt. One Series B company Many teams worked with in 2024 had accumulated so much inconsistency that they had to launch a "v2" rewrite that took seven engineers nine months. Their entire reason was that they'd shipped too many mistakes and too many partners were stuck on old, broken shapes. That rewrite cost more than $2 million in fully loaded engineering time. The original sin was small inconsistencies that nobody addressed for two years.
In 2026, the cost is amplified by AI tools. Cursor, Claude Code, Copilot, and similar assistants autocomplete against existing patterns. If your patterns are inconsistent, your generated code will be inconsistent, and humans won't catch it because they trust the suggestions. Bad APIs get worse, faster, with AI.
The Core Idea
The mistakes below are sorted roughly by impact — the ones at the top have caused the most damage In documented practice. Each one has a quick "what it looks like" and a "what to do instead." The following section will go deeper than a typical listicle because each mistake deserves understanding.
The 10 Mistakes
1. Returning 200 OK for errors
What it looks like:
HTTP/1.1 200 OK
Content-Type: application/json
{
"success": false,
"error": "User not found",
"code": "user_not_found"
}
Why it's bad: Status codes are the contract. Gateways, retry policies, monitoring, and AI clients all key off them. A 200 for an error gets treated as success by every automated system. Your monitoring will miss it, your retries won't trigger, and your debugging will be hell.
What to do instead: Use 4xx for client errors and 5xx for server errors. Always. Return the error in a consistent JSON envelope.
HTTP/1.1 404 Not Found
Content-Type: application/json
{
"error": {
"code": "user_not_found",
"message": "No user with id usr_123"
}
}
2. Using verbs in URLs
What it looks like:
POST /api/createUser
GET /api/getUserById?id=123
POST /api/deleteUser
Why it's bad: The HTTP method already supplies the verb. Using verbs in URLs forces ugly workarounds (/getUserById?id=123 instead of /users/123) and confuses clients that try to map REST to a clean resource hierarchy.
What to do instead:
POST /v1/users
GET /v1/users/123
DELETE /v1/users/123
The verb is in the method. The URL identifies the resource.
3. Inconsistent error formats
What it looks like:
// Endpoint A
{"error": "Invalid email"}
// Endpoint B
{"error": {"code": "invalid_email", "message": "Invalid email"}}
// Endpoint C
{"errors": [{"field": "email", "message": "Invalid"}]}
// Endpoint D
{"status": "error", "data": null, "message": "Invalid"}
Why it's bad: Clients can't write generic error handling. They have to special-case each endpoint. AI tools generate inconsistent code. Debugging is impossible.
What to do instead: Pick one shape and stick with it everywhere. Stripe's format is the de facto standard:
{
"error": {
"type": "validation_error",
"code": "invalid_email",
"message": "Email address is not valid",
"param": "email"
}
}
Document it. Lint it. Test for it.
4. Exposing database IDs
What it looks like:
{"id": 4781, "customer_id": 3921, "order_id": 18423}
Why it's bad: Auto-incrementing integer IDs leak information. Competitors can enumerate your customers. Attackers can guess URLs (/users/4780, /users/4782). And when you shard your database or migrate to UUIDs, every consumer breaks.
What to do instead: Use UUIDs (v4 for random, v7 for sortable). Prefix them with the resource type for log-grep-ability: usr_01H8XGJ..., ord_01H8XGJ.... Stripe, Linear, and Shopify all do this.
{"id": "usr_01H8XGJWB3NZFQ5PW6Q4S8MK7E", "customer_id": "cus_01H8XGJ..."}
5. Not implementing pagination (or doing it wrong)
What it looks like:
GET /v1/users
[{"id": "usr_1", ...}, {"id": "usr_2", ...}, /* ... 50,000 users ... */]
Why it's bad: Returns everything. Every time. Kills your database, your network, your CDN, and the client. A team in 2024 had an internal endpoint that returned 8 million rows because nobody added pagination. The first time a junior engineer ran a JOIN query against it, they brought down the API for 20 minutes.
What to do instead: Use cursor-based pagination with an opaque cursor. Skip-based pagination works for small datasets but breaks when records are inserted during traversal.
GET /v1/users?limit=20&cursor=usr_01H8X...
{
"data": [{"id": "usr_01H8X...", ...}, ...],
"pagination": {
"next_cursor": "usr_01H8X...",
"has_more": true
}
}
Always require an explicit limit. Always cap the maximum (50 or 100 is typical).
6. Skipping idempotency on POST
What it looks like:
POST /v1/payments
{"amount": 1000, "currency": "USD"}
→ 200 OK
{"id": "pay_123", "status": "succeeded"}
If the client times out and retries, they get a second payment.
Why it's bad: Networks are unreliable. Retries are correct behavior. Without idempotency, retries cause duplicates. This is how customers get double-charged, how emails get sent twice, how inventory gets decremented twice.
What to do instead: Require an Idempotency-Key header on all state-changing endpoints. Store the key with the response for at least 24 hours. Replay the stored response if you see the same key.
POST /v1/payments
Idempotency-Key: 8c0d9b8e-7e9c-4a91-b6e3-2e0b9e8a7c4d
Content-Type: application/json
{"amount": 1000, "currency": "USD"}
7. Ignoring HTTP semantics
What it looks like: Using POST for everything because it's easier. Returning 200 OK with HTML error pages. Setting cookies on a stateless API. Using GET for state-changing operations because it "feels safer."
Why it's bad: HTTP semantics exist for a reason. They tell caches, gateways, browsers, AI clients, and humans what to do. Violating them causes silent breakage.
What to do instead:
GETis safe and idempotent. Never side-effects.POSTcreates. Returns201 Createdwith aLocationheader.PUTreplaces. The whole resource.PATCHupdates partially.DELETEremoves. Returns204 No Contenton success.HEADandOPTIONSexist for a reason. Use them.
8. Not validating input
What it looks like:
app.post('/v1/users', (req, res) => {
const user = {
name: req.body.name, // could be undefined
email: req.body.email, // could be a number
age: req.body.age, // could be a string
};
users.save(user);
});
Why it's bad: Garbage in, garbage out. Unvalidated input causes crashes, security holes (SQL injection, XSS), and data corruption. A team It is widely observed that spent three weeks debugging an outage because a partner started sending email as a number; their code path assumed it was a string.
What to do instead: Validate at the boundary. Use a schema library. Reject bad input with 422 Unprocessed Entity and a precise error message.
import { z } from 'zod';
const CreateUserSchema = z.object({
email: z.string().email(),
fullName: z.string().min(1).max(100),
age: z.number().int().min(13).max(150),
});
app.post('/v1/users', (req, res) => {
const parsed = CreateUserSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(422).json({
error: {
code: 'invalid_user',
message: 'User payload failed validation',
details: parsed.error.flatten(),
},
});
}
// ... use parsed.data, which is typed and validated
});
9. Poor or missing authentication
What it looks like: Sending API keys as URL query parameters. Storing tokens in localStorage and shipping them in every request. Re-implementing OAuth. Not rotating keys. Not using HTTPS in dev. Not rate-limiting by user.
Why it's bad: Authentication is the front door. If it's broken, everything else is irrelevant. Leaked API keys are the number-one source of data breaches. Plaintext credentials in URLs end up in logs, browser history, and referrer headers.
What to do instead:
- Use bearer tokens in the
Authorizationheader. - Use HTTPS everywhere, including dev.
- Implement rate limiting per token, not just per IP.
- Rotate keys. Support multiple active keys per account.
- For OAuth 2.0, use a library, not a hand-roll.
- For service-to-service, prefer mTLS or signed requests.
10. Inconsistent or missing versioning
What it looks like: No version anywhere. Some endpoints use /v1/, others don't. New fields appear in responses without warning. Field types change silently (amount was a number, now it's a string).
Why it's bad: You will eventually need to make a breaking change. Without a versioning strategy, that change breaks every existing integration.
What to do instead:
- Version in the URL:
/v1/users,/v2/users. - Document what's a breaking change (field removal, type change, validation tightening).
- Add a
Deprecationheader when sunsetting old versions. - Maintain at least one previous major version for 6-12 months after the new one ships.
Bonus mistakes (that didn't make the top 10)
- Not setting
Content-Typetoapplication/jsonon responses. Some clients fail to parse the body. - Returning
nullfor missing fields instead of omitting them. Both are valid; pick one. - Using
*/*as the defaultAccept. It works but it's a sign of laziness. - Forgetting CORS configuration. Browser clients fail silently.
- Not supporting
HEADrequests. Monitoring tools can't ping your endpoints. - Logging sensitive data. PII, tokens, passwords — all show up in logs.
A Concrete Example: Putting It Together
Here's what a well-designed endpoint looks like in 2026, demonstrating the absence of all 10 mistakes:
import express from 'express';
import { z } from 'zod';
import crypto from 'crypto';
const app = express();
app.use(express.json());
const idempotencyStore = new Map<string, { status: number; body: unknown; createdAt: number }>();
const IDEMPOTENCY_TTL = 24 * 60 * 60 * 1000;
const CreateOrderSchema = z.object({
customer_id: z.string().uuid(),
line_items: z.array(z.object({
sku: z.string().min(1),
quantity: z.number().int().positive(),
unit_price_cents: z.number().int().nonnegative(),
})).min(1),
});
app.post('/v1/orders', (req, res) => {
// 1. Auth check — never skip
const auth = req.header('Authorization');
if (!auth?.startsWith('Bearer ')) {
res.set('WWW-Authenticate', 'Bearer realm="api"');
return res.status(401).json({
error: { code: 'unauthenticated', message: 'Missing bearer token' },
});
}
// 2. Idempotency check — replay if we've seen this key
const idemKey = req.header('Idempotency-Key');
if (!idemKey) {
return res.status(400).json({
error: { code: 'idempotency_key_required', message: 'POST requires Idempotency-Key header' },
});
}
const cached = idempotencyStore.get(idemKey);
if (cached && Date.now() - cached.createdAt < IDEMPOTENCY_TTL) {
return res.status(cached.status).json(cached.body);
}
// 3. Validate input — fail loud at the boundary
const parsed = CreateOrderSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(422).json({
error: {
code: 'invalid_order',
message: 'Order payload failed validation',
details: parsed.error.flatten(),
},
});
}
// 4. Use UUIDs — never expose auto-increment IDs
const order = {
id: `ord_${crypto.randomUUID()}`,
customer_id: parsed.data.customer_id,
line_items: parsed.data.line_items,
status: 'open',
created_at: new Date().toISOString(),
};
// 5. Cache the response for retries
idempotencyStore.set(idemKey, {
status: 201,
body: { data: order },
createdAt: Date.now(),
});
// 6. 201 with Location header — proper REST
res.set('Location', `/v1/orders/${order.id}`)
.status(201)
.json({ data: order });
});
Notice what's not here: no verbs in URLs, no 200 for errors, no exposed database IDs, no missing pagination (this is a create, not a list), no missing idempotency, no ignored HTTP semantics, no unvalidated input, no missing auth, no broken versioning. Every property has been thought about.
Common Pitfalls When Fixing These Mistakes
Trying to fix everything at once. Don't. Pick the highest-impact mistake (usually #1, returning 200 for errors) and fix it across your API in one sprint. Then pick the next one.
Linting without enforcement. A lint rule that doesn't run in CI is a suggestion, not a rule. Wire your conventions into CI so they fail the build.
Writing docs nobody reads. A style guide buried in Confluence won't be followed. Surface the rules in code review, in PR templates, in your error responses themselves.
When to Apply These
Always. These mistakes are not edge cases or opinions — they're patterns that have caused production outages, security incidents, and customer churn across hundreds of teams. Apply them from day one of a new API. If you have an existing API, fix them in order of impact.
Wrapping Up
Most REST API mistakes are not exotic. They're the same handful of issues, repeated by every team that doesn't write them down. The fix is to write them down, automate the checks, and make the conventions part of your code review process. That's it. That's the whole game.
Your next step: look at your current API and count how many of these 10 mistakes are present. Tally the cost. Pick the top three by impact and add linting or testing for them this sprint. The discipline compounds.
Further Reading
- Microsoft REST API Guidelines — a thorough checklist
- API Security Checklist — OWASP-aligned
- Stripe API errors — a great reference for error format
- Google API Design Guide — gRPC and REST patterns
- JSON:API specification — a stricter alternative to freeform REST
Hermes Smith
