Idempotency
Retry a POST safely with an Idempotency-Key: the 24-hour replay window, the replay header, and what happens when a key is reused with a different body.
The Idempotency-Key header
A retried POST after a timeout must not send a customer two messages or create two deals. Send an Idempotency-Keyheader — any string up to 255 characters, a UUID is the usual choice — and the first request's response is stored; any later request with the same key returns that response byte for byte, without running the operation again.
curl -X POST "https://your-crm.example.com/api/v1/contacts" \
-H "Authorization: Bearer $RAABTA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2a9e-3b4d-4c5e-8f7a-9b0c1d2e3f4a" \
-d '{
"phone": "+971501234567",
"name": "Ayesha Khan",
"email": "ayesha@example.com",
"company": "Khan Trading LLC",
"tag_ids": [
"1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f"
],
"custom_fields": {
"6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d": "Instagram ad"
}
}'Keys are unique per account, not per API key, so a request retried after a key rotation still replays, and two keys in one account sending the same idempotency key are treated as expressing the same intent.
The replay window
A stored result is replayed for 24 hours. A replay is marked so you can tell it from a fresh execution:
HTTP/1.1 201 Created
Idempotent-Replayed: true
X-Request-Id: req_01J9X4T7C2XK8M1QThe X-Request-Id on a replay is a new id — the original and the replay stay distinguishable in logs even though the bodies are identical.
Same key, different request
The stored key is bound to a hash of the endpoint and the raw request body. Reusing a key with a different body, or on a different endpoint, is a client bug rather than a resource conflict:
| Response | When |
|---|---|
| 422 unprocessable reason: idempotency_key_reused | The key was seen before with a different body or on a different endpoint. Nothing is executed; generate a new key for a new request. |
| 409 conflict reason: idempotency_in_progress | The first request with this key is still running. The response carries Retry-After: 1; retry and you will get the stored result once it completes. |
Bytes, not JSON
Abandoned requests
A request that claimed a key but never finished — the connection dropped mid-flight — is trusted for 60 seconds. After that the key is reclaimed and the next request with it executes for real. So a 409 here is always short-lived.
What is never stored
- A
5xxor a429. Storing a transient failure would make every retry replay the failure forever; instead the key is released and the retry runs the operation. - A request without the header. The header is opt-in per request; omit it and the request is simply executed.
- A
4xxother than 429 is stored: a validation error for the same bytes will be a validation error again, so replaying it is correct and cheap.
See the rate-limits guide for the retry timing on a 429.
Endpoints that honour it
Every endpoint that creates something or has a side effect worth protecting. GETs are naturally idempotent and ignore the header; PATCH and DELETE are idempotent by definition.
- POST
/api/v1/contacts— Create a contact - POST
/api/v1/contacts/{id}/notes— Add a note to a contact - POST
/api/v1/tags— Create a tag - POST
/api/v1/inquiries— Submit an inquiry - POST
/api/v1/inquiries/{id}/notes— Add a note to an inquiry - POST
/api/v1/messages— Send a message - POST
/api/v1/broadcasts— Create and send a template broadcast - POST
/api/v1/deals— Create a deal - POST
/api/v1/deals/{id}/close— Close a deal as won or lost - POST
/api/v1/assistants— Create an assistant - POST
/api/v1/instructions— Add a library instruction - POST
/api/v1/memories— Write a memory - POST
/api/v1/triggers— Create a trigger - POST
/api/v1/triggers/{id}/run— Fire a trigger now - POST
/api/v1/runs— Start a run - POST
/api/v1/asks/{id}/answer— Answer a human approval - POST
/api/v1/webhooks— Create a webhook endpoint