API Design

15 REST design decisions. Click any to see the canonical endpoint and why.

You're designing the cart service for an e-commerce app. The frontend needs to add a specific item to a logged-in user's cart. Pick the best endpoint.

Items should be modeled as a sub-resource of the cart · Avoid verbs in the path · Should return the updated cart or the new line item
Canonical endpoint
POST/v1/carts/{cartId}/items
{ "itemId": "sku-9", "qty": 2 }
201 Created { "lineItemId": "li_44", "itemId": "sku-9", "qty": 2 }

Items are a sub-resource of a cart, so POST /carts/{cartId}/items is the RESTful (Representational State Transfer) expression of 'create a new line item under this cart' and returns 201 with the created resource. Option (a) puts a verb in the path; (c) shoves an action into a full-resource PUT; (d) uses GET for a state-changing operation, which violates safe-method semantics and lets caches/prefetchers accidentally mutate the cart.

Dive deeper

Treat URLs (Uniform Resource Locators) as nouns and HTTP (Hypertext Transfer Protocol) methods as verbs. Sub-resources collect things that only exist in the context of a parent (a line item has no meaning without a cart). Reserve action-style paths like /carts/{id}/checkout for true state-machine transitions that don't fit CRUD (Create, Read, Update, Delete).

Your profile screen lets users edit just their display name or just their avatar URL without touching other fields. Pick the best endpoint for saving the change.

Client sends only the fields that changed · Must not blank out fields the client didn't send · Should be safe to call repeatedly with the same payload
Canonical endpoint
PATCH/v1/users/{id}
{ "displayName": "Ada" }
200 OK { "id": "u_1", "displayName": "Ada", "avatarUrl": "..." }

PATCH is the partial-update verb: the body describes the diff, and unspecified fields are left alone. A naive PUT (option a) is defined as 'replace the resource' — many servers and validators will treat missing fields as null, which can wipe the avatar URL. Option (c) reinvents PATCH with a verb path, and (d) reuses POST on an existing resource, which conflicts with POST's 'create' semantics.

Dive deeper

PUT replaces, PATCH modifies. If you really want PUT semantics for partial updates you have to GET-then-PUT the whole resource, which races against concurrent edits. PATCH bodies are typically JSON (JavaScript Object Notation) Merge Patch (RFC 7396) or JSON Patch (RFC 6902) — pick one and document it.

You're building a payments API (Application Programming Interface). Clients sometimes retry after a timeout and you cannot double-charge. Pick the best endpoint for creating a charge.

Retries of the same logical charge must not create two payments · Server should be able to dedupe without the client knowing if the first call committed · Should still feel like normal REST
Canonical endpoint
POST/v1/payments
{ "amount": 1999, "currency": "usd", "source": "tok_abc" }
201 Created — request includes header Idempotency-Key: 8f3e... ; server caches response per key for 24h

The industry-standard pattern (Stripe, AWS, Square) is POST with a client-supplied Idempotency-Key header; the server stores the response keyed by it, so retries get the cached result instead of a second charge. Option (a) is the obvious bug. Option (b) is the right idea but baking the nonce into the body conflates business data with infra; a header keeps it out of validation and signing surfaces. Option (d) works but forces clients to mint resource IDs and changes the create verb from POST to PUT, which most teams find awkward.

Dive deeper

Idempotency keys should be scoped per endpoint and per authenticated principal, stored with the full response (status + body + headers), and expire after a window long enough to cover client retries (24h is typical). Crucially the server must distinguish 'same key, same body' (replay → cached response) from 'same key, different body' (conflict → 409 or 422).

A post on your social app can have millions of comments and the feed appends new ones constantly. The mobile client needs to scroll through them. Pick the best endpoint.

List grows while the user is paging · Must not skip or duplicate items as new comments arrive · Deep pagination must stay fast
Canonical endpoint
GET/v1/posts/{id}/comments?cursor=eyJ0Ijo...&limit=50
200 OK { "items": [...], "nextCursor": "eyJ0Ijo..." }

Cursor (keyset) pagination encodes 'where you left off' (e.g. created_at + id) in an opaque token; it stays O(log n) at any depth and is stable when new rows are inserted at the head. Offset/page pagination (a, b) gets slower the deeper you go and silently shifts items when new comments arrive, causing duplicates and skips. Option (d) uses POST for a pure read, which breaks cacheability and safety semantics.

Dive deeper

