OP Inference

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

  1. In the dashboard, open Webhooks and enter your Endpoint URL. It must be a public https URL on the default port, 443. A URL with any other port is refused.
  2. 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 / typeSent when
job.succeededThe job finished and output is ready
job.failedThe job ended without output
job.canceledThe 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-2xx status, 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))
  1. Read the raw request body as bytes. Do not parse and re-serialise the JSON first; the signature covers the exact bytes sent.
  2. Refuse the delivery if t is more than 300 seconds from your clock, either way. This stops a captured delivery from being replayed later.
  3. Compute the HMAC with your secret and accept if it equals any v1 value, 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.