Practical API design patterns covering resource modeling, error handling, pagination, filtering, and versioning for production APIs.
The difference between an API that developers love and one they tolerate comes down to consistency and predictability. A well-designed API follows patterns that, once learned, apply everywhere. A poorly designed API requires reading documentation for every single endpoint.
This guide covers the patterns that matter most when building APIs consumed by frontend teams, mobile apps, and third-party integrations.
Your API resources should map to business concepts, not database tables. If your customers table has 40 columns but API consumers only care about 12 fields, your resource should expose those 12 fields.
Naming conventions that work:
/invoices, /customers, /subscriptions/customers/42/invoices/customers/42/invoices/17/lines/3, use /invoice-lines/3 with filter parameters/payment-methods, not /paymentMethodsActions that do not fit CRUD are the exception to noun-based URLs. Use a verb: POST /invoices/42/finalize or POST /subscriptions/7/cancel. Keep these rare.
Pick one response structure and use it everywhere:
{
"data": {
"id": 42,
"type": "invoice",
"attributes": {
"number": "INV-2026-0042",
"total": "1250.00",
"currency": "EUR",
"status": "finalized"
}
}
}
For collections:
{
"data": [
{ "id": 42, "type": "invoice", "attributes": { ... } },
{ "id": 43, "type": "invoice", "attributes": { ... } }
],
"meta": {
"current_page": 1,
"per_page": 25,
"total": 148
}
}
Whether you use JSON:API format, a custom envelope, or something else matters less than using it consistently across every endpoint.
Let consumers request only the fields they need:
GET /invoices?fields=id,number,total,status
This reduces payload size dramatically for mobile clients and list views. Implement it once as middleware and it works across all resources.
Avoid the N+1 problem at the API level. Let consumers request related resources in one call:
GET /invoices/42?include=customer,lines.product
This pattern (popularized by JSON:API) prevents consumers from making dozens of requests to assemble a single view. Limit the depth and allowed includes to prevent performance issues.
Errors are where most APIs fall apart. The consumer receives a 500 status with a generic message and has no idea what went wrong.
Structured error responses:
{
"error": {
"type": "validation_error",
"message": "The request could not be processed due to validation errors.",
"errors": {
"customer_id": ["The selected customer does not exist."],
"lines": ["At least one invoice line is required."]
}
}
}
Status code discipline:
200 for successful GET, PUT, PATCH201 for successful POST that creates a resource204 for successful DELETE400 for malformed requests401 for missing or invalid authentication403 for valid authentication but insufficient permissions404 for resources that do not exist409 for conflict (duplicate, state conflict)422 for validation errors (request is well-formed but semantically invalid)429 for rate limit exceeded500 for server errors (never expose internal details)Include a machine-readable error type alongside the human-readable message. This lets consumers handle errors programmatically without parsing strings.
GET /invoices?page=3&per_page=25
Simple to implement and understand. Works well for most use cases. Breaks down with very large datasets because OFFSET 10000 is expensive in most databases.
GET /invoices?cursor=eyJpZCI6NDJ9&per_page=25
The cursor encodes the position in the result set (typically the last seen ID). Performs consistently regardless of dataset size because it translates to a WHERE id > 42 clause instead of an offset.
Use cursor-based pagination for feeds, activity logs, and any resource where consumers scroll through large datasets. Use offset-based for admin panels and dashboards where jumping to a specific page matters.
Provide consistent filtering across resources:
GET /invoices?filter[status]=finalized&filter[customer_id]=42&filter[created_after]=2026-01-01
For sorting:
GET /invoices?sort=-created_at,number
The minus prefix indicates descending order. Document available filter and sort fields per resource.
Every production API needs rate limiting. Communicate limits clearly in response headers:
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 847
X-RateLimit-Reset: 1706140800
When the limit is exceeded, return 429 Too Many Requests with a Retry-After header. Differentiate rate limits by authentication level: anonymous requests get stricter limits than authenticated API key requests.
POST requests should support idempotency for critical operations. The consumer sends an Idempotency-Key header:
POST /invoices
Idempotency-Key: a1b2c3d4-e5f6-7890-abcd-ef1234567890
If the server has already processed a request with this key, it returns the original response instead of creating a duplicate. This is essential for payment operations, order creation, and any action with real-world side effects.
Store idempotency keys with their responses for 24-48 hours. This prevents duplicate processing from network retries, user double-clicks, and mobile connectivity issues.
For first-party frontends, use session-based authentication with CSRF tokens. For third-party integrations and mobile apps, use OAuth 2.0 with short-lived access tokens and refresh tokens.
API keys (bearer tokens) work for server-to-server communication where the client is a trusted backend service. Never use API keys in browser-based applications since they cannot be kept secret on the client.
An undocumented API is an unusable API. Generate documentation from your code using OpenAPI specifications. Keep examples realistic (not "string" and "0" placeholders). Include error responses in your documentation, not just happy paths.
The best API documentation includes runnable examples that consumers can copy, paste, and adapt. If your documentation requires 30 minutes of setup before making the first request, you have lost most of your audience.
Whether you're modernizing your infrastructure, navigating compliance, or building new software - we can help.
Book a 30-min Call