Cursors should be opaque to clients — encode a tuple like (sort_key, tiebreaker_id), base64 it, and reserve the right to change the encoding. Always return a nextCursor (and optionally prevCursor) rather than asking clients to construct it. For UIs that genuinely need 'page 5 of 12', that's an offset use case — accept it for small bounded lists only.

You're shipping the first public version of your API (Application Programming Interface). You know breaking changes are inevitable and you need a versioning scheme that's easy to operate. Pick the best approach.

Easy to test from a browser or curl · Easy to route at the load balancer · Easy for clients to opt into a new major version
Canonical endpoint
GET/v1/users/{id}
200 OK — major version in path; bump to /v2/ for breaking changes

URL (Uniform Resource Locator) path versioning (/v1/, /v2/) is the most operationally boring choice: easy to curl, easy to route at an edge proxy, easy to deprecate in logs, and unambiguous in client SDKs. Header-based versioning (b) is theoretically purer but fights every CDN (Content Delivery Network), browser tool, and ad-hoc debugging session. Query-param (c) muddles the read-cache key. Server-pinned versions (d) hide the API contract from the client and create silent behavior drift when keys are rotated.

Dive deeper

Reserve URL versions for breaking changes only; additive changes (new optional fields, new endpoints) should ship without a bump. Pair path versioning with a deprecation policy: announce, dual-run for N months, emit a Sunset header, then 410 Gone. Date-based versions (Stripe-style) are a worthwhile middle ground when you ship breaking changes often.

Customers can request a CSV export of all their orders. Generating it takes 30 seconds to 10 minutes. Pick the best endpoint design.

Must not hold an HTTP (Hypertext Transfer Protocol) connection open for minutes · Client needs to poll or be notified when ready · Should survive client disconnects
Canonical endpoint
POST/v1/exports
{ "resource": "orders", "format": "csv" }
202 Accepted, Location: /v1/exports/exp_77 { "id": "exp_77", "status": "pending" }

Long-running work is its own resource: POST creates a job, the server returns 202 Accepted with a Location pointing to the job, and the client polls (or subscribes to a webhook) until status flips to succeeded with a download URL. Synchronous designs (a, c, d) tie up sockets, fail load-balancer timeouts, and lose progress on disconnect. 202 is the specific status for 'I have accepted this, it is not done yet.'

Dive deeper

Model jobs as first-class resources with their own lifecycle (pending, running, succeeded, failed, canceled). Expose GET /jobs/{id} for polling and ideally a webhook for completion. Include progress fields when you can, a result URL when done, and an error object when failed. This generalizes to imports, batch operations, and async ML inference.

Your blog API needs to let an author delete one of their posts. Pick the best endpoint and response.

Should be idempotent — deleting twice is fine · Caller doesn't need the resource back · Use the most specific status code
Canonical endpoint
DELETE/v1/posts/{id}
204 No Content

DELETE on the resource URL returning 204 No Content is the canonical pattern: the verb says what happened, the status code says 'success, no body to send.' Option (c) is acceptable but wastes a payload when the client already knows what it deleted; reserve a body for soft-delete metadata the client truly needs. Option (b) hides the verb in the path; (d) uses GET for a mutation, which is dangerous because it's safe and cacheable by spec.

Dive deeper

