Design create and replace API operations that retry safely
Choose POST when the service assigns identity, PUT when the client knows the target URI, enforce replacement semantics, add concurrency controls, and test ambiguous retries.
A mobile client submits an order, the connection drops before the response arrives, and it cannot tell whether the server committed the order. Retrying a naive POST can create a duplicate. Replacing the request with PUT mechanically does not solve the contract. The method must match who chooses the resource URI and what repeating the request is intended to do.
Start with the target, not the CRUD verb
POST /orders HTTP/1.1
Content-Type: application/json
{"customer_id":"cus_42","items":[{"sku":"edge-8","quantity":2}]}
Here the target is the order collection or order-processing resource. The server validates the command, allocates an order identifier and returns the new URI. RFC 9110 defines POST broadly: the target processes the enclosed representation according to its own semantics.
HTTP/1.1 201 Created
Location: /orders/ord_9f31
Content-Type: application/json
{"id":"ord_9f31","status":"pending"}
Use PUT when the client knows the target resource
PUT /customer-preferences/cus_42 HTTP/1.1
Content-Type: application/json
{"currency":"NGN","notifications":{"email":true,"sms":false}}
The request says: make the state of this known target match the enclosed representation. RFC 9110 describes PUT as creating or replacing the state of the target resource. If the target did not exist and creation is permitted, return 201 Created; if an existing representation was replaced successfully, 200 OK with a representation or 204 No Content can be appropriate.
Do not implement PUT as an accidental partial update
# Existing
{"currency":"USD","notifications":{"email":true,"sms":true}}
# Incoming PUT
{"currency":"NGN"}
# Replacement result
{"currency":"NGN","notifications":{...documented defaults...}}
A PUT representation defines replacement state. Silently retaining omitted fields gives the request patch-like semantics and makes clients unable to reason about idempotency. If partial modification is required, define a documented PATCH media type and its merge rules rather than calling it PUT.
Understand what idempotent does—and does not—promise
PUT is idempotent because multiple identical requests have the same intended effect as one. The response may differ: the first request might create and return 201; a repeat might replace and return 204. Servers may still log every attempt, emit metrics or retain revision history. The business state requested by the user must not compound.
PUT /stock-levels/warehouse-7/edge-8
{"quantity":12}
Repeated PUTs keep quantity at 12. This is not idempotent:
PUT /stock-levels/warehouse-7/edge-8
{"increment_by":12}
The second payload is a command whose effect accumulates. A method label cannot make non-idempotent application logic safe.
Make server-assigned creation retryable with an idempotency key
POST /orders HTTP/1.1
Idempotency-Key: 7beeb6f0-15a5-4aac-bc11-7c6d2285f4e9
Content-Type: application/json
{"customer_id":"cus_42","items":[{"sku":"edge-8","quantity":2}]}
This header has meaning only if the service implements it. Store a scoped key, request fingerprint, processing state and final response atomically. A repeated key with identical input returns the stored outcome; the same key with different input is rejected.
BEGIN;
INSERT INTO idempotency_requests
(tenant_id, request_key, request_hash, state)
VALUES
(:tenant, :key, :hash, 'processing')
ON CONFLICT DO NOTHING;
-- Create order and persist final status/headers/body in one transaction.
COMMIT;
Define key scope, retention, maximum body size, concurrent-request behavior and treatment of 4xx/5xx outcomes. If the key record commits separately from the business write, a crash can still create an ambiguous state.
Protect PUT from lost updates
Idempotency does not prevent two different clients from replacing each other’s changes. Use a validator and conditional request:
GET /customer-preferences/cus_42 HTTP/1.1
HTTP/1.1 200 OK
ETag: "prefs-v17"
PUT /customer-preferences/cus_42 HTTP/1.1
If-Match: "prefs-v17"
Content-Type: application/json
{"currency":"NGN","notifications":{"email":true,"sms":false}}
If another update changed the resource, respond 412 Precondition Failed instead of overwriting it. Require If-Match with 428 Precondition Required where unconditional replacement is unsafe.
Choose status codes that let clients recover
| Situation | Typical response | Critical metadata |
|---|---|---|
| POST created server-named resource | 201 | Location and representation |
| PUT created known target | 201 | Target URI and validator |
| PUT replaced target | 200 or 204 | New ETag when available |
| Representation invalid | 400 or 422 per API contract | Stable field-level error format |
| Target version changed | 412 | Current validator or recovery link |
| Duplicate key with changed payload | 409 | Explicit idempotency conflict |
Do not expose client-chosen identifiers without authorization
PUT /users/alice/billing-profile HTTP/1.1
Knowing or selecting a URI does not authorize writing it. Resolve the authenticated principal and tenant on the server, enforce object-level authorization, reject cross-tenant identifiers and prevent predictable IDs from becoming an access-control bypass.
Set cache behavior deliberately
Responses to POST are cacheable only under the conditions defined by HTTP caching rules and are rarely cached by general-purpose intermediaries. Successful PUT invalidates stored responses for the target URI in participating caches. Do not assume a CDN understands an application’s related collection or search pages; purge or version those explicitly.
Cache-Control: no-store
Vary: Authorization
Use no-store for sensitive command responses when appropriate. Authentication, representation negotiation and cache keys need a design review together.
Test the lost-response case, not only normal retries
request_id=$(uuidgen)
curl --silent --show-error --header "Idempotency-Key: $request_id" --header 'Content-Type: application/json' --data-binary @order.json https://api.example.test/orders
In an integration environment, commit the server transaction and deliberately cut the connection before the response reaches the client. Retry the exact request concurrently and sequentially. Assert one order exists, one payment authorization occurs, and every successful replay resolves to the same resource.
Verify PUT converges under repetition
for attempt in 1 2 3; do
curl --fail-with-body --request PUT --header 'Content-Type: application/json' --data-binary @preferences.json https://api.example.test/customer-preferences/cus_42
done
curl --fail-with-body https://api.example.test/customer-preferences/cus_42
Compare stored state, event count, downstream notifications and audit entries. Sending the same representation three times must not create three welcome emails or increment a revision-dependent business counter unless that side effect is explicitly outside the requested semantics and safely handled.
Document the contract clients actually need
- Who chooses the resource identifier.
- Whether PUT creates missing targets.
- Whether omitted fields reset, default or remain unchanged.
- Which conditional headers are required.
- Idempotency-key scope, retention and conflict behavior.
- Which errors are retryable and the maximum retry window.
- Where the canonical created-resource URI is returned.
POST and PUT can both participate in creation. The durable distinction is whether the target processes a command or the enclosed representation replaces a client-known target—and whether retries preserve that contract.
Sources: the solved Stack Overflow question (question by alex; accepted answer by Brian R. Bondy, CC BY-SA), and the current HTTP semantics standard, RFC 9110 POST, PUT, and idempotent methods.
Primary source: Review the official reference ↗