Webhooks
Video renders are asynchronous. Webhooks push the result to you the moment it is ready, so you never have to poll.
Setting up#
Register an endpoint under Settings → Webhooks, or set callback on an individual render. Both deliver the same payload with the same signature scheme.
An endpoint is a description of the whole request, not just a URL: the method, how the body is encoded, the headers to send, and the payload itself. That is there so you can point us straight at a receiver you do not control — an automation platform, a CRM's inbound hook, a chat webhook — instead of running a service whose only job is renaming our fields.
The secret is shown once at creation. Store it alongside your API key — you need it to verify every delivery.
HTTPS only in production
A plain HTTP endpoint would put the signed payload on the wire in clear text, so we refuse to register one outside development.Events#
| Event | Fired when |
|---|---|
render.queued | Accepted into the queue |
render.started | A worker begins |
render.completed | Output stored and ready |
render.failed | Failed — credits already refunded |
render.cancelled | Stopped before delivery — credits already refunded |
template.published | A template passed review |
credits.low | Balance below the warning threshold |
credits.exhausted | Balance reached zero |
subscription.updated | Plan or subscription status changed |
payment.captured | A payment succeeded |
Subscribe only to what you use. The default — render.completed and render.failed — is what most integrations need.
A render has three endings, not two. If your integration blocks until a render settles, subscribe to render.cancelled as well or a cancelled job will never arrive and the work waiting on it will hang. Treat it as terminal-but-fine: the credits are already back, and unlike render.failed there is nothing to retry.
Shaping the request#
Four settings decide what arrives at your endpoint. All of them are on the endpoint form, and all of them are accepted by POST /organization/webhooks if you would rather script it.
| Setting | Values | What it does |
|---|---|---|
method | POST | Also PUT, PATCH or GET. A GET carries no body at all — put what you need in the URL and headers. |
bodyFormat | json | json, form (urlencoded), multipart, or none. Sets the encoding and the Content-Type. |
payloadMode | default | default sends our event envelope; custom sends the JSON you write in payloadTemplate. |
headers | — | Up to 20 { key, value } pairs sent with every delivery — an Authorization header, a routing key, whatever the receiver asks for. |
Under form and multipart, top-level keys of the payload become fields and nested values are sent as JSON text — neither encoding has a notion of nesting.
Our headers always win
X-Pixbix-Signature, X-Pixbix-Timestamp, X-Pixbix-Event and X-Pixbix-Attempt are added to every delivery and cannot be overridden by a configured header. A delivery you cannot verify is worse than one your receiver has to ignore a header on.Variables#
The URL, every header value and the payload accept {{placeholders}}, resolved against the event as it is delivered. A path that does not exist on a given event never fails the delivery: it resolves to an empty string, or to null where the placeholder is a whole JSON value.
| Variable | Resolves to |
|---|---|
{{event.type}} | The event name, e.g. render.completed |
{{event.id}} | Unique id for this event — the key to deduplicate on |
{{event.created}} | ISO 8601 timestamp |
{{timestamp}} | Unix seconds, the same value that is signed |
{{data}} | The whole event object |
{{data.id}} | Any field of it — also {{data.status}}, {{data.url}}, {{data.kind}}, {{data.templateId}}, {{data.duration}}, {{data.credits}}, {{data.error}} |
{{org.id}} | Your workspace id |
{{endpoint.id}} | The endpoint the delivery is going to |
A custom payload is parsed as JSON before substitution and re-serialised after, so a value containing a quote or a newline cannot break the body. It also means a value that is only a placeholder keeps its type: "{{data}}" sends the object, not a string of it.
{
"event": "{{event.type}}",
"renderId": "{{data.id}}",
"status": "{{data.status}}",
"fileUrl": "{{data.url}}",
"creditsUsed": "{{data.credits}}",
"raw": "{{data}}"
}Custom payloads and idempotency
Deduplicate on something that survives your own template. If you drop{{event.id}} and {{data.id}} from the body, a retry becomes indistinguishable from a second render — the X-Pixbix-Event header alone will not tell you which render it was about.The default payload#
Left on its default, the body is our event envelope: an event id to deduplicate on, the event type, when it happened, and the event itself under data.
{
"id": "evt_m1x8k2p9",
"type": "render.completed",
"created": "2026-08-13T09:15:48.221Z",
"data": {
"id": "rnd_8f2a1c",
"status": "done",
"kind": "video",
"templateId": "story-reel",
"url": "https://cdn.pixbix.app/render/…/render.mp4",
"posterUrl": "https://cdn.pixbix.app/render/…/poster.jpg",
"thumbnailUrl": "https://cdn.pixbix.app/render/…/thumbnail.jpg",
"format": "mp4",
"width": 1080,
"height": 1920,
"duration": 15.02,
"fileSizeBytes": 2841022,
"renderTime": 42180,
"credits": 15,
"watermarked": false
}
}Verifying a delivery#
Every request carries:
X-Pixbix-Event: render.completed
X-Pixbix-Timestamp: 1786000548
X-Pixbix-Signature: sha256=9f2c8a…
X-Pixbix-Attempt: 1
User-Agent: pixbix-webhooks/1.0The signature is HMAC-SHA256(secret, "{timestamp}.{rawBody}"). Two things matter and both are easy to get wrong:
- Use the raw body. Re-serialising a parsed JSON body changes key order and whitespace, and the signature will never match. This holds for a custom payload and for the form encodings too: what we sign is the exact bytes we send, whatever shape you configured.
- Check the timestamp. Rejecting anything older than five minutes is what stops a captured payload being replayed at you later.
Not sending JSON?
The samples below match the body toapplication/json. If you configured form or multipart, widen that matcher — express.raw({ type: "*/*" }) — or the framework will parse the body before you can hash it. A GET delivery has no body, so its signature covers the empty string.import crypto from "crypto";
// express.raw, NOT express.json — we need the exact bytes.
app.post(
"/hooks/pixbix",
express.raw({ type: "application/json" }),
(req, res) => {
const timestamp = req.get("X-Pixbix-Timestamp") || "";
const signature = req.get("X-Pixbix-Signature") || "";
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(400).send("stale");
}
const expected = "sha256=" + crypto
.createHmac("sha256", process.env.PIXBIX_WEBHOOK_SECRET)
.update(timestamp + "." + req.body.toString())
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send("bad signature");
}
const event = JSON.parse(req.body.toString());
// Acknowledge immediately, then work asynchronously.
res.sendStatus(200);
void handleEvent(event);
},
);Retries and failures#
Return 2xx within 10 seconds. Anything else is treated as a failure and retried with exponential backoff — 1s, 4s, 9s, 16s — up to 5 attempts.
- A
4xxother than429is not retried: the receiver understood and rejected it, so retrying cannot help. - An endpoint failing 20 times consecutively is disabled automatically and flagged in the dashboard.
- Re-enabling an endpoint clears its failure streak.
Make your handler idempotent
A retry can deliver an event you already processed — for example if your200 was lost on the way back. Key your handling on data.id, the render id, and ignore duplicates.Testing locally#
Use a tunnel to reach your machine, register the tunnel URL, and press Send test on the endpoint. The test is built and signed through the same path as a real delivery, so it exercises the method, encoding, headers and payload you configured — and it carries X-Pixbix-Test: true so your handler can tell.
A failed test does not count towards the failure streak that disables an endpoint, so you can iterate on a receiver without being locked out of your own webhook.
# Any tunnelling tool works
ngrok http 3000
# → https://a1b2c3.ngrok.io
# Register https://a1b2c3.ngrok.io/hooks/pixbix in
# Settings → Webhooks, then hit "Send test".If a webhook never arrives#
Webhooks are best-effort. A render is still authoritative in the API, so a robust integration reconciles: for any render still marked pending after a reasonable window, call GET /v1/render/{id} and settle from the response.
A sweep every few minutes over renders older than five minutes and not yet terminal is enough, and it makes your pipeline immune to a lost delivery.