Webhooks

React to events.
Without polling.

Subscribe an HTTPS endpoint to on8.ai events. Every delivery is HMAC-signed so you can verify it came from us, and failed deliveries are retried with exponential backoff.

Register an endpoint

Webhooks are managed from the portal (Account, then Webhooks) by an account owner or staff member; API keys cannot create them. Give an absolute https:// URL and pick the events. Plain http, localhost and private or loopback addresses are refused when you save the endpoint, so for local development expose your handler through a public https tunnel. The signing secret (whsec_...) is shown once, when the endpoint is created: store it server-side and use it to verify every delivery. Reading the endpoint back does not return it.

delivery (what your endpoint receives)
POST https://app.example.com/hooks/on8
Content-Type: application/json; charset=utf-8
X-On8-Event: alert.created
X-On8-Timestamp: 1756890664
X-On8-Signature: sha256=3f2a9c...e41b
X-On8-Signature-V2: sha256=7b1e0d...9a2c

{
  "event": "alert.created",
  "id": "8d1c4c6e-...",
  "tenantId": "3f9a12bc-...",
  "entityId": "b7e0...",
  "severity": "High",
  "title": "New sanctions match for Acme Holdings SA",
  "riskScore": 87,
  "createdAt": "2026-08-22T09:31:04Z"
}

Available events

alert.createdOngoing monitoring, or an orchestrated screen that came out high or critical, raised a new alert. Payload: event, id, tenantId, entityId, severity, title, riskScore, createdAt.
case.createdAn alert was escalated into an investigation case. Payload: event, id, tenantId, alertId, title, priority, createdAt.
batch.completedA batch screening job finished: completed, partially failed or failed. Payload: jobId, status, fileName, subjectCount, processedCount, flaggedCount, errorMessage, completedAt.
webhook.testSent when you press Test on an endpoint in the portal. Payload: event, webhookId, tenantId, timestamp.

The sanctions and search endpoints (/api/v1/screening/*) answer synchronously and emit no event. An orchestrated screen (POST /api/v1/orchestrate/screen) whose aggregate result is high or critical raises an alert and so emits alert.created, unless the subject already has an open alert. New events are announced in the changelog.

batch.completed is shaped differently from the other events: the body is the job summary alone, with no event or id field - read the event name from the X-On8-Event header. status is Completed, PartialFailure or Failed; fetch the rows with GET /api/v1/portal/batch/jobs/{jobId}/results.

batch.completed body
X-On8-Event: batch.completed

{
  "jobId": "3f9a12bc-...",
  "status": "Completed",
  "fileName": "customers.csv",
  "subjectCount": 1250,
  "processedCount": 1250,
  "flaggedCount": 7,
  "errorMessage": null,
  "completedAt": "2026-08-22T09:41:12Z"
}

Verify the signature

Every delivery carries X-On8-Timestamp (unix seconds) and two signatures, both HMAC-SHA256 keyed with your endpoint secret and hex-encoded in lowercase. Prefer X-On8-Signature-V2: it signs <timestamp>.<rawBody>, so you can reject a stale or replayed delivery by checking the timestamp against a tolerance window (5 minutes below) before comparing in constant time. X-On8-Signature signs the body only and is kept for backward compatibility, but on its own it cannot detect a replay - new integrations should verify V2.

verify.js
import crypto from 'crypto';

const TOLERANCE_SECONDS = 300; // reject deliveries older than 5 minutes

function isValid(rawBody, headers, secret) {
  const timestamp = headers['x-on8-timestamp'];
  const received = (headers['x-on8-signature-v2'] || '').replace(/^sha256=/, '');
  // Freshness gate: a captured delivery replayed later fails here.
  if (!timestamp ||
      Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
    return false;
  }
  // Shape gate: a malformed header must answer false, not throw. Comparing
  // String.length would pass a 64-character non-ASCII value straight into
  // timingSafeEqual, which then throws on the byte-length mismatch.
  if (!/^[0-9a-f]{64}$/.test(received)) {
    return false;
  }
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`) // timestamp bound in; raw bytes, not re-serialised JSON
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(received, 'hex'),
    Buffer.from(expected, 'hex'),
  );
}

Respond fast

We wait 10 seconds for a response. Return a 2xx quickly and process asynchronously; any other status, or a timeout, counts as a failed delivery.

Retries with backoff

Up to 5 attempts, waiting 30 s, 60 s, 120 s and 240 s between them; every attempt re-sends the same body. There is no delivery-id header, so de-duplicate on the body: the payload id for alert.created and case.created, and jobId with completedAt for batch.completed, which has no id - a re-queued job completes again under the same jobId. Every attempt is visible in the portal delivery log.

Wire up your first webhook

Sign in, open Account, then Webhooks, add an endpoint and press Test to receive a signed webhook.test delivery.

Open the portal