Skip to navigation

Idempotency

Retry sends safely — the same key never sends twice

Network timeouts happen. To retry POST /v1/messages without risking a duplicate send, pass an Idempotency-Key header — any unique string that identifies the operation (an order id, a job id):

curl -X POST https://api.rray.app/v1/messages \
-H "Authorization: Bearer $RRAY_API_KEY" \
-H "Idempotency-Key: order-1042-receipt" \
-H "Content-Type: application/json" \
-d '{"from": "receipts@yourdomain.com", "to": ["customer@example.com"], "subject": "Your receipt", "text": "Thanks!"}'

POST /v1/messages/batch accepts the same header; there the stored response is the whole positional batch result.

Semantics

  • The first request with a key performs the send. The key is claimed in the same database transaction as the message, so a request that fails before committing leaves no claim behind and the key stays usable.
  • A retry with the same key and the same body replays the stored response — the same message id, no second send.
  • A concurrent retry while the original is still in flight serializes behind it: it waits for the original to commit, then replays that response.
  • The same key with a different body returns 409 idempotency.body_mismatch — a key identifies one exact operation.
  • A claim that exists but cannot be replayed returns 409 idempotency.conflict. Retry shortly; if it persists, quote the request_id.

Claims are scoped to the workspace and retained for 24 hours, after which the key is free to use again.

Replay in practice

# first call
POST /v1/messages Idempotency-Key: order-1042-receipt
202 {"id": "msg_01M4ANAVD7EYMHY0PWSH5PBDJD"}
# identical retry — same id, nothing sent
POST /v1/messages Idempotency-Key: order-1042-receipt
202 {"id": "msg_01M4ANAVD7EYMHY0PWSH5PBDJD"}
# same key, changed subject
POST /v1/messages Idempotency-Key: order-1042-receipt
409 {"code": "idempotency.body_mismatch", ...}

Practical guidance

  • Generate the key where the business operation is born (e.g. invoice-{id}-receipt), not per HTTP attempt — that’s the whole point.
  • Safe to adopt gradually: requests without the header behave exactly as before (each attempt sends).
  • Replay matching is semantic, not textual: the key is matched against a hash of the parsed request, so whitespace and field order don’t matter, and an omitted optional field is the same as an explicit null. Changing any actual value — a recipient, the subject, the body — is a different operation and trips idempotency.body_mismatch.