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.
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.
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"
}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.
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"
}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.
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'),
);
}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.
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.
Sign in, open Account, then Webhooks, add an endpoint and press Test to receive a signed webhook.test delivery.
We use strictly necessary cookies to run on8, and, with your consent, optional cookies for analytics and marketing. Read our Cookie Policy.