Completion Callbacks
A batch job can call you back when it finishes. Add a callbackUrl to the submission and, once the
job reaches a terminal state, Zandovi POSTs a summary to that URL — so an automation can submit a
job and go quiet instead of holding a polling loop open for the length of the render.
POST /api/v1/batch-jobs{ "template": { … }, "rows": [ … ], "callbackUrl": "https://example.com/hooks/zandovi" }Requesting one
Section titled “Requesting one”callbackUrl sits at the top level of the submit body — beside template and rows, not inside
template. It is a delivery instruction about the job, not a render parameter.
| Field | Type | Required | Description |
|---|---|---|---|
callbackUrl | string | no | Publicly reachable https URL, max 2048 characters. Omit it to poll instead. |
curl -X POST https://app.zandovi.com/api/v1/batch-jobs \ -H "X-Api-Key: $ZANDOVI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "template": { "templateId": "019463b8-1234-7890-abcd-ef1234567890", "format": "png" }, "rows": [ { "first_name": "Alice", "discount_code": "ALICE10" }, { "first_name": "Bob", "discount_code": "BOB20" } ], "callbackUrl": "https://example.com/hooks/zandovi" }'A URL we already know we could never deliver to is rejected at submission with 400
BATCH_COMPLETION_CALLBACK_URL_INVALID — no job is created and nothing is charged. See
URL requirements.
What we send
Section titled “What we send”One POST, Content-Type: application/json, when the job reaches COMPLETED, FAILED, or
CANCELLED:
{ "deliveryId": "019a6b71-2f4c-7e08-9d31-5b7c2e40f118", "jobId": "019a5f22-4c1e-7d90-b3a8-6f2e91c04d77", "status": "COMPLETED", "totalItems": 400, "completedItems": 398, "failedItems": 2, "completedAt": "2026-08-20T10:04:11Z"}| Field | Description |
|---|---|
deliveryId | Stable across every retry of this delivery — deduplicate on it |
jobId | The job that finished |
status | Terminal status: COMPLETED, FAILED, or CANCELLED |
totalItems / completedItems / failedItems | Final counters |
completedAt | When the job reached its terminal state |
All three terminal statuses are delivered. A caller who stopped polling because a callback was promised has to hear about a failure and a cancellation too, not just a success.
Verifying the signature
Section titled “Verifying the signature”Every delivery carries one header:
X-Zandovi-Signature: t=1755683051,v1=8f3c2ad9e1b74c05…tis the Unix timestamp (seconds) the signature was computed at.- Each
v1value isHMAC-SHA256(signing_secret, t + "." + rawBody), hex-encoded. v1names the scheme, so a future algorithm ships asv2alongsidev1rather than breaking every receiver at once.v1is a list. It carries one value today; a future secret rotation emits two at once during an overlap window so an un-updated receiver keeps working. Accept the delivery if anyv1value verifies.
import crypto from 'node:crypto'
// rawBody must be the exact bytes received — see the rules below.function verify(header, rawBody, secret, toleranceSeconds = 300) { const parts = Object.create(null) const signatures = [] for (const piece of header.split(',')) { const [key, value] = piece.split('=', 2) if (key === 'v1') signatures.push(value) else parts[key] = value }
const timestamp = Number(parts.t) if (!Number.isFinite(timestamp)) return false if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false
const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`) .digest('hex')
return signatures.some((candidate) => { const a = Buffer.from(candidate, 'utf8') const b = Buffer.from(expected, 'utf8') return a.length === b.length && crypto.timingSafeEqual(a, b) })}Three rules, each of which silently breaks verification:
- Read the raw bytes before parsing. Verifying against a re-serialized object fails on key
order and whitespace — the single most common bug with signed callbacks. In Express, that means
express.raw({ type: 'application/json' })on this route; in FastAPI,await request.body(). - Compare in constant time. A short-circuiting
===leaks how much of the signature was correct. - Reject stale timestamps (~5 minutes is a good tolerance) and deduplicate on
deliveryId. The timestamp is inside the signed input precisely so a captured delivery cannot be replayed forever, and delivery is at-least-once by design.
The signing secret
Section titled “The signing secret”Your organization’s secret lives in the app under Settings → API Keys, below the key list, as Completion callback secret. Click the eye icon to reveal it, or the copy icon to copy it.
- It is re-displayable, not reveal-once — unlike an API key, you can come back and read it again whenever you need it.
- It never expires, and nothing rotates it on a schedule. An integration that starts failing on a timer is the worst failure mode for an automation nobody is watching, so this is a guarantee, not an accident.
- It is not your API key, deliberately. A leaked API key cannot forge callbacks, and a leaked signing secret cannot render.
- It is per organization, so every job submitted by any key in the workspace is signed with the same secret.
The ninety-second alternative
Section titled “The ninety-second alternative”Verification is optional, because you supply the URL per job and can carry your own token in it:
https://hook.example.com/zandovi?token=6f21b0c4e9Then reject any request whose token doesn’t match. This is weaker — a URL token shows up in access
and proxy logs, and it authenticates the sender without binding the body — but it costs one
if statement, and it beats not checking anything. Use the signature when the payload drives
anything that matters.
Delivery, retries, and giving up
Section titled “Delivery, retries, and giving up”| Property | Value |
|---|---|
| Attempts | 5 |
| Backoff | Roughly +0, +1m, +3m, +7m, +15m after the job ends |
| Request timeout | 10 seconds |
| Success | Any 2xx |
| Redirects | Not followed — a 3xx counts as a refusal |
| Ordering | Not guaranteed |
| Semantics | At-least-once; deliveryId is stable across retries |
Fifteen minutes comfortably outlives a rolling deploy or a brief outage, which is what a retry is for. Past that, the endpoint is not coming back on its own and a dead URL must not become a permanent queue, so the fifth refusal is final.
Return 2xx as soon as you have durably accepted the payload, and do the real work afterwards —
a handler that renders, uploads, or emails inline will hit the 10-second timeout and be retried
even though it succeeded.
Checking what happened
Section titled “Checking what happened”GET /api/v1/batch-jobs/{jobId} carries a completionCallback object when the submission asked
for one (and null when it didn’t):
{ "jobId": "019a5f22-4c1e-7d90-b3a8-6f2e91c04d77", "status": "COMPLETED", "completionCallback": { "deliveryId": "019a6b71-2f4c-7e08-9d31-5b7c2e40f118", "callbackUrl": "https://example.com/hooks/zandovi", "status": "FAILED", "attempts": 5, "deliveredAt": null, "lastError": "Callback endpoint answered 503" }}This is how you find out a delivery gave up. Retries are bounded, so without checking here, an endpoint that refused every attempt looks exactly like a job that never finished.
URL requirements
Section titled “URL requirements”Checked at submission and again at each delivery attempt — a hostname that resolves publicly today and privately tomorrow is refused tomorrow.
- https only. Plain
httpis rejected. - No user-info in the URL (
https://user:pass@host/…). - The host must resolve exclusively to public addresses. Private, internal, loopback,
link-local, CGNAT, cloud-metadata, and multicast addresses are all refused — so
localhostand in-VPC hostnames cannot be used, and a tunnel (ngrok, Cloudflare Tunnel) is the way to test locally.
A URL that fails these checks at submission returns 400
BATCH_COMPLETION_CALLBACK_URL_INVALID; the response is deliberately vague about why, and the
detail is logged on our side. One that starts failing them later marks the delivery failed with the
reason in lastError.
See also
Section titled “See also”- Batch Rendering — submitting jobs, polling, per-row outcomes, and results.
- Errors, quotas & rate limits — response bodies and retry guidance.
- API Keys — where the signing secret lives.