RESTful API Best Practices: HTTP Methods, Status Codes, and Idempotency

22 min read
API DesignBackendEngineering

Good API design is invisible. When it works well, consumers never think about it — they read the docs, integrate in minutes, and move on. When it's done poorly, every team that touches it inherits the confusion: inconsistent verbs, ambiguous status codes, undocumented side effects, and retry logic that quietly double-charges users.

In this post, I break down the foundational building blocks of RESTful API design: the semantics behind each HTTP method, the status codes that communicate intent clearly, how to structure requests and responses for longevity, and why idempotency isn't optional in production systems.

RESTful API Design — HTTP methods, status codes, and the idempotency problem visualized


HTTP Methods and Their Semantics

Each HTTP method carries a contract. Violating that contract doesn't break the protocol — it breaks the expectations of every client who consumes your API.

GET — Read Without Side Effects

GET is idempotent and safe. It retrieves a representation of a resource without modifying server state. Calling it once or a hundred times produces the same result.

  • Cacheable by default — responses can be stored by intermediaries (CDNs, front doors, load balancers, browsers). This is one of REST's biggest performance wins.
  • Exception: endpoints returning PII/PHI or dynamic calculations should set appropriate Cache-Control headers to prevent caching.
  • No side effects — a GET request must never create, update, or delete data.

POST — Create and Trigger

POST creates new resources or triggers non-idempotent operations (like initiating a payment or performing a complex search via request body).

  • Returns 201 Created with a Location header pointing to the new resource.
  • Non-idempotent by nature — sending the same POST twice can create duplicate resources. This is where idempotency keys become critical (more on this below).
  • For safety in financial or critical operations, use a custom header like Idempotency-Key or a payment-specific key to guarantee exactly-once processing.

PUT — Full Replacement

PUT replaces the entire resource at a given URI. It's not for partial updates, and it's not for creation (despite some of my very own implementations).

  • Idempotent — sending the same PUT multiple times results in the same resource state.
  • Optimistic locking — use If-Match with an ETag to prevent collisions. If the resource was modified since the client last read it, the server responds with 412 Precondition Failed. This prevents the "last write wins" problem in concurrent environments.
  • Use versioned fields or ETags to detect stale updates cleanly.

PATCH — Partial Update

PATCH applies a partial modification to a resource. Only the fields included in the request body are updated.

  • Ideal for bandwidth efficiency — no need to send the full resource representation when only one field changed.
  • Accepts delta payloads (only the changed fields) rather than full replacements.
  • Unlike PUT, PATCH is not necessarily idempotent — applying the same patch to a resource that has changed in between may produce different results.

DELETE — Remove (Idempotently)

DELETE removes a resource. It's idempotent — deleting a resource that no longer exists should still return success, not an error.

  • Returns 204 No Content on success (no response body needed).
  • In practice, most production systems use soft deletes — marking a record as deleted rather than physically removing it. This enables audit trails and the ability to undo operations.
  • Idempotent even when the resource does not exist: a second DELETE to the same URI should return 204, not 404. The client's intent (the resource mustn't exist) is already satisfied.
  • Security nuance: APIs using URL-based access control may return 404 on subsequent deletes to avoid confirming the resource ever existed. Both approaches are valid — choose based on your threat model.

Status Codes That Actually Mean Something

Status codes aren't decorative. They are the API's primary communication channel with its consumers.

2xx — Success

Code Meaning When to Use
200 OK Request succeeded Reads, updates, and general success
201 Created Resource created POST that creates a new entity. Include a Location header
202 Accepted Request accepted for processing Async operations where the result isn't yet available
204 No Content Success, no response body DELETE, or PUT/PATCH when no body is needed

4xx — Client Errors

Code Meaning When to Use
400 Bad Request Malformed input Syntax errors, missing required fields, unparseable JSON
401 Unauthorized Not authenticated No credentials or expired token (yes, the name is misleading)
403 Forbidden Authenticated but not allowed Valid credentials, insufficient permissions
404 Not Found Resource doesn't exist The URI doesn't map to any known resource
409 Conflict State conflict Concurrent modification, duplicate creation, business rule violation
422 Unprocessable Entity Validation failure Syntactically valid but semantically wrong (e.g., order is correctly structured but references a product that no longer exists)

