Skip to main content

How to Design REST APIs Developers Love 2026

·APIScout Team
Share:

How to Design REST APIs Developers Love 2026

The best APIs share a quality: developers figure them out without reading the docs. Resource naming is intuitive. Error messages explain what went wrong and how to fix it. Pagination works consistently. Authentication is a single header. Here's how to design APIs that developers reach for.

TL;DR

  • Use plural nouns as resource names, HTTP methods as verbs, and keep URL hierarchy to a maximum of 3 levels — deep nesting signals a design problem
  • Return the right status code: 201 for created resources (not 200), 422 for validation failures (not 400), 409 for conflicts, and 410 Gone when resources were deleted and won't come back
  • Error responses need machine-readable codes, field-level validation details, a request ID, and a documentation URL — every one of Stripe's error fields exists because customers needed it
  • Wrap responses consistently: either always use {data: ..., meta: ...} or always return flat objects — mixing envelope styles across endpoints is the most common DX complaint in API design reviews
  • PATCH semantics require a decision: JSON Merge Patch (RFC 7396) is simpler and covers 90% of use cases; use it

1. Resource Naming

Use Nouns, Not Verbs

Resources are things, not actions. HTTP methods provide the verbs.

✅ GET    /users          → List users
✅ POST   /users          → Create user
✅ GET    /users/123      → Get user
✅ PUT    /users/123      → Update user
✅ DELETE /users/123      → Delete user

❌ GET /getUsers
❌ POST /createUser
❌ POST /deleteUser/123

Use Plural Nouns

Consistency matters more than grammar debates. Use plurals everywhere.

✅ /users
✅ /orders
✅ /products

❌ /user
❌ /order/123   (mixing singular and plural)

Nest for Relationships

Sub-resources express relationships. Keep nesting to 2 levels max.

✅ GET /users/123/orders          → User's orders
✅ GET /users/123/orders/456      → Specific order

❌ GET /users/123/orders/456/items/789/reviews  → Too deep

Use kebab-case

✅ /api/order-items
✅ /api/user-profiles

❌ /api/orderItems
❌ /api/order_items

2. HTTP Methods and Status Codes

Methods

MethodPurposeIdempotentSafe
GETRead resource(s)YesYes
POSTCreate resourceNoNo
PUTReplace resource (full)YesNo
PATCHUpdate resource (partial)NoNo
DELETERemove resourceYesNo

Status Codes

Use specific codes. Don't return 200 for everything.

CodeMeaningWhen to Use
200OKSuccessful GET, PUT, PATCH, DELETE
201CreatedSuccessful POST (include Location header)
204No ContentSuccessful DELETE with no response body
400Bad RequestInvalid input (validation errors)
401UnauthorizedMissing or invalid authentication
403ForbiddenAuthenticated but not authorized
404Not FoundResource doesn't exist
409ConflictDuplicate resource, state conflict
422Unprocessable EntitySemantically invalid input
429Too Many RequestsRate limit exceeded
500Internal Server ErrorUnexpected server failure

3. Error Handling

The Error Response

Every error should include a machine-readable code, human-readable message, and actionable detail.

{
  "error": {
    "code": "validation_error",
    "message": "The request body contains invalid fields.",
    "details": [
      {
        "field": "email",
        "code": "invalid_format",
        "message": "Must be a valid email address."
      },
      {
        "field": "age",
        "code": "out_of_range",
        "message": "Must be between 13 and 120."
      }
    ],
    "request_id": "req_abc123",
    "documentation_url": "https://api.example.com/docs/errors#validation_error"
  }
}

Error Design Rules

  1. Machine-readable codesvalidation_error, not_found, rate_limit_exceeded
  2. Human-readable messages — explain what happened in plain English
  3. Field-level details — for validation errors, tell the client which fields failed and why
  4. Request ID — for debugging and support tickets
  5. Documentation link — point to docs explaining the error

4. Pagination

{
  "data": [...],
  "pagination": {
    "has_more": true,
    "next_cursor": "eyJpZCI6MTIzfQ=="
  }
}

Request: GET /users?limit=20&cursor=eyJpZCI6MTIzfQ==

Pros: Consistent results when data changes. Performs well at any depth. Cons: Can't jump to page 5 directly. Can't show "page 3 of 10."

Offset-Based

{
  "data": [...],
  "pagination": {
    "total": 500,
    "limit": 20,
    "offset": 40,
    "has_more": true
  }
}

Request: GET /users?limit=20&offset=40

Pros: Simple. Can jump to any page. Can show total count. Cons: Inconsistent results if data changes between pages. Slow at deep offsets (OFFSET 10000).

Recommendation: Cursor-based for most APIs. Offset-based only when random access is required. See our deep-dive on pagination patterns for implementation details.

5. Filtering and Sorting

GET /users?status=active&role=admin          → Filter
GET /users?sort=created_at&order=desc        → Sort
GET /users?fields=id,name,email              → Sparse fields
GET /users?search=john                       → Search

