Skip to content

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#

EventFired when
render.queuedAccepted into the queue
render.startedA worker begins
render.completedOutput stored and ready
render.failedFailed — credits already refunded
render.cancelledStopped before delivery — credits already refunded
template.publishedA template passed review
credits.lowBalance below the warning threshold
credits.exhaustedBalance reached zero
subscription.updatedPlan or subscription status changed
payment.capturedA 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.

SettingValuesWhat it does
methodPOSTAlso PUT, PATCH or GET. A GET carries no body at all — put what you need in the URL and headers.
bodyFormatjsonjson, form (urlencoded), multipart, or none. Sets the encoding and the Content-Type.
payloadModedefaultdefault sends our event envelope; custom sends the JSON you write in payloadTemplate.
headersUp 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.

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

render.completed
{
  "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:

Headers
X-Pixbix-Event: render.completed
X-Pixbix-Timestamp: 1786000548
X-Pixbix-Signature: sha256=9f2c8a…
X-Pixbix-Attempt: 1
User-Agent: pixbix-webhooks/1.0

The signature is HMAC-SHA256(secret, "{timestamp}.{rawBody}"). Two things matter and both are easy to get wrong:

  1. 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.
  2. 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 to application/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 4xx other than 429 is 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 your 200 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.

Tunnel
# 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.