Skip to content
Control Plane Labs

428 Precondition Required

Client error response defined by RFC 6585 §3.

Last updated July 27, 2026

Common causes at a glance

  • A PUT or PATCH sent without an If-Match header on an API that demands one
  • A client that discarded the ETag it received from the earlier GET
  • An intermediary or CDN stripping If-Match from the forwarded request
  • A DELETE against an API requiring conditional deletes to prevent races

Reproduce it

curl -i -X PUT -H 'Content-Type: application/json' -d '{"title":"new"}' https://api.example.com/v1/docs/42

What 428 Precondition Required means

The scenario 428 prevents is two clients doing read-modify-write on the same resource. Both GET version 5, both edit locally, both PUT. The second write silently overwrites the first and nobody finds out. Requiring an If-Match header carrying the ETag the client read means the second write fails loudly with 412 instead.

428 is the bootstrap for that policy. A server that answered 200 to unconditional writes could never migrate to requiring them; answering 428 tells the client precisely what is missing and lets it correct itself in one round trip. Note the pairing: 428 means “you sent no precondition”, 412 means “you sent one and it did not hold”.

What the spec says

RFC 6585 §3 defines 428 and requires the response body to explain how to resubmit successfully. It is explicitly not cacheable. The preconditions themselves are RFC 9110 §13.1If-Match is §13.1.1 and If-Unmodified-Since is §13.1.4 — with evaluation order in §13.2. Note that 428 is optional: a server may accept unconditional writes and live with the race. If you do require preconditions, RFC 9110 §8.8.3 defines the ETag clients have to echo back.

What actually causes it

The usual failure is a client that never captured the ETag in the first place. Reading a resource through one code path and writing it through another loses the header somewhere in between, and generated SDKs are especially prone to it because the model object carries the fields but not the response metadata. The second cause is mangled validators: a compressing proxy that rewrites a strong ETag into a weak one breaks If-Match, since weak validators are not usable for §13.1.1 comparison. And a client that hardcodes If-Match: * to get past the 428 has defeated the mechanism — that matches any existing representation and reintroduces the lost update it was meant to prevent.

How to debug it

Do the two-step by hand. Fetch the resource, keep the ETag, send it back:

etag=$(curl -sSI https://api.example.com/v1/docs/42 | awk -F': ' '/^[Ee][Tt]ag/{print $2}')
curl -sSi -X PUT -H "If-Match: $etag" -H 'Content-Type: application/json' \
-d '{"title":"new"}' https://api.example.com/v1/docs/42

If that succeeds and the application still gets 428, the header is being lost between your code and the server; the header inspector makes the diff obvious. If you get 412 instead of 200, the mechanism is working and someone else changed the resource after your read: re-fetch, reapply, retry. That loop is safe, unlike retrying a 428 unchanged, which fails identically every time.

Working examples

curl

# Read the current validator.
curl -sSI https://api.example.com/v1/docs/42 | grep -i '^etag:'

# Write conditionally with it. 412 means someone beat you to it; 200 means you won.
curl -sSi -X PUT \
  -H 'If-Match: "a1b2c3d4"' \
  -H 'Content-Type: application/json' \
  -d '{"title":"new"}' \
  https://api.example.com/v1/docs/42

Python (requests)

import requests

url = "https://api.example.com/v1/docs/42"

current = requests.get(url, timeout=10)
etag = current.headers["ETag"]  # keep this alongside the parsed body

resp = requests.put(
    url,
    json={**current.json(), "title": "new"},
    headers={"If-Match": etag},
    timeout=10,
)
# 428 = you forgot the header. 412 = the resource moved on; re-read and retry.
print(resp.status_code)

Node (fetch)

const url = "https://api.example.com/v1/docs/42";

const current = await fetch(url);
const etag = current.headers.get("etag");
const doc = await current.json();

const res = await fetch(url, {
  method: "PUT",
  headers: { "If-Match": etag, "Content-Type": "application/json" },
  body: JSON.stringify({ ...doc, title: "new" }),
});

// Never substitute If-Match: * to silence a 428 — it defeats the whole point.
if (res.status === 412) console.warn("concurrent update, re-read and retry");

Reference and tooling

The authoritative list of assigned status codes is theIANA HTTP Status Code Registry. For a searchable copy with the semantics summarised, use theHTTP status code reference on this site. To see exactly what a server is sending, theheader inspector shows the raw status line and response headers, and thecurl builder assembles the request flags for you.

Other 4xx codes