Your API lets clients create resources with HTTP PUT to a URL they choose, instead of POSTing to a collection. What does that buy you, what identifier scheme does it require, and what should the server return?
answer
- Client mints UUID → retry hits same URL
- 201 created / 200 or 204 replaced
- If-None-Match: * = create-only
- Validate id shape, scope by tenant
- PUT still needs the full body
basics
~20 sPUT-as-upsert makes creation idempotent: the client generates the id (usually a UUID), so a retried request lands on the same URL and cannot create a duplicate. Return 201 when it created, 200 or 204 when it replaced.
solid answer
~50 sWith `POST /orders` the server assigns the id, so a timed-out request that actually succeeded cannot be distinguished from one that failed — retrying risks a duplicate. With `PUT /orders/{client-uuid}` the identity comes from the client, so the retry targets the same resource and the second attempt is a no-op replacement. Creation becomes idempotent without an idempotency-key sidecar. Requirements: the client must be able to mint a collision-free id (UUIDv4/v7, or a natural key like `/users/{email}/preferences`), and the server must accept that id rather than overwrite it with its own. Guard against clients choosing hostile ids — validate the format and never let the id leak into SQL or file paths. Responses: `201 Created` when the PUT brought the resource into existence, `200 OK` (with body) or `204 No Content` when it replaced an existing one. If you want create-only semantics, add `If-None-Match: *` so an existing resource yields `412`.
go deeper
Know that the client can create by PUTting to a URL it chose, that the id is usually a UUID, and that 201 means created.
Explain the retry story and the 201-vs-200/204 distinction, and note that PUT still requires the complete representation.
Cover id validation, tenant scoping, server-owned fields, and using If-None-Match/If-Match to get create-only or compare-and-swap semantics.
Decide the platform default — client-minted ids everywhere versus idempotency keys — weighing offline clients, database index behaviour of random ids, and audit requirements.
## The problem PUT-upsert solves The classic creation flow is `POST /orders` → `201 Created` + `Location: /orders/42`. It has one structural weakness: the *identity does not exist until the server acts*. If the client's socket dies before the response arrives, the client has no way to know whether an order exists, and no name to ask about. Retrying may create a duplicate. PUT-based creation moves identity generation to the client: ``` PUT /orders/6f1c0b6e-9f6a-4f2c-9b0e-6a1d2e3f4a5b Content-Type: application/json {"item":"book","qty":1} ``` Now the retry targets exactly the same URL. The first attempt created; the second replaces the same resource with the same body, leaving identical state. Creation has become idempotent by construction rather than by bolted-on bookkeeping. ## What the identifier must be The client-chosen id must satisfy two properties. **Collision-free across clients.** UUIDv4 is the default; UUIDv7 or ULID additionally sort by time, which keeps database index inserts cheap. Sequential client-side counters are not acceptable — two clients collide immediately. **Meaningless to the server's authorization model.** A client that can pick ids can pick `../admin`, an id belonging to another tenant, or a value chosen to probe existence. So: validate the shape (a strict UUID regex), scope the resource to the caller's tenant regardless of the id, and treat "PUT to an id that already exists but belongs to someone else" as the same answer you would give for any inaccessible resource. A natural key is the other legitimate scheme: `PUT /users/alice/settings` or `PUT /repos/acme/web/labels/bug`. Here the URL is derivable from domain data, so PUT is the obvious method and upsert is the obvious semantic. ## Status codes The server knows which of the two things happened, so it should say: - **`201 Created`** — the resource did not exist and now does. A `Location` header is redundant (the client chose the URL) but harmless. - **`200 OK`** — replaced, and the server is returning the stored representation (useful when it normalizes or adds server-set fields like `updatedAt`). - **`204 No Content`** — replaced, nothing to return. Collapsing everything into `200` is a smell: clients that care about "did I create this" lose the signal. If you want *create-only* rather than upsert, HTTP already has the mechanism: `If-None-Match: *` means "only if it does not exist", and the server answers `412 Precondition Failed` when it does. Similarly `If-Match: "etag"` turns PUT into a compare-and-swap replace, which protects against lost updates. ## Where it hurts **Full-representation burden.** PUT still means "here is the whole thing". A client doing upsert must send every field, including ones it did not author. Schema growth makes this fragile; teams often end up offering PUT for create and PATCH for subsequent edits. **Server-owned fields.** `createdAt`, `version`, computed totals. The contract must state that the server ignores them on input; otherwise a naive replace lets a client rewrite audit data. **Enumeration and squatting.** Because clients name resources, a hostile client can pre-create ids it expects someone else to use, or probe which ids exist by observing 201 vs 200. Random UUIDs make this uninteresting; natural keys do not, so guard those with authorization checks rather than obscurity. **Idempotency is only as good as the body.** PUT retry safety assumes the retry sends the *same* body. Two different clients PUTting different bodies to the same URL is last-write-wins, not a conflict — add `If-Match` if you need conflict detection. ## Choosing between the two styles Use POST-to-collection when identity is genuinely the server's (sequence numbers, human-readable order ids, business keys derived from server state). Use PUT-with-client-id when you want cheap idempotent creation, offline-first clients that must mint ids before they can sync, or resources with natural keys. Many mature APIs support both: POST for convenience, PUT for reliable retries.
- How does PUT-upsert compare with an idempotency key on POST?They solve the same problem at different layers. PUT-upsert uses the resource URL itself as the dedupe key, so no extra storage or expiry policy is needed, but it forces client-generated ids and full-representation bodies. An idempotency key keeps server-assigned ids and works for non-CRUD actions like payments, at the cost of a keyed request-record store with a TTL and rules for replaying the original response.
- How do you stop a client from setting server-owned fields such as createdAt via PUT?Define them as read-only in the contract and strip or reject them on input rather than binding the request body straight onto the persisted entity. Either ignore unknown/read-only fields silently and document that, or fail with 400/422 when they are present — pick one and apply it consistently, because silent acceptance plus silent discard is what causes "my update did nothing" bug reports.
saying these in an interview costs you the question
- Letting the server overwrite the client's id, which destroys the idempotency the scheme exists for
- Returning 200 for both create and replace, so the client cannot tell which happened
- Using a sequential client-side counter instead of a UUID/ULID
- Trusting the client-supplied id for authorization or interpolating it into a path or query
- Assuming PUT-upsert also protects against two clients concurrently writing different bodies