Webhooks

Receive job results asynchronously via HTTP callbacks

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:

Shell
curl -X POST https://api.omnifence.ai/api/v1/webhook/register \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://your-app.com/webhooks/omnifence"}'

Calling this endpoint again replaces the previously registered URL.

To detach the URL without replacing it, send a DELETE to the same path:

Shell
curl -X DELETE https://api.omnifence.ai/api/v1/webhook/register \
  -H "Authorization: Bearer YOUR_API_KEY"

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.

Shell
curl -X POST https://api.omnifence.ai/api/v1/moderate/image \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F "image=https://example.com/photo.jpg" \
  -F "webhook_url=https://your-app.com/webhooks/moderation"

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#

Field Always present Description
is_prohibited Yes true if the content was rejected, false if it passed, null if the job failed.
job_id Yes The job this result belongs to. Match it against your original request.
status Yes completed when the job reached a decision, failed when it did not. See below.
completed_at On completed ISO 8601 timestamp of the decision.
failed_at On failed ISO 8601 timestamp of the final failure.
delivery_id Yes Stable id for this delivery, repeated on every retry.
reason No Why the content was rejected. Present only when is_prohibited is true.
nsfw No Informational NSFW label. Present only on image and video jobs with the NSFW check on.
scores No Informational risk score per built-in category, from 0 to 1, keyed by the category ID without the default_ prefix (for example violence for default_violence). Present only on a completed text or audio webhook that produced scores. Image and video jobs do not return risk scores. See risk scores.
custom_scores No The same risk scores for your custom categories, keyed by custom category slug. Present only when at least one custom category was scored.
error_code No Why the job failed. Present only on a failed webhook, and only when a cause is known.

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 job 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. Only a batch webhook carries type, with the value text_batch.

Text and audio moderation result#

