All pages

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

EventWhen
moderation.reviewA verdict came back review. The one worth handling: it is the queue a person has to work through.
moderation.blockedA verdict came back block.
moderation.resolvedA person resolved something that was held. This is how a decision made in our dashboard reaches your site.
batch.completedAn async batch finished. What makes async usable without polling.
quota.exhaustedA call was refused for want of credits. Once per window, not once per refusal, or a problem becomes two.
credits.lowUnder 20% of the allowance left. Sent once per window, not once per call.
webhook.testSent 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 reason but without evidence, 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. Use reference to 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

Attempts6
Backoff10s, 1m, 5m, 30m, 2h
Timeout5s (3s to connect)
Auto-disabled after20 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 on data.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.