API Docs

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

If your body includes a non-deterministic field (e.g. 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_body

Errors 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.