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.
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-Controlheaders to prevent caching. - No side effects — a
GETrequest 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 Createdwith aLocationheader pointing to the new resource. - Non-idempotent by nature — sending the same
POSTtwice 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-Keyor 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
PUTmultiple times results in the same resource state. - Optimistic locking — use
If-Matchwith anETagto prevent collisions. If the resource was modified since the client last read it, the server responds with412 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,PATCHis 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 Contenton 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
DELETEto the same URI should return204, not404. The client's intent (the resource mustn't exist) is already satisfied. - Security nuance: APIs using URL-based access control may return
404on 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:
- Client sends
POST /chargeswithamount: $50. - The server processes the charge successfully.
- The response is lost due to a network timeout.
- The client, having received no confirmation, retries the same request.
- The server sees a new POST, processes it as a new charge.
- 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:
- Receives the request and checks if that key has been seen before.
- If new: processes the request, stores the key + response.
- 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 Conflictif 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.