JSON
{
  "is_prohibited": true,
  "reason": "The text requests sexual content involving a minor.",
  "job_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "status": "completed",
  "completed_at": "2026-05-19T12:00:01.500Z",
  "delivery_id": "wh_a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}

reason is present only when is_prohibited is true; text that passes omits it.

A completed text or audio result can also carry scores and custom_scores. They are informational: is_prohibited is the decision, and a score can be high on content that passed. Both fields are optional, and a single category key can be missing, so do not require any key. See risk scores.

Image and video moderation result#

JSON
{
  "is_prohibited": true,
  "nsfw": false,
  "reason": "The image contains references to coffee and coffee beans (coffee).",
  "job_id": "8298ed3d-b2e9-409d-9084-bb481934476a",
  "status": "completed",
  "completed_at": "2026-08-10T16:42:49.655Z",
  "delivery_id": "wh_8298ed3d-b2e9-409d-9084-bb481934476a"
}

A job that passes omits reason:

JSON
{
  "is_prohibited": false,
  "nsfw": false,
  "job_id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
  "status": "completed",
  "completed_at": "2026-05-19T12:05:30.000Z",
  "delivery_id": "wh_b2c3d4e5-f6a7-8901-bcde-f12345678901"
}

nsfw is informational and never causes a rejection on its own. It is omitted when you have disabled the NSFW check.

Image and video jobs do not return risk scores.

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.

JSON
{
  "is_prohibited": null,
  "error_code": "PROVIDER_RATE_LIMITED",
  "job_id": "c3d4e5f6-a7b8-9012-cdef-123456789012",
  "status": "failed",
  "failed_at": "2026-09-02T10:11:36.000Z",
  "delivery_id": "wh_c3d4e5f6-a7b8-9012-cdef-123456789012"
}

error_code matches the value the job status endpoint reports for the same job. Common values:

Code Meaning
PROVIDER_RATE_LIMITED The AI provider rate-limited the request and the limit did not clear in time.
PROVIDER_UNAVAILABLE The AI provider returned server errors, or could not fetch a reachable media URL, on every attempt.
PROVIDER_TIMEOUT The AI provider did not answer in time on every attempt.
MODEL_UNAVAILABLE The configured model is not served for this input. Retrying will not help.
MODEL_REFUSED The model refused or content-filtered the request. Retrying will not help.
MEDIA_UNREACHABLE The media URL could not be fetched (for example HTTP 404). Fix the URL first.

The code is omitted when the pipeline could not name a cause.

Batch webhook#

A text moderation batch sends one webhook when every item is completed or failed. Its items send no webhook of their own. It goes to the batch's webhook_url, or to your global webhook when the batch has none. The signature, the delivery_id and the retry schedule are the same as for a job webhook.

JSON
{
  "type": "text_batch",
  "batch_id": "96cf89a8-4baa-44a4-a953-010f4486882d",
  "status": "completed",
  "created_at": "2026-10-06T14:02:01.249Z",
  "completed_at": "2026-10-06T14:02:03.494Z",
  "items": [
    {
      "key": "backstory",
      "job_id": "6c3f2e78-a17a-44a9-b259-d3344eee8bf3",
      "status": "completed",
      "is_prohibited": true,
      "reason": "The text contained a blacklisted keyword."
    },
    {
      "key": "name",
      "job_id": "b4c38526-b18b-431c-ab54-9e3b82bf26f0",
      "status": "completed",
      "is_prohibited": false,
      "scores": { "violence": 0.01 }
    },
    {
      "key": "tagline",
      "job_id": "9c8a428b-be51-4776-86ba-e013f4d519fb",
      "status": "failed",
      "is_prohibited": null,
      "error_code": "PROVIDER_TIMEOUT"
    }
  ],
  "delivery_id": "wh_batch_96cf89a8-4baa-44a4-a953-010f4486882d"
}
Field Description
type Always text_batch. Use it to tell a batch webhook from a job webhook.
batch_id The batch this webhook belongs to.
status Always completed: every item has settled. A failed item is shown in its own status.
completed_at ISO 8601 timestamp of the moment the last item settled.
items One entry for each item, ordered by key. Each entry has key, job_id, status and is_prohibited, plus reason on a rejection, the optional scores and custom_scores on a completed item, and error_code on a failure, with the same meaning as in a job webhook.
delivery_id wh_batch_ followed by the batch_id. Stable across every retry.

If you submit batches and single jobs to the same URL, check for type first: a job webhook has no type field.

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 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#

Header Value
webhook-id Unique id for this message. The same on every retry.
webhook-timestamp When we signed this attempt, in Unix seconds.
webhook-signature One or more space-delimited v1,<base64> values.

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

Code
{webhook-id}.{webhook-timestamp}.{raw request body}

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#

Node.jsJavaScript
import { Webhook } from 'standardwebhooks';
import express from 'express';

const app = express();
const wh = new Webhook(process.env.OMNIFENCE_WEBHOOK_SECRET);

// express.raw, not express.json — the signature covers the bytes on the wire.
app.post('/webhooks/omnifence', express.raw({ type: 'application/json' }), (req, res) => {
  let payload;
  try {
    payload = wh.verify(req.body, req.headers);
  } catch {
    return res.status(400).send('invalid signature');
  }

  res.status(200).send({ ok: true });
  void processResult(payload);
});
PythonPython
from standardwebhooks import Webhook, WebhookVerificationError
from flask import Flask, request

app = Flask(__name__)
wh = Webhook(os.environ["OMNIFENCE_WEBHOOK_SECRET"])

@app.post("/webhooks/omnifence")
def omnifence_webhook():
    try:
        payload = wh.verify(request.data, dict(request.headers))
    except WebhookVerificationError:
        return "invalid signature", 400

    process_result(payload)
    return {"ok": True}, 200

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:

Code
webhook-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= v1,bm90LWEtcmVhbC1zaWduYXR1cmUtZXhhbXBsZS0xMjM0NQ==

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.