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-run  is 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_sends
  • POST /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.