Idempotency
Same key, one send, even when the provider goes quiet.
A key is dropped only when nothing was sent. Markdown: /docs/idempotency.md · endpoint reference.
Key rules
POST sends accept an Idempotency-Key header. The routes are POST /api/v1/inboxes/{id}/messages, /reply, /reply-all, /forward, POST /api/v1/inboxes/{id}/drafts, POST /api/v1/inboxes/{id}/drafts/{draftId}/send, POST /api/v1/tasks/{id}/reply, POST /api/v1/inboxes/{id}/sms, POST /api/v1/inboxes/{id}/calls, and POST /api/v1/inboxes/{id}/phone-number. The value is 1 to 256 characters of letters, digits, and - . _ ~. An empty or invalid value is HTTP 400 invalid_idempotency_key. A request with no Idempotency-Key header is unchanged. When client_id is also set, client_id is checked first.
The same key and the same request are stored for that account. The request hash is the route, the inbox id, and the canonical JSON body. Object key order does not matter. A different body, or the same body on a different message id in the path, is a different request.
Replay
The first successful call sends once and stores the key as done for 24 hours. The same key and the same request in that window returns HTTP 200 with idempotent true and the response header Idempotent-Replayed: true. It does not send again. A task-reply replay returns the stored message. A done key cannot be deleted. Retrying it still replays.
Conflict
The same key with a different request is HTTP 409 idempotency_key_conflict. That includes a key whose earlier attempt is unknown. The param is Idempotency-Key. Use a new key for the new request.
In progress
While the first call is still running, the row is in_progress. Another call with that key is HTTP 409 idempotency_request_in_progress and does not send. The in-progress lease is 10 minutes, which is longer than the route's max execution time. An in_progress row older than that lease is treated as unknown. It is not started again. A fresh in_progress row cannot be released.
Unknown outcome
The key is removed only when it is provably safe: the request was refused before the provider was called, or the provider clearly did not accept it. That covers validation and policy errors (HTTP 400, 402, 403, 404, 409, and 422), including inbox_paused, sending_paused, recipient_blocked, recipient_suppressed, plan limits, and invalid input, plus smtp_not_configured, sms_not_configured, and voice_not_configured, and a provider 4xx rejection. A later retry with the same key can send.
Anything else keeps the key in the state unknown. That includes a provider timeout or AbortError, a fetch or network error, a provider 5xx, HTTP 502 smtp_provider_error, sms_provider_error, or voice_provider_error built from those failures, and any other exception after the provider call may have started. Cloudflare Email Sending falls through to Resend only on an auth or permission refusal (HTTP 401 or 403, or an authentication error). A Cloudflare timeout or 5xx does not fall through, because that call may already have been accepted. When the send runs under an Idempotency-Key, a Resend call also sends Idempotency-Key: cooper-<account id>-<key> (or a sha256 of that pair when the header would be longer than 256 characters). Resend keeps that key for 24 hours.
The same key and the same request while the state is unknown returns HTTP 409 idempotency_outcome_unknown. The message says the earlier attempt may or may not have been delivered. The error includes state, since (ISO time), last_error when a machine code was stored, and message_id when a stored row is linked. Check the sent folder or GET messages (client_id or delivery_events). Then resolve the key, release it, or retry with a new key. Releasing means you confirmed nothing was sent, or you accept the risk of a second send.
Retention
A done row expires 24 hours after it was claimed. The daily cron deletes it after that. An unknown row, including a stale in_progress row that was converted to unknown, is kept for 7 days from the moment it became unknown. The cron does not delete it at 24 hours. After 7 days the cron deletes it, and the same key can be used again. last_error is a short machine code. Message bodies and secrets are not stored on the key.
Inspect, release, and resolve
GET /api/v1/idempotency-keys/{key} needs message_read. It returns key, state (in_progress, done, or unknown), endpoint, inbox_id, message_id, created_at, expires_at, and unknown_since and last_error when they are set. A missing key is HTTP 404 idempotency_key_not_found. An inbox-scoped key only sees a row for its inbox.
DELETE /api/v1/idempotency-keys/{key} needs message_send. It releases an unknown key, including one that was a stale in_progress row, so the same key can be used again. A done key is HTTP 409 idempotency_key_done. A fresh in_progress key is HTTP 409 idempotency_request_in_progress. A missing key is HTTP 404 idempotency_key_not_found.
POST /api/v1/idempotency-keys/{key}/resolve needs message_send. The body is {message_id}. It marks an unknown key done and links that message, draft, phone number, or chat reply when it belongs to the key's inbox. Later retries replay it. A done key is HTTP 409 idempotency_key_done. A fresh in_progress key is HTTP 409 idempotency_request_in_progress. An unknown message is HTTP 404 message_not_found. The done window is 24 hours from the resolve.
curl -s "https://cooperemail.com/api/v1/idempotency-keys/send-001" \
-H "Authorization: Bearer $COOPER_API_KEY"
curl -s -X DELETE "https://cooperemail.com/api/v1/idempotency-keys/send-001" \
-H "Authorization: Bearer $COOPER_API_KEY"
curl -s -X POST "https://cooperemail.com/api/v1/idempotency-keys/send-001/resolve" \
-H "Authorization: Bearer $COOPER_API_KEY" \
-H "content-type: application/json" \
-d '{"message_id":"msg_123"}'MCP
cooper_send_message and cooper_reply_task take idempotency_key and send it as the header. An idempotency_outcome_unknown result has isError true and the same guidance as the HTTP error. cooper_get_idempotency_key reads one key. cooper_release_idempotency_key releases an unknown key. cooper_resolve_idempotency_key links a message id and marks the key done. Read uses message_read. Release and resolve use message_send.
{"name":"cooper_get_idempotency_key","arguments":{"key":"send-001"}}
{"name":"cooper_release_idempotency_key","arguments":{"key":"send-001"}}
{"name":"cooper_resolve_idempotency_key","arguments":{"key":"send-001","message_id":"msg_123"}}