Idempotency
A 24-hour contract, not a footnote. Vendor APIs typically leave idempotency to "please make sure you don't double-send" — which fails the moment a load balancer retries. QC replays the exact same response for 24 hours on the same key, serialises concurrent retries with a lock, and lets you repost a booking-ack a week later without a double-write. The contract is written down here, with tests to prove it. See Why Quendoo Connect for how this stacks up against alternatives.
Every write endpoint on /v1/qc/* accepts an Idempotency-Key
header. If the network drops after your request left and before
the response reached you, or your worker crashed mid-batch, or a
scheduler doubled a job — resending the request with the same key
returns the same response instead of writing twice.
Redis-backed, 24 h window, no durable table.
The contract
Idempotency-Key: any-uuid-or-opaque-string-up-to-128-chars
Every write endpoint. Every method. GET / HEAD ignore the header because they don't need it.
Three outcomes on retry
Assume you sent a write with key K and body B.
| Second request | What happens |
|---|---|
Key K + body B (byte-identical) |
Cached response replayed. Idempotency-Replayed: true in the response headers. Same HTTP status, same body. |
Key K + body B' (different) |
409 errors.qc.idempotency_conflict. No write. |
Different key + body B or B' |
Treated as a fresh operation. Two writes land. |
Body comparison is byte-for-byte on the raw request bytes we received. Key comparison is exact-string. Case matters, whitespace matters.
What "the same response" means
The cached response is exactly the bytes we returned the first time — same status, same headers, same body — except for two diagnostic headers:
Idempotency-Replayed: true
Idempotency-Original-Timestamp: 2027-02-14T09:12:03Z
Cached responses are stored regardless of the outcome — a first call that returned 422 is replayed as the same 422 on retry with the same key. That's the point: a validation error you already handled shouldn't turn into a duplicate write when the network retries the request.
Window
Cache TTL is 24 h from the ORIGINAL response, not from the retry. A key you first used at 09:12 stops replaying at 09:12 the next day; a fresh write with the same key after that is treated as a new operation and writes.
The TTL is a hard 24 h — we don't sliding-window it. A partner who retries a key at 23:59 gets one more replay, then it's gone.
Redis is not a durable table
Redis loses state on failover. When that happens (rare — twice in the last 12 months) every key drops. The safe failure mode we picked: during a Redis outage, writes still succeed but the cache is a miss on every retry. A partner retrying an in-flight write during that window will double-write.
This is the LESS bad option: the alternative was refusing writes until Redis came back, which would have stopped every inventory push on a platform-wide outage. We accept a rare double-write in exchange for keeping the write path itself available.
Mitigate on your side by natural-key deduplication where you can —
availability writes are keyed by (room_id, date) and are safe to
replay by design; a duplicate booking-side write is what actually
needs the idempotency contract, and those are rare.
Choosing keys
Pick a key per logical operation, not per HTTP call.
- Good:
Idempotency-Key: inventory-push-2027-02-14-batch-42— every retry of the same batch reuses the same key. - Good: a UUID minted client-side, saved before the write is sent, and reused on retry until the write is confirmed.
- Bad:
Idempotency-Key: uuidgeninline in a shell loop — a crash between minting the key and sending the request means the retry mints a different key and the write is not deduplicated. - Bad: reusing the same key across unrelated operations — you get the older cached response, which is very confusing to debug.
Concurrent retries — first writer wins
Two requests with the same key arriving simultaneously do NOT
both write and coordinate. The first to acquire the Redis lock on
the key writes; the second waits up to 30 s, then either replays
(if the first finished) or answers 409
errors.qc.idempotency_in_flight (if the first is still running).
409 _in_flight is transient — retry after a short backoff.
409 _conflict is not — the bodies genuinely disagree.
Cross-endpoint reuse
Keys are scoped per (client_id, HTTP method, path, key).
A key you used on POST /inventory/availability and reuse on
POST /inventory/rates writes twice — one fresh key per endpoint.
What we don't do
- No idempotency on reads. GET / HEAD ignore the header.
- No cache-warm from a replay. A cached response returned to a retry does NOT re-touch the TTL — the 24 h is from the original response, always.
- No key introspection. There is no endpoint to ask "did you already see this key?" — the answer is send the write and see the header.
Cheat sheet
KEY=$(uuidgen)
BODY='{"values":[{"room_id":4211,"date":"2027-02-14","qty":3,"is_stop_sale":false}]}'
# First call — writes
curl -X POST https://api.quendoo.com/v1/qc/properties/$EXT_PID/inventory/availability \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$BODY"
# Retry — replays, no second write
curl -X POST https://api.quendoo.com/v1/qc/properties/$EXT_PID/inventory/availability \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$BODY"
# → headers: `Idempotency-Replayed: true`, body identical
# Bad reuse — different body, same key → 409
curl -X POST https://api.quendoo.com/v1/qc/properties/$EXT_PID/inventory/availability \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d '{"values":[{"room_id":4211,"date":"2027-02-14","qty":4,"is_stop_sale":false}]}'
# → 409 { "status":"error", "message":"errors.qc.idempotency_conflict" }