Keep It Consistent

  • Use the same parameter names across all endpoints
  • sort for field name, order for direction (asc/desc)
  • limit and cursor/offset for pagination
  • fields for sparse fieldsets
  • search for full-text search

6. Versioning

Start with URL path versioning: /api/v1/users. It's the most understood approach.

Rules:

  • Add fields without incrementing version (additive changes are not breaking)
  • Increment version for breaking changes (field removal, type changes, behavior changes)
  • Support old versions for 12+ months after deprecation announcement
  • Document what constitutes a "breaking change" for your API

For a detailed treatment of versioning strategies, see our guide on API breaking changes without breaking clients.

7. Authentication

Use Authorization: Bearer <token> header. Don't put tokens in URLs (they leak in logs, referrers, and browser history).

✅ Authorization: Bearer sk_live_abc123
❌ GET /users?api_key=sk_live_abc123

8. Response Envelope

Consistent Wrapping

Wrap all responses in a consistent envelope:

{
  "data": { ... },
  "meta": {
    "request_id": "req_abc123",
    "timestamp": "2026-03-08T12:00:00Z"
  }
}

For lists:

{
  "data": [ ... ],
  "pagination": { ... },
  "meta": { ... }
}

URL Design That Doesn't Age

URL design decisions outlive the code that implements them. Clients build integrations against your URL structure, documentation sites link to it, support tickets reference it. Getting this right upfront avoids painful migration work later.

The two-level nesting limit is a practical constraint, not an aesthetic one. URLs like /api/v1/organizations/123/projects/456/tasks/789 are technically valid but create implementation problems. You're forced to validate ownership at every level (does project 456 belong to org 123?), authorization logic becomes deeply nested, and adding a new parent resource in the future requires URL changes. When you need to access a task, flat access with filtering (GET /tasks?project_id=456) is often cleaner than deep nesting.

The path parameter vs query parameter decision follows a simple rule: path parameters identify specific resources, query parameters filter or modify the representation. GET /users/123123 is a path parameter identifying a specific user. GET /users?role=adminrole=admin is a query parameter filtering the collection. Misuse is common: GET /users/active looks like it identifies a specific user named "active" but is actually filtering by status — this should be GET /users?status=active. Keeping this distinction clean prevents confusing URL semantics that trip up API consumers.

Actions that don't map cleanly to CRUD operations need a convention. The approach that ages best: use a noun-based sub-resource for state transitions. POST /invoices/123/payment (create a payment on an invoice) is cleaner than POST /invoices/123/pay (verb in URL). POST /subscriptions/123/cancellation is cleaner than POST /subscriptions/123/cancel. This keeps URLs noun-based while handling lifecycle transitions naturally.

For query parameter conventions: use comma-separated values for multi-value filters (?status=active,pending) or repeated parameters (?status=active&status=pending) — document which approach you use and be consistent. Range parameters work well as ?created_after=2026-01-01&created_before=2026-03-01. Use ISO 8601 timestamps everywhere — never Unix timestamps in query parameters.

HTTP Status Codes Developers Expect

The status codes that cause the most confusion in practice are the 4xx codes where the right choice isn't obvious.

200 vs 201 vs 202: Return 201 Created (not 200) when a resource is created via POST, and include a Location header pointing to the new resource. Return 202 Accepted for asynchronous operations that are queued but not yet complete — the response body should include a status URL or job ID for polling. Return 200 for all other successful operations.

400 vs 422: Use 400 Bad Request for requests that are malformed at the HTTP level — invalid JSON, missing Content-Type header, request body too large. Use 422 Unprocessable Entity for requests that are valid HTTP but fail business logic validation — an email field with an invalid format, a date range where end is before start, a reference to a non-existent resource. Stripe uses 400 for both, which is a common simplification. The distinction matters when clients need to differentiate "my JSON is broken" from "my data is invalid" — use 422 if you want to give clients that signal.

401 vs 403: 401 Unauthorized means the request lacks valid authentication credentials — the client is not authenticated. 403 Forbidden means the client is authenticated but doesn't have permission to access the resource. Returning 404 instead of 403 when you want to hide the existence of a resource is a valid security practice — an unauthenticated user shouldn't know whether resource 123 exists, only that they can't access it.

404 vs 410: 404 Not Found means the resource doesn't exist (or you're hiding it). 410 Gone is the semantically correct code when a resource existed previously but has been permanently deleted. Clients and search engines treat 410 differently — it signals "stop requesting this URL." For APIs that permanently delete resources, consider returning 410 with a body explaining when the resource was deleted.

500 vs 503: Return 500 Internal Server Error for unexpected application errors — unhandled exceptions, database failures, unexpected nil pointers. Return 503 Service Unavailable when your API is intentionally down — maintenance windows, deployment pauses, dependency failures you're aware of. 503 should include a Retry-After header telling clients when to try again. The distinction matters for monitoring: a spike in 500s means your code has bugs; a 503 is expected during planned maintenance.

Error Response Design

