Idempotency
Every POST to a mutating endpoint carries an Idempotency-Key header. Replay the same key + body to safely retry after a network glitch or timeout. The second call returns the original response and never re-applies the side effect.
When it's required
On every POST against /api/v1/*. If PUT endpoints are added in future, the same requirement applies. GET and DELETE are not idempotency-tracked, since they either don't mutate or are idempotent in the HTTP-spec sense.
A missing header on a tracked verb returns 400 missing_idempotency_key.
Key conventions
- UUIDv4 is the canonical choice. Any string up to 255 bytes works.
- Generate one key per logical operation, not per HTTP call. Retries reuse the same key; new operations get a new one.
- Don't derive the key from the request body. That defeats the point of the body-mismatch check below.
- Records expire after 24 hours. Reuse the key beyond that window and the next call is treated as a fresh operation.
Replay semantics
First call: the API runs the handler, persists the response body and status, and returns it. Subsequent calls with the same key + same body: the API skips the handler entirely and returns the persisted response with an extra header:
HTTP/1.1 201 Created
Idempotent-Replay: true
X-Request-Id: req_70ab6f8aaa9945958b0564c337d6121e
Content-Type: application/json
{ "id": "41296980-…", "object": "customer", … }The replay returns the original request_id, not a fresh one. That is deliberate. It lets you correlate logs across retry attempts.
Body mismatch (409)
We hash the request body and store it alongside the key. If a later request reuses the key but the body hash differs, we don't silently re-run anything. You get back 409 key_reused_with_different_body:
{
"error": {
"type": "idempotency_error",
"code": "key_reused_with_different_body",
"message": "Idempotency-Key was previously used with a different request body.",
"doc_url": "https://watchtraderhub.com/docs/api/errors#key_reused_with_different_body",
"request_id": "req_…"
}
}Common pitfall: timestamps in the body
ordered_at: new Date().toISOString() regenerated on each retry), every retry will look like a different body. Either freeze that value at retry-loop creation time, or omit it from the body and let the server stamp it.Worked example
# First call, creates the customer.
curl -X POST https://watchtraderhub.com/api/v1/customers \
-H "Authorization: Bearer wth_YOUR_API_KEY" \
-H "Idempotency-Key: 4fe3c1e5-9c0e-49a8-9d77-2c0a4b6a3d11" \
-H "Content-Type: application/json" \
-d '{"external_id":"cust-001","email":"a@example.com","name":"Alice"}'
# 201 Created
# Network blip, same call, same key, same body. Replays the original.
curl -X POST https://watchtraderhub.com/api/v1/customers \
-H "Authorization: Bearer wth_YOUR_API_KEY" \
-H "Idempotency-Key: 4fe3c1e5-9c0e-49a8-9d77-2c0a4b6a3d11" \
-H "Content-Type: application/json" \
-d '{"external_id":"cust-001","email":"a@example.com","name":"Alice"}'
# 201 Created Idempotent-Replay: true
# Buggy retry, same key, different body. Rejected.
curl -X POST https://watchtraderhub.com/api/v1/customers \
-H "Authorization: Bearer wth_YOUR_API_KEY" \
-H "Idempotency-Key: 4fe3c1e5-9c0e-49a8-9d77-2c0a4b6a3d11" \
-H "Content-Type: application/json" \
-d '{"external_id":"cust-001","email":"different@example.com","name":"Alice"}'
# 409 key_reused_with_different_bodyErrors are cached too
Replay caching applies to any response, including 4xx and 5xx. If your first call returned 500 upsert_failed, the replay returns that same 500. Use a new key when you want to actually retry.
This is intentional. Replays exist to keep side effects from happening twice, not to mask past failures.