Key distinction: 401 means "I don't know who you are." 403 means "I know exactly who you are, and you're not allowed, baby!"

5xx — Server Errors

Code Meaning When to Use
500 Internal Server Error Unexpected failure Unhandled exceptions, bugs
502 Bad Gateway Upstream failure A dependency (database, third-party service) returned an invalid response
503 Service Unavailable Temporarily overloaded Server's up but cannot handle requests (use with Retry-After header)

Request and Response Design

Good design makes your API predictable across dozens of endpoints.

API Versioning

Use URL-based versioning (/v1/users, /v2/users). It's explicit, visible in logs, and easy to route at the infrastructure level. Alternatives exist (query params, headers, date-based), but path versioning is the most widely adopted (e.g. Stripe, GitHub, and Google).

  • Maintain backwards compatibility within a version. Breaking changes require a new version.
  • Deprecate old versions with clear timelines and migration guides.

Consistent Response Structure

Every response — success or error — should follow the same envelope:

{
  "data": { ... },
  "meta": { "requestId": "abc-123", "timestamp": "2026-06-30T12:00:00Z" }
}

For errors:

{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "The quantity field must be greater than zero."
  },
  "meta": { "requestId": "abc-123", "timestamp": "2026-06-30T12:00:00Z" }
}
  • Meaningful field names — avoid abbreviations that require a glossary.
  • Correlation/request ID — include it in every response. When something breaks in production, this is how you trace the request across services.
  • Human-readable messages — your error messages will end up in Slack threads and bug reports. Make them useful.

Pagination, Filtering, and Sorting

For list endpoints:

  • Cursor-based pagination over offset-based. Offset pagination breaks when records are inserted or deleted between pages. Cursor pagination uses the last record's unique sort key (typically a composite of ID + timestamp) as a stable pointer.
  • Filtering via query parameters: GET /orders?status=pending&created_after=2026-01-01
  • Sorting via query parameters: GET /orders?sort=created_at:desc

Idempotency: What It Is and Why It Matters in Production

Idempotency means that performing the same operation multiple times produces the same result as performing it once. In the context of APIs, an idempotent request can be safely retried without causing unintended side effects.

Why This Matters

In production, retries are not a choice — they are inevitable. Networks are unreliable. Requests time out. Packets get lost. Load balancers restart. Clients retry automatically.

The question is never: "Will retries happen?". It's: "What happens when they do?"

The Double-Charge Scenario

Consider a payment API without idempotency protection:

  1. Client sends POST /charges with amount: $50.
  2. The server processes the charge successfully.
  3. The response is lost due to a network timeout.
  4. The client, having received no confirmation, retries the same request.
  5. The server sees a new POST, processes it as a new charge.
  6. The user is charged $100 instead of $50.

Which Methods Are Safe?

  • GET, PUT, DELETE are inherently idempotent. Retrying them doesn't change the outcome.
  • POST is the dangerous one. Without explicit idempotency handling, every retry is treated as a new request.

Implementing Idempotency Keys

The solution: require clients to send a unique Idempotency-Key header with every POST request.

The server:

  1. Receives the request and checks if that key has been seen before.
  2. If new: processes the request, stores the key + response.
  3. If seen: returns the stored response without re-executing the operation.

Stored keys should have a TTL of 24 to 48 hours — long enough to cover retry windows, short enough to avoid unbounded storage growth.

Edge case: if a retry arrives while the original request is still processing, the server won't find a stored response yet. Production systems handle this with a distributed lock on the key, returning 409 Conflict if the same key is already in-flight.

This guarantees exactly-once semantics from the client's perspective, regardless of how many times the request is retried.


Putting It All Together

Principles in isolation are easy to agree with. Seeing them applied as a cohesive controller is where the real signal is. Here's an OrdersController in ASP.NET Core demonstrating every verb with proper semantics:

