Skip to content

API docs · Tasks

Webhooks & signatures

Event format, X-Signature verification and delivery retries.

Pass a callback_url when creating a task, and when the task reaches a terminal status the platform sends a POST with an event: task.succeeded or task.failed. The event's data is the full task object, identical to what the task endpoint returns.

Signing secret

Generate a webhook signing secret under Console · Account settings. The secret is shown only once. After you rotate it, the old and new secrets are both valid for 24 hours so you can switch over without downtime.

Verifying signatures

Once you have generated a signing secret, every callback request carries a signature header. Without a secret, callbacks are sent unsigned, so always generate one and verify signatures in production:

X-Signature: t=<unix seconds>,v1=<hex>

where v1 = hex(HMAC_SHA256(secret, t + "." + raw_body)). To verify:

  1. Parse t and every v1 from the header: for 24 hours after a secret rotation, the header carries two v1 values (new and old), and a match on either one passes.
  2. Concatenate t + "." + body using the raw, unparsed request body and compute HMAC-SHA256.
  3. Compare the result with each v1 using a constant-time comparison.
  4. Check that t is within 5 minutes of the current time to prevent replay attacks.

Delivery and retries

  • Any 2xx response counts as a successful delivery. Each request times out after 10 seconds, so respond quickly and move slow work to a queue. Redirects (3xx) are not followed and count as failures.
  • If the first delivery fails (non-2xx or timeout), it is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, then dropped. The task result is unaffected and can still be fetched.
  • callback_url must be a publicly reachable http / https URL.
  • The same event may be delivered more than once; deduplicate by id (evt_…).
  • Events may arrive out of order; treat the task object's status as the source of truth.
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.AITOKEN_WEBHOOK_SECRET;

// Verify against the raw request body — never JSON.parse and re-serialize first
app.post("/hooks/video", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("X-Signature") ?? ""; // t=1730259000,v1=5f2c...[,v1=old-secret signature]
  const pairs = header.split(",").map((kv) => kv.split("="));
  const t = Number(pairs.find(([k]) => k === "t")?.[1]);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return res.status(400).end(); // 5-minute window

  const expected = Buffer.from(
    crypto.createHmac("sha256", SECRET).update(`${t}.${req.body.toString("utf8")}`).digest("hex"),
  );
  // For 24 hours after a secret rotation, two v1 values (new and old) are sent; either match passes
  const ok = pairs
    .filter(([k, v]) => k === "v1" && v?.length === expected.length)
    .some(([, v]) => crypto.timingSafeEqual(Buffer.from(v), expected));
  if (!ok) return res.status(400).end();

  const event = JSON.parse(req.body.toString("utf8"));
  // Deduplicate by event.id; return 2xx quickly and queue slow work
  res.status(204).end();
});