Problem
A PUT, PATCH, or DELETE comes back 428 Precondition Required. Auth passed, the route is correct, the body validates — but the write is refused because you sent it unconditionally. The server has a policy: it will not accept a mutation that isn’t pinned to a specific version of the resource, and your request carried no If-Match (or If-Unmodified-Since) header to pin it. A plain GET on the same resource works fine, because reads don’t need a precondition.
Symptoms
- A
PATCHorPUTreturns 428 whileGETon the same endpoint returns 200. - The error body says something like “an If-Match header is required” or “precondition required.”
- The identical request starts working the moment you add
If-Match: "..."with an ETag. - Only writes fail — reads, health checks, and unrelated endpoints are all fine.
- An OData / SAP / enterprise API rejects every update until you round-trip the entity’s ETag.
What 428 Actually Means
RFC 6585 (§3) defines 428 as the origin server requiring the request to be conditional: “its typical use is to avoid the ‘lost update’ problem, where a client GETs a resource’s state, modifies it, and PUTs it back to the server, when meanwhile a third party has modified the state on the server, leading to a conflict.” The response SHOULD explain how to resubmit successfully — usually by naming the header it wants.
This is optimistic concurrency control made mandatory. The lost update is the whole reason it exists: two clients read version "v1", both edit, both write back. Without a precondition, the second write silently overwrites the first, and the first client’s change vanishes with no error. A server that returns 428 is refusing to play that game. It demands that every write declare which version it intends to replace — If-Match: "v1" — so it can reject the write if the resource has moved on since you read it.
428 is the mirror image of 412 Precondition Failed. 412 fires when a precondition was present and evaluated false. 428 fires when a precondition was required and absent. And unlike 412, 428 is almost never emitted by nginx or Apache out of the box — it’s an application-level decision. Some framework, API gateway, or handler was explicitly written to demand conditional writes. The best real-world examples are enterprise data APIs: SAP S/4HANA’s OData services return 428 on any PATCH that doesn’t carry the entity’s ETag as If-Match.
Top 3 Causes
-
The API enforces optimistic concurrency by design - The intended path, not a bug. Platforms like SAP OData and Salesforce require every mutation to be conditional. You’re supposed to
GETthe resource, read itsETag, and echo it back asIf-Matchon the write. The 428 is the server teaching you its contract: no unpinned writes allowed. Once you round-trip the validator, the write goes through. -
The client never sent the header - You meant to include
If-Match, but it didn’t arrive. A client library that doesn’t forward custom headers, a reverse proxy strippingIf-Matchon the way through, or a copy-paste request that dropped the header all produce a 428 that looks like a server problem but is a missing header. The tell: the request works from a REST client where you set the header by hand. -
No validator to send - You went to attach
If-Match, but theGETnever gave you an ETag to use. If the resource’sGETresponse has noETagorLast-Modified— because the endpoint doesn’t emit one, or a CDN stripped it — you have nothing to condition on, and every write dead-ends at 428. This is a broken contract on the server side: it demands a precondition while refusing to supply the validator that would satisfy it.
Diagnose with DechoNet
- HTTP Check to confirm the endpoint actually returns a validator on
GET. You cannot satisfy a 428 without one —If-Matchneeds an ETag, andIf-Unmodified-Sinceneeds aLast-Modified. If the check shows theGETresponse carries a realETag, capture it and echo it back on your write; that’s the whole fix. If theGETreturns noETagand noLast-Modifiedat all, the concurrency contract is broken — the server is demanding a precondition it never gives you the means to build, and that’s a server-side bug, not a client one.
Resolution Checklist
-
GETthe resource and capture theETagfrom the response headers (orLast-Modifiedif that’s all it offers). - Resend the write with
If-Match: "<etag>"(orIf-Unmodified-Since: <date>) and confirm it now returns 2xx. - If the
GETreturns no validator, check whether a CDN or proxy is stripping theETag— you cannot satisfy 428 without one. If the origin genuinely emits none, that’s a server-side fix. - Reach for
If-Match: *only when you truly mean “write if it exists, any version” — it clears the 428 but disables the lost-update guard the code was enforcing. - If adding
If-Matchturns the 428 into a 412, your ETag is stale. Re-GET, take the fresh validator, and retry — don’t loop the old one. - Verify the fix under real conditions: the write should succeed with a current ETag and fail cleanly (412) with a stale one. If both succeed, the precondition isn’t actually being enforced.
When to Escalate
- If the
GETreturns noETagorLast-Modifiedand the API still demands 428 on writes, that’s a contract bug: the server can’t logically be satisfied. Fixing it means the API team emits a validator on reads — it’s not something a client can work around short ofIf-Match: *, which defeats the purpose. - If
If-Matchworks direct-to-origin but 428 persists through the edge, a proxy is stripping the header between client and origin. That’s an infrastructure fix, and it belongs with whoever owns the gateway or CDN.
Check your own domain now
Free, no sign-up. Runs the exact check this guide describes and shows what to fix.