Registration methods
You can set a webhook URL in two ways:Global webhook
Register a URL that receives the outcome of every job on your account:DELETE to the same path:
204 whether or not a URL was registered. Jobs you submit afterwards send no callback unless the request carries its own webhook_url, so read their results from the job status endpoint instead. Deliveries already queued for earlier jobs still run.
Per-request webhook
Include awebhook_url field when submitting a moderation request. This overrides the global webhook for that specific job.
Webhook payload
When a job settles, the API sends a POST request to your URL with a JSON body. The shape depends on the modality and on whether the job reached a decision. Every payload carries adelivery_id, repeated in the X-Omnifence-Delivery-Id request header. It stays the same across every retry of the same result. Use it to deduplicate redeliveries.
Fields
Check
status before you read the result. A job sends exactly one terminal webhook: either the
completed result or the failed notice, never both.
There is no
categories object and no type field in a webhook payload. Earlier versions of the
API sent a per-category boolean map; the reason string now explains a rejection instead. Use
job_id to look up the modality of the job if you need it.Text and audio moderation result
reason is present only when is_prohibited is true; text that passes omits it.
Image and video moderation result
reason:
nsfw is informational and never causes a rejection on its own. It is omitted when you have
disabled the NSFW check.
Failed job
A job that exhausts its retries without reaching a decision sends afailed webhook instead of a
result. The charge for the job is refunded. Resubmit the content to get a decision.
error_code matches the value the job status endpoint reports
for the same job. Common values:
The code is omitted when the pipeline could not name a cause.
Verifying a webhook
Every callback we send is signed. Verify the signature before you act on a payload: the webhook URL is not a secret you can rely on, and anyone who learns it can POST a forgedcompleted result at your endpoint.
We implement Standard Webhooks,
the same scheme OpenAI, Anthropic, Twilio, Supabase, PagerDuty and Replicate
send. Use an off-the-shelf library for your language rather than writing the
HMAC yourself.
Your signing secret
Find it in the dashboard under Account → Webhooks. It looks likewhsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw and pastes straight into any Standard
Webhooks library. Reveal it whenever you need it; rotate it immediately if it
leaks.
The headers
webhook-id carries the same value as the delivery_id field in the body and
the X-Omnifence-Delivery-Id header.
The signed string
The signature isHMAC-SHA256 over
whsec_), then base64
encoded and prefixed with v1,.
Sign the raw body bytes, exactly as received. Parsing the JSON and
re-serialising it changes key order and whitespace, and every signature check
will fail.
Worked example
Timestamp tolerance
Standard Webhooks libraries reject awebhook-timestamp more than five
minutes from their own clock, which stops an attacker replaying a delivery
they captured earlier.
You do not need to widen it. We sign each attempt as we send it, so a retry
carries a fresh webhook-timestamp and a fresh webhook-signature and verifies
against the default tolerance even hours later. Retries span about five
hours.
webhook-id does not change between attempts — it is the stable delivery_id. Deduplicate on
it, because a retry after a slow response delivers the same result twice with a different
signature.Rotating the secret
Rotate from Account → Webhooks. The new secret is shown once, and the previous secret keeps signing alongside it for 24 hours so deliveries stay verifiable while you deploy the new value. During that windowwebhook-signature carries two space-delimited values:
Split
webhook-signature on spaces. The comma belongs inside each
v1,<base64> value and is not a separator.Delivery behaviour
- Your server should return a
2xxstatus code to acknowledge receipt. - A failed delivery, which is a non-
2xxresponse or a timeout, is retried with exponential backoff over 12 attempts spanning about five hours. After the final attempt delivery is abandoned; the result is still available via the job status endpoint. - Each attempt has a 10-second timeout.
- Delivery stops immediately, without retrying, if your URL is not valid HTTPS or resolves to a private address.
Best practices
- Verify the signature before you act on a payload. Everything below is secondary to this.
- Use HTTPS for your webhook URL.
- Deduplicate on
webhook-id(the same value asdelivery_id). A retry after a slow response delivers the same result twice. - Respond with
200quickly, then process the result asynchronously. - Store the secret the way you store any other credential, and rotate it if it leaks.