REST API Best Practices: HTTP Semantics, Idempotency, Errors, Pagination, Caching, and Security
A good REST API is not “JSON over HTTP with nice URLs.” It uses HTTP semantics deliberately so clients, proxies, caches, SDKs, gateways, and operators can understand what a request means without private conventions everywhere.
The most useful REST practices are not style debates about singular vs plural nouns. They are correctness rules around resource identity, methods, idempotency, status codes, validation, errors, caching, pagination, authorization, and compatibility.
Model resources, not database tables
A public API resource should represent a product/domain concept:
/orders/o_42
/customers/c_7
/invoices/i_900
It does not need to mirror one SQL table exactly.
For example, an Order response may combine:
- relational data
- payment state
- shipping state
- derived totals
Keep storage implementation behind the API boundary so database refactors do not force unnecessary client migrations.
Use stable resource identifiers
Once a public resource URI is published, clients may store it in logs, workflows, links, and integrations.
Prefer stable opaque identifiers over mutable human labels:
/orders/o_42
instead of:
/orders/asha-singh-2026-september-keyboard
when the latter can change.
Readable slugs are fine where they are the actual durable identifier; do not make mutable presentation data the only identity by accident.
Let HTTP methods carry their standard meaning
RFC 9110 defines semantics for methods such as GET, POST, PUT, DELETE, and others.
A practical mapping is:
GET
Retrieve a current representation without requesting a state change.
GET /orders/o_42
POST
Submit data for resource-specific processing; often used to create a new server-assigned resource.
POST /orders
PUT
Create/replace state at a known target URI using idempotent semantics.
PUT /users/u_42/preferences
PATCH
Apply a partial modification using a documented patch format/semantics.
PATCH /orders/o_42
DELETE
Request removal of the target resource or its current association/state according to your contract.
Do not make every operation POST /doSomething when HTTP already has useful method semantics.
Safe and idempotent are different properties
RFC 9110 calls a method safe when the client does not request a state change.
GET and HEAD are safe.
An idempotent method has the same intended server effect when the same request is repeated.
HTTP defines:
- safe methods as idempotent
PUTas idempotentDELETEas idempotent
POST is not inherently idempotent.
That distinction matters when networks fail and clients retry.
Idempotency is essential for important POST operations
Consider payment creation:
client sends POST /payments
server charges card
response is lost
client retries
Without protection, the user may be charged twice.
For non-idempotent business operations that may be retried, accept a client-generated idempotency key:
Idempotency-Key: checkout-7f8c...
Store the logical result under a scope that includes the correct caller/tenant and return the same outcome for a valid duplicate attempt.
Idempotency keys need:
- uniqueness scope
- retention duration
- request-payload mismatch handling
- concurrency safety
Do not treat an arbitrary key header as idempotency unless the server actually enforces it atomically.
Use status codes for transport-level semantics
Clients should not need to parse a JSON string to discover whether authentication failed.
Use HTTP status codes intentionally.
Common examples:
200 OK successful retrieval/update response
201 Created new resource created
202 Accepted asynchronous work accepted, not completed
204 No Content success with no response representation
400 Bad Request malformed/invalid generic request
401 Unauthorized authentication required/invalid
403 Forbidden authenticated but not permitted
404 Not Found resource unavailable/not exposed
409 Conflict state conflict
412 Precondition Failed conditional request failed
415 Unsupported Media Type
422 Unprocessable Content semantically invalid content where appropriate
429 Too Many Requests
500 Internal Server Error
503 Service Unavailable
Choose based on RFC semantics and your contract, not arbitrary internal numeric mappings.
201 Created should identify the new resource
When a POST creates a resource, return its identifier/representation and usually a Location header where appropriate:
HTTP/1.1 201 Created
Location: /orders/o_42
Content-Type: application/json
Body:
{
"id": "o_42",
"status": "pending"
}
This gives the client canonical server state without requiring an immediate second lookup.
Use 202 Accepted only for truly asynchronous completion
202 means the request was accepted for processing, not that the business operation completed successfully.
A useful asynchronous API provides a status resource:
POST /exports
returns:
{
"job_id": "job_42",
"status_url": "/export-jobs/job_42"
}
Then clients can poll or subscribe to progress.
Do not return 202 for a synchronous database write simply because the server may do optional background work afterward.
Standardize error bodies
RFC 9457 defines Problem Details for HTTP APIs using application/problem+json.
A response can look like:
{
"type": "https://api.example.com/problems/insufficient-stock",
"title": "Insufficient stock",
"status": 409,
"detail": "Only 1 unit remains",
"instance": "/orders/attempts/a_900"
}
You can add carefully documented extension members such as:
{
"error_code": "INSUFFICIENT_STOCK",
"available": 1
}
This is better than inventing a different error envelope for every endpoint.
Separate machine-readable codes from human messages
Clients should branch on a stable machine-readable code/type, not exact English text.
Bad:
if message == "Card declined": ...
Better:
problem.type / error_code → stable client behavior
problem.detail → human/debug context
Human-readable text can change, be localized, or contain contextual details.
Do not leak internal exceptions
OWASP recommends avoiding unnecessary technical details in REST error responses.
Do not return:
- stack traces
- SQL text
- filesystem paths
- internal hostnames
- secret values
- downstream tokens
Return a safe public problem response and log diagnostic detail internally with a trace/request ID.
Validate request shape and business rules separately
There are several validation layers.
Transport/content validation
- allowed
Content-Type - body size
- JSON syntax
Schema validation
- required fields
- types
- string length
- enum values
- numeric range
Business validation
- enough inventory
- user may modify this order
- transition
paid→pendingnot allowed
Keeping these distinctions clear produces better errors and safer code.
Reject unsupported media types explicitly
If an endpoint only accepts JSON:
Content-Type: application/json
then reject unexpected content types with appropriate HTTP semantics such as 415 Unsupported Media Type.
Do not feed arbitrary bodies into whichever parser happens to accept them.
Set request-body size limits before parsing large attacker-controlled payloads.
Authorization must be resource-specific
Authentication says who the caller is.
Authorization asks whether that caller may access this resource and action.
For:
GET /tenants/t7/invoices/i42
verify:
- authenticated identity
- membership/access to tenant
t7 - access to invoice
i42 - permission for the requested action
Do not trust tenant/user IDs supplied by the client merely because the token is valid.
OWASP recommends access control at each non-public REST endpoint.
Avoid object-level authorization bugs
One of the most common API failure patterns is:
user can access invoice 10
changes URL to invoice 11
server checks authentication only
returns someone else's invoice
Resource lookup should be scoped through authorization context:
find invoice i11 that belongs to tenant allowed for caller
rather than fetching arbitrary object first and hoping every handler remembers an access check later.
Use cursor pagination for large mutable collections
Offset pagination is simple:
/orders?limit=50&offset=5000
It is fine for small/admin datasets.
For large frequently changing feeds, cursor/keyset pagination is often better:
/orders?limit=50&after=opaque_cursor
The cursor should encode or reference a stable indexed ordering such as:
created_at + unique ID
Keep cursors opaque so clients do not depend on internal database details.
Put a hard maximum on page size
A client request such as:
?limit=1000000
should not become an accidental export endpoint.
Define:
- default page size
- maximum page size
- stable ordering
- next cursor/link behavior
For true bulk export, provide a separate asynchronous export workflow optimized for that use case.
Return navigation metadata that clients can use
A cursor page might return:
{
"data": [ ... ],
"page": {
"next_cursor": "opaque...",
"has_more": true
}
}
or links:
next
prev
Do not force clients to reverse-engineer whether another page exists from “array length happened to equal 50.”
Filtering and sorting should be explicit
Use documented query parameters:
/orders?status=paid&sort=-created_at
Avoid exposing raw SQL-like filter expressions unless you intentionally want to support a query language and can secure/cost-control it.
Whitelist sortable/filterable fields.
An arbitrary sort=huge_unindexed_expression should not let clients turn the API into a database stress tool.
Use HTTP caching where data semantics allow it
REST resource URLs work naturally with HTTP caching.
A public resource can return:
Cache-Control: public, max-age=60
Private user-specific data might use:
Cache-Control: private, no-cache
or no-store when storage must be prohibited.
Do not mark personalized responses public simply to get CDN hits.
Caching policy is part of the security boundary.
Use validators to avoid retransmitting unchanged data
A server can return an ETag:
ETag: "order-o42-v17"
The client later sends:
If-None-Match: "order-o42-v17"
If unchanged, the server can return:
304 Not Modified
This preserves freshness checks while reducing body transfer.
Last-Modified / If-Modified-Since are another validator pair when timestamps represent the resource accurately.
Use conditional writes to prevent lost updates
ETags are useful for concurrency too.
Client reads:
ETag: "order-o42-v17"
Then updates with:
If-Match: "order-o42-v17"
If someone changed the order meanwhile, the precondition fails instead of silently overwriting the newer version.
This is an HTTP-native optimistic concurrency pattern.
Rate limit by meaningful identity
Possible rate-limit dimensions include:
- user
- API key
- OAuth client
- tenant
- IP as one signal
- endpoint/cost class
Do not rely only on IP for authenticated multi-tenant APIs; NATs and proxies can put many users behind one address.
Return 429 Too Many Requests when policy is exceeded and include documented retry information if your API supports it.
Rate limiting should protect both infrastructure and fair-use/product quotas.
Different endpoints may need different cost limits
One cheap lookup and one expensive report should not necessarily share the same request quota.
Model endpoint weights or separate buckets:
GET /countries cheap
POST /search medium
POST /large-export asynchronous / strict quota
Rate limiting only by raw request count can make cheap traffic unusable or expensive traffic under-protected.
Document the API as a contract
OpenAPI provides a language-agnostic description format for HTTP APIs.
A good OpenAPI document can drive:
- reference documentation
- client/server code generation
- contract tests
- mocks
- security review
- linting
Keep the specification generated from or checked against actual server behavior.
A beautiful OpenAPI file describing an endpoint the server no longer implements is worse than no contract because consumers trust it.
Do not confuse OpenAPI version with your API version
An OpenAPI document may say:
openapi: 3.1.1
That describes the OpenAPI Specification version used by the document.
Your business API may still be:
API v1
These version numbers solve different problems.
Design retries from method semantics and business semantics
RFC 9110 defines safe/idempotent method behavior so clients/intermediaries know when retries can be reasonable.
But the application still matters.
Examples:
GET /products/42is normally safe to retry- a correctly designed PUT replacement is idempotent
POST /paymentsis not automatically safe
Use timeouts, bounded exponential backoff, jitter, and idempotency keys for suitable operations.
Never configure a gateway to blindly retry every POST after a timeout.
Distinguish request timeout from business completion
A client timing out does not prove the server did nothing.
If the connection breaks after a write committed, the result may be ambiguous.
For important mutations:
- use idempotency keys
- expose operation/resource status
- let clients safely query the canonical result
This is a business protocol problem, not just an HTTP library setting.
Handle asynchronous operations as resources
For long-running work:
POST /video-exports
return a job resource:
{
"id": "job_42",
"status": "queued"
}
Clients can retrieve:
GET /video-exports/job_42
and eventually see:
queued → working → completed/failed
This is easier to reason about than holding an HTTP request open for several minutes without need.
Keep webhooks and callbacks idempotent too
If your API sends webhooks, receivers can experience duplicates due to retries and ambiguous network failures.
Give each delivery/event a stable ID and sign webhook requests.
Document:
- retry schedule
- signature verification
- event ordering guarantees
- duplicate semantics
- retention/replay availability
A REST API ecosystem often includes outbound APIs as well as inbound endpoints.
Use HTTPS everywhere for protected APIs
OWASP's REST Security guidance recommends HTTPS-only endpoints for secure REST services.
TLS protects:
- bearer tokens
- API keys
- cookies
- request/response bodies
Do not send credentials in query parameters when they can end up in logs/history.
Use headers/cookies/appropriate authentication mechanisms with least-privilege scopes.
CORS is a browser permission boundary, not API authentication
CORS tells browsers which origins may read responses from frontend JavaScript.
It does not stop:
- curl
- backend services
- attackers sending direct HTTP requests
Configure CORS narrowly for browser clients, but keep authentication/authorization independent of it.
Do not use Access-Control-Allow-Origin: * with a credentialed private API design that requires restricted origins.
Version only when compatibility truly breaks
Avoid creating /v2 for every additive response field.
Prefer backward-compatible evolution where possible.
When a major incompatibility is required:
- launch the new version alongside the old one
- provide migration documentation
- instrument version usage
- announce deprecation
- provide a sunset window
Version lifecycle is covered deeply in Lofingo's separate API Versioning guide.
Design observability into the API surface
Track by route template, not raw IDs:
GET /orders/:id
not:
GET /orders/o_42
GET /orders/o_43
Useful metrics include:
- request count
- p50/p95/p99 latency
- status class/code
- request/response size
- auth failures
- rate-limit rejects
- dependency latency
- retry count
Do not put user IDs, tokens, arbitrary URLs, or raw search strings into metric labels.
Return request/trace IDs safely
A response header such as:
X-Request-ID: req_...
can help support/debugging when it maps to internal logs/traces.
Treat externally supplied IDs as untrusted input; validate or generate your own canonical correlation IDs.
Do not make sensitive internal database IDs part of public debugging metadata without a reason.
A production REST baseline
A strong baseline looks like:
- Domain-oriented stable resource URIs.
- Standard HTTP method semantics.
- Correct status codes.
- Idempotency protection for retryable non-idempotent operations.
- RFC Problem Details or another one consistent error contract.
- Strict schema/business validation and body-size limits.
- Resource-level authorization.
- Bounded cursor pagination for large mutable lists.
- Deliberate caching and ETag/precondition support where useful.
- Rate limits/quotas by meaningful caller and cost.
- OpenAPI contract + automated compatibility tests.
- Route-level metrics and distributed tracing.
Production checklist
Before shipping a REST endpoint, ask:
- is the resource/action modeled clearly?
- does the HTTP method match its semantics?
- is retry behavior safe and documented?
- are status codes meaningful to generic clients?
- is the error format consistent?
- are input type/size/range limits enforced?
- is object/tenant authorization checked?
- can list endpoints return unbounded data?
- are cache headers safe for personalized responses?
- can ETags/preconditions prevent lost updates or unnecessary transfers?
- do rate limits reflect caller identity and operation cost?
- does OpenAPI match real behavior?
- can old clients survive additive changes?
- do metrics avoid high-cardinality/sensitive labels?
REST APIs are strongest when they use HTTP as a real application protocol, not merely a tunnel for JSON. Correct method semantics, cache behavior, errors, preconditions, and authorization make APIs easier to integrate, retry, operate, and evolve.
References
BUILDING A SAAS PRODUCT?
Explore Lofingo’s end-to-end SaaS platforms, multi-tenant cloud architecture, and high-concurrency backend engineering.
EXPLORE SAAS SERVICES →