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.

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

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" }