View as Markdown

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.

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).

Payload field names per event type are documented in the OpenAPI spec.

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.

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. Disabled subscriptions do not count toward the limit; deleting or disabling one frees a slot immediately.

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://stg-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
  }
}

The test delivery uses the subscription’s own eventType and sends a synthetic test payload — the wrapper carries eventType, integrationId, timestamp, and a data object with a test flag plus a brief message. The delivery is signed with the subscription’s current secret so the receiver exercises the same verification path as a real delivery.

The synthetic request also carries an X-Webhook-Test: true header so your endpoint can filter test traffic out of real-event handling without inspecting the payload body. Test deliveries do not retry and do not count toward the consecutive-failure auto-disable counter.

See also

  • The CRUD endpoints under the Webhooks tag in the OpenAPI spec.
  • Authentication for how API keys gate POST /v1/webhooks and friends.
  • Rate Limits for plan-tier throttling on the management endpoints.