Skip to content

Webhooks

Instead of asking whether a job is done, let us tell you. We POST an event to your URL each time a job changes:

Event When
job.processing The job left the queue and started.
job.succeeded Every output is ready.
job.failed It failed: data.error says why.
job.canceled It was canceled.

Events go to every endpoint of your organization (add them in the dashboard, under Webhooks) and to the job’s own webhook_url, if it has one.

POST /hooks/iomza HTTP/1.1
Content-Type: application/json
User-Agent: Iomza-Webhooks/1.0
webhook-id: 0199a0e3-9c1a-7f02-b6d8-3e7a4c0f5b91
webhook-timestamp: 1793462400
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{
"id": "0199a0e3-9c1a-7f02-b6d8-3e7a4c0f5b90",
"object": "event",
"type": "job.succeeded",
"created_at": "2026-10-30T18:04:11.000Z",
"data": { "id": "0199a0e1-…", "object": "job", "status": "succeeded", "outputs": { "…": "…" } }
}

data is the job, as GET /v1/jobs/{id} shows it (without billed_minutes).

Reply with any 2xx within 10 seconds, then do the work. Anything else (another status, a redirect, a timeout) counts as a failed attempt, and the event is sent again after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h. You can see every delivery and its outcome, and send one again, in the dashboard, or list them:

GET /v1/webhook-deliveries?job_id={job id}

Webhooks follow Standard Webhooks. Each request is signed with your organization’s secret (whsec_…, in the dashboard under Webhooks), so you can check it came from us and wasn’t altered:

  1. Take the webhook-id and webhook-timestamp headers and the raw body, exactly as received (before any JSON parsing).
  2. Compute HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<body>, keyed with the base64-decoded part of the secret after whsec_.
  3. Compare it, base64-encoded, with the signature after v1, in webhook-signature, in constant time.
  4. Reject timestamps more than 5 minutes away from your clock, so a captured request can’t be replayed later.

The official standardwebhooks libraries do all of it:

import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.IOMZA_WEBHOOK_SECRET); // "whsec_…"
app.post("/hooks/iomza", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = wh.verify(req.body, req.headers); // throws if the signature or timestamp is wrong
} catch {
return res.sendStatus(400);
}
res.sendStatus(204);
handle(event); // after answering
});

Or by hand, in Node:

import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, headers, rawBody) {
const id = headers["webhook-id"];
const timestamp = Number(headers["webhook-timestamp"]);
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
return headers["webhook-signature"].split(" ").some((s) => {
const given = Buffer.from(s.replace(/^v1,/, ""), "base64");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}

Rotating the secret in the dashboard replaces it at once: deliveries from then on are signed with the new one.

Delivery is at least once: a network failure after you answered can make us send an event again. Keep the event ids you have handled and skip repeats. Events may also arrive out of order (a retried job.processing after job.succeeded, say): trust the job’s status in data, or fetch the job, rather than the order of arrival.

  • https, on a public address: private and internal addresses are refused, and checked again on every delivery.
  • Answer the POST itself: redirects are not followed.