Emmanuel Corels
← Blog
Networking & ISP Systems

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.

By Emmanuel Corels

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

SituationTypical responseCritical metadata
POST created server-named resource201Location and representation
PUT created known target201Target URI and validator
PUT replaced target200 or 204New ETag when available
Representation invalid400 or 422 per API contractStable field-level error format
Target version changed412Current validator or recovery link
Duplicate key with changed payload409Explicit 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 ↗