409 Conflict means the request is valid, but it conflicts with the current state of the resource on the server. Creating something that already exists, saving changes to a record someone else updated after you read it, or deleting a resource other records still depend on are all 409s. The fix is usually to fetch the current state, reconcile, and try again.
HTTP/1.1 409 Conflict
Content-Type: application/json
{"error": "already_exists", "message": "A user with email [email protected] already exists", "existing_id": "usr_42"}
Common causes
- Duplicate creation. A unique email, username, slug or external ID is already taken.
- Concurrent edits. Optimistic locking: the client sends a version number or
updated_atthat no longer matches the stored one. Kubernetes returns 409 when an update'sresourceVersionis stale. - Idempotency key in use. Stripe and similar APIs return 409 if a second request reuses an idempotency key while the first one is still running.
- Invalid state transition. Refunding a payment that was already refunded, or merging a pull request that has merge conflicts.
- Dependent resources. Deleting a project that still has active resources inside it.
409 vs similar codes
| Code | Use it when |
|---|---|
409 Conflict | The request clashes with the server's current state. |
412 Precondition Failed | A conditional header (If-Match, If-Unmodified-Since) didn't match. This is the standard code for ETag-based concurrency. |
422 Unprocessable Content | The data is invalid on its own, whatever the server state. See 400 vs 422. |
428 Precondition Required | The server requires a conditional request and the client didn't send one. |
How to fix a 409 as a client
- Read the body. A good API says what conflicts and often returns the ID of the existing resource.
- If it already exists, use it. For "create if missing" logic, treat 409 as success and fetch the existing record, or switch to an upsert endpoint if the API has one.
- If it was modified, refetch and retry. Get the latest version, reapply your change on top, and send it with the new version or ETag. Don't overwrite blindly.
- If an idempotent request is in flight, wait and retry with the same key. You'll get the original request's result.
409 and duplicates on a webhook endpoint
Webhooks are delivered at least once. The same event can arrive twice when your endpoint processed it but timed out before answering, when a provider retries, or when someone replays it. Your handler needs to be idempotent.
The tempting response to a duplicate is 409. Don't. Most providers treat any non-2xx response as a failed delivery and retry it, so a 409 produces more duplicates, and it can push the endpoint toward being flagged as failing. Answer a duplicate with 200 and skip the work.
The standard pattern:
- Take the provider's unique event or delivery ID (Stripe's
event.id, GitHub'sX-GitHub-Delivery, thewebhook-idheader in Standard Webhooks). - Insert it into a table with a unique constraint, or a cache with a TTL, in the same transaction as the work.
- If the insert conflicts, it's a duplicate: return
200and stop.
If you'd rather drop duplicates before they reach your app, a Webhook Relay function can do it with the built-in KV store: if kv.has(id) is true, call r.stopForwarding() so the duplicate is logged but not delivered; otherwise store the ID with a TTL that covers the provider's retry window. KV operations aren't atomic, so two copies arriving at the exact same moment can both pass. Keep a unique constraint in your database as the final guard. The retries and idempotency guide covers the full design.
When to return 409 from your own API
Return 409 when a request would succeed against a different server state: a unique field already in use, a stale version, an invalid state transition, or an idempotency key whose first request is still running. Include enough detail in the body (what conflicts, the existing ID, the current version) for the client to resolve it automatically. Use 412 if you implement ETags with If-Match.