204 is for successful requests with deliberately empty bodies; 200 implies 'here is a representation.' DELETE should be idempotent: a second DELETE of an already-gone resource returns 204 (or 404 if you want to signal 'wasn't here') — pick one and document it. For soft deletes, prefer PATCH with a status field over reinterpreting DELETE.

Your warehouse system needs to update on-hand counts for up to 5,000 SKUs at once after a nightly stock count. Pick the best endpoint.

One round trip, not 5,000 · Server should report per-item success/failure · Partial failure must not look like total success
Canonical endpoint
POST/v1/inventory/bulk
{ "items": [{ "sku": "A1", "onHand": 12 }, { "sku": "A2", "onHand": 0 }] }
207 Multi-Status { "results": [{ "sku": "A1", "status": 200 }, { "sku": "A2", "status": 404, "error": "unknown sku" }] }

A bulk endpoint that returns 207 Multi-Status (or 200 with per-item statuses) lets the client see exactly which SKUs failed and retry only those, in one round trip. Option (b)'s full-resource PUT would wipe any SKU not in the body — dangerous. Option (c) is correct REST (Representational State Transfer) but pathologically slow. Option (d) collapses partial failure into a single boolean, which loses information the client needs to recover.

Dive deeper

For batch APIs, decide up front whether the batch is atomic (all-or-nothing, like a DB transaction) or per-item (each succeeds or fails independently). Document it explicitly. Per-item is more common for ingest; atomic is right for accounting or inventory transfers. Cap batch size and document the limit so clients can pre-chunk.

Your storefront needs to list products filtered by category, price range, and in-stock status, with a free-text search query. Pick the best endpoint.

Should be cacheable · Should be linkable / shareable · Filters should compose cleanly
Canonical endpoint
GET/v1/products?category=shoes&price_min=20&price_max=80&in_stock=true&q=runner
200 OK { "items": [...] }

Reading a filtered collection is GET on the collection with filters as query params; it's cacheable by URL (Uniform Resource Locator), shareable as a link, and composes naturally as filters are added. POST-for-search (b) is sometimes necessary when the query genuinely exceeds URL length limits or contains structured DSL, but for simple flat filters it forfeits HTTP (Hypertext Transfer Protocol) caching. Path-encoded filters (c) explode combinatorially and pin filter order. (d) buries the resource type as a param.

Dive deeper

Reach for POST /resource/search only when the query is large enough to overflow ~2KB URLs, contains structured logical operators, or carries data you wouldn't want in access logs. When you do, still return a Location header to a cacheable result resource if results are paged. For range filters, the price_min / price_max convention scales better than overloading a single 'price' param.

Users upload videos up to 5 GB to your platform. Pick the best endpoint design for the upload path.

Don't proxy 5 GB through your application servers · Support resumable uploads on flaky mobile networks · Server still needs metadata to register the file
Canonical endpoint
POST/v1/uploads
{ "filename": "trip.mp4", "size": 4823918273, "contentType": "video/mp4" }
201 Created { "uploadId": "up_1", "uploadUrl": "https://s3...?X-Amz-Signature=...", "method": "PUT" } — client PUTs bytes to uploadUrl, then POSTs /v1/videos referencing uploadId

The standard pattern is a two-step: client asks the API (Application Programming Interface) for a pre-signed upload URL (Uniform Resource Locator; and optionally a multipart upload session), then PUTs bytes directly to object storage, then notifies the API to finalize. This keeps gigabytes out of your app tier, supports resumable / multipart uploads natively (S3, GCS), and isolates auth at the URL level. Options (a) and (c) burn application bandwidth and memory; (d) base64-inflates payloads by 33% and is JSON (JavaScript Object Notation) parser-hostile.

Dive deeper

Pre-signed URLs should be short-lived (minutes), scoped to a single key and content-type, and the API should validate the uploaded object server-side before flipping the resource to 'ready' — never trust the client's claim that the upload finished or matched. For very large files, S3 multipart or tus.io's resumable upload protocol gives you per-chunk retries.

Two editors open the same document and both save changes a few seconds apart. You need to detect and reject the second save instead of silently overwriting the first. Pick the best endpoint.

Detect concurrent edits without long-lived locks · Use a standard HTTP (Hypertext Transfer Protocol) mechanism · Reject with a clear status code, not 500
Canonical endpoint
PUT/v1/docs/{id}
{ "title": "New", "body": "..." }
200 OK — request includes If-Match: "etag_abc"; server returns 412 Precondition Failed on mismatch

ETag + If-Match is the standard HTTP (Hypertext Transfer Protocol) optimistic-concurrency mechanism: the server hands out an ETag on GET, the client echoes it via If-Match on write, and the server returns 412 Precondition Failed if the resource changed underneath. Option (a) loses data. Option (c) introduces stateful locks that leak on crashed clients. Option (d) is the same idea but reinvents in-body what HTTP already standardizes — and bypasses CDN (Content Delivery Network) / proxy support for conditional requests.

Dive deeper

ETags can be strong (byte-equal) or weak (semantically equal); use strong for resources where every byte matters. Pair If-Match with If-Unmodified-Since as a fallback. For partial updates, the ETag should reflect the whole resource, not a field, so that PATCH bodies still race-detect correctly. 412 means 'your precondition failed'; reserve 409 for true business conflicts.

An authenticated user requests GET /v1/documents/{id}, but that document belongs to a different tenant and they have no permission to see it. Pick the best response.

Don't leak the existence of private resources · Distinguish 'not signed in' from 'signed in but forbidden' · Stay consistent with how unknown IDs are handled
Canonical endpoint
GET/v1/documents/{id}
404 Not Found { "error": "document not found" } — same response as for unknown IDs

Returning 404 for both 'doesn't exist' and 'exists but you can't see it' prevents an enumeration oracle that would otherwise let an attacker probe which document IDs are real. 401 (a) means 'no valid credentials' — wrong when the user is already authenticated. 403 (b) is semantically accurate but leaks existence; only use it when the resource's existence is already public (e.g. a known username's private profile). 500 (d) is a server-error code for unhandled exceptions, not access decisions.

