Skip to content

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" }

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.

FieldTypeRequiredDescription
callbackUrlstringnoPublicly reachable https URL, max 2048 characters. Omit it to poll instead.
Terminal window
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_INVALIDno job is created and nothing is charged. See URL requirements.

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"
}
FieldDescription
deliveryIdStable across every retry of this delivery — deduplicate on it
jobIdThe job that finished
statusTerminal status: COMPLETED, FAILED, or CANCELLED
totalItems / completedItems / failedItemsFinal counters
completedAtWhen 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.

Every delivery carries one header:

X-Zandovi-Signature: t=1755683051,v1=8f3c2ad9e1b74c05…
  • t is the Unix timestamp (seconds) the signature was computed at.
  • Each v1 value is HMAC-SHA256(signing_secret, t + "." + rawBody), hex-encoded.
  • v1 names the scheme, so a future algorithm ships as v2 alongside v1 rather than breaking every receiver at once.
  • v1 is 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 any v1 value 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:

  1. 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().
  2. Compare in constant time. A short-circuiting === leaks how much of the signature was correct.
  3. 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.

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.

Verification is optional, because you supply the URL per job and can carry your own token in it:

https://hook.example.com/zandovi?token=6f21b0c4e9

Then 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.

PropertyValue
Attempts5
BackoffRoughly +0, +1m, +3m, +7m, +15m after the job ends
Request timeout10 seconds
SuccessAny 2xx
RedirectsNot followed — a 3xx counts as a refusal
OrderingNot guaranteed
SemanticsAt-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.

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.

Checked at submission and again at each delivery attempt — a hostname that resolves publicly today and privately tomorrow is refused tomorrow.

  • https only. Plain http is 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 localhost and 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.