← All docs

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 address 169.254.169.254), shared and reserved ranges, and their IPv6 and IPv4-mapped equivalents are refused, as are names like localhost, *.local and *.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))
})