Webhooks
Webhook subscriptions let Not AI push events to your endpoint the moment they fire. Bot detections, session anomalies, scored writing samples, ready reports, fired alert rules — all delivered as signed POST requests to a URL you control. Use this surface when your application or agent needs to react in real time instead of polling. Subscription URLs may be https (strongly recommended) or http on ports 443 and 80; private, loopback, link-local, and cloud-metadata addresses are rejected at create time.
This page covers the subscription model, the signature contract every delivery carries, the seven event types you can subscribe to, and the retry behavior on the wire. Per-endpoint reference (list, create, update, delete, rotate-secret, test) is generated from the OpenAPI spec the Public API publishes at /openapi/v3.json; the same paths show up under the Webhooks tag in the bundled spec as the docs site picks up each new deploy.
Model
One subscription targets one event type with one URL. A subscription with eventType: BotDetected will not deliver SessionAnomaly. Subscribe twice if you want both.
{
"id": "sub_8f3d2c1a",
"url": "https://example.com/webhooks/isnotai",
"eventType": "BotDetected",
"threshold": { "field": "botScore", "operator": "gte", "value": 0.85 },
"name": "High-confidence bot alerts to Slack",
"enabled": true,
"createdAt": "2026-06-15T14:22:01.314Z",
"lastDeliveryAt": "2026-06-21T09:11:48.812Z",
"lastDeliveryStatus": "Delivered"
}
The optional threshold filter is evaluated against the payload JSON before fan-out: a botScore gte 0.85 subscription will skip events with botScore < 0.85. One threshold per subscription. threshold.field is a dot-separated path into the delivery payload, threshold.operator is one of gte, gt, lte, lt, eq, neq, in (case-insensitive), and threshold.value is the comparison operand (a number or numeric string for the numeric operators, a scalar for eq/neq, an array of scalars for in).
Wire format of the enums
eventType and lastDeliveryStatus are strings on the wire AND in the spec the API publishes at /openapi/v3.json, exactly as shown above. Requests accept the event-type name case-insensitively; responses return the PascalCase form. lastDeliveryStatus is one of Pending, Delivered, Failed, MaxRetriesExceeded, PermanentFailure, PoisonAborted (null until the first delivery). Clients generated from the published spec get the string enums natively; string names are what every delivery header and response body carries.
Event types
eventType |
Fires when |
|---|---|
BotDetected |
A scored session crosses the bot threshold at session-close. |
SessionAnomaly |
The correlation pipeline writes an anomaly row (DeviceSwitch, ImpossibleTravel, etc.). |
SessionCorrelated |
A pixel session is matched to an LMS user with confidence. |
WritingSessionScored |
A writing-session closes with an AI-content score. |
ReportReady |
A scheduled or on-demand report transitions to Ready and has a download URL. |
AlertTriggered |
A configured alert rule fires within its time window. |
ThresholdExceeded |
A configured metric crosses a customer-defined threshold (reserved for future use). |
Delivery payloads by event type
Real deliveries are flat: the request body is the event payload itself, with no wrapper object. Event metadata (type, subscription id, delivery id, timestamp, signature) travels in HTTP headers, never in the body. Fields marked nullable may be null or absent; everything else is always present. All timestamps are ISO-8601 strings.
BotDetected
| Field | Type | Notes |
|---|---|---|
sessionId |
string | Stable session id from the pixel telemetry stream. |
botScore |
number | Bot likelihood in the closed range [0, 1]. |
scoredAt |
string | UTC instant the session was closed and scored. |
userId |
string, nullable | LMS-resolved user id when correlation has run. |
contextId |
string, nullable | Course or quiz context id when the LMS supplies one. |
sessionUrl |
string, nullable | Dashboard deep link to the session detail view. |
SessionAnomaly
| Field | Type | Notes |
|---|---|---|
anomalyId |
string | Anomaly document id. |
anomalyType |
string | e.g. DeviceSwitch, ImpossibleTravel, SharedDevice. |
severity |
string | Lowercase: low, medium, high. |
detectedAt |
string | UTC instant the anomaly was detected. |
sessionId |
string, nullable | Affected session, when scoped to one session. |
userId |
string, nullable | Affected LMS user id. |
metadata |
object, nullable | Free-form detector context (e.g. otherUserCount; deviceFingerprint truncated to 8 chars on SharedDevice). |
ThresholdExceeded
| Field | Type | Notes |
|---|---|---|
metric |
string | Stable metric name (e.g. monthlySessions). |
value |
number | Observed value that crossed the threshold. |
threshold |
number | The configured threshold that was crossed. |
occurredAt |
string | UTC instant of the crossing. |
reason |
string, nullable | Human-readable context. |
AlertTriggered
| Field | Type | Notes |
|---|---|---|
alertId |
string | The configured alert rule id. |
severity |
string | Lowercase severity. |
message |
string | What triggered. |
triggeredAt |
string | UTC instant the alert fired. |
alertUrl |
string, nullable | Dashboard deep link. |
ReportReady
| Field | Type | Notes |
|---|---|---|
reportId |
string | Report document id. |
reportName |
string | Customer-visible report name. |
templateId |
string | Template used to generate the report. |
format |
string | csv, pdf, or json. |
recordCount |
integer, nullable | Rows aggregated. |
fileSize |
integer, nullable | Estimated payload size in bytes. |
completedAt |
string | UTC instant generation completed. |
expiresAt |
string, nullable | UTC instant the download link expires. |
downloadUrl |
string | Relative path; prepend your region’s dashboard API host. The link is gated by your existing integration auth. |
SessionCorrelated
| Field | Type | Notes |
|---|---|---|
sessionId |
string | Pixel session that was matched. |
userId |
string | LMS user the session was matched to. |
correlationConfidence |
number | Closed range [0, 1]. |
correlatedAt |
string | UTC instant the correlation persisted. |
contextId |
string, nullable | Course or quiz context id. |
WritingSessionScored
| Field | Type | Notes |
|---|---|---|
sessionId |
string | Writing-session id. |
aiScore |
number | AI-content likelihood in the closed range [0, 1]. |
scoredAt |
string | UTC instant the writing session was scored. |
userId |
string, nullable | LMS-resolved user id when correlation has run. |
documentId |
string, nullable | Document id within the writing surface. |
wordCount |
integer, nullable | Final word count at session close. |
These field names are also the dot-paths a threshold filter can target (e.g. botScore, severity, metadata.otherUserCount). Adding optional fields to a payload is a non-breaking change; renaming or removing one requires a versioned event type.
Delivery contract
Every delivery is a POST to your URL with the payload as the request body and the event metadata in HTTP headers.
POST https://example.com/webhooks/isnotai
Content-Type: application/json
X-Webhook-Event: BotDetected
X-Webhook-Id: sub_8f3d2c1a
X-Webhook-Delivery-Id: dlv_2c8f3d1a-9e6b-4f1e-94e2-9c6a3d8f1b7e
X-Webhook-Timestamp: 1718983893
X-Webhook-Signature: sha256=58a3...e2c1
X-Webhook-Test: true (synthetic deliveries from /test only)
{
"sessionId": "ses_7a2c1f8d",
"botScore": 0.92,
"scoredAt": "2026-06-21T14:11:33.812Z",
"userId": "[email protected]",
"contextId": "course-cs101",
"sessionUrl": "https://dash.isnotai.com/.../sessions/ses_7a2c1f8d"
}
Signature verification
The signature is HMAC-SHA256(secret, "${timestamp}.${rawBody}") rendered as lowercase hex and emitted in the X-Webhook-Signature header in the format sha256=<hex>. The <timestamp> is the value of the X-Webhook-Timestamp header (Unix seconds).
The secret is shown ONCE — in the response to POST /v1/webhooks and POST /v1/webhooks/{id}/rotate-secret. Store it in your secret manager; if you lose it, rotate.
Verifying a delivery in Node:
import { createHmac, timingSafeEqual } from 'crypto';
function verify(req, secret) {
const timestamp = req.headers['x-webhook-timestamp'];
const signature = req.headers['x-webhook-signature']; // "sha256=<hex>"
const received = signature.startsWith('sha256=') ? signature.slice(7) : '';
const raw = req.rawBody; // the byte-exact request body
// Reject deliveries more than 5 minutes off the wall clock — replay defense.
const ageSeconds = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (ageSeconds > 300) return false;
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${raw}`)
.digest('hex');
// Always use a constant-time comparison. timingSafeEqual requires
// equal-length buffers; bail early on length mismatch.
if (expected.length !== received.length) return false;
return timingSafeEqual(Buffer.from(expected), Buffer.from(received));
}
In Python:
import hmac, hashlib, time
def verify(headers, raw_body, secret):
timestamp = headers["X-Webhook-Timestamp"]
sig_header = headers["X-Webhook-Signature"] # "sha256=<hex>"
received = sig_header.removeprefix("sha256=")
if abs(int(time.time()) - int(timestamp)) > 300:
return False
signed = f"{timestamp}.{raw_body.decode('utf-8')}".encode("utf-8")
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received)
Idempotency
The X-Webhook-Delivery-Id header is stable across retry attempts for the same envelope — every retry of a single delivery carries the same id. Use it to dedupe at your end: if you see the same delivery id twice (typical of a retry after your handler 2xx’d but the response didn’t reach us before timeout), treat the second one as already-processed.
Retry behavior
A delivery is considered successful when your endpoint returns any 2xx. Anything else is a failure.
| Outcome | What we do |
|---|---|
2xx |
Mark delivered. No retry. |
4xx (except 408, 429) |
Mark PermanentFailure. No retry. Fix the endpoint. |
5xx, 408, 429, timeout, reset |
Retry up to 6 attempts at 0 / 1m / 5m / 30m / 2h / 8h. |
| Six attempts all fail | Delivery is marked MaxRetriesExceeded. See auto-disable below. |
The HTTP timeout per attempt is 30 seconds. The retry counter is on the delivery envelope; queue dequeue counters are not authoritative.
Each retry attempt is signed at send time with a fresh X-Webhook-Timestamp and a freshly computed X-Webhook-Signature; X-Webhook-Delivery-Id stays constant across attempts. A legitimate retry therefore never carries a stale timestamp, and the 5-minute freshness check in the verification samples above will not reject one. If you do see a stale or badly signed delivery, it is not one of ours: reject it with 401 and do not process it.
Responding to a delivery
- Return any
2xx(a bare204is ideal) as fast as possible. The response body is ignored. - Acknowledge first, process after. Each attempt times out after 30 seconds; a handler that does its real work before responding will time out under load and burn retry attempts on work that already succeeded (dedupe on
X-Webhook-Delivery-Idprotects you if that happens). - Reserve non-2xx for real problems:
401for signature failures,5xxfor transient faults you want redelivered. Remember that any4xxother than408/429is terminal and feeds the auto-disable counter.
Auto-disable
Three consecutive deliveries that end in MaxRetriesExceeded flip the subscription to enabled: false with disabledReason: "auto:consecutive-failures" and a disabledFailureClass stamped from the terminal delivery. The classifier emits one of 5xx, 4xx, Timeout, RateLimited, BlockedSsrf, Network, or HTTP{status} (e.g. HTTP301 for an unfollowed redirect). Re-enable via PATCH /v1/webhooks/{id} once you’ve fixed the receiver — the consecutive-failure counter resets to zero on re-enable.
Plan limits
| Plan | Max active subscriptions |
|---|---|
free |
0 |
starter |
0 |
pro |
1 |
enterprise |
10 |
POST /v1/webhooks returns 402 PLAN_LIMIT_REACHED when an integration is at its cap, and so does PATCH /v1/webhooks/{id} when it would flip enabled: true past the cap. Disabled subscriptions do not count toward the limit; deleting or disabling one frees a slot immediately.
Two notes on how this table relates to the Rate Limits tier table: starter is a billing plan that buckets into the free tier at the API edge (the x-isnotai-tier header echoes free and free-tier rate limits and page caps apply), which is why the rate-limit table does not list it separately. The webhook cap, by contrast, is enforced per billing plan, so starter appears here with its own row.
Management call semantics
POST /v1/webhooksis not idempotent: every successful call creates a new subscription with a fresh id and a fresh secret, and duplicate URLs are not deduplicated. If a create times out, list withGET /v1/webhooksand reconcile before re-creating, or you will register the same endpoint twice and receive double deliveries.POST /v1/webhooks/{id}/rotate-secretinvalidates the old secret immediately and returns the new one. A blind retry rotates again; if deliveries start failing verification after a rotate, rotate once more, store the result, and confirm with/test.DELETE /v1/webhooks/{id}returning404on a retry means the subscription is already gone; treat it as success.PATCH /v1/webhooks/{id}applies only the fields present in the body. Re-enabling a subscription resets the consecutive-failure counter to zero.
SSRF defenses
Two layers run on every subscription URL.
At create / update time, the URL string itself is checked: scheme must be http or https, port must be 80 or 443, no userinfo (user:pass@), and the host — when it is a literal IP — must not be in a private, loopback, link-local, CGNAT, or reserved range. Known cloud-metadata hosts (metadata.google.internal, metadata.azure.com, localhost, etc.) are also rejected up front.
At delivery time, every resolved IP is checked against the same block list before the TCP connect runs. This defeats DNS-rebinding: a hostname that resolved to a public IP at register time but flips to 169.254.169.254 later cannot be reached. The delivery client also refuses to follow HTTP redirects: a 30x response is treated as a non-2xx outcome (Failed) rather than chased, so the bot can’t be tricked into reaching an internal target by issuing a 30x.
If you control your endpoint via a CDN, point the subscription at the public address.
Testing
The dedicated test endpoint synthesizes a delivery using your endpoint’s currently-stored secret and reports the outcome inline:
curl -X POST https://api.isnotai.com/v1/webhooks/sub_8f3d2c1a/test \
-H "Authorization: Bearer $ISNOTAI_API_KEY"
{
"data": {
"deliveryId": "dlv_2c8f3d1a9e6b4f1e94e29c6a3d8f1b7e",
"status": "Delivered",
"responseCode": 204,
"errorClass": null,
"attempts": 1
}
}
Test deliveries and real deliveries have different body shapes. A real delivery’s body is the flat event payload (see the “Delivery payloads by event type” section above) with all metadata in headers. A test delivery’s body is a wrapper object instead. Side by side, what your endpoint receives:
A real delivery body (BotDetected event) - flat payload, metadata in headers only:
{
"sessionId": "ses_7a2c1f8d",
"botScore": 0.92,
"scoredAt": "2026-06-21T14:11:33.812Z",
"userId": "[email protected]",
"contextId": "course-cs101",
"sessionUrl": "https://dash.isnotai.com/.../sessions/ses_7a2c1f8d"
}
A test delivery body (from POST /v1/webhooks/{id}/test) - wrapped:
{
"eventType": "BotDetected",
"integrationId": "integration-abc123",
"timestamp": "2026-06-21T14:11:33.812Z",
"data": {
"test": true,
"message": "This is a test webhook delivery from the Not AI Public REST API.",
"subscriptionId": "sub_8f3d2c1a",
"deliveryId": "dlv_2c8f3d1a9e6b4f1e94e29c6a3d8f1b7e"
}
}
Do not model your parser on the test shape. Use the test endpoint to exercise signature verification and connectivity; use the X-Webhook-Test: true header (present only on test deliveries) to route test traffic away from your real-event handler without inspecting the body. The test delivery is signed with the subscription’s current secret, uses the subscription’s own eventType in the X-Webhook-Event header, does not retry, and does not count toward the consecutive-failure auto-disable counter.
See also
- The CRUD endpoints under the
Webhookstag in the OpenAPI spec. - Per-event delivery payload schemas under the spec’s
x-webhookssection (one entry per event type, referencing the payload models), plus the delivery-header and signature contract in itsx-webhook-delivery-contractnote. - Authentication for how API keys gate
POST /v1/webhooksand friends. - Rate Limits for plan-tier throttling on the management endpoints.