[ApiController]
[Route("v1/orders")]
public class OrdersController : ControllerBase
{
    // GET /v1/orders?cursor=abc&limit=20
    [HttpGet]
    [ProducesResponseType(typeof(ApiResponse<List<OrderDto>>), StatusCodes.Status200OK)]
    public async Task<IActionResult> List(
        [FromQuery] string? cursor, [FromQuery] int limit = 20)
    {
        var (orders, nextCursor) = await _service.ListOrdersAsync(cursor, limit);

        return Ok(new ApiResponse<List<OrderDto>>
            { Data = orders, Meta = new { nextCursor } });
    }

    // GET /v1/orders/{id:guid}
    [HttpGet("{id:guid}")]
    [ProducesResponseType(typeof(ApiResponse<OrderDto>), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> Get(Guid id)
    {
        var order = await _service.GetOrderAsync(id);
        if (order is null) return NotFound();

        Response.Headers.ETag = $"\"{order.Version}\"";
        return Ok(new ApiResponse<OrderDto> { Data = order });
    }

    // POST /v1/orders
    [HttpPost]
    [ProducesResponseType(typeof(ApiResponse<OrderDto>), StatusCodes.Status201Created)]
    [ProducesResponseType(StatusCodes.Status409Conflict)]
    public async Task<IActionResult> Create(
        [FromHeader(Name = "Idempotency-Key")] string idempotencyKey,
        [FromBody] CreateOrderRequest body)
    {
        // Atomic: acquires a lock on the key, returns cached result or null
        var (cached, lockHandle) = await _idempotencyStore
            .GetOrLockAsync(idempotencyKey);

        if (cached is not null)
            return StatusCode(cached.StatusCode, cached.Response);

        // Lock acquired — we are the only request processing this key
        await using (lockHandle)
        {
            var order = await _service.CreateOrderAsync(body);
            var response = new ApiResponse<OrderDto> { Data = order };

            await _idempotencyStore.SetAsync(
                idempotencyKey, 201, response, ttl: TimeSpan.FromHours(48));

            return CreatedAtAction(nameof(Get), new { id = order.Id }, response);
        }
    }

    // PUT /v1/orders/{id:guid}
    [HttpPut("{id:guid}")]
    [ProducesResponseType(typeof(ApiResponse<OrderDto>), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    [ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
    public async Task<IActionResult> Replace(
        Guid id, [FromBody] ReplaceOrderRequest body)
    {
        var order = await _service.GetOrderAsync(id);
        if (order is null) return NotFound();

        var expected = new EntityTagHeaderValue($"\"{order.Version}\"");
        var ifMatch = Request.GetTypedHeaders().IfMatch;
        if (!ifMatch.Any(e => e.Compare(expected, useStrongComparison: true)))
            return StatusCode(412); // Precondition Failed

        var updated = await _service.ReplaceOrderAsync(id, body);
        Response.Headers.ETag = $"\"{updated.Version}\"";
        return Ok(new ApiResponse<OrderDto> { Data = updated });
    }

    // PATCH /v1/orders/{id:guid}
    [HttpPatch("{id:guid}")]
    [ProducesResponseType(typeof(ApiResponse<OrderDto>), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> Patch(
        Guid id, [FromBody] PatchOrderRequest body)
    {
        var order = await _service.GetOrderAsync(id);
        if (order is null) return NotFound();

        var updated = await _service.ApplyDeltaAsync(id, body);
        return Ok(new ApiResponse<OrderDto> { Data = updated });
    }

    // DELETE /v1/orders/{id:guid}
    [HttpDelete("{id:guid}")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    public async Task<IActionResult> Delete(Guid id)
    {
        await _service.SoftDeleteOrderAsync(id); // No-op if already deleted
        return NoContent(); // 204 regardless — intent is satisfied
    }
}

No verb violates its contract. GET is safe and cacheable via ETag. POST is protected by an atomic idempotency lock with a 48-hour TTL — concurrent retries on the same key cannot race past the check. PUT parses the If-Match header using EntityTagHeaderValue, correctly handling multiple ETags, weak references, and whitespace. DELETE is idempotent by design. Route constraints ({id:guid}) reject malformed identifiers at the routing layer, and a typed ApiResponse<T> wrapper keeps ProducesResponseType attributes aligned with the actual response shape for accurate OpenAPI generation.

Comments

Loading comments…

Leave a comment

50 characters remaining

1000 characters remaining