# 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](/developers/spec/swagger.json) 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.

```json
{
  "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.

```http
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": "u_mike@example.com",
  "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:

```javascript
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:

```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:

```bash
curl -X POST https://stg-api.isnotai.com/v1/webhooks/sub_8f3d2c1a/test \
  -H "Authorization: Bearer $ISNOTAI_API_KEY"
```

```json
{
  "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](/developers/spec/swagger.json).
- [Authentication](/developers/authentication) for how API keys gate `POST /v1/webhooks` and friends.
- [Rate Limits](/developers/rate-limits) for plan-tier throttling on the management endpoints.
