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. 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 bare 204 is 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-Id protects you if that happens).
  • Reserve non-2xx for real problems: 401 for signature failures, 5xx for transient faults you want redelivered. Remember that any 4xx other than 408/429 is 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/webhooks is 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 with GET /v1/webhooks and reconcile before re-creating, or you will register the same endpoint twice and receive double deliveries.
  • POST /v1/webhooks/{id}/rotate-secret invalidates 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} returning 404 on 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 Webhooks tag in the OpenAPI spec.
  • Per-event delivery payload schemas under the spec’s x-webhooks section (one entry per event type, referencing the payload models), plus the delivery-header and signature contract in its x-webhook-delivery-contract note.
  • Authentication for how API keys gate POST /v1/webhooks and friends.
  • Rate Limits for plan-tier throttling on the management endpoints.