Webhooks
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.
Discover events
Section titled “Discover events”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. |
Register an endpoint
Section titled “Register an endpoint”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.
Delivery headers
Section titled “Delivery headers”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 signatures
Section titled “Verify signatures”Verify every delivery before acting on it. Deliveries follow Standard Webhooks:
- Read the exact raw request body and the
webhook-id,webhook-timestamp, andwebhook-signatureheaders. Parsing and re-serializing the JSON first breaks the HMAC. - Reject the delivery if
webhook-timestampis more than 300 seconds from your clock. - The key is the base64-decoded part of your endpoint secret after the
whsec_prefix. - Compute HMAC-SHA256 over
{webhook-id}.{webhook-timestamp}.{raw body}and base64-encode it. webhook-signatureholds one or more space-separatedv1,<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 base64import hashlibimport hmacimport 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.
Test deliveries
Section titled “Test deliveries”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.
Retries, replay, and rotation
Section titled “Retries, replay, and rotation”- A
2xxresponse acknowledges the delivery.408,425,429, and5xxresponses are retried. Other status codes end the delivery. - Inspect deliveries and attempts with
GET /v1/webhook-endpoints/{endpoint_id}/deliveries. Replay one withPOST /v1/webhook-endpoints/deliveries/{delivery_id}/replay. - Rotate secrets with
POST /v1/webhook-endpoints/{endpoint_id}/rotate-secret. Once your receiver has the new secret, finish withPOST /v1/webhook-endpoints/{endpoint_id}/secret-overlap/complete.
