Webhooks
Us telling you: a verdict that needs a person, a batch that finished, an allowance running out.
Everything else here is you asking us. This is us telling you: a verdict that needs a person, an async batch that finished, an allowance about to run out.
Add an endpoint in your dashboard, choose the events, and copy the signing secret. Nothing is sent until you do.
An endpoint belongs to one project and
hears about that project's verdicts and batches only, so each site gets its own callbacks.
The two events about credits, credits.low and quota.exhausted,
belong to the organization and go to every endpoint that asked for them.
Events
| Event | When |
|---|---|
moderation.review | A verdict came back review. The one worth handling: it is the queue a person has to work through. |
moderation.blocked | A verdict came back block. |
moderation.resolved | A person resolved something that was held. This is how a decision made in our dashboard reaches your site. |
batch.completed | An async batch finished. What makes async usable without polling. |
quota.exhausted | A call was refused for want of credits. Once per window, not once per refusal, or a problem becomes two. |
credits.low | Under 20% of the allowance left. Sent once per window, not once per call. |
webhook.test | Sent by hand from the dashboard, to prove the plumbing works. |
There is deliberately no event for allow. It is the overwhelming majority
of traffic, and subscribing to it would be asking for a copy of your own site delivered
back to you one POST at a time. A sync batch sends nothing either: you already have the
results in the response.
What arrives
POST /your/handler
X-ToxicFilter-Signature: t=1725100000,v1=6f1c…
X-ToxicFilter-Event: moderation.review
X-ToxicFilter-Delivery: whd_01jr7q9x2c8h4m6v0b3n5k7t9d
X-ToxicFilter-Attempt: 1
{
"id": "whd_01jr7q...",
"event": "moderation.review",
"created_at": "2026-08-31T09:14:22+00:00",
"data": {
"id": "mod_01jr7q...",
"reference": "comment_9931",
"kind": "text",
"batch_id": null,
"decision": "review",
"flagged": ["toxicity"],
"scores": { "toxicity": 0.58 },
"signals": [ { "category": "toxicity", "score": 0.58, "detector": "term",
"reason": "An insult aimed at the reader." } ],
"used_ai": false,
"took_ms": 3,
"cached": false,
"charged": 1
}
}
The payload is the verdict, never the content. Signals arrive with their
reasonbut withoutevidence, because evidence is fragments of what was submitted and that is not stored, here least of all, where it would sit in a queue waiting for a receiver that may never come back. Usereferenceto find the thing in your own database. You have it; we do not need to send it back to you.
Verify the signature
Your URL is public. Anybody who learns it can post to it, and a handler that acts on whatever arrives is a way to write into your moderation queue from outside. The signature is the only thing between those two facts, so check it before you read the body.
$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_TOXICFILTER_SIGNATURE'] ?? '';
$secret = getenv('TOXICFILTER_WEBHOOK_SECRET');
parse_str(strtr($header, ',', '&'), $parts); // t=..., v1=...
$expected = hash_hmac('sha256', $parts['t'] . '.' . $payload, $secret);
if (! hash_equals($expected, $parts['v1'] ?? '')) {
http_response_code(400);
exit;
}
if (abs(time() - (int) $parts['t']) > 300) { // five minutes
http_response_code(400);
exit;
}
Three things that are easy to get wrong. Sign the raw body, before any framework parses and re-encodes it. Re-encoding can reorder a key or escape a slash differently and the signature will never match. Compare in constant time: a comparison that returns early tells an attacker how much of their guess was right. And check the timestamp: it is signed along with the body precisely so a captured delivery cannot be replayed tomorrow.
Answer quickly
Any 2xx means delivered. Anything else, including a redirect, counts as a failure, redirects are not followed, because the address was checked before the request was made and a 302 is an invitation to make a second one somewhere that was not.
We wait five seconds. Do the minimum in the handler: verify, write the event somewhere, return 200, and do the real work after. A handler that processes inline and takes six seconds is a handler we are retrying while it works.
Retries and duplicates
| Attempts | 6 |
| Backoff | 10s, 1m, 5m, 30m, 2h |
| Timeout | 5s (3s to connect) |
| Auto-disabled after | 20 consecutive failures. A success resets the count. |
Delivery is at-least-once, which is the only kind anybody can honestly offer: a receiver that answered 200 down a connection that then dropped will be sent the same event again. Deduplicate on
X-ToxicFilter-Delivery, or ondata.id, which names the verdict itself.
Order is not guaranteed either. Two verdicts reached a millisecond apart may arrive in
either order, and a retry of an old one can land after a new one. If order matters to
you, sort by created_at.
Sending one again
The automatic retries give up after about two hours, which is right: a queue full of an abandoned server helps nobody. An outage longer than that leaves you knowing exactly which events you missed, so every failed delivery has a Send again button in the dashboard. It creates a new attempt rather than editing the old one, because what happened, happened, and a record of it should not be rewritten.
Secrets and rotation
The secret can be read again in the dashboard, unlike an API key, which is hashed and can never be shown twice. The difference is what each one is for: a key proves you are you, so we only ever compare it; a signing secret proves the sender is us, so we have to compute with it, and a hash cannot sign anything.
Rotating issues a new one immediately, and deliveries stop verifying against the old one from that moment. Update your handler first if you cannot afford a gap.
What a URL has to be
https, and not an address that resolves inside a private network. Both are
checked when you add the endpoint and again immediately before every delivery,
because a hostname that resolved to a public address last week can resolve to
169.254.169.254 today, and only the lookup made just before the request
knows which.