Idempotency solves one specific failure: your request arrived, we created the batch, and the response was lost on the way back. Retry blindly and you have paid twice for the same list.
Send an Idempotency-Key header with a value you generate, usually a UUID tied to the unit of work in your own system. If we have already seen that key we replay the original response instead of doing anything new, and your retry becomes free.
Reuse a key with a different body and you get a 409 rather than a surprise. That is deliberate: silently accepting the second body is how a system starts producing results nobody can explain.
curl https://api.zapbounce.com/v1/batches \
-H "Authorization: Bearer zb_live_..." \
-H "Idempotency-Key: 8f14e45f-ea2b-4d15-9d1e-6b2c3a44f001" \
-H "Content-Type: application/json" \
-d '{"name":"q4-webinar-list","emails":["ada@example.com"]}'{
"batch_id": "bat_8f2c1d",
"status": "queued",
"submitted": 1,
"unique": 1,
"idempotent_replay": true
}Fields
| Field | Type | What it carries |
|---|---|---|
| Idempotency-Key | header | Your value, up to 255 characters. A UUID per unit of work is the usual choice. |
| idempotent_replay | boolean (provisional) | True when this response was replayed rather than freshly created. The field name is not final. |
A field marked provisional could still be renamed before v1 is declared stable. Every other field is fixed.
Reference
| Situation | What we do |
|---|---|
| New key | Process normally and store the response. |
| Same key, same body | Replay the stored response. Nothing new runs, nothing new is billed. |
| Same key, different body | 409 idempotency_conflict. Change the key or the body. |
| Same key after 24 hours | The record has expired and this is treated as new. |
A lost response at 2 a.m., second by second
Say your job posts a 40,000-address batch at 02:00:00 with the key clean-2026-10-01-newsletter. We create the batch. Thirty seconds later your HTTP client gives up waiting, so your job never sees a batch_id. At 02:01:00 it retries with the same key and the same body, and it gets the original response back as a replay. One batch exists and the credits are spent once.
Without the key, that retry creates a second batch, and you pay for up to 40,000 verdicts twice.
There's a catch in the words same body. If your retry rebuilds the list from a fresh database query, one new signup in that minute changes the body. You get a 409 where you wanted a replay. Write the exact payload to disk before the first attempt, and have every retry send that saved copy.
Choosing a key, and what a 409 is telling you
Build the key from the unit of work: a job id, or a campaign name plus a date. Save it on the job's row in your database before you send anything. A key that lives only in memory is gone when the process crashes, and a crash is exactly when you need it.
Be careful with retries that come late. The record lasts 24 hours, so a job that sat in a dead queue over a weekend looks brand new to us. Have late retries check your own job row for a batch_id first. If there isn't one, look at the credits ledger for a deduction that names a batch from that night before you resubmit.
A 409 usually means your key recipe is too loose, and two different lists ended up with the same key. Fix the recipe. Don't patch it by adding a random suffix on each try, because that switches the protection off.
Questions developers ask
Do I need it on GET requests?
No. Reads change nothing, so a repeat is already safe.
What should the key be?
Something stable in your own system: the job id, the campaign id plus a date. Randomly generating it on each retry defeats the whole mechanism.
Is a replay billed?
No. You get the stored response and your balance does not move.