Skip to main content
Webhooks let you receive a job’s outcome as soon as it is settled — the moderation result, or a notice that the job failed — without polling.

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:
Calling this endpoint again replaces the previously registered URL. To detach the URL without replacing it, send a DELETE to the same path:
The call returns 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 a webhook_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 a delivery_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

A job that passes omits 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 a failed 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 forged completed 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 like whsec_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 is HMAC-SHA256 over
keyed with the base64-decoded secret (the part after 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

Libraries also exist for Go, Rust, Java, Kotlin, Ruby, PHP, C# and Elixir. See the Standard Webhooks repository.

Timestamp tolerance

Standard Webhooks libraries reject a webhook-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 window webhook-signature carries two space-delimited values:
A Standard Webhooks library tries each value and accepts the message if any one matches, so no change is needed on your side beyond swapping the secret when you are ready. Only the one previous secret is kept: rotating twice in the same window retires the older value immediately, so the header never grows past two signatures.
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 2xx status code to acknowledge receipt.
  • A failed delivery, which is a non-2xx response 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.
See Webhook reliability for the full retry schedule and how to recover from a longer outage.

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 as delivery_id). A retry after a slow response delivers the same result twice.
  • Respond with 200 quickly, then process the result asynchronously.
  • Store the secret the way you store any other credential, and rotate it if it leaks.