Webhooks replace the polling loop. Give us a URL, and when a batch finishes we POST the completion event to it. Your handler acknowledges with any 2xx and we stop.
Every delivery is signed. The ZapBounce-Signature header carries a timestamp and an HMAC of the raw request body, computed with the signing secret shown once when you create the endpoint. Verify against the raw bytes, before any JSON parsing: re-serialising the body changes it, and the signature stops matching for reasons that take an afternoon to find.
If your endpoint is down we retry with backoff over roughly a day, then mark the delivery failed and leave it in the dashboard for you to replay by hand.
POST /hooks/zapbounce
ZapBounce-Signature: t=1789459200,v1=5f4d8c...
{
"id": "evt_91ba72",
"type": "batch.completed",
"created_at": "2026-09-18T10:19:12Z",
"data": {
"batch_id": "bat_8f2c1d",
"name": "q4-webinar-list",
"summary": {
"valid": 7380, "invalid": 1430, "catch_all": 2410, "unknown": 260,
"duplicates": 520, "role": 310, "disposable": 90, "billed": 11220
},
"results_url": "https://api.zapbounce.com/v1/batches/bat_8f2c1d/results"
}
}import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
ts, sig = parts["t"], parts["v1"]
if abs(time.time() - int(ts)) > tolerance:
return False # too old: a replay, or a very slow queue
expected = hmac.new(
secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, sig)Reference
| Event | When it fires | Carries |
|---|---|---|
| batch.completed | Every unique address has a verdict. | Summary plus a results URL. |
| batch.failed | We gave up and refunded the batch. | An error object. |
| batch.canceled | You canceled it. | Partial summary. |
| credits.low | Your balance crosses the threshold you set. | Remaining balance. |
A handler that survives the same event twice
Say a batch.completed event arrives at 10:19. Your handler checks the signature against the raw body, inserts the event id into a table with a unique constraint, and answers 200. The whole thing takes a few milliseconds. A background worker picks the row up afterwards and downloads the results.
Two hours later the same event arrives again, because our first delivery never saw your 200. This time the insert fails on the unique constraint. Your handler should treat that as success and answer 200 anyway. Returning an error for a repeat is the classic mistake, because it tells us the delivery failed and the retries keep coming for a day.
Keep the heavy work out of the handler. A handler that downloads 100,000 rows before it answers will time out on a big batch. It gets retried, and a second download starts on top of the first.
When the webhook never arrives
Treat the webhook as the fast path and keep a slow path beside it. A small hourly job can look up every batch you still have marked as open, using the batch status call. It'll catch anything the webhook missed. Without it, one lost delivery can leave a list sitting unprocessed for a week.
The usual causes are dull ones. Your endpoint might sit behind a firewall or a login wall that our POST can't pass. A TLS certificate may have expired. Or the handler could be doing its work inline and answering too slowly.
Signature failures have two common roots. One is hashing a parsed body when you needed the raw bytes. The other is time. Our sample verifier rejects any timestamp more than five minutes old, so a server clock that has drifted will throw away good events.
The signing secret is shown once, at creation. Put it in your secret manager in that same minute, because if it's lost your only fix is a new endpoint.
Questions developers ask
Why does my signature check fail?
Almost always because the body was parsed and re-serialised before hashing. Capture the raw bytes in your framework and sign those.
How long do you retry?
With backoff across about a day. After that it sits in the dashboard as a failed delivery you can replay.
Can I use one endpoint for several batches?
Yes. The event carries the batch id and your own name field, so route on those.