Dive deeper

401 vs 403 vs 404 is fundamentally a question of what you're willing to reveal. 401 says 'authenticate'; 403 says 'we know who you are and the answer is no'; 404 says 'no such thing here, from your perspective.' Multi-tenant SaaS APIs almost always prefer 404 for cross-tenant access. Whatever you pick, return the same shape and timing for both cases — timing side-channels can leak existence too.

Earlier you exposed POST /v1/exports to start an export job. Now you need to let the user cancel an in-progress job. Pick the best endpoint.

Cancel is a state transition, not a deletion of the record · Job history should still be visible after cancel · Should not race-condition with completion
Canonical endpoint
POST/v1/exports/{id}/cancel
200 OK { "id": "exp_77", "status": "canceled", "canceledAt": "..." } — 409 if already finished

Cancel is a state-machine transition, not a deletion: the job record should persist (for audit, billing, and the UI's history view) but flip to a canceled state. A dedicated POST /exports/{id}/cancel makes the transition explicit, lets the server reject illegal transitions (e.g. 409 if already succeeded), and avoids clients ever setting arbitrary status values. DELETE (a) implies the record is gone. PATCH/PUT with status (c, d) let clients write any state, which leaks server-controlled lifecycle into the wire contract.

Dive deeper

Actions that don't map to CRUD (Create, Read, Update, Delete) — cancel, retry, publish, archive, refund — are the canonical exception to 'no verbs in URLs.' Express them as POST /resource/{id}/action sub-resources. Make them idempotent where possible (canceling an already-canceled job is fine; refunding twice is not). Pair with a clear list of allowed transitions in the API docs.

Your API (Application Programming Interface) needs an endpoint that returns the orders belonging to the currently authenticated user. Pick the best design.

Server already knows who the caller is · Caller should not be able to spoof another user's ID · Should still let admin tools list any user's orders
Canonical endpoint
GET/v1/me/orders
200 OK — scoped to authenticated principal; admin tools use /v1/users/{id}/orders instead

/v1/me/... is the standard pattern for 'the authenticated principal': the server resolves identity from the token, so clients can't accidentally or maliciously pass another user's ID, and the URL is the same across sessions, which makes caching and client code simpler. Admin or impersonation flows use the explicit /users/{id}/orders form, which can require elevated scopes. Options (a) and (c) let the client name the user, which is an authorization footgun if the server forgets to check that callerId == user_id. (d) buries a read in a POST.

Dive deeper

Two parallel resource trees — /me/* for self and /users/{id}/* for admin — give you a clean place to attach different OAuth scopes (e.g. orders:read.self vs orders:read.any). Never trust a user_id passed in body or query when you can derive it from the auth token. Document that /me requires authentication and returns 401 (not 404) for anonymous callers.

Your platform sends webhook events to subscribers when a payment succeeds. You're designing the outbound HTTP (Hypertext Transfer Protocol) call. Pick the best design.

Subscriber must verify the call really came from you · Network failures and 500s must not lose events · Subscriber must be able to dedupe replays
Canonical endpoint
POST{subscriberUrl}
{ "id": "evt_8f3", "type": "payment.succeeded", "createdAt": "...", "data": { "id": "pi_1" } }
headers: X-Signature: t=...,v1=hmac_sha256(...); X-Event-Id: evt_8f3 — retried with exponential backoff on 5xx/timeout; subscriber returns 2xx to ack

A production webhook needs four things: (1) a stable event ID so subscribers can dedupe replays, (2) an HMAC (Hash-based Message Authentication Code) signature over the body with a shared secret so the subscriber can verify origin without trusting TLS (Transport Layer Security) alone, (3) a documented retry policy with exponential backoff on non-2xx / timeouts, and (4) POST with a JSON (JavaScript Object Notation) body. Option (a) silently loses events. Option (c) crams payload into a URL (Uniform Resource Locator) and uses GET for a side-effecting notification. Option (d) ships your secret to whatever URL the subscriber configured — a credential leak waiting to happen.

Dive deeper

Sign with HMAC-SHA256 over a timestamp + raw body, and include both in the header (e.g. Stripe's t= and v1= scheme) so subscribers can reject replays older than ~5 minutes. Retry with capped exponential backoff for hours, then dead-letter. Subscribers must return 2xx fast (< few seconds) and do heavy work async; 'at-least-once delivery' means subscribers must be idempotent on event ID.