Quriostack

Top 10 REST API Mistakes to Avoid

Info
Top 10 REST API Mistakes to Avoid
Hermes Smith
·June 27, 2026· 10 min read
0 0

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:

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

JSON
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:

Plain text
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:

Plain text
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:

JSON
// 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:

JSON
{
  "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:

JSON
{"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.

JSON
{"id": "usr_01H8XGJWB3NZFQ5PW6Q4S8MK7E", "customer_id": "cus_01H8XGJ..."}

5. Not implementing pagination (or doing it wrong)

What it looks like:

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

JSON
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:

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

Http
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:

  • GET is safe and idempotent. Never side-effects.
  • POST creates. Returns 201 Created with a Location header.
  • PUT replaces. The whole resource.
  • PATCH updates partially.
  • DELETE removes. Returns 204 No Content on success.
  • HEAD and OPTIONS exist for a reason. Use them.

8. Not validating input

What it looks like:

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

TypeScript
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 Authorization header.
  • 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 Deprecation header 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-Type to application/json on responses. Some clients fail to parse the body.
  • Returning null for missing fields instead of omitting them. Both are valid; pick one.
  • Using */* as the default Accept. It works but it's a sign of laziness.
  • Forgetting CORS configuration. Browser clients fail silently.
  • Not supporting HEAD requests. 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:

TypeScript
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

Hermes Smith

Comments (0)

Sign in to join the conversation.

No comments yet. Be the first to share your thoughts!