Idempotency
Idempotency
Network retries are a fact of life when integrating with any HTTP API. Relay's idempotency support lets you retry POST /v1/messages and POST /v1/messages/send-template safely — a duplicate request with the same key replays the original response instead of sending a second SMS and charging you twice.
The contract
Supply an Idempotency-Key header on any request you want to make safe to retry:
Code
- The key may be any string between 1 and 255 characters. A UUID is a good default.
- The key is scoped by workspace — keys generated by different workspaces cannot collide.
- Keys are cached for 24 hours after first use.
Behavior
| Scenario | Response |
|---|---|
| First request with a given key | Processed normally. Response is cached for 24h. |
| Retry with the same key and identical request body | Returns the exact cached response — same status code, same body — plus the header Relay-Idempotent-Replay: true. No second SMS is sent. |
| Retry with the same key but a different request body | Returns 409 Conflict with error: "VALIDATION_ERROR" and a message explaining the key was reused with divergent payload. |
| Request without the header | Processed normally. No caching applied. |
Relay matches Stripe's behavior on the last point: re-using an idempotency key across divergent payloads is a client bug (you almost certainly meant to generate a fresh key for the new request), so we reject it rather than silently processing.
When to use it
Always on message sends from automated systems where retry logic is in play:
- Cron jobs and scheduled workers.
- Queue consumers where a worker crash could re-deliver the same job.
- Retry middleware in HTTP clients (
axios-retry, Go'shttp.Transportwith retries, etc.). - Any path where a network timeout might leave you unsure whether the send landed.
How to pick a key
The key should be:
- Deterministic for a given logical operation. Generate it before the first attempt and reuse the same value across retries.
- Unique per logical operation. If you are sending two different messages, use two different keys.
Good patterns:
- A UUID generated when you enqueue the job.
- A combined hash:
sha256(order_id + "." + step_name)for fixed-step workflows. - A database primary key from your side:
send-${outbox_row_id}.
Bad patterns:
- The current timestamp (not stable across retries — each retry gets a new key).
- The recipient phone number alone (collides across different messages to the same recipient).
- Random-per-request (defeats the point — each retry will look like a new operation).
Response marker
Replays include the header:
Code
You can use this for observability — for example, emitting a metric each time a replay fires tells you how often your retry logic is actually catching transient failures.
What it does NOT cover
GETrequests are idempotent by HTTP semantics and do not need the header.- Non-send POST endpoints (webhook CRUD, API-key create, etc.) do not currently honor the header. Message sending is the path where duplicates are most expensive; other surfaces will be added as demand arises.
- Partial-send failures (for example, SMS provider returns success but the delivery webhook later reports failure) are not idempotency concerns — the key only guarantees that the API call runs at most once, not that the downstream SMS succeeds.
Error handling with idempotency
Only 2xx responses are cached. If the first attempt returned a 4xx or 5xx, the response is not cached — you can retry with the same key and the request will be re-processed. This matches Stripe's behavior: transient failures (SMS provider timeout, rate limit, server error) do not permanently consume the key.
Validation errors (400) that occur before the idempotency layer (e.g., schema validation) never enter the cache at all — the middleware runs after request validation.
Window
Keys expire 24 hours after they are first used. Retries beyond the 24-hour window are treated as new requests — they will process and send again.
If your retry logic could span more than 24 hours, either tighten the retry window or plan for the fact that at the boundary the deduplication protection lapses.