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" }
    ]
  }
}
FieldNotes
idUnique per event, stable across retries. This is your deduplication key.
typeSee the event reference below.
created_atWhen the event occurred, not when it was delivered.
job_idThe job this event completes, when the work was started through the API. null for events caused by activity in the Zentag Studio UI.
idempotency_keyThe Idempotency-Key you sent on the request that started the job. null if you sent none, or if the event was not partner-initiated.
dataThe full resource, serialized exactly as the corresponding GET would return it.

Event reference

Clips

EventFires whenWhat to do
clip.createdA clip record exists. Processing has usually not finished.Create your local record. Do not expect video_url to be playable yet.
clip.readyA 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.failedProcessing 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

EventFires whenWhat to do
highlight.readyGeneration completed and the reel is renderable.data.preview_url is now populated.
highlight.failedGeneration 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

EventFires when
publish.succeededThe destination platform accepted the post. data.post_url and data.platform_post_id are populated.
publish.failedThe platform rejected it, or the upload failed. data.error_message explains why.

Streams

EventFires when
stream.createdA 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.startedA stream actually goes live. Not emitted by POST /streams, which returns before ingest begins.
stream.endedA stream is marked complete. Emitted once - ending an already-ended stream does not emit a second event.

Headers

HeaderDescription
Zentag-Signaturet=<unix>,v1=<hex HMAC-SHA256>
Zentag-EventEvent type, same as type in the body
Zentag-Event-IdSame as id in the body
Zentag-DeliveryId of this delivery attempt. Changes between retries of the same event.
User-AgentZentag-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:

  1. Inspect. GET /webhook-endpoints/{id}/deliveries lists recent attempts
    with response_status, error and next_retry_at. Start here when events
    stop arriving.
  2. Replay. POST /webhook-endpoints/deliveries/{deliveryId}/replay
    re-sends the original payload byte-for-byte, including its event id - so a
    correctly deduplicating consumer safely ignores a replay of something it
    already handled.
  3. Poll. For job-backed work, GET /jobs/{id} and
    GET /clips/{id}/jobs return 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.


Did this page help you?