Use ETags and the If-Match header for optimistic concurrency control — prevent silent data loss when multiple clients update the same resource.
Last updated 2026-06-11
The Core API attaches an ETag header to every single-resource response. Clients can send this value back in an If-Match header on update or delete requests to ensure they are operating on the latest version of the resource. If the resource has changed since the ETag was issued, the API returns 412 Precondition Failed instead of silently overwriting the other client’s changes.
Fetch a resource and note the ETag response header:
curl -i https://api.authpi.com/v1/accounts/{account_id}/issuers/{issuer_id}/users/{user_id} \
-H "Authorization: Bearer $TOKEN"
HTTP/2 200
ETag: "1709139600000"
Content-Type: application/json
{"data": {"id": "usr_abc", "name": "Alice", "updated_at": 1709139600000, ...}}
When updating the resource, include the ETag in an If-Match header:
curl -X PATCH https://api.authpi.com/v1/accounts/{account_id}/issuers/{issuer_id}/users/{user_id} \
-H "Authorization: Bearer $TOKEN" \
-H "If-Match: \"1709139600000\"" \
-H "Content-Type: application/json" \
-d '{"name": "Alice Smith"}'
If no one else modified the user since you last read it, the update succeeds and you receive a new ETag:
HTTP/2 200
ETag: "1709139700000"
{"data": {"id": "usr_abc", "name": "Alice Smith", "updated_at": 1709139700000, ...}}
If someone else changed the user in the meantime, you get a 412:
HTTP/2 412
{"error": "precondition_failed", "error_description": "User has been modified since the provided ETag. Fetch the latest version and retry.", "currentETag": "\"1709139700000\"", "retryable": false}
The ETag is a strong ETag (no W/ prefix) whose value is the resource’s updated_at timestamp in milliseconds, quoted per RFC 7232. If the resource has never been updated, created_at is used instead.
ETag: "<updated_at ?? created_at>"
Since this timestamp is already present in the response body, the ETag reveals no additional information.
The API sets the ETag header on all 200 and 201 responses that contain a single resource ({ data: { ... } }). Specifically:
| Response type | ETag? | Example |
|---|---|---|
| Single resource (GET, PATCH, POST create) | Yes | GET /users/{id} |
| List response | No | GET /users |
| Delete (204 No Content) | No | DELETE /users/{id} |
| Error response (4xx, 5xx) | No | Any error |
When an If-Match header is present on a PATCH or DELETE request, the API compares the provided ETag against the resource’s current timestamp inside the Durable Object that owns the resource. Because Durable Objects are single-threaded per resource ID, the comparison and the subsequent mutation happen atomically — there is no time-of-check-to-time-of-use (TOCTOU) gap.
Supported If-Match values:
| Value | Behavior |
|---|---|
"<timestamp>" | Matches if the resource’s current ETag equals this value |
"<ts1>", "<ts2>", ... | Matches if any value in the comma-separated list matches |
* | Always matches (unconditional) |
W/"<timestamp>" | Rejected — weak ETags are not accepted (strong comparison only) |
The If-Match header is entirely optional. When omitted, the request proceeds unconditionally (last-write-wins). This means existing clients and integrations continue to work without any changes.
When the ETag does not match, the API returns 412 Precondition Failed:
{
"error": "precondition_failed",
"error_description": "User has been modified since the provided ETag. Fetch the latest version and retry.",
"currentETag": "\"1709139700000\"",
"retryable": false
}
The currentETag field contains the resource’s actual current ETag, so you can inspect how stale your version is. However, you should still re-fetch the resource to get the full current state before re-applying your changes.
All PATCH and DELETE endpoints that operate on a single resource support the If-Match header. This includes:
| Resource | PATCH | DELETE |
|---|---|---|
| Accounts | PATCH /accounts/{id} | — |
| Issuers | PATCH .../issuers/{id} | DELETE .../issuers/{id} |
| Users | PATCH .../users/{id} | DELETE .../users/{id} |
| Clients | PATCH .../clients/{id} | DELETE .../clients/{id} |
| Organizations | PATCH .../organizations/{id} | DELETE .../organizations/{id} |
| Organization Groups | PATCH .../groups/{id} | DELETE .../groups/{id} |
| Agents | PATCH .../agents/{id} | DELETE .../agents/{id} |
| Auth Methods | PATCH .../auth-methods/{id} | DELETE .../auth-methods/{id} |
| Webhooks | PATCH .../webhooks/{id} | DELETE .../webhooks/{id} |
| API Keys | PATCH .../api-keys/{id} | DELETE .../api-keys/{id} |
| Personal Tokens | — | DELETE .../personal-tokens/{id} |
| Notes | PATCH .../notes/{id} | DELETE .../notes/{id} |
When you fetch a resource, save the ETag header alongside the data. When you send an update, include it as If-Match. This ensures your update is based on the version you actually read.
// Fetch the resource
const res = await fetch(`${BASE}/users/${userId}`, { headers: { Authorization: `Bearer ${token}` } });
const etag = res.headers.get("ETag");
const user = (await res.json()).data;
// Later — update with optimistic concurrency
const updateRes = await fetch(`${BASE}/users/${userId}`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"If-Match": etag,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "New Name" }),
});
When you receive a 412, the correct recovery is to re-read the resource, re-apply your changes (merging if necessary), and retry:
async function safeUpdate(url, updates, token, maxRetries = 3) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
// 1. Read current state
const getRes = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
const etag = getRes.headers.get("ETag");
const current = (await getRes.json()).data;
// 2. Merge your changes
const merged = { ...updates };
// 3. Write with If-Match
const patchRes = await fetch(url, {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"If-Match": etag,
"Content-Type": "application/json",
},
body: JSON.stringify(merged),
});
if (patchRes.status === 412) {
// Resource was modified — retry with fresh state
continue;
}
return patchRes;
}
throw new Error("Max retries exceeded — resource is under heavy contention");
}
When an AI agent performs a multi-step workflow (e.g., read user → decide → update user), other agents or human users may modify the same resource between steps. Without If-Match, the agent would silently overwrite their changes. With If-Match, the agent receives a clear 412 signal and can re-read, re-evaluate, and retry.
// Agent workflow: conditionally update user metadata
const res = await fetch(userUrl, { headers: { Authorization: `Bearer ${token}` } });
const etag = res.headers.get("ETag");
const user = (await res.json()).data;
// Agent decides what to change (may take time)
const updates = await agentDecide(user);
// Safe update — won't clobber changes made while agent was thinking
const patchRes = await fetch(userUrl, {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"If-Match": etag,
"Content-Type": "application/json",
},
body: JSON.stringify(updates),
});
if (patchRes.status === 412) {
// Re-read and let agent re-evaluate with fresh data
}
ETags and idempotency keys solve different problems and work well together:
For maximum safety, use both:
# Create with idempotency (protects against retries)
curl -X POST .../users \
-H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
-d '{"name": "Alice"}'
# Update with If-Match (protects against races)
curl -X PATCH .../users/usr_abc \
-H "If-Match: \"1709139600000\"" \
-d '{"name": "Alice Smith"}'
Omitting If-Match is perfectly valid when:
The If-Match header is a tool for correctness, not a requirement. Use it when concurrent modifications would cause problems.