Idempotency — retrying safely
The dangerous case is this. You call POST /api/v1/statement_sends for 300 customers. The connection drops before the response reaches you. Did the send happen?
Without idempotency you have to choose between two bad options: retry and risk emailing 300 of your customers twice, or don't retry and risk never sending at all.
An idempotency key removes the choice. Send one, and a retry is safe.
How to use it
Generate a unique value — a UUID is ideal — and send it as a header:
curl -X POST https://app.statey.app/api/v1/statement_sends \
-H "Authorization: Bearer stky_your_key_here" \
-H "Idempotency-Key: 6f1c2b7e-9d3a-4f51-8c27-1a4b5d6e7f80" \
-H "Content-Type: application/json" \
-d '{"contact_ids": ["9f8e7d6c-…", "3a2b1c0d-…"]}'
If that request times out, send it again with the same key and the same body. You get the original response back. Nothing is sent twice.
What happens behind the scenes
- First request with a key — runs normally. If it succeeds, we store the response against the key.
- Repeat with the same key and the same body — you get the stored response. The send does not run again.
- **Repeat with the same key and a different body** — rejected with
409 idempotency_key_reused. This is a guard rail: it almost always means a bug in which one key is being reused across genuinely different requests, and silently replaying the first would hide it.
Only successes are remembered
If a request fails — a validation error, an exhausted allowance, a transient fault — the key is released.
This matters. It means a request rejected with 422 or 429 can be fixed and retried with the same key. You are not locked into replaying the same error forever.
The flip side: a failure is not a promise that nothing happened. Retry rather than assume.
Choosing keys
- One key per intended action, not per attempt. All retries of one send share a key.
- Generate it before the first attempt and keep it until you have a definite answer. A key generated per attempt protects nothing.
- Derive it from something meaningful if you can —
send-2026-09-monthly-runis easier to debug than a bare UUID, as long as it is genuinely unique per action. - Keys are scoped to your organisation and to the specific endpoint, so the same value on two different endpoints will not collide.
How long keys last
Keys are kept for 7 days, then removed.
That is long enough to cover any realistic retry, and short enough that the table does not grow forever. A retry more than a week later is treated as a fresh request — which is almost certainly what you want, since a month-old send being replayed would be a surprise.
Which endpoints support it
The two that send email, and only those:
POST /api/v1/statement_sendsPOST /api/v1/schedules/{id}/runs
Reads do not need it — fetching the same list twice changes nothing. Neither do the other writes (notes, contact people, statement exports, webhook subscriptions): they create a record rather than contacting your customers, so a duplicate is a tidy-up rather than a statement someone should not have received. Those endpoints ignore the header rather than rejecting it, so retry them only if a duplicate is acceptable.
A worked retry
1. Generate key: 6f1c2b7e-9d3a-4f51-8c27-1a4b5d6e7f80
2. POST /statement_sends with that key → connection drops, no response
3. POST /statement_sends with the SAME key → 202 Accepted, campaign 4c8d…
(the original response, replayed)
4. Done. 300 customers received one statement each.
And a failure that is safe to fix:
1. Generate key: 6f1c2b7e-9d3a-4f51-8c27-1a4b5d6e7f80
2. POST /statement_sends with that key → 422, "contact_ids matched no contacts
with an email address"
3. Fix the list.
4. POST /statement_sends with the SAME key → 202 Accepted
Step 4 works because the failure released the key. If step 2 had succeeded, step 4 with a changed body would have been rejected with 409 idempotency_key_reused instead — which is correct: the first send already went out, and the second is a different request that needs its own key.