Idempotency
Network failures happen. A write request that times out, gets a 5xx, or loses its connection mid-flight leaves the client unsure whether the operation completed. Naively retrying could double-charge a refund, send duplicate cancellation emails, or fire a registration transfer twice.
The Viewcy API supports the Idempotency-Key HTTP header on every write endpoint (POST, PATCH, DELETE). Retrying a request with the same key returns the original response instead of executing the operation again.
Using the header is optional. Requests without it behave exactly as a normal API call. We strongly recommend setting one on every write.
How it works
- Generate a unique key for each logical operation — a UUIDv4 is the conventional choice.
- Send the key in the
Idempotency-Keyrequest header. - The first request runs normally. We cache the response (status code, body, content type) for 24 hours, scoped to your API token's owner.
- Any subsequent request with the same key replays the cached response without re-executing the operation.
Every response to a request that carried a valid Idempotency-Key includes two signals:
- Response header
Idempotency-Replayed: trueis set when the response was served from the idempotency cache (handy for proxies and logs that don't parse JSON). - Meta envelope
meta.idempotencyKeyechoes the client-supplied key, andmeta.idempotentReplayedistruefor replays andfalsefor fresh execution.
The replay carries a fresh meta.requestId (it is a distinct HTTP request), so two replays of the same operation will not share a request ID. Use meta.idempotencyKey to correlate retries.
Request
POST /2025_06/events HTTP/1.1
Host: api.viewcy.com
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json
Idempotency-Key: 4f8a2b3c-9d1e-4a5b-8c7d-1e2f3a4b5c6d
{ "name": "My Event", "category": "Concerts" }
Key requirements
- Format: any string between 1 and 255 characters. UUIDs are conventional; opaque strings work too.
- Uniqueness: scoped to the resource owner (the User or School your API token authenticates as). Two different accounts can use the same key independently; within one account, reusing a key replays the original response.
- Lifetime: 24 hours from the first request. After that, the same key can be used again for a new operation.
Response codes you may see
| Status | When | Meaning |
|---|---|---|
<original status> | Replay of a completed request | The first request's status and body are returned. Idempotency-Replayed: true header is set; meta.idempotentReplayed is true; meta.requestId is fresh. |
400 Bad Request | Header is empty or longer than 255 characters | Fix the header value and retry with a valid key. |
409 Conflict | Another request with the same key is still being processed | The first call hasn't finished yet. Retry after a short backoff. |
422 Unprocessable Entity | Same key, different request | A previous request used this key with a different method, path, query string, or body. Either resend the original request to get the cached response, or generate a new key for this new operation. |
503 Service Unavailable | Idempotency cache is temporarily unavailable | Retry after a brief delay. The write was not executed. |
Example 2xx replay body (note meta.idempotentReplayed: true):
{
"object": "event",
"id": "9b2c1d4e-7f3a-4c5b-8d6e-1a2b3c4d5e6f",
"meta": {
"requestId": "req_def456",
"idempotencyKey": "4f8a2b3c-9d1e-4a5b-8c7d-1e2f3a4b5c6d",
"idempotentReplayed": true
}
}
Example 409 body:
{
"object": "error",
"status": 409,
"message": "A request with this Idempotency-Key is currently being processed. Retry after a brief delay.",
"errors": [{ "code": "idempotency_in_progress" }],
"meta": {
"requestId": "req_...",
"idempotencyKey": "4f8a2b3c-9d1e-4a5b-8c7d-1e2f3a4b5c6d",
"idempotentReplayed": false
}
}
Example 422 body:
{
"object": "error",
"status": 422,
"message": "This Idempotency-Key was previously used with a different request. Use a new key for a different operation, or resend the original request.",
"errors": [{ "code": "idempotency_key_mismatch" }],
"meta": {
"requestId": "req_...",
"idempotencyKey": "4f8a2b3c-9d1e-4a5b-8c7d-1e2f3a4b5c6d",
"idempotentReplayed": false
}
}
meta.idempotencyKey and meta.idempotentReplayed appear together on every response to a write request that carried a valid header — including 409, 422, and 503. They are absent on 400 idempotency_key_invalid (the header didn't pass validation, so there's nothing well-formed to echo).
What we cache, what we don't
- Cached and replayed: successful responses (
2xx) and deterministic client errors (400,404,409,422). A retry can't change a payload-rejected validation result, so we replay it. - Not cached:
5xxserver errors (so transient failures don't block recovery);401and403(auth state can legitimately change between retries — a token rotation or scope grant should be reflected, not replayed);429(rate-limit windows shift); and the409 idempotency_in_progressresponse itself (so the key isn't poisoned by a transient collision). - Response body size limit: responses larger than 1 MB are not cached. The original response still returns to the client; a retry with the same key will re-execute the operation. Bulk exports and very large list endpoints are the typical case.
- Cache unavailable: if the idempotency cache cannot be read or a lock cannot be claimed before the write runs, the API returns
503 idempotency_unavailableand does not execute the write.
What the header does not do
- It is not authentication. The key doesn't prove identity; your bearer token still does.
- It is not a confirmation token. It does not authorize destructive operations or bind a payload signature.
- It is not a transaction ID. Use your own correlation ID for cross-system tracking.
- It does not apply to
GETrequests. Reads are already idempotent — sending the header on aGETis a no-op. - It does not apply to
POST /uploads. Direct-upload responses carry short-lived presigned URLs; replaying a cached one could hand back an expired URL. Re-request a fresh upload instead — it's cheap. See File uploads.
Recommended client pattern
Generate the key at the call site, before the first attempt, and reuse it across every retry of the same logical operation:
- ruby
- javascript
# Ruby
key = SecureRandom.uuid
3.times do |attempt|
response = api.post("/events", body: payload, headers: { "Idempotency-Key" => key })
break if response.success?
sleep(2 ** attempt) # exponential backoff
end
// Node
const key = crypto.randomUUID();
for (let attempt = 0; attempt < 3; attempt++) {
const response = await fetch("/events", {
method: "POST",
headers: { "Idempotency-Key": key, "Authorization": `Bearer ${token}` },
body: JSON.stringify(payload),
});
if (response.ok) break;
await new Promise(r => setTimeout(r, 2 ** attempt * 1000));
}
A new key per logical operation, the same key across every retry of that operation.
See also
- Authentication & token model — what your token represents and what it can act on.
- Async, destructive, and idempotent operations — when writes are
202 Acceptedvs.200 OK, and why side-effect flags must be explicit.