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 PATCH or PUT returns 428 while GET on 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

  1. 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 GET the resource, read its ETag, and echo it back as If-Match on 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.

  2. 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 stripping If-Match on 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.

  3. No validator to send - You went to attach If-Match, but the GET never gave you an ETag to use. If the resource’s GET response has no ETag or Last-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-Match needs an ETag, and If-Unmodified-Since needs a Last-Modified. If the check shows the GET response carries a real ETag, capture it and echo it back on your write; that’s the whole fix. If the GET returns no ETag and no Last-Modified at 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

  • GET the resource and capture the ETag from the response headers (or Last-Modified if that’s all it offers).
  • Resend the write with If-Match: "<etag>" (or If-Unmodified-Since: <date>) and confirm it now returns 2xx.
  • If the GET returns no validator, check whether a CDN or proxy is stripping the ETag — 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-Match turns 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 GET returns no ETag or Last-Modified and 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 of If-Match: *, which defeats the purpose.
  • If If-Match works 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.