Idempotency solves one specific failure: your request arrived, we created the job, 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 on POST /v1/jobs with a value you generate, usually a UUID tied to the unit of work in your own system. If we already hold a response for that key, we return it instead of doing anything new, so your retry is free.
The key is looked up for your account alone, so another customer using the same string cannot collide with you. The header is read on job creation only. Other routes either change nothing or are safe to repeat.
curl -X POST https://app.zapbounce.com/api/v1/jobs \
-H "X-API-Key: $ZB_API_KEY" \
-H "Idempotency-Key: 8f14e45f-ea2b-4d15-9d1e-6b2c3a44f001" \
-H "Content-Type: application/json" \
-d '{"emails":["ada@example.com"],"source":"api"}'{
"id": "0b6f2d3e-6a52-4c58-9d0e-3f6a1c9a7e10",
"source": "api",
"submittedEmails": 1,
"totalEmails": 1,
"status": "pending"
}Fields
| Field | Type | What it carries |
|---|---|---|
| Idempotency-Key | header | Your value, 8 to 128 characters after trimming. A UUID per unit of work is the usual choice. |
Reference
| Situation | What we do |
|---|---|
| New key | Create the job and store the response. |
| Same key within 24 hours | Return the stored response. No new job is created and nothing more is billed. |
| Same key after 24 hours | The record has expired and the request is treated as new. |
| Key shorter than 8 or longer than 128 characters | 400 with the code IDEMPOTENCY_KEY_INVALID. |
A lost response at 2 a.m., second by second
Say your job posts a 40,000-address list at 02:00:00 with the key clean-2026-10-01-newsletter. We create the job. Thirty seconds later your HTTP client gives up waiting, so your job never sees the job id. At 02:01:00 it retries with the same key and the same body, and it gets the original response back. One job exists and the credits are spent once.
Without the key, that retry creates a second job, and you pay for up to 40,000 verdicts twice.
There's a catch in the way keys match. The key is matched on its own, not on the body. If your retry rebuilds the list from a fresh database query, it still gets the first job back, including a list that no longer matches what you meant to send. Write the exact payload to disk before the first attempt, and have every retry send that saved copy.
Choosing a key, and what a 400 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 saved job id first. If there isn't one, look at GET /v1/jobs and the credits ledger for a reservation from that night before you resubmit.
A 400 with IDEMPOTENCY_KEY_INVALID means the key was shorter than 8 or longer than 128 characters. A key recipe that is too loose causes the opposite problem, where two different lists end up with the same key and the second gets the first job back. 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, such as a job id or a campaign id plus a date. Generating a fresh random key on every retry defeats the mechanism.
Is a replay billed?
No. You get the stored response and your balance does not move.