Webhooks & signature verification
Receive signed event callbacks, verify them, and handle retries and ordering correctly.
Zentag delivers signed HTTP POST callbacks when domain events occur. Register
endpoints in Settings → Partner integrations → Webhooks, or with the
webhooks:manage scope via POST /webhook-endpoints.
Webhooks are the primary way to learn that asynchronous work finished. Every
endpoint that returns 202 hands you a job id, and the matching event carries
that id back in job_id.
Payload
{
"id": "evt_1a2b3c",
"type": "clip.ready",
"created_at": "2026-07-01T18:24:00.000Z",
"job_id": "job_autoflip_9f2c1d4e7a8b3c50",
"idempotency_key": "my-retry-key-42",
"data": {
"object": "clip",
"id": "clip_123",
"status": "COMPLETED",
"variants": [
{ "aspect_ratio": "9:16", "video_url": "https://...", "status": "completed" }
]
}
}| Field | Notes |
|---|---|
id | Unique per event, stable across retries. This is your deduplication key. |
type | See the event reference below. |
created_at | When the event occurred, not when it was delivered. |
job_id | The job this event completes, when the work was started through the API. null for events caused by activity in the Zentag Studio UI. |
idempotency_key | The Idempotency-Key you sent on the request that started the job. null if you sent none, or if the event was not partner-initiated. |
data | The full resource, serialized exactly as the corresponding GET would return it. |
Event reference
Clips
| Event | Fires when | What to do |
|---|---|---|
clip.created | A clip record exists. Processing has usually not finished. | Create your local record. Do not expect video_url to be playable yet. |
clip.ready | A clip finished processing, or an autoflip / reframe / crop job produced a new rendition. | Read data.variants to see what was produced. Match job_id to the job you started. |
clip.failed | Processing or a render job failed. | Match on job_id. GET /jobs/{job_id} has error_message. |
clip.ready is not once-per-clip. A clip that is created, then autoflipped
to 9:16, then cropped, emits three clip.ready events. Distinguish them by
job_id, and treat the event as "this clip changed, here is its current state"
rather than "this clip is now finished".
Highlights
| Event | Fires when | What to do |
|---|---|---|
highlight.ready | Generation completed and the reel is renderable. | data.preview_url is now populated. |
highlight.failed | Generation failed. | GET /highlights/{id}/progress has the error. |
POST /highlights/generate returns 202 immediately, and one of the two events
above follows when rendering finishes, which can be minutes later. Use
GET /highlights/{id}/progress to drive a progress bar in the meantime.
Publishing
| Event | Fires when |
|---|---|
publish.succeeded | The destination platform accepted the post. data.post_url and data.platform_post_id are populated. |
publish.failed | The platform rejected it, or the upload failed. data.error_message explains why. |
Streams
| Event | Fires when |
|---|---|
stream.created | A stream is created, whether through POST /streams or in Zentag Studio. Subscribe to this to pick up streams your integration did not create itself. |
stream.started | A stream actually goes live. Not emitted by POST /streams, which returns before ingest begins. |
stream.ended | A stream is marked complete. Emitted once - ending an already-ended stream does not emit a second event. |
Headers
| Header | Description |
|---|---|
Zentag-Signature | t=<unix>,v1=<hex HMAC-SHA256> |
Zentag-Event | Event type, same as type in the body |
Zentag-Event-Id | Same as id in the body |
Zentag-Delivery | Id of this delivery attempt. Changes between retries of the same event. |
User-Agent | Zentag-Webhooks/1.0 |
Verifying the signature
The signature is HMAC-SHA256(signing_secret, "{timestamp}.{raw_body}").
Verify against the raw request body, before any JSON parsing. Parsing and
re-serializing changes the bytes and the signature will not match. Compare with
a constant-time function, and reject timestamps outside a ~5 minute window to
limit replay.
Your signing secret is returned once, in the response to
POST /webhook-endpoints. It is never retrievable afterwards; only the last
four characters are shown. If you lose it, delete the endpoint and create a new
one.
Node.js
import crypto from "node:crypto";
export function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Python
import hashlib, hmac
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=") for p in header.split(","))
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])Delivery guarantees
Read this section before writing your handler. The guarantees are weaker than
people usually assume.
At-least-once, not exactly-once. An event can arrive more than once. The
most common cause is your endpoint doing the work successfully but responding
too slowly, so Zentag records a timeout and retries.
Deduplicate on id. Keep a record of processed event ids and ignore
repeats. id is stable across retries and across manual replays; the
Zentag-Delivery header is not, so do not use it for this.
No ordering guarantee. Events are delivered independently and retries
reorder them. clip.ready can arrive before clip.created. Do not build a
state machine that depends on arrival order - treat data as the current state
of the resource and apply it idempotently. If you need to reject stale writes,
compare created_at, or re-fetch the resource.
Acknowledge first, work second. Return 2xx as soon as you have durably
recorded the event, then process asynchronously. A handler that renders,
uploads or calls a third party before responding will exceed the 10-second
delivery timeout and generate duplicates.
Any 2xx acknowledges. Anything else, including a redirect, counts as a
failure.
Retries
A delivery is retried when your endpoint returns a non-2xx status, or does not
respond within 10 seconds.
There are 6 attempts in total: the first, then 5 retries spaced 30 seconds,
2 minutes, 10 minutes, 30 minutes and 1 hour after the preceding failure. After
the sixth, the delivery is marked exhausted and is not retried again.
Recovering from missed deliveries
Exhausted deliveries are not lost, and you have three ways to recover:
- Inspect.
GET /webhook-endpoints/{id}/deliverieslists recent attempts
withresponse_status,errorandnext_retry_at. Start here when events
stop arriving. - Replay.
POST /webhook-endpoints/deliveries/{deliveryId}/replay
re-sends the original payload byte-for-byte, including its eventid- so a
correctly deduplicating consumer safely ignores a replay of something it
already handled. - Poll. For job-backed work,
GET /jobs/{id}and
GET /clips/{id}/jobsreturn the current job state. This is the reliable
fallback: it lets you reconcile without re-running the render.
Testing your endpoint
POST /webhook-endpoints/{id}/test queues a synthetic event so you can verify
connectivity and your signature check before relying on live traffic. Its
payload carries "test": true and no real resource data, and its job_id and
idempotency_key are always null.
Updated about 2 months ago
