Webhooks
Instead of polling, let us call you. We POST a signed JSON event to your HTTPS URL when a job finishes or an input file is checked. Polling keeps working either way.
Two ways to receive events
| Property | Per-job webhook_url | Webhook endpoints |
|---|---|---|
| Set up | Add webhook_url to POST /v1/audio/denoise, /separate or /enhance | Once, in the dashboard under Webhooks or with POST /v1/webhook-endpoints |
| Receives | That job's job.succeeded, job.failed and job.canceled | Every event you subscribe to, for every job and file of that mode |
| Signed with | Your account's signing secret for the key's mode | The endpoint's own secret, shown when you create it |
| Best for | One request in, one webhook out; a different URL per job | One receiver for everything; file events too |
Both can be used together: a job with a webhook_url also goes to every endpoint subscribed to its event. Live and test are fully separate: test-mode events go only to test endpoints and are signed with test secrets, and they are delivered too, so you can build your receiver with a free test key.
Per-job webhook_url
Send webhook_url (an https URL on port 443, at most 2,048 characters) with any job. When the job ends, we POST its job.succeeded, job.failed or job.canceled event there. The URL is checked when the job is created and again before each delivery; one that is not allowed (see Delivery rules) fails that delivery but never the job.
These deliveries are signed with your account's signing secret for the key's mode. Create it once before your first per-job webhook and keep it in a secret store: the secret is shown only in the response. Calling it again replaces the secret at once, for every later attempt including retries; the old one stops working, so update your receiver right after rotating. Needs the jobs:write scope.
curl -s -X POST https://api.zilapi.com/v1/webhook-endpoints/signing-secret/rotate \
-H "Authorization: Bearer $ZILAPI_KEY" \
-H "Idempotency-Key: $(uuidgen)"
# {"secret": "whsec_…", "mode": "live", "rotated_at": "…", "already_displayed": false}
# Store the secret, for example as ZILAPI_WEBHOOK_SECRET in your server's environment.webhook_url before you ever created a signing secret, we create one for you to sign with, but you have not seen it. Rotate to get a secret you know, then verify with that.Webhook endpoints
An endpoint receives every event it subscribes to for its mode. Create endpoints in the dashboard under Webhooks (choose live or test there) or with the API, where the mode is the key's. events defaults to ["*"], every event. The response carries the endpoint's whsec_… signing secret; it is shown only then, never in a list or read. Reads need jobs:read, changes jobs:write.
# Kept in a variable, not a file: the response carries the endpoint's secret.
RESP=$(curl -s https://api.zilapi.com/v1/webhook-endpoints -H "Authorization: Bearer $ZILAPI_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"url": "https://your-app.example.com/hooks/zilapi",
"events": ["job.succeeded", "job.failed"],
"description": "Production worker"}')
ENDPOINT_ID=$(jq -r .id <<< "$RESP")
jq -r .secret <<< "$RESP" # whsec_…: shown only in this response, store it now- At most 20 endpoints per account and mode, disabled ones included (
409 too_many_endpoints), and at most 50 endpoints created per account in 24 hours (429 rate_limited). - Creating a live endpoint in the dashboard needs a confirmed email address (
403 email_not_verified). GET /v1/webhook-endpointslists them, newest first.PATCH /v1/webhook-endpoints/{id}changesurl,events,descriptionorenabled.DELETEremoves one.- A replayed create (same
Idempotency-Key) returns the same endpoint withsecret: null.
Events
| Event | Sent when | data.object |
|---|---|---|
| job.succeeded | A job finished. Its outputs are ready: download them with GET /v1/jobs/{id}/output. | The Job |
| job.failed | A job did not finish, or its input was rejected (error.code). Nothing is charged. | The Job |
| job.canceled | A job was canceled while it was waiting for its file or queued. Nothing is charged. | The Job |
| file.ready | An input file passed its check and can be used in jobs. | The File |
| file.rejected | An input file was rejected: invalid_audio, audio_too_long, fetch_failed, file_too_large, unsupported_media_type or quota_exceeded. | The File |
| webhook.test | You sent a test ping. Not subscribable; only the endpoint you pinged gets it. | {"object": "webhook_endpoint", "id": "whk_…"} |
Payload
Every delivery is a JSON event: id (evt_…), type, created_at, mode and data.object, the full Job or File as GET /v1/jobs/{id} or GET /v1/files/{id} would return it at that moment. Retries of one event carry the same id and the same body.
{
"id": "evt_4Tn8Wq2Lp6Zr",
"type": "job.succeeded",
"created_at": "2026-10-10T12:00:31+00:00",
"mode": "live",
"data": {
"object": {
"id": "job_7Hq2xV9mKp4R",
"object": "job",
"task": "audio.separate",
"model": "zilapi/vms-2.5",
"status": "succeeded",
"progress": 1.0,
"input": { "file": "file_3Lm8QpZ2rT6w", "duration_seconds": 200.2 },
"output": {
"format": "mp3",
"bitrate_kbps": 320,
"expires_at": "2026-10-17T12:00:31Z",
"files": [
{ "name": "vocals", "size_bytes": 4804800 },
{ "name": "instrumental", "size_bytes": 4804800 }
]
},
"cost": { "estimated_micros": 110550, "charged_micros": 110550, "display": "$0.11" },
"receipt": {
"billable_seconds": 201,
"minimum_applied": false,
"unit_price_micros": 550,
"unit_price_micros_per_minute": 33000,
"unit_price_display": "$0.033 / audio min",
"pricing_version": 2,
"charged_micros": 110550,
"ledger_txn_id": "txn_5Rk2Vb8Lq1Zx"
},
"error": null,
"mode": "live",
"created_at": "2026-10-10T11:59:58Z",
"started_at": "2026-10-10T12:00:04Z",
"finished_at": "2026-10-10T12:00:31Z"
}
}
}Headers
| Header | Value |
|---|---|
| ZilAPI-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
| ZilAPI-Event-Id | evt_…, equal to the body's id. Not covered by the signature: deduplicate on the verified body id, not on this header |
| ZilAPI-Event-Type | For example job.succeeded |
| ZilAPI-Delivery-Id | dlv_…, one per destination |
| User-Agent | ZilAPI-Webhooks/1 |
| Content-Type | application/json |
Verify the signature
- The HMAC key is the full secret,
whsec_included, as UTF-8. The signed message is thettimestamp in decimal, a period, then the raw request body bytes. - Verify before you parse the JSON. Do not parse and re-serialize it first: the bytes would change.
- Reject a timestamp more than 5 minutes (300 seconds) away from your clock, in either direction, so a captured request cannot be replayed. Keep your server's clock synchronized.
- Compare in constant time, and accept the request if any
v1value in the header matches. Each attempt gets a fresh timestamp.
# Check one delivery by hand. body.json holds the raw request body, byte for byte,
# and TS is the t= value from its ZilAPI-Signature header.
{ printf '%s.' "$TS"; cat body.json; } \
| openssl dgst -sha256 -hmac "$ZILAPI_WEBHOOK_SECRET" -hex
# Compare the hex digest with the v1= value from the same header.Buffer. With Express, use express.raw({type: "application/json"}) on the webhook route before any JSON middleware.Respond quickly, process later
- Record the event durably, answer with any
2xx, then do the slow work (downloads, transcoding) in the background. Any other status,3xxincluded, is a failure. - We wait at most 10 seconds for the whole exchange and read at most 64 KB of your response, which we discard.
- Delivery is at least once: the same event can arrive again. Deduplicate on the
id(evt_…) of the verified body, and acknowledge duplicates with2xx. Do not deduplicate on theZilAPI-Event-Idheader: the signature covers only the timestamp and the body, so a replayed request could carry a different header. If you read the header, reject a request whose header does not match the bodyid. - There is no ordering guarantee. Each endpoint gets at most one delivery at a time; different endpoints may receive concurrently. When you need the latest state, read the job or file.
- Delivery rules: https on port 443, no user name or password in the URL, and every address the host resolves to must be public (no private, loopback, link-local or CGNAT addresses). Redirects are never followed. The certificate and host name are verified.
Retries
A failed delivery is retried 7 times, so at most 8 attempts, with these waits after each failure: 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h. That is about 45 hours in all.
| Status | Meaning |
|---|---|
| pending | Waiting for its next attempt. |
| succeeded | Your endpoint answered 2xx. |
| giving_up | All 8 attempts failed. We stop retrying. |
| failed | Stopped without more retries, for example because the URL is not allowed, the endpoint was disabled or deleted, or the signing secret was unavailable. |
Deliveries and events are kept for 30 days: GET /v1/webhook-endpoints/{id}/deliveries lists an endpoint's, newest first, with attempts, response_code, response_ms, error and next_attempt_at. The dashboard shows the same under Webhooks.
Disabling and deleting
If deliveries to an endpoint keep failing for 3 days, we disable it (status: disabled, disabled_reason: failing_for_3_days), stop its pending deliveries and email you once. Failures count as continuous only while failed attempts keep coming: after 25 hours without a failed attempt the next failure starts a new 3-day window, and one successful delivery of a real event resets it. Test pings never start, extend or reset the window.
Fix the receiver, then turn the endpoint back on. Re-enabling does not replay the events it missed; read the jobs you care about instead.
curl -s -X PATCH https://api.zilapi.com/v1/webhook-endpoints/$ENDPOINT_ID -H "Authorization: Bearer $ZILAPI_KEY" \
-H "Content-Type: application/json" \
-d '{"enabled": true}' | jq '{status, disabled_reason}' # "enabled", null{"enabled": false}disables an endpoint yourself (disabled_reason: disabled_by_user).- Deleting removes it at once from every list and from the 20-endpoint cap, wipes its secret and fails its pending deliveries (
endpoint_deleted). A request already sent cannot be recalled.
curl -s -X DELETE https://api.zilapi.com/v1/webhook-endpoints/$ENDPOINT_ID -H "Authorization: Bearer $ZILAPI_KEY" -o /dev/null -w "%{http_code}\n" # 204Test with a ping
Send a webhook.test event to one enabled endpoint with Send test in the dashboard or POST /v1/webhook-endpoints/{id}/test. A ping gets one attempt, no retries. Your account can send one ping every 10 seconds, across all endpoints and both modes, and none while the previous one is still pending (429 rate_limited with Retry-After). Pinging a disabled endpoint is 409 endpoint_disabled.
curl -s -X POST https://api.zilapi.com/v1/webhook-endpoints/$ENDPOINT_ID/test -H "Authorization: Bearer $ZILAPI_KEY"
sleep 2
curl -s "https://api.zilapi.com/v1/webhook-endpoints/$ENDPOINT_ID/deliveries?limit=5" -H "Authorization: Bearer $ZILAPI_KEY" \
| jq '.data[] | {event: .event.type, status, response_code, response_ms}'To try the real events end to end for free, use a test key: create a test-mode endpoint (or send a webhook_url) and run a test job. Test jobs finish almost at once with a sample result, and their events are delivered and signed like live ones.