Idempotency & rate limits
Two platform-wide behaviors that make integrations robust: idempotency keys turn retries after network timeouts into safe no-ops, and per-key rate limits with standard headers tell your client exactly when to back off.
Idempotency
Any write request (POST, PUT, PATCH, DELETE) may carry an optional Idempotency-Key header — up to 100 printable ASCII characters of your choosing. If the request succeeds and you retry it with the same key and the same body within 24 hours, the API replays the original response byte-for-byte instead of executing the operation again, and marks the replay with an Idempotency-Replayed: true response header. This makes retries after network timeouts safe: a list, campaign, or subscriber is created exactly once no matter how many times the request is repeated.
# Create a campaign — safe to retry on timeout curl -X POST https://emailflow.ai/api/v1/campaigns \ -H "Authorization: Bearer efa_YOUR_KEY" \ -H "Idempotency-Key: spring-sale-launch-2026" \ -d list_uid=ab12cd34ef \ -d name="Spring sale" \ -d subject="20% off this week" \ -d from_email=hi@acme.com \ -d from_name="Acme"
The rules in detail:
| Situation | Result |
|---|---|
| Same key, same body, within 24 hours | Stored response replayed with Idempotency-Replayed: true. |
| Same key, different body | 409 IDEMPOTENCY_CONFLICT — pick a fresh key for a different request. |
| Retry while the first attempt is still running | 409 IDEMPOTENCY_CONFLICT with details.reason: "in_flight" — wait and retry. |
First attempt failed with a 5xx | Nothing is stored; the retry executes normally. |
| Key longer than 100 characters or non-printable | 400 INVALID_REQUEST with param: "Idempotency-Key". |
GET request with the header | Header ignored. |
Keys are scoped to your account, so they never collide with other customers'. A good key encodes the operation and its trigger, for example order-created-8861-1718200000 (<event>-<your id>-<timestamp>), or simply a random UUID generated per logical operation. The TypeScript SDK can generate one per call with idempotencyKey: true.
Rate limits
Requests are limited per minute by operation class. Each API key gets its own independent bucket (legacy-token callers share one bucket per user), so one busy integration never starves another. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset (the Unix time when the window resets); when you exceed a limit the API returns 429 with a RATE_LIMITED error envelope whose retryAfter matches the Retry-After header — wait that many seconds and retry.
| Class | Requests / minute | Applies to |
|---|---|---|
read |
100 | All read (GET) requests on resource endpoints. |
write |
60 | Create, update, and delete requests on resource endpoints. |
batch |
10 | Bulk subscriber import (sync and async) and batch operations. |
ai |
20 | AI email generation and edit requests. |
sends |
10 | Campaign run (send) triggers. |
default |
60 | All other endpoints (administrative and legacy surfaces). |
Putting the two together: a robust retry loop
Idempotency and rate limits are designed to compose into one simple client policy:
- Generate an
Idempotency-Key(a UUID is fine) before the first attempt of any write. - On a network timeout or
5xx, retry with the same key and body — failed attempts are never stored, so a real failure re-executes, and a response you simply never received is replayed. - On
429, sleep forretryAfterseconds, then retry with the same key. - On any other
4xx, stop — the request itself is wrong, and retrying will not change the outcome.
This is exactly the policy the TypeScript SDK implements out of the box: pass idempotencyKey: true and it generates the UUID, reuses it across retries, honors Retry-After, and backs off exponentially on transient failures.