Webhooks
Webhooks let your backend get notified the moment something important happens in your Sentriq environment, instead of polling the API. Configuration (creating and managing endpoints) is a dashboard-only, account-level setting — deliveries themselves are plain signed HTTP POSTs to a URL you control.
Event types
One risk assessment can trigger several event types at once — for example, a brute-force login that gets blocked fires risk.assessment.completed, decision.block, and security.brute_force together. Each is delivered independently, once per subscribed endpoint per type.
| Type | Fires from |
|---|---|
risk.assessment.completed | Every completed risk assessment. |
decision.allow / .monitor / .challenge / .block | The matching decision on that assessment. |
device.new | The assessment's NEW_DEVICE signal fired. |
security.credential_stuffing | The assessment's CREDENTIAL_STUFFING_SUSPECTED signal fired. |
security.brute_force | The assessment's BRUTE_FORCE_SUSPECTED signal fired. |
security.automation_suspected | The assessment's AUTOMATION_SUSPECTED signal fired. |
device.trusted / device.trust_revoked | A device is trusted or untrusted. |
outcome.created / outcome.updated | A new or revised outcome — see Outcomes. |
webhook.test | POST /v1/webhooks/{id}/test only. |
Payload envelope
Every delivery shares the same envelope shape. The payload is deliberately minimal — no raw secrets, no investigation-only signal metadata, nothing beyond what's needed to act on the notification. Full detail is always a follow-up GET call away with a secret key.
{
"id": "wh_evt_01JATG7K9N8Q2R3S4T5U6V7W8X",
"type": "decision.challenge",
"created_at": "2026-08-20T14:32:01Z",
"api_version": "2026-08-01",
"data": {
"event_id": "evt_...",
"event_type": "login",
"device_id": "dev_...",
"risk": {
"score": 72,
"level": "high",
"decision": "challenge",
"signals": [{ "code": "NEW_DEVICE", "weight": 20, "description": "..." }]
}
}
}id is stable across every retry of the same logical delivery — dedupe on it (see § Replay protection below).
Signing
Every delivery is signed with HMAC-SHA256 over the timestamp and raw request body, using the webhook secret shown once when you create or rotate the endpoint. This is the same shape Stripe/GitHub-style webhook signing already uses — no custom cryptography.
Content-Type: application/json
Sentriq-Webhook-Id: wh_evt_01JATG7K9N8Q2R3S4T5U6V7W8X
Sentriq-Webhook-Timestamp: 1755700321
Sentriq-Webhook-Signature: 5f3c9a2e... (hex HMAC-SHA256)Sentriq-Webhook-Signature = HMAC-SHA256("{timestamp}.{raw_json_body}", your_webhook_secret).
Verification — PHP
function verifySentriqWebhook(string $rawBody, string $timestamp, string $signature, string $secret, int $toleranceSeconds = 300): bool
{
if (abs(time() - (int) $timestamp) > $toleranceSeconds) {
return false; // stale — see § Replay protection
}
$expected = hash_hmac('sha256', "{$timestamp}.{$rawBody}", $secret);
return hash_equals($expected, $signature); // constant-time compare
}Verification — Node.js/TypeScript
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifySentriqWebhook(rawBody: string, timestamp: string, signature: string, secret: string, toleranceSeconds = 300): boolean {
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
return false; // stale — see § Replay protection
}
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(signature, 'hex');
return a.length === b.length && timingSafeEqual(a, b); // constant-time compare
}Both examples use a constant-time comparison (hash_equals/timingSafeEqual) deliberately — a naive ===/== string comparison leaks timing information an attacker could use to forge a valid signature byte-by-byte.
Replay protection
Verify deliveries in this order:
- Verify the HMAC signature (above).
- Check the timestamp is within a tolerance window — 5 minutes is a reasonable default; this is enforced on your side, not by Sentriq.
- Deduplicate on
Sentriq-Webhook-Id— store recently seen IDs and ignore repeats. A retry of the same logical delivery reuses the same ID.
Delivery, retries, timeouts
- Connect timeout: 5s. Request timeout: 10s — short on purpose so a slow endpoint never backs up delivery for everyone else.
- Retryable:
408,429, any5xx, and connection/timeout/DNS failures. Permanent (no retry): most4xx, and any3xx— redirects are never followed. - 5 total attempts, with delay before each retry of 1 minute, 5 minutes, 30 minutes, then 2 hours.
- Every attempt — success, transient failure, or permanent failure — is recorded with its HTTP status, duration, and a bounded, non-secret response snippet, visible in the dashboard delivery log.
Event ordering
Retries mean your endpoint can receive deliveries out of the order the underlying events actually happened, and — rarely — a delivery that did succeed can still trigger a retry if Sentriq's own detection of that success failed. Sentriq does not guarantee global delivery ordering. Always deduplicate on Sentriq-Webhook-Id, and use data.event_id/timestamps inside the payload for ordering — never delivery arrival order.
http/https URLs are accepted, and the underlying connection is pinned to the exact address that was validated for that request.Managing endpoints
Dashboard-level configuration — requires an authenticated session, never a secret or public key:
GET /v1/environments/{env}/webhooks
POST /v1/environments/{env}/webhooks
PATCH /v1/webhooks/{id}
DELETE /v1/webhooks/{id}
POST /v1/webhooks/{id}/rotate-secret
POST /v1/webhooks/{id}/test
GET /v1/webhooks/{id}/deliveriesThe endpoint secret (whsec_...) is shown once, at creation or rotation time, and never returned by the API again. POST /v1/webhooks/{id}/test sends a real webhook.test delivery through the identical signing/delivery pipeline as any other event — it is never a bypass path.