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:
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:
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.
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#
{
"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#
{
"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:
{
"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.
{
"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.
{
"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
{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#
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);
});
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:
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
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.
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 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.