Webhooks

Motionpit can POST JSON payloads to a URL you control when render jobs complete or fail. Configure webhook endpoints in your dashboard settings. Deliveries are at-least-once — your handler must be idempotent on the X-VAM-Delivery header.

Events

render.completedA render job finished successfully. The MP4 is in Spaces at `outputSpacesKey`; request a presigned URL from `/v1/renders/{id}/signed-url` to download.

Payload schema

FieldTypeDescription
renderJobIdstring
outputSpacesKeystring

Fired by: src/services/renderService.ts `markComplete()`

render.failedA render job failed terminally. The `error` string is human-readable only; do not pattern-match on it.

Payload schema

FieldTypeDescription
renderJobIdstring
errorstring

Fired by: src/services/renderService.ts `markFailed()`

Transport

Each delivery is an HTTP POST to your endpoint URL with the following headers:

HeaderValue
X-VAM-EventThe event type, e.g. render.completed
X-VAM-Signaturet=<unix_seconds>,v1=<hex_hmac> — HMAC-SHA256 signature
X-VAM-DeliveryUnique UUID for this delivery attempt (idempotency key)
Content-Typeapplication/json

Delivery semantics

  • At-least-once. De-duplicate on X-VAM-Delivery + renderJobId.
  • Retry backoff (after first failure): 1m, 5m, 30m, 2h, 6h5 attempts total.
  • Timeout per attempt: 15s. Respond with any 2xx to acknowledge.
  • After 10 consecutive dead deliveries, the endpoint is auto-disabled.

Signature verification

Verify the X-VAM-Signature header before processing the event. This prevents spoofed deliveries.

Signed string

<timestamp>.<raw_body>

Where timestamp is the t= value from the X-VAM-Signature header (Unix seconds), and raw_body is the exact request body bytes (do not parse JSON first).

Verification example (TypeScript / Node.js)

import crypto from "node:crypto";

function verifyWebhookSignature(
  rawBody: string,
  signatureHeader: string,
  secret: string,
): boolean {
  // Parse "t=<ts>,v1=<hex>"
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((p) => p.split("=")),
  );
  const timestamp = parts["t"];
  const receivedHex = parts["v1"];

  if (!timestamp || !receivedHex) return false;

  // Reject replays older than 5 minutes
  const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
  if (age > 300) return false;

  const signed = `${timestamp}.${rawBody}`;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(signed)
    .digest("hex");

  // Constant-time compare to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(expected, "hex"),
    Buffer.from(receivedHex, "hex"),
  );
}

Your endpoint secret is configured per-endpoint in the dashboard. Keep it private — anyone with the secret can forge valid signatures.