API Design Review: Common Mistakes and How to Fix Them
You've designed TaskFlow's URLs, methods, payloads, versioning strategy, OpenAPI spec, and security model. You know the theory deeply. Now we're going to stress-test it.
This is the capstone article. We're going to look at a broken API — a deliberately flawed version of TaskFlow, the kind of API that gets built when teams move fast without a shared design foundation — and audit it systematically. Every mistake has a name, a diagnosis, and a fix. By the end, you'll have internalized the patterns well enough to catch these problems in your own designs before they ship.
The broken API is realistic. Every mistake in it is drawn from patterns that appear regularly in production APIs across companies of all sizes. None of them are exotic edge cases — they're the errors that happen when developers who know HTTP but haven't studied REST design make reasonable-seeming choices that turn out to be wrong.
Let's find them all.
Quick Reference
The ten mistakes covered in this article:
| # | Mistake | Symptom |
|---|---|---|
| 1 | Verbs in URLs | /getTasks, /createTask, /deleteUser |
| 2 | Inconsistent naming conventions | due_date and createdAt in the same response |
| 3 | Wrong HTTP methods | POST for reads; GET for deletes |
| 4 | Missing or wrong status codes | 200 for errors; 404 for validation failures |
| 5 | No envelope or inconsistent envelope | Bare arrays vs wrapped objects, no meta |
| 6 | Deeply nested URLs | /orgs/5/projects/42/tasks/99/comments/7 |
| 7 | Leaking internal details | Stack traces, table names, database errors in responses |
| 8 | All-or-nothing validation | Returning one error at a time |
| 9 | No versioning strategy | Breaking changes shipped directly to /tasks |
| 10 | Incomplete security model | Missing auth on endpoints; 403 where 404 is correct |
The review process (use on any API):
- Audit the URL surface — nouns only, consistent naming, appropriate nesting depth
- Audit method semantics — correct verb for each operation, idempotency preserved
- Audit status codes — correct family, no 2xx errors, 401 vs 403 correct
- Audit response shape — consistent envelope, consistent naming, complete error format
- Audit versioning — strategy chosen, breaking vs non-breaking changes identified
- Audit security — all endpoints covered, 401/403/404 used correctly
See also:
- All previous articles in this module — this article synthesizes everything
- The production checklist at the end of this article
Version Information
This article synthesizes:
- OpenAPI 3.1.0: spec.openapis.org/oas/v3.1.0
- REST architectural constraints: Fielding, 2000
- HTTP semantics: RFC 7231
Last verified: June 2025
What You Need to Know First
Required reading — all previous articles in this module:
- REST APIs: What They Are and How They Work
- Resource Design: URLs, Nouns, and Hierarchies
- HTTP Methods and Status Codes: The Full Picture
- Request & Response Design: Payloads, Headers, and Conventions
- API Versioning: Strategies and Tradeoffs
- OpenAPI Specification: Documenting Your API
- Authentication and Authorization: API Security Patterns
This is the capstone. All seven prior articles are prerequisites — the review draws on every concept from all of them.
What We'll Cover in This Article
By the end of this guide, you'll understand:
- How to identify the ten most common REST API design mistakes
- The systematic process for auditing any API
- How to fix each mistake — not just name it
- What the corrected, production-ready TaskFlow spec looks like
- A portable checklist you can apply to any API you design or review
The "Before" API: A Deliberately Broken TaskFlow
Here's a simplified TaskFlow API built by a team that moved fast without a shared design foundation. It works — requests get responses, data gets stored and retrieved — but it has ten distinct design problems that will cause pain for clients, maintainability issues for the team, and security risks in production.
Read through it. See how many problems you can spot before we start the audit.
# ❌ BROKEN TaskFlow API — DO NOT USE
# This spec contains intentional mistakes for the audit exercise
openapi: 3.1.0
info:
title: TaskFlow API (Broken Version)
version: "1"
paths:
# Task endpoints
/getTasks:
get:
operationId: getTasks
summary: Get all tasks
parameters:
- name: user_id
in: query
schema:
type: string
responses:
'200':
description: Tasks retrieved
content:
application/json:
schema:
type: array # Bare array — no envelope
items:
type: object
properties:
id:
type: integer # Integer IDs
task_name: # Different field name than other endpoints
type: string
assignee_id:
type: string
Status: # Capitalized
type: string
created_at: # snake_case here
type: string
dueDate: # camelCase here — inconsistent
type: string
/createTask:
post:
operationId: createTask
summary: Create a task
requestBody:
content:
application/json:
schema:
type: object
properties:
task_name:
type: string
assignee_id:
type: string
responses:
'200': # Should be 201
description: Task created
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
task_id:
type: integer
message:
type: string
example: Task created successfully
/getTask:
get:
operationId: getTask
summary: Get a task by ID
parameters:
- name: id
in: query # ID in query string instead of path
schema:
type: integer
responses:
'200':
description: Task found
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: object # Inconsistent — sometimes wrapped, sometimes not
'200': # Duplicate 200 — second one for error case
description: Task not found
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
example: false
error:
type: string
example: "Task not found"
/updateTask:
post: # POST for an update — should be PATCH
operationId: updateTask
summary: Update a task
requestBody:
content:
application/json:
schema:
type: object
properties:
id:
type: integer
task_name:
type: string
Status:
type: string
responses:
'200':
description: Updated
/deleteTask:
get: # GET for a delete — should be DELETE
operationId: deleteTask
summary: Delete a task
parameters:
- name: id
in: query
schema:
type: integer
responses:
'200':
description: Deleted
/tasks/{task_id}/getAllComments:
get:
operationId: getAllComments
summary: Get all comments for a task
parameters:
- name: task_id
in: path
required: true
schema:
type: integer
responses:
'200':
description: Comments
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
comments: # Non-standard envelope key
type: array
items:
type: object
/addComment:
post:
operationId: addComment
summary: Add comment to task
requestBody:
content:
application/json:
schema:
type: object
properties:
task_id:
type: integer
comment_text:
type: string
responses:
'200':
description: Comment added
content:
application/json:
schema:
type: object
properties:
result:
type: string
example: "ok"
/users/{userId}/projects/{projectId}/tasks/{task_id}/comments/{comment_id}:
delete:
operationId: deleteComment
summary: Delete a specific comment
parameters:
- name: userId
in: path
required: true
schema:
type: integer
- name: projectId
in: path
required: true
schema:
type: integer
- name: task_id
in: path
required: true
schema:
type: integer
- name: comment_id
in: path
required: true
schema:
type: integer
responses:
'200':
description: Comment deleted
content:
application/json:
schema:
type: object
properties:
message:
type: string
example: "Comment 7 deleted from task 99 in project 42 by user 5"
# No global security defined
# No components section — everything inline, nothing reusable
# No versioning strategy
How many did you find? Let's go through them one by one.
The Audit: Ten Mistakes, Ten Fixes
Mistake 1: Verbs in URLs
Diagnosis:
/getTasks # verb: "get"
/createTask # verb: "create"
/updateTask # verb: "update"
/deleteTask # verb: "delete"
/addComment # verb: "add"
/tasks/{id}/getAllComments # verb: "getAll"
Every endpoint uses a verb. The HTTP method carries action semantics — the URL should identify the resource, not describe what to do with it. This API is doing both, doubling up on meaning and creating an inconsistent surface where new developers have to guess the naming pattern (createTask but is there a makeProject? a buildComment?).
Fix:
# ✅ After — nouns identify resources
GET /v1/tasks # was /getTasks
POST /v1/tasks # was /createTask
GET /v1/tasks/{taskId} # was /getTask?id=99
PATCH /v1/tasks/{taskId} # was /updateTask (POST)
DELETE /v1/tasks/{taskId} # was /deleteTask (GET)
GET /v1/tasks/{taskId}/comments # was /tasks/{id}/getAllComments
POST /v1/tasks/{taskId}/comments # was /addComment
The resource is the noun (/tasks, /comments). The HTTP method is the verb (GET, POST, PATCH, DELETE). Clients can infer the URL pattern for any resource in the API without consulting documentation.
Mistake 2: Inconsistent Naming Conventions
Diagnosis:
# Same response object uses three different conventions
task_name: # snake_case
assignee_id: # snake_case
Status: # PascalCase (!)
created_at: # snake_case
dueDate: # camelCase
And comparing across endpoints:
/getTasks response: task_name
/createTask request: task_name
/getTask response: (no task_name field — different schema entirely)
Three naming styles in one object. Different field names for the same concept across endpoints. This forces every client to write field-specific mapping code rather than a single deserializer that works everywhere.
Fix:
Choose one convention — TaskFlow uses camelCase — and apply it without exception to every field name in every request and response body:
# ✅ After — camelCase throughout, consistent field names
properties:
id:
type: string
title: # "task_name" → "title" (consistent with projects)
type: string
assigneeId: # "assignee_id" → camelCase
type: string
status: # "Status" → lowercase camelCase
type: string
createdAt: # "created_at" → camelCase
type: string
format: date-time
dueDate: # already camelCase — keep it
type: string
format: date
The field name for the task's title is now title everywhere — in GET responses, POST request bodies, PATCH request bodies, and nested inside comment responses. Same field, same name, no exceptions.
Mistake 3: Wrong HTTP Methods
Diagnosis:
/updateTask:
post: # ❌ POST for an update — should be PATCH or PUT
/deleteTask:
get: # ❌ GET for a delete — catastrophic
POST for an update violates the uniform interface — POST is for creation. Any HTTP client that logs "safe" operations for retry purposes will incorrectly treat this update as retryable.
GET for a delete is the most dangerous mistake in this list. GET requests are:
- Cached — a CDN might respond to a repeated GET with a cached response, silently skipping the delete
- Preloaded — browsers speculatively prefetch links; a browser could delete resources without user action
- Logged in full — proxy and CDN logs capture GET request URLs, meaning the ID of every deleted resource is in plaintext logs
- Retried automatically — HTTP clients retry failed GETs, potentially executing the delete multiple times
Fix:
# ✅ After — correct methods
PATCH /v1/tasks/{taskId} # Update (partial) — was POST /updateTask
DELETE /v1/tasks/{taskId} # Delete — was GET /deleteTask
Match the HTTP method to the operation's semantics. PATCH for partial update. DELETE for deletion. Never use GET for any state-changing operation.
Mistake 4: Missing and Wrong Status Codes
Diagnosis:
# Creating a resource returns 200, not 201
/createTask POST → 200 OK
# Both success and "not found" return 200
/getTask GET → 200 (success)
/getTask GET → 200 (task not found — same status code, different body)
# Deleting returns 200, not 204
/deleteTask GET → 200 OK
The second case is the worst. Two different outcomes — task found and task not found — return the same status code. Every client must parse the body to determine whether the request succeeded. HTTP clients, monitoring tools, and error trackers all assume 200 means success. Silent failures become invisible.
Fix:
# ✅ After — correct status codes for every outcome
POST /v1/tasks → 201 Created (resource created)
GET /v1/tasks/{taskId} → 200 OK (task found)
GET /v1/tasks/{taskId} → 404 Not Found (task doesn't exist)
PATCH /v1/tasks/{taskId} → 200 OK (updated, returns task)
DELETE /v1/tasks/{taskId} → 204 No Content (deleted, no body)
POST /v1/tasks/{taskId}/comments → 201 Created
Status codes are the primary signal. The body provides detail. An HTTP client should be able to determine whether a request succeeded without reading the body at all.
Mistake 5: No Envelope / Inconsistent Envelope
Diagnosis:
# /getTasks returns a bare array
type: array
items: ...
# /getTask sometimes returns wrapped data
properties:
success: boolean
data: object
# /addComment returns a custom key
properties:
result: string # "ok"
# /getAllComments wraps with non-standard key
properties:
success: boolean
comments: array # custom key — not "data"
Four endpoints, four different response shapes. A client can't write a single response handler — each endpoint needs its own parsing logic. Adding metadata (pagination cursors, request IDs) to the bare array response later is impossible without a breaking change.
Fix:
Apply the envelope pattern universally. Single resources go in data. Collections go in data with meta for pagination. Errors go in error. No exceptions:
# ✅ After — consistent envelope everywhere
# Collection
{
"data": [...],
"meta": { "cursor": "...", "hasMore": true, "limit": 20 }
}
# Single resource
{
"data": { "id": "task_99", ... }
}
# Create response
{
"data": { "id": "task_99", ... } # Full resource, not just the ID
}
# Delete response
# 204 No Content — no body at all
# Error response
{
"error": {
"code": "not_found",
"message": "Task 'task_99' does not exist.",
"details": []
}
}
Notice the broken API's create response returned only { "success": true, "task_id": 123 }. The client would need to make a second request to get the full task. The corrected version returns the complete created resource in the 201 response — eliminating the round trip.
Mistake 6: Deeply Nested URLs
Diagnosis:
/users/{userId}/projects/{projectId}/tasks/{task_id}/comments/{comment_id}:
delete: ...
Four levels of nesting to reach a comment. Problems:
- The URL is 60+ characters before any IDs
- A wrong parent ID causes 404 with no indication which level failed
- The path parameters use inconsistent naming (
userIdvstask_idvscomment_id) - A comment has a globally unique ID — the user and project context is unnecessary
Fix:
Comments are owned by tasks. Tasks are owned by projects. But a comment's ID is globally unique — you don't need the full ancestry to identify it. Flatten:
# ✅ After — two levels maximum
# Comments scoped to a task (appropriate nesting)
DELETE /v1/tasks/{taskId}/comments/{commentId}
# If you need a global comment endpoint (for admin or cross-task lookups)
DELETE /v1/comments/{commentId}
The rule: nest resources when the parent context is required for scoping or security. Don't nest just to express a relationship that's already captured in the data model. Comments require a task context for creation and listing — but deletion only requires the comment ID.
Also fixed: path parameter naming is now consistent — all use camelCase (taskId, commentId), matching the response body field naming convention.
Mistake 7: Leaking Internal Details
Diagnosis:
This mistake doesn't appear explicitly in the spec — it shows up in the implementation. But the spec gives hints:
# The response includes internal IDs and raw database error messages
example: "Comment 7 deleted from task 99 in project 42 by user 5"
In practice, this kind of API also tends to produce responses like:
{
"success": false,
"error": "ERROR: duplicate key value violates unique constraint \"users_email_key\" DETAIL: Key (email)=(alex@example.com) already exists. (PostgreSQL 14.5)"
}
Or on server errors:
{
"success": false,
"error": "TypeError: Cannot read property 'id' of undefined\n at /app/handlers/tasks.js:47:23\n at Layer.handle..."
}
Both expose:
- Database technology (PostgreSQL)
- Internal schema (constraint names, table names)
- File paths and line numbers
- Stack traces
This information is useless to API clients and a roadmap for attackers.
Fix:
// ✅ Error handler — never leak internals
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
// Log full details server-side (for your team)
logger.error({
message: err.message,
stack: err.stack,
requestId: req.headers["x-request-id"],
path: req.path,
method: req.method,
});
// Return only what the client needs (sanitized, actionable)
if (err instanceof ValidationError) {
return res.status(422).json({
error: {
code: "validation_error",
message: "The request contains invalid fields.",
details: err.details, // Field-level errors only — no internals
},
});
}
if (err instanceof UniqueConstraintError) {
return res.status(409).json({
error: {
code: "conflict",
message: "A resource with these details already exists.",
details: [],
// ❌ NOT: "duplicate key value violates unique constraint users_email_key"
},
});
}
// Default: generic 500 — no internal details
return res.status(500).json({
error: {
code: "internal_error",
message: "An unexpected error occurred. Please try again.",
details: [],
},
});
});
The test: could an attacker use any field in this response to learn something useful about your infrastructure? If yes, remove it.
Mistake 8: All-or-Nothing Validation
Diagnosis:
The broken API's create endpoint:
/createTask POST:
responses:
"200":
properties:
success: boolean
task_id: integer
message: string
No validation errors are defined at all. In implementation, this API validates the first field, returns the error, validates nothing else. A client with three invalid fields submits the form, gets an error for field one, fixes it, submits again, gets an error for field two, fixes it, submits again, gets an error for field three. Three round trips for one form submission.
Fix:
Collect all validation errors before responding. Return them all in a single 422 response:
// ✅ Full validation — collect all errors first
async function createTask(req: Request, res: Response) {
const errors: ErrorDetail[] = [];
if (!req.body.title || req.body.title.trim() === "") {
errors.push({
field: "title",
message: "title is required and cannot be empty.",
});
} else if (req.body.title.length > 500) {
errors.push({
field: "title",
message: "title cannot exceed 500 characters.",
value: req.body.title.length,
});
}
if (req.body.dueDate) {
const due = new Date(req.body.dueDate);
if (isNaN(due.getTime())) {
errors.push({
field: "dueDate",
message: "dueDate must be a valid ISO 8601 date (YYYY-MM-DD).",
value: req.body.dueDate,
});
} else if (due < new Date()) {
errors.push({
field: "dueDate",
message: "dueDate must be in the future.",
value: req.body.dueDate,
});
}
}
if (req.body.assigneeId) {
const assignee = await db.users.findById(req.body.assigneeId);
if (!assignee) {
errors.push({
field: "assigneeId",
message: `User '${req.body.assigneeId}' does not exist.`,
value: req.body.assigneeId,
});
}
}
// Return ALL errors at once — never stop at the first one
if (errors.length > 0) {
return res.status(422).json({
error: {
code: "validation_error",
message: "The request contains invalid fields.",
details: errors,
},
});
}
// All valid — proceed
const task = await db.tasks.create({ ... });
return res.status(201).json({ data: serializeTask(task) });
}
The spec change:
# ✅ After — validation errors documented
/v1/tasks:
post:
responses:
"201":
description: Task created
"422":
$ref: "#/components/responses/ValidationError"
# ValidationError includes the details array with all field errors
Mistake 9: No Versioning Strategy
Diagnosis:
info:
version: "1"
paths:
/getTasks: # No version prefix
/createTask: # No version prefix
No versioning strategy. The spec declares a version ("1") but the API surface has no version in the URL, no version header, no mechanism at all for making breaking changes without breaking existing clients.
When the team needs to rename task_name to title — which they will — they have two choices: break all existing clients immediately, or live with the inconsistency forever.
Fix:
Apply the URL path versioning strategy chosen in Article 5. All endpoints get a /v1/ prefix. The strategy is documented in the spec and the API's README:
# ✅ After — versioned URL structure
servers:
- url: https://api.taskflow.com
description: Production
paths:
/v1/tasks:
get: ...
post: ...
/v1/tasks/{taskId}:
get: ...
patch: ...
delete: ...
/v1/tasks/{taskId}/comments:
get: ...
post: ...
/v1/tasks/{taskId}/comments/{commentId}:
patch: ...
delete: ...
The version prefix is in the URL, visible in every request, and routes cleanly through any API gateway or load balancer. When v2 launches, /v1/ keeps running until all clients have migrated.
Mistake 10: Incomplete Security Model
Diagnosis:
# No global security defined
# No securitySchemes in components
# No security property on any individual operation
The broken API has no security model at all. Every endpoint is implicitly public. In practice, the team probably added authentication logic inside individual handlers — inconsistently, without a shared middleware, with some endpoints accidentally left unprotected.
The missing security model also means 401 and 403 aren't used correctly. The broken API returns 200 with { "success": false, "error": "Unauthorized" } — an error disguised as a success.
Fix:
Define the complete security model at the spec level, enforce it via middleware, and apply it correctly to every endpoint:
# ✅ After — complete security model
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
# Global — all endpoints require auth by default
security:
- BearerAuth: []
paths:
# Auth endpoints explicitly opt out
/v1/auth/login:
post:
security: [] # No auth required
/v1/auth/refresh:
post:
security: []
# All other endpoints inherit global BearerAuth
/v1/tasks:
get:
# inherits BearerAuth — no override needed
responses:
"401":
$ref: "#/components/responses/Unauthorized" # Now documented
"200": ...
/v1/tasks/{taskId}:
delete:
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
"204":
description: Task deleted
And in the implementation — middleware enforces auth at the router level, not handler-by-handler:
// ✅ Auth applied at the router level — can't accidentally skip it
const v1Router = express.Router();
// All v1 routes require auth — applied once, not per-handler
v1Router.use(requireAuth);
// Opt-out for auth endpoints specifically
v1Router.post("/auth/login", loginHandler); // before requireAuth
v1Router.post("/auth/refresh", refreshHandler);
// Everything else inherits requireAuth
v1Router.get("/tasks", listTasksHandler);
v1Router.post("/tasks", createTaskHandler);
v1Router.get("/tasks/:taskId", getTaskHandler);
v1Router.patch("/tasks/:taskId", updateTaskHandler);
v1Router.delete("/tasks/:taskId", deleteTaskHandler);
The "After" API: The Corrected Spec
Here's the complete corrected spec — every mistake addressed, every convention applied:
openapi: 3.1.0
info:
title: TaskFlow API
description: |
Production REST API for the TaskFlow task management product.
## Authentication
All endpoints require a Bearer JWT token unless marked `security: []`.
Obtain tokens via `POST /v1/auth/login`.
## Versioning
URL path versioning — all endpoints prefixed with `/v1/`.
## Conventions
- Field names: camelCase throughout
- Dates: ISO 8601 (YYYY-MM-DD for dates, YYYY-MM-DDTHH:MM:SSZ for timestamps)
- IDs: string prefixed identifiers (e.g., task_99, proj_5, user_42)
- Responses: always wrapped in `{ "data": ... }` for resources,
`{ "data": [...], "meta": {...} }` for collections
- Errors: always `{ "error": { "code", "message", "details" } }`
version: 1.0.0
contact:
name: TaskFlow API Support
email: api@taskflow.com
servers:
- url: https://api.taskflow.com
description: Production
- url: https://api.staging.taskflow.com
description: Staging
- url: http://localhost:3000
description: Local development
tags:
- name: Auth
description: Authentication and token management
- name: Tasks
description: Task creation, retrieval, and management
- name: Comments
description: Comments on tasks
- name: Projects
description: Project management
- name: Users
description: User accounts and profiles
security:
- BearerAuth: []
paths:
# ── Auth ──────────────────────────────────────
/v1/auth/login:
post:
operationId: login
summary: Log in
tags: [Auth]
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email, password]
properties:
email:
type: string
format: email
password:
type: string
format: password
minLength: 8
responses:
"200":
description: Login successful
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
accessToken:
type: string
refreshToken:
type: string
expiresIn:
type: integer
example: 900
"401":
$ref: "#/components/responses/Unauthorized"
/v1/auth/refresh:
post:
operationId: refreshToken
summary: Refresh access token
tags: [Auth]
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [refreshToken]
properties:
refreshToken:
type: string
responses:
"200":
description: New tokens issued
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
accessToken:
type: string
refreshToken:
type: string
expiresIn:
type: integer
example: 900
"401":
$ref: "#/components/responses/Unauthorized"
# ── Tasks ─────────────────────────────────────
/v1/tasks:
get:
operationId: listTasks
summary: List tasks
tags: [Tasks]
parameters:
- name: status
in: query
schema:
type: string
enum: [open, in_progress, complete, archived]
- name: assigneeId
in: query
schema:
type: string
- name: projectId
in: query
schema:
type: string
- name: sort
in: query
schema:
type: string
default: "-createdAt"
- $ref: "#/components/parameters/LimitParam"
- $ref: "#/components/parameters/CursorParam"
responses:
"200":
description: Paginated list of tasks
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: "#/components/schemas/Task"
meta:
$ref: "#/components/schemas/PaginationMeta"
"401":
$ref: "#/components/responses/Unauthorized"
post:
operationId: createTask
summary: Create a task
tags: [Tasks]
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateTaskRequest"
responses:
"201":
description: Task created
headers:
Location:
schema:
type: string
example: /v1/tasks/task_99
content:
application/json:
schema:
type: object
properties:
data:
$ref: "#/components/schemas/Task"
"401":
$ref: "#/components/responses/Unauthorized"
"422":
$ref: "#/components/responses/ValidationError"
/v1/tasks/{taskId}:
get:
operationId: getTask
summary: Get a task
tags: [Tasks]
parameters:
- $ref: "#/components/parameters/TaskIdParam"
responses:
"200":
description: The requested task
content:
application/json:
schema:
type: object
properties:
data:
$ref: "#/components/schemas/Task"
"401":
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
patch:
operationId: updateTask
summary: Update a task
tags: [Tasks]
parameters:
- $ref: "#/components/parameters/TaskIdParam"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateTaskRequest"
responses:
"200":
description: Task updated
content:
application/json:
schema:
type: object
properties:
data:
$ref: "#/components/schemas/Task"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
"422":
$ref: "#/components/responses/ValidationError"
delete:
operationId: deleteTask
summary: Delete a task
description: Permanently deletes the task and all its comments.
tags: [Tasks]
parameters:
- $ref: "#/components/parameters/TaskIdParam"
responses:
"204":
description: Task deleted
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
# ── Comments ──────────────────────────────────
/v1/tasks/{taskId}/comments:
get:
operationId: listComments
summary: List comments on a task
tags: [Comments]
parameters:
- $ref: "#/components/parameters/TaskIdParam"
- $ref: "#/components/parameters/LimitParam"
- $ref: "#/components/parameters/CursorParam"
responses:
"200":
description: Paginated list of comments
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: "#/components/schemas/Comment"
meta:
$ref: "#/components/schemas/PaginationMeta"
"401":
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
post:
operationId: createComment
summary: Add a comment to a task
tags: [Comments]
parameters:
- $ref: "#/components/parameters/TaskIdParam"
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [body]
properties:
body:
type: string
minLength: 1
maxLength: 10000
responses:
"201":
description: Comment created
headers:
Location:
schema:
type: string
content:
application/json:
schema:
type: object
properties:
data:
$ref: "#/components/schemas/Comment"
"401":
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
"422":
$ref: "#/components/responses/ValidationError"
/v1/tasks/{taskId}/comments/{commentId}:
patch:
operationId: updateComment
summary: Edit a comment
tags: [Comments]
parameters:
- $ref: "#/components/parameters/TaskIdParam"
- $ref: "#/components/parameters/CommentIdParam"
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [body]
properties:
body:
type: string
minLength: 1
maxLength: 10000
responses:
"200":
description: Comment updated
content:
application/json:
schema:
type: object
properties:
data:
$ref: "#/components/schemas/Comment"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
"422":
$ref: "#/components/responses/ValidationError"
delete:
operationId: deleteComment
summary: Delete a comment
tags: [Comments]
parameters:
- $ref: "#/components/parameters/TaskIdParam"
- $ref: "#/components/parameters/CommentIdParam"
responses:
"204":
description: Comment deleted
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
components:
schemas:
Task:
type: object
required: [id, title, status, createdAt, updatedAt]
properties:
id:
type: string
readOnly: true
example: task_99
title:
type: string
minLength: 1
maxLength: 500
example: Review pull request #204
assigneeId:
type: string
nullable: true
example: user_42
projectId:
type: string
nullable: true
example: proj_5
status:
type: string
enum: [open, in_progress, complete, archived]
default: open
dueDate:
type: string
format: date
nullable: true
example: "2025-08-01"
createdAt:
type: string
format: date-time
readOnly: true
example: "2025-06-15T10:30:00Z"
updatedAt:
type: string
format: date-time
readOnly: true
example: "2025-06-15T10:30:00Z"
CreateTaskRequest:
type: object
required: [title]
properties:
title:
type: string
minLength: 1
maxLength: 500
assigneeId:
type: string
nullable: true
projectId:
type: string
nullable: true
dueDate:
type: string
format: date
nullable: true
UpdateTaskRequest:
type: object
properties:
title:
type: string
minLength: 1
maxLength: 500
assigneeId:
type: string
nullable: true
status:
type: string
enum: [open, in_progress, complete, archived]
dueDate:
type: string
format: date
nullable: true
Comment:
type: object
required: [id, taskId, authorId, body, createdAt, updatedAt]
properties:
id:
type: string
readOnly: true
example: comment_7
taskId:
type: string
readOnly: true
example: task_99
authorId:
type: string
readOnly: true
example: user_42
body:
type: string
minLength: 1
maxLength: 10000
example: Looks good to me — ready to merge.
createdAt:
type: string
format: date-time
readOnly: true
example: "2025-06-15T11:00:00Z"
updatedAt:
type: string
format: date-time
readOnly: true
example: "2025-06-15T11:00:00Z"
PaginationMeta:
type: object
required: [cursor, hasMore, limit]
properties:
cursor:
type: string
nullable: true
example: dXNlcjox
hasMore:
type: boolean
example: true
limit:
type: integer
example: 20
ErrorResponse:
type: object
required: [error]
properties:
error:
type: object
required: [code, message, details]
properties:
code:
type: string
example: validation_error
message:
type: string
example: The request contains invalid fields.
details:
type: array
items:
type: object
properties:
field:
type: string
message:
type: string
value: {}
parameters:
TaskIdParam:
name: taskId
in: path
required: true
schema:
type: string
example: task_99
CommentIdParam:
name: commentId
in: path
required: true
schema:
type: string
example: comment_7
LimitParam:
name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 100
default: 20
CursorParam:
name: cursor
in: query
schema:
type: string
responses:
Unauthorized:
description: Authentication required
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
example:
error:
code: authentication_required
message: Provide a valid Bearer token.
details: []
Forbidden:
description: Insufficient permissions
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
example:
error:
code: insufficient_permissions
message: You do not have permission to perform this action.
details: []
NotFound:
description: Resource not found
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
example:
error:
code: not_found
message: The requested resource does not exist.
details: []
ValidationError:
description: Validation failed
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
example:
error:
code: validation_error
message: The request contains invalid fields.
details:
- field: dueDate
message: Must be a future date.
value: "2020-01-01"
RateLimited:
description: Rate limit exceeded
headers:
Retry-After:
schema:
type: integer
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: |
JWT access token. Obtain via POST /v1/auth/login.
Access tokens expire after 15 minutes.
Use POST /v1/auth/refresh to obtain a new token.
The Production Checklist
Use this checklist on any API you design or review. Every item maps back to a concept from this module.
URLs and Resources
- All URLs use nouns — no verbs anywhere
- Resource names are plural (
/tasks, not/task) - URLs are lowercase with hyphens for multi-word names
- IDs are in the path, not the query string (
/tasks/99, not/tasks?id=99) - Nesting depth is two levels maximum
- URL structure is versioned (
/v1/prefix or equivalent)
HTTP Methods
-
GETfor all reads — no state changes on GET -
POSTfor creation only (or triggering processes) -
PUTfor full replacement;PATCHfor partial update -
DELETEfor deletion — neverGETfor deletion - Safe and idempotent methods are used correctly
Status Codes
-
201 Created(withLocationheader) for successful creation -
204 No Contentfor successful deletion -
400or422for validation failures — never404 -
401for unauthenticated requests;403for unauthorized requests -
404for missing resources (and for authorized resources the user can't see) -
409for state conflicts -
429withRetry-Afterfor rate limiting - No
200responses with error bodies — anywhere
Request and Response Design
- Consistent field naming convention throughout (camelCase for TaskFlow)
- ISO 8601 dates and timestamps throughout
- Null fields included explicitly — never silently omitted
- Consistent envelope:
{ "data": ... }for resources,{ "data": [...], "meta": {...} }for collections - Error responses always use
{ "error": { "code", "message", "details" } } - Stable machine-readable error codes (not freeform strings)
- All validation errors returned at once — not one at a time
- No internal details in error responses (no stack traces, table names, or database messages)
- Created resources returned in full in the
201response — no second fetch needed - Pagination implemented (cursor-based for growing collections)
- Default sort order defined for every collection endpoint
Versioning
- Versioning strategy chosen and documented before launch
- All endpoints carry the version identifier
- Breaking changes go in a new version — non-breaking changes deploy in-place
- Deprecation headers (
Deprecation,Sunset) added to deprecated endpoints - Migration guide exists for every version transition
OpenAPI Spec
- Every endpoint is documented in the spec
- Every possible response (including all error codes) is documented
- Reusable components (
schemas,parameters,responses) defined incomponents -
$refused throughout — no copy-paste duplication -
operationIdpresent and unique for every operation -
readOnly: trueon server-assigned fields (id,createdAt,updatedAt) - Realistic examples on all schemas and operation-level responses
- Spec validated with a linter before publishing (
npx @redocly/cli lint)
Security
- Authentication required on all endpoints by default
- Auth endpoints explicitly opt out (
security: []) -
401returned for unauthenticated requests (not403, not200with error body) -
403returned when authenticated but unauthorized -
404returned when authenticated but unauthorized to see a specific resource (prevents enumeration) - JWT expiry is short (≤ 15 minutes for access tokens)
- Refresh tokens are hashed in storage; rotated on every use
- No sensitive data in JWT payloads
- No stack traces or internal errors in any response
- Auth enforced at the router/middleware level — not handler-by-handler
Common Misconceptions
❌ Misconception: A working API is a well-designed API
Reality: The broken API in this article works. Requests reach the server. Responses come back. Data is stored and retrieved. But it breaks client caching, forces multiple round trips for single form submissions, leaks internal implementation details, and has no migration path for future changes.
Working and well-designed are different properties. A well-designed API is one that remains maintainable, extensible, and safe to consume as the product evolves.
❌ Misconception: Design review is a one-time event before launch
Reality: API design review should happen at three points:
- Before implementation — review the OpenAPI spec before any code is written. Changes are free at this stage.
- Before launch — review the implemented API against the spec and the checklist. Catch implementation drift.
- Ongoing — review every new endpoint before it ships. Catch convention drift before it accumulates.
The earlier a problem is caught, the cheaper it is to fix.
❌ Misconception: Fixing design problems requires a full rewrite
Reality: Most of the ten mistakes in this article can be fixed incrementally:
- Add a
/v1/prefix and redirect old URLs — clients update gradually - Add the envelope wrapper to new endpoints — keep backward compatibility on old ones temporarily
- Add the correct error format alongside the old format, deprecate the old format with a header
Breaking changes require a version bump. Non-breaking improvements can ship anytime.
Troubleshooting: Applying the Checklist to Your Own API
"I found a mistake but fixing it would break clients"
Resolution path:
- Is it a breaking or non-breaking change? (Review Article 5's classification)
- Non-breaking: deploy the fix, document the improvement in the changelog
- Breaking: introduce v2 with the fix; add deprecation headers to v1; set a sunset date; write a migration guide
- If you can't version right now: add the corrected behavior alongside the broken behavior (dual response shapes), document the correct one as canonical, and plan the breaking cleanup for the next major version
"My team disagrees about whether something is a design problem"
Resolution path:
Use the checklist as the objective reference point. If it's on the checklist, it's not a matter of opinion — it's a documented convention with a rationale. If the checklist doesn't cover it, write a team decision record (ADR — Architecture Decision Record) documenting the choice and reasoning, then add it to your team's extension of the checklist.
"The spec and the implementation are out of sync"
Symptoms: The spec says field assigneeId, the server returns assignee_id. The spec says 422 for validation, the server returns 400.
Prevention: Add express-openapi-validator with validateResponses: true in your development environment. It will flag every divergence between spec and implementation as a test failure.
Recovery: Run the spec through a linter (npx @redocly/cli lint), fix spec errors, then do a manual pass comparing spec responses against live API responses endpoint by endpoint. Generate a TypeScript client from the spec — type errors reveal shape mismatches immediately.
Check Your Understanding
Final Quiz
-
The broken API uses
POST /updateTaskinstead ofPATCH /tasks/{taskId}. Name two concrete problems this causes beyond "it's not RESTful."Show Answer
Caching: HTTP intermediaries (CDNs, browsers) don't cache POST requests.
PATCH /tasks/{taskId}explicitly signals a state change — infrastructure treats it correctly.POST /updateTaskgives no signal, but also doesn't benefit from the caching infrastructure POST normally gets for creation operations.Idempotency and retries: POST is not idempotent. HTTP clients that automatically retry failed requests (on network timeout, for example) will safely retry a
PATCHon state-change grounds only if the API documents idempotency. But they'll treatPOST /updateTaskas a creation-style operation and potentially retry it in ways that could cause issues.PATCHon the correct resource URL gives clients and infrastructure the correct semantics to decide whether retrying is safe.Other valid answers: the operation doesn't benefit from the uniform interface (clients must read docs to discover
updateTaskexists), the URL breaks REST resource identity (no clear noun), the method mismatch makes API gateways, monitoring tools, and logs categorize the operation incorrectly. -
The broken API returns
200with{ "success": false, "error": "Task not found" }. List three systems or tools this breaks.Show Answer
Any three of:
- HTTP client error handlers —
fetch,axios, and similar libraries check status codes to determine whether to reject a promise.200will not trigger the error handler, so the error is silently ignored. - API monitoring and alerting tools — uptime monitors and APM tools (Datadog, New Relic) track error rates by status code.
200responses aren't counted as errors — error rates appear to be 0% even when many requests are failing. - HTTP caches — a CDN might cache a
200response body containing"success": falseand serve it to subsequent requesters for the cache TTL. - Log aggregation — error log pipelines filter on status codes. A
200with an error body won't be routed to the error dashboard. - OpenAPI validation middleware — response validators check that
200responses match the success schema. An error body masquerading as a200will fail schema validation — or worse, corrupt the success schema to accommodate both shapes.
- HTTP client error handlers —
-
A teammate argues that the
/users/{userId}/projects/{projectId}/tasks/{task_id}/comments/{comment_id}URL is correct because it fully expresses the ownership hierarchy. How do you respond?Show Answer
The ownership hierarchy is more correctly expressed in the data model — the comment record has a
taskId, the task has aprojectId, the project has acreatorId. The URL doesn't need to re-express the entire chain.The practical problems with four-level nesting:
- A wrong parent ID (wrong
userIdorprojectId) causes a confusing404with no indication of which level failed - The comment has a globally unique ID —
userId,projectId, andtask_idin the URL provide no additional scoping that isn't already implied bycomment_id - The URL is unwieldy to construct, log, and share
- Path parameter naming is inconsistent in the broken version (
userIdvstask_id)
The rule: nest to the depth required for scoping and security, not to the depth of the full ownership chain.
DELETE /v1/tasks/{taskId}/comments/{commentId}provides the necessary scoping (comment belongs to a task — the task context is needed for authorization). The user and project context is redundant. - A wrong parent ID (wrong
-
Walk through the complete set of changes needed to fix just Mistake 9 (no versioning) without breaking existing clients.
Show Answer
-
Add
/v1/prefix to all new endpoint paths in the server routing. The/v1/endpoints are the canonical going-forward surface. -
Add redirects from old paths to
/v1/paths. Use301 Moved Permanentlyso clients and search engines update their stored URLs:GET /getTasks → 301 → /v1/tasks -
Keep old endpoints alive during the transition period — don't remove them until traffic monitoring shows they're no longer used.
-
Document the change in the API changelog. Announce the new URL structure. Give a migration timeline.
-
Add
DeprecationandSunsetheaders to responses from the old (un-versioned) endpoints:Deprecation: true
Sunset: Sat, 01 Jan 2026 00:00:00 GMT
Link: <https://api.taskflow.com/v1/tasks>; rel="successor-version" -
Monitor traffic to the old endpoints. When traffic drops to near zero, remove the old routes. Return
410 Goneafter the sunset date.
Note: the redirect-and-monitor approach is the only way to introduce versioning retroactively without an immediate breaking change. It's more work than designing with versioning from day one — which is why Article 5 argues for deciding on a versioning strategy before launch.
-
Final Exercise
Challenge: You've been asked to review this endpoint from a new service:
POST /api/search/doSearch
Content-Type: application/json
Authorization: Bearer <token>
{
"query": "quarterly report",
"type": "documents",
"MaxResults": 25,
"sort_by": "relevance",
"include_archived": true
}
Response: 200 OK
{
"status": "success",
"Count": 3,
"Documents": [
{ "document_id": 1001, "Title": "Q3 2025 Report", "createdDate": "2025-09-01" },
{ "document_id": 1002, "Title": "Q4 2025 Report", "createdDate": "2025-12-01" }
]
}
Apply the checklist. List every problem you find and write the corrected request and response.
Show Answer
Problems found:
- Verb in URL —
/doSearchis an action verb. Should be a noun:/searchor/documents?q=... - Inconsistent naming in request —
MaxResults(PascalCase),sort_by(snake_case),include_archived(snake_case) mixed with no convention - Inconsistent naming in response —
Count(PascalCase),Documents(PascalCase),document_id(snake_case),Title(PascalCase),createdDate(camelCase) — five naming styles in one response - Wrong method for a search/read —
POSTfor a read operation breaks caching and idempotency; should beGETwith query parameters (orPOSTonly if the search body is genuinely too complex for a URL) - No envelope —
{ "status": "success", "Count": 3, "Documents": [...] }is a custom structure instead of{ "data": [...], "meta": {...} } - Integer IDs —
document_id: 1001exposes row count and is not URL-safe as a string prefix status: "success"in the body — status code already communicates success; this field is redundant and tempts developers toward the200+ error body antipattern- No versioning —
/api/search/has no version prefix - No pagination —
MaxResults: 25limits results but provides no cursor for the next page
Corrected request:
GET /v1/search?q=quarterly+report&type=documents&limit=25&sort=relevance&includeArchived=true
Authorization: Bearer <token>
Accept: application/json
Corrected response:
HTTP/1.1 200 OK
Content-Type: application/json
{
"data": [
{
"id": "doc_1001",
"title": "Q3 2025 Report",
"type": "document",
"createdAt": "2025-09-01T00:00:00Z"
},
{
"id": "doc_1002",
"title": "Q4 2025 Report",
"type": "document",
"createdAt": "2025-12-01T00:00:00Z"
}
],
"meta": {
"cursor": "ZG9jXzEwMDM=",
"hasMore": true,
"limit": 25,
"total": 3
}
}
All field names are camelCase. IDs are string-prefixed. The response uses the standard envelope. Pagination is represented with a cursor. Status is communicated by the 200 status code — not a redundant body field.
Note on POST vs GET for search: if the search query can become complex enough to exceed URL length limits (typically 2,000 characters), POST to /v1/search is acceptable — but the response shape and naming conventions still apply.
Summary: Key Takeaways
-
Ten mistakes account for the majority of REST API design problems in production: verbs in URLs, inconsistent naming, wrong HTTP methods, wrong status codes, missing or inconsistent envelopes, deep nesting, leaking internals, one-at-a-time validation, missing versioning, and incomplete security.
-
The audit process has six stages: URL surface → method semantics → status codes → response shape → versioning → security. Apply them in order — earlier problems often cascade into later ones.
-
Working is not the same as well-designed. A broken API can still serve requests successfully — the problems surface over time as clients grow, the team scales, and the product evolves.
-
Design problems are cheapest to fix before implementation. Spec-first development means design review happens before a single line of production code is written. A conversation about a YAML file is free. A migration guide for ten thousand API clients is not.
-
The production checklist is portable. Apply it to every API you design, every API you join a team that's already built, and every API you consume as a client (to understand its failure modes before you depend on it).
-
TaskFlow's corrected API — twenty-three endpoints across auth, tasks, comments, projects, and users — is consistent, versioned, secured, documented, and extensible. Every design decision has a reason you can articulate and defend.
What's Next?
You've completed the REST API Design & Specification module.
You've gone from the foundational mental model (what REST actually is) through resource design, HTTP semantics, payload conventions, versioning strategy, OpenAPI documentation, authentication patterns, and a full design audit — all grounded in a single running example, TaskFlow, that grew from a blank slate into a production-ready, fully-specified API.
Where to go from here depends on what you want to build next:
If you want to go deeper on API security, explore OAuth 2.0 in more depth — specifically OpenID Connect (OIDC) for identity, token introspection, and dynamic client registration.
If you want to go deeper on API tooling, look at Spectral (OpenAPI linting rules you define for your team's conventions), Prism (mock servers from your spec), and openapi-generator (client SDK generation across languages).
If you want to go deeper on API evolution, explore GraphQL as a contrast — understanding where it wins helps you understand when REST's tradeoffs aren't the right fit.
References
- Architectural Styles and the Design of Network-based Software Architectures — Roy Fielding's original REST dissertation. The foundational reference for the uniform interface and resource identification principles that underlie Mistakes 1 and 3.
- RFC 7231 — HTTP/1.1 Semantics and Content — Formal HTTP method and status code semantics. Primary reference for Mistakes 3 and 4.
- RFC 8594 — The Sunset HTTP Header Field —
DeprecationandSunsetheaders used in the versioning fix (Mistake 9). - OpenAPI Specification 3.1.0 — The OpenAPI specification. Reference for the corrected spec structure throughout this article.
- OWASP REST Security Cheat Sheet — Security guidance underpinning Mistake 10, the resource enumeration protection discussion, and the internal detail leakage section (Mistake 7).
- Redocly CLI — OpenAPI linting tool referenced in the troubleshooting section for spec/implementation sync issues.
- express-openapi-validator — Request/response validation middleware referenced for preventing spec drift.
- Stripe API Reference — Real-world example of consistent API design conventions (envelope pattern, idempotency keys, error format) that informed several of the "after" patterns in this module.