The Stripe error format is the gold standard because every field solves a real problem that emerged from years of developer support. Copying it (or improving on it) is better than inventing your own.

The code field must be machine-readable and stable. Strings like validation_error, resource_not_found, rate_limit_exceeded are values that client code can switch on without parsing human-readable messages. The message can change; the code should not. Document every possible error code in your API reference.

Field-level errors are critical for form validation workflows. When a developer is building a registration form and the user submits invalid data, they need to know which field failed and why. A top-level "message": "Validation failed" forces the client to either guess or show a generic error. A details array with per-field codes allows displaying inline error messages next to each field. Always return all validation errors at once rather than stopping at the first failure — forcing users to fix one error at a time is bad UX.

The request_id field solves a specific support workflow. When a developer contacts you with "I got an error," you need to be able to look up the exact request in your logs. Without a request ID, you're searching by timestamp and IP address. With a request ID, you can pull up the exact request, its parameters, and what failed. Include a request ID in every response (success and error) and document how to find it and include it in support tickets.

The documentation_url field is the highest-leverage DX improvement on this list. Linking from an error directly to the documentation for that specific error code eliminates the most common support query pattern ("I got this error, what does it mean?"). Every error code should have a dedicated documentation page explaining the code, what triggers it, and how to resolve it.

Envelope vs No Envelope Debate

The question of whether to wrap API responses in a container object ({data: ..., meta: ...}) or return resources directly has no definitively correct answer. Both approaches are in production at major API providers.

Stripe returns objects directly:

{
  "id": "cus_abc123",
  "object": "customer",
  "email": "user@example.com",
  "created": 1234567890
}

GitHub's REST API uses a flat array for lists:

[
  { "id": 1, "name": "repo-one" },
  { "id": 2, "name": "repo-two" }
]

JSON:API uses a structured envelope:

{
  "data": {
    "type": "customers",
    "id": "abc123",
    "attributes": { "email": "user@example.com" }
  },
  "meta": { "request_id": "req_xyz" }
}

The practical argument for envelopes: they allow adding metadata (pagination, rate limit info, request IDs) without changing the resource schema. {data: [...], pagination: {...}} is cleaner than HTTP headers for pagination. The argument against: they add a level of nesting that client code must always unwrap, and sophisticated HTTP clients already have access to headers.

The pragmatic choice: use a consistent {data: ..., meta: ...} envelope for list endpoints (where pagination metadata is needed) and flat responses for single-resource endpoints. This is roughly what Stripe does — consistency within a pattern matters more than the specific pattern chosen.

PATCH vs PUT Semantics

PUT replaces the entire resource. Send all fields. Fields not included in the request are cleared to their defaults. PATCH modifies specific fields. Include only the fields you want to change.

The ambiguity in PATCH is what null means. If a client sends {"bio": null}, does that mean "set the bio field to null/empty" or "leave the bio field unchanged (I'm not sending it)"? This is the core PATCH semantic question, and both interpretations are in use.

JSON Merge Patch (RFC 7396) standardizes PATCH semantics: null means "delete/clear the field." Fields not present in the request are left unchanged. This is simple and covers most use cases:

// PATCH /users/123
// Body: {"name": "New Name", "bio": null}
// Result: name updated, bio cleared, all other fields unchanged

JSON Patch (RFC 6902) is more expressive — it uses an array of operations (add, remove, replace, move, copy, test). It's useful for conditional updates (test operation fails if a field doesn't match, aborting the patch) and for complex transformations. It's significantly more complex to implement and consume:

// PATCH /users/123
// Body:
[
  { "op": "replace", "path": "/name", "value": "New Name" },
  { "op": "remove", "path": "/bio" }
]

Recommendation: implement JSON Merge Patch for most APIs. The RFC 7396 semantics are clear, well-documented, and handled by libraries in every language. Use the Content-Type: application/merge-patch+json header to signal the format. Only implement JSON Patch if you have specific use cases requiring conditional updates or atomic complex operations.

Making PATCH idempotent: PATCH is not idempotent by default (the spec says it might not be). In practice, most PATCH /users/123 {"name": "Alice"} operations are naturally idempotent — applying the same patch twice produces the same result. Document whether your PATCH endpoints are idempotent, and for operations that aren't (like append operations), use idempotency keys. See our API idempotency guide.

9. Learn from the Best

APIWhat They Do Well
StripeConsistent naming, excellent errors, idempotency keys, expandable objects
GitHubDiscoverable (HATEOAS links), consistent pagination, excellent docs
TwilioResource-oriented, consistent patterns across all products
SlackSimple methods, clear error codes, rate limit transparency
OpenAIClean JSON, streaming support, clear model selection

Designing APIs? Explore API design tools and patterns on APIScout. Also see our guides on API error handling and status codes and API rate limiting best practices for building production-grade APIs.

The API Integration Checklist (Free PDF)

Step-by-step checklist: auth setup, rate limit handling, error codes, SDK evaluation, and pricing comparison for 50+ APIs. Used by 200+ developers.

Join 200+ developers. Unsubscribe in one click.