Webhooks
Get told when a media job finishes, instead of polling.
A webhook sends a signed POST to your endpoint when a media job reaches a final state.
Add an endpoint
- In the dashboard, open Webhooks and enter your Endpoint URL. It must be a public
httpsURL on the default port, 443. A URL with any other port is refused. - Select Add webhook. The signing secret is shown once. Copy it and store it with your other secrets.
The endpoint receives every job in your workspace. Use data.id to match an event to a job you
submitted.
Events
OP-Event / type | Sent when |
|---|---|
job.succeeded | The job finished and output is ready |
job.failed | The job ended without output |
job.canceled | The job was canceled |
The delivery
POST /op-webhooks HTTP/1.1
Content-Type: application/json
OP-Event: job.succeeded
OP-Delivery: whd_...
OP-Signature: t=1790940561,v1=5d2c...
{"id":"evt_...","type":"job.succeeded","created_at":"2026-10-02T11:29:25Z","data":{"id":"job_...","object":"job","state":"succeeded","output":[...]}}data is the job resource, the same object GET /v1/jobs/{id} returns. Output links in it expire
like any other; call GET /v1/jobs/{id} for a fresh one.
Respond
Answer with any 2xx within 10 seconds. Do slow work after you respond. Anything else counts as
a failure:
- a non-
2xxstatus, or no answer in time; - a redirect. Deliveries do not follow redirects.
A failed delivery is retried with growing gaps, up to eight attempts in all, over most of a day. A delivery that never succeeds does not change the job; you can always poll it.
Deliveries are at least once. The same event can arrive twice, for example after a slow 2xx.
Store each event's id and ignore one you have already handled.
Verify the signature
Check every delivery before you trust it. OP-Signature holds a timestamp t and one or more
v1 signatures:
v1 = hex(HMAC_SHA256(secret, t + "." + raw_body))- Read the raw request body as bytes. Do not parse and re-serialise the JSON first; the signature covers the exact bytes sent.
- Refuse the delivery if
tis more than 300 seconds from your clock, either way. This stops a captured delivery from being replayed later. - Compute the HMAC with your secret and accept if it equals any
v1value, compared in constant time.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyOpSignature(header: string, rawBody: string, secret: string): boolean {
const parts = header.split(",").map((p) => p.trim());
const t = Number(parts.find((p) => p.startsWith("t="))?.slice(2));
if (!Number.isInteger(t)) return false;
if (Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = Buffer.from(
createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"),
);
return parts
.filter((p) => p.startsWith("v1="))
.map((p) => Buffer.from(p.slice(3)))
.some((sig) => sig.length === expected.length && timingSafeEqual(sig, expected));
}
// Express: keep the raw body for this route.
// app.post("/op-webhooks", express.raw({ type: "application/json" }), (req, res) => {
// const raw = req.body.toString("utf8");
// if (!verifyOpSignature(req.get("OP-Signature") ?? "", raw, process.env.OP_WEBHOOK_SECRET!)) {
// return res.sendStatus(400);
// }
// res.sendStatus(200);
// handle(JSON.parse(raw));
// });Rotate the secret
On the webhook's page, select Rotate secret. From then on every delivery carries two v1
signatures, one from the new secret and one from the previous one, so a receiver holding either
keeps working. Deploy the new secret to your receiver, then select Retire previous secret.
Verification code that accepts any matching v1, as above, needs no change for a rotation.