Webhooks
Every identification, delivered to your server as it happens. Useful when you want a record without polling, or want to react to traffic that never reaches your backend.
Webhooks → Add endpoint. Send a test event before you go live.
An endpoint added, changed, paused or deleted is in effect within five seconds: each server keeps an environment's endpoints that long between reads. In those seconds an endpoint paused or deleted may still receive an event, and one pointed elsewhere may still receive one at its old address.
The delivery
POST to your URL, JSON body: the identification as it was made, with every
signal under products — what the agent or the mobile SDK received, less what
is only for them (the receipt, sealedResult, the pulse key, the visitor
hint). It has most of the fields of the Server API's event,
which is what to re-read when a delivery decides something. Every body on this
page, and its headers, is in the OpenAPI document's webhooks.
antibot-signature: t=1785719825943,v1=8f3a…
Endpoints get three seconds to respond. Anything outside 2xx counts as a failure. A redirect is a failure too: the delivery is not sent on to wherever it points, and only the first 4 KB of your response is kept.
Which addresses are allowed
The delivery is made by this server to an address you typed, so it will only go somewhere safe to call. In production that means:
- https, on the default port, with no credentials in the URL;
- a public address. Loopback, private ranges (
10/8,172.16/12,192.168/16), link-local (which includes the cloud metadata address169.254.169.254), shared and reserved ranges, and their IPv6 and IPv4-mapped equivalents are refused, as are names likelocalhost,*.localand*.internal.
A host name is checked where the connection is made, against every address it resolves to, so a name that points inward — or that answers differently the second time it is asked — is refused as well. The delivery log says only "Could not connect to that host" for a refusal and for a name that does not exist alike, so it cannot be used to find out which internal names resolve.
A development server (NODE_ENV=development) also accepts http and
localhost, so you can try an endpoint on your own machine. To allow private
destinations anywhere — an integration that genuinely lives on an internal
network — set ANTIBOT_ALLOW_PRIVATE_OUTBOUND=1; =0 enforces the production rules
even in development.
Traffic alerts
An endpoint can also receive traffic alerts — tick Also send traffic
alerts when adding it, or set "alerts": true through the Management API. They
are off by default because their body has another shape:
{
"type": "alert.started",
"alert": {
"id": "alr_…",
"kind": "attack",
"environmentId": "env_…",
"startedAt": "2026-10-23T01:05:00.000Z",
"endedAt": null,
"peakRequestsPerFiveMinutes": 1840,
"peakChallengedOrBlockedPercent": 93,
"reasons": [{ "reason": "userAgent:httpLibrary", "count": 1712 }]
}
}
kind is attack — most of the assessed
traffic of the last five minutes challenged or blocked, where in the hour before
little was — or surge, five times the usual volume whatever was decided
about it — or unrun, where the edge places the agent on every page: most of
the last five minutes' first pages served without a receipt never followed by
their identification, pages no browser ran (a scraper behind a rotating proxy
pool, a new client on every request; see Attack mode)
— or identifications, five times the usual identifications, the agent's and
the mobile SDKs': each is a billable call, and the public key that makes one is
in every page, so a wave of them is your pages flooded or your key used from
elsewhere (limit the origins it answers, or filter the networks they come from).
For unrun, peakChallengedOrBlockedPercent is the share of those pages not
run; for identifications, the share suspicious or blocked. alert.ended
follows once the condition has been absent for ten minutes. An attack is
opened by the change and held by the wave: open, it stays open while half the
last five minutes are still challenged or blocked, though by then the hour
before is the wave's own (attack mode's own challenges hold none open). Identification
events carry no type, so a handler can tell the two apart. An alert is opened once however many instances see it, is signed and
retried like any delivery, appears under Edge traffic and in the audit log,
and is emailed to the organization's owners and admins when mail is configured.
Each instance judges its own share of the traffic, so behind a load balancer the
thresholds apply per instance. An instance holds the five minutes against only
the part of the hour before that it counted: started by a deploy, or added to
the pool under load, it opens no alert for an environment older than itself
until it has counted ten minutes of that hour, and until it has counted the
whole of it the usual is what it saw. An environment made since the instance
started is alerted on at once: its quiet hour was a quiet one.
The same endpoints are told when the system ends a session by itself. That happens for a receipt used from five networks, or a device at a machine's pace (see What a receipt is used for):
{
"type": "session.revoked",
"revocation": {
"id": "sr_…",
"environmentId": "env_…",
"kind": "token",
"value": "st_…",
"reason": "receipt:shared",
"automatic": true,
"expiresAt": "2026-10-23T13:05:00.000Z"
}
}
kind is token (one receipt) or visitor (a device). The revocation is also
in the audit log and in GET /sessions,
where DELETE lifts it.
Page scripts
The same endpoints — those that take alerts — are told when a script enters a watched page's inventory: three browsers saw it run there, and nobody has decided on it yet.
{
"type": "script.new",
"script": {
"id": "scr_…",
"environmentId": "env_…",
"page": "/checkout/*",
"script": "https://cdn.example.com/widget.js",
"kind": "external",
"firstSeenAt": "2026-10-28T01:05:00.000Z"
}
}
kind is external (by origin and path), inline (script is its CSP hash,
sha256-…), or what a browser's policy report says of the rest: eval,
data, blob. It is also in the audit log, and emailed to the organization's
owners and admins when mail is configured (once in ten minutes at most).
An environment's inventory takes twenty new scripts an hour and five thousand in all; past either, the rest wait for the next hour and you are told once an hour — a flood of new scripts is what a forger, not a site, sends:
{ "type": "script.withheld", "environmentId": "env_…", "withheld": 37, "limits": { "perHour": 20, "inAll": 5000 } }
The daily check of what changed after authorizing (Page scripts)
tells the same endpoints when an authorized script's file changed — the script
is new again — and when a watched page's security headers did:
{ "type": "script.changed", "script": { "id": "scr_…", "environmentId": "env_…", "page": "/checkout/*", "script": "https://cdn.example.com/widget.js", "previousHash": "sha256-…", "hash": "sha256-…", "changedAt": "2026-10-29T03:00:00.000Z" } }
{ "type": "page_headers.changed", "page": { "url": "https://shop.example/checkout/", "environmentId": "env_…", "before": { "content-security-policy": "script-src 'self'" }, "after": { "content-security-policy": "script-src *" }, "changedAt": "2026-10-29T03:00:00.000Z" } }
Session escalations
An endpoint can also be told when the session pulse
hardens a session after it identified — a driver's code found in a single-page
app, a proxy seen from the next beacon — so your backend can act on what was
done under it: an order placed, a sign-in let through. Tick Also send session
escalations when adding the endpoint, or set "sessions": true through the
Management API. Off by default, like alerts: the body has another shape, and an
attack can send many.
{
"type": "session.escalated",
"session": {
"id": "st_…",
"visitorId": "…",
"action": "block",
"previousAction": "allow",
"score": 82,
"notes": ["driver_evaluation"],
"at": "2026-10-24T18:05:00.000Z"
}
}
id is the session token's own id (its jti), the receipt your edge and your
server SDK saw. An escalation is sent when a beacon moves the session up to
challenge or block — never for a flag, which is a note — once for each of
those levels a session reaches. Its antibot-event-id is the same on a retry,
ses_<id>_<action>, so a receiver can drop a repeat. A block also revokes the
receipt; the edge and the server SDKs stop honouring it within seconds without
this event. Signed and retried like any delivery, and in the delivery log.
Verify the signature
The signature is HMAC-SHA256 over `${t}.${rawBody}` using your endpoint
secret.
Pass the raw body. Re-serialising parsed JSON changes the bytes — key order, whitespace, unicode escaping — and the signature will not match. Most frameworks keep the original around; that is the one you want.
import { verifyWebhookSignature } from "@antibot/node-sdk"
app.post("/antibot", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifyWebhookSignature({
rawBody: req.body.toString("utf8"),
header: req.get("antibot-signature"),
secret: process.env.ANTIBOT_WEBHOOK_SECRET,
})
if (!ok) return res.sendStatus(400)
const event = JSON.parse(req.body.toString("utf8"))
res.sendStatus(200)
})
Equivalents ship in every server SDK.
Doing it by hand, from antibot-signature: t=<unix ms>,v1=<hex>:
expected = hex(hmac_sha256(secret, `${t}.${rawBody}`))
t goes into the signed string exactly as the header writes it (plain digits:
the server never writes a sign or a leading zero), and v1 is compared as the
lowercase hex it is sent as. Compare in constant time, and reject a t outside
your tolerance — five minutes is the default. A request without the header, or
with a t that is not a number, is a rejection like any other: anyone can post
to your endpoint. The timestamp is inside the signed string, so a captured
delivery cannot be given a fresh timestamp without forging the HMAC. Within the
tolerance it can be sent again as it was: there is no nonce, so de-duplicate on
the event id (antibot-event-id, the body's requestId), as for retries below.
Testing an endpoint
Test beside the endpoint (or
POST /api/management/v1/webhooks/{webhookId}/test, which
answers with the endpoint's status code, or why it was not reached) sends a
test event now, signed as any delivery is. Its body has "test": true and
names no visitor of yours. A paused endpoint is sent one too, so it can be
checked before it goes live. It is in Recent deliveries, and not retried.
Rolling the secret
Roll secret beside the endpoint (or
POST /api/management/v1/webhooks/{webhookId}/secret) puts
a new signing secret in place of the old, at once — for one that leaked, or on
a schedule. Every delivery from then on is signed with the new one, retries
included: a retry is signed when it is sent. Put the new secret in your
receiver; whatever it refuses in between (a 400 for a signature it cannot
verify) is retried on the schedule below, signed with the new secret, so
nothing is lost if the receiver has it within some twenty hours. The roll is in
the audit log as webhook.secret_rolled, without the secret.
Retries
A failure — no answer within three seconds, or anything but a 2xx — is retried
five times, backing off: five minutes after the first attempt, then half
an hour after the retry before, two hours, six, and twelve. The last is some
twenty hours after the first attempt, so an endpoint down through a bad deploy
and its rollback, or overnight, still receives the event. Each retry carries
the same event id, and antibot-delivery-attempt says which attempt it is
(1 to 6). Use the event id as an idempotency key: delivery is at least once,
so the same event can arrive twice (a retry whose send was cut off is sent
again). A retry can arrive after events newer than it — an alert.started
after its own alert.ended — so go by the times in the body, not by the order
deliveries arrive in.
if (await alreadyProcessed(event.requestId)) return res.sendStatus(200)
Retries are fair between endpoints — one that keeps failing does not hold up another's — and an endpoint back from an outage has its due retries sent straight away, not a few at a time. A disabled endpoint receives none, nor a replay. A test event or a replay sent from the dashboard is not retried: its answer is shown to whoever sent it.
Deliveries and their responses are recorded in the dashboard under Recent deliveries (and listed, and replayed, through the Management API), and the exact signed bytes are stored — so replaying one from the dashboard reproduces the original delivery rather than re-serialising and invalidating the signature.
When an endpoint keeps failing
From its first failed delivery after one it answered, an endpoint is marked
Failing on the Webhooks page (and failingSince in the
Management API), until it answers again. A test or a replay
sent by hand that fails does not mark it — an endpoint not yet live — but one
answered ends the mark.
An hour in, the organization's owners and admins are told by mail, once, and
it is in the audit log as webhook.failing, with the last error. An hour is
early on purpose: the retries of its first failures still have most of a day to
run, so an endpoint fixed then loses nothing. The mail names the endpoint
without its query string, where a collector's token often travels. A paused
endpoint is not told of; one that answers again and later fails is told afresh.
What to do in the handler
Return 200 quickly and do the work elsewhere. Three seconds is not much, and a slow handler turns into a retry, which turns into a duplicate you have to think about.
app.post("/antibot", raw, async (req, res) => {
if (!verify(req)) return res.sendStatus(400)
res.sendStatus(200) // acknowledge first
await queue.push(JSON.parse(req.body))
})