Skip to content

Webhooks

llms.txtAsk agent

Original Pictures can send signed event notifications to an HTTPS endpoint you control. You can manage endpoints in the console under Webhooks or with the API.

GET /v1/webhook-endpoints/event-catalog returns the versioned catalog. The current catalog version is op.customer-events.v1, and its event types are:

Event Meaning
asset.signed An asset was signed and published.
asset.watermarked An asset was watermarked and published.

POST /v1/webhook-endpoints requires an Idempotency-Key header:

{
"url": "https://yourapp.example.com/op-webhook",
"event_catalog_version": "op.customer-events.v1",
"events": ["asset.signed"]
}

You can also set optional delivery controls: max_concurrency, rate_limit_per_minute, and auto_disable_after_failures. The response shows the endpoint’s signing secret exactly once. Store it straight away.

Each delivery is a POST with a JSON body and these headers, following the Standard Webhooks convention:

Header Value
webhook-id The event ID. It is also sent as Idempotency-Key.
webhook-timestamp Unix seconds when the delivery was signed.
webhook-signature One or more space-separated v1,<base64> HMAC-SHA256 signatures.

During a secret rotation, webhook-signature carries a signature from both the old and the new secret. Compatibility headers prefixed Original-Pictures- are also sent.

Verify every delivery before acting on it. Deliveries follow Standard Webhooks:

  1. Read the exact raw request body and the webhook-id, webhook-timestamp, and webhook-signature headers. Parsing and re-serializing the JSON first breaks the HMAC.
  2. Reject the delivery if webhook-timestamp is more than 300 seconds from your clock.
  3. The key is the base64-decoded part of your endpoint secret after the whsec_ prefix.
  4. Compute HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{raw body} and base64-encode it.
  5. webhook-signature holds one or more space-separated v1,<base64> values. Accept the delivery if any of them matches, comparing in constant time.

Node.js, with no dependencies:

import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verifyWebhook(rawBody, headers, secret) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = String(headers["webhook-signature"] ?? "").split(" ");
if (!id || !timestamp) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = Buffer.from(
`v1,${createHmac("sha256", key)
.update(`${id}.${timestamp}.`)
.update(rawBody) // a Buffer holding the exact request bytes
.digest("base64")}`,
);
return signatures.some((candidate) => {
const value = Buffer.from(candidate);
return value.length === expected.length && timingSafeEqual(value, expected);
});
}

Python, with the standard library:

import base64
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_webhook(raw_body: bytes, headers: dict[str, str], secret: str) -> bool:
event_id = headers.get("webhook-id", "")
timestamp = headers.get("webhook-timestamp", "")
if not event_id or not timestamp.isdigit():
return False
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{event_id}.{timestamp}.".encode() + raw_body
expected = "v1," + base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
return any(
hmac.compare_digest(expected, candidate)
for candidate in headers.get("webhook-signature", "").split()
)

Reject any request that fails verification. Treat webhook-id as an idempotency key, because a delivery can arrive more than once, and answer 2xx quickly: do the work after responding.

A test delivery lets you check a receiver end to end without waiting for a real event. It is not yet part of the published contract, so treat this section as a preview: the API reference lists it once it is released.

When released, POST /v1/webhook-endpoints/{endpoint_id}/test (scope webhooks:write) sends a synthetic webhook.test event with "test": true to that one endpoint only. It uses the normal signed delivery path, so the verification code above applies unchanged, and it returns 202 with the delivery id. It is limited to 10 per hour per endpoint; beyond that it returns 429. webhook.test is not in the event catalog, so no endpoint can subscribe to it and it never reaches any other endpoint.