Webhooks and signatures
Event delivery, the payload, request headers, HMAC-SHA256 signature verification in Node and Python, the replay window and the retry schedule.
v1Updated
Webhooks deliver Events to an HTTPS endpoint you control. Every delivery is signed so you can verify it came from ScoutSentinel and was not replayed. Configure one under Integrations, Webhook (POST /v1/integrations with config.type: "webhook"): a url (HTTPS only, public hosts only), an optional secret of 16 to 256 characters (generated for you when omitted and shown once) and optional static headers to add to every request.
Deliveries
ScoutSentinel sends a POST with Content-Type: application/json when an Event opens, is updated, resolves, or when a behaviour change is recorded. Deliveries are at-least-once: make your handler idempotent on the X-ScoutSentinel-Delivery header, which is the delivery id you also see in the Integrations delivery log. Redirects are not followed and the request times out after 10 seconds; return a 2xx quickly and do the work afterwards.
Payload
The body is the shared Event payload used by every notification adapter. version is a date and only changes when the shape changes.
{
"version": "2026-09-01",
"type": "event.opened",
"event": {
"id": "0192b7e2-1f6a-7d55-8b0c-2e9f4a6b8c1d",
"kind": "outcome",
"severity": "critical",
"status": "open",
"title": "Buy online: step 7 \"Complete purchase\" HTTP 502 from payment provider (2 consecutive runs, 3 locations)",
"summary": "Step 7 (Complete purchase) failed: HTTP 502 from payment provider. Failing since 2026-09-17T04:25:04Z across 2 runs from cloudflare edge lhr, cloudflare edge fra.",
"journey": "Buy online",
"watch": "Purchase with test card",
"subject": "shop.example.com",
"environment": "Production",
"failureBoundary": { "stepIndex": 6, "stepName": "Complete purchase", "detail": "HTTP 502 from payment provider" },
"openedAt": "2026-09-17T04:31:12Z",
"resolvedAt": null,
"consecutiveFailures": 2,
"location": "cloudflare edge lhr",
"url": "https://app.scoutsentinel.com/events/0192b7e2-1f6a-7d55-8b0c-2e9f4a6b8c1d",
"evidence": [
{ "kind": "screenshot", "url": "https://app.scoutsentinel.com/evidence/0192b7e3-5c1d-7e2f-a3b4-c5d6e7f8a9b0", "thumbnailUrl": null }
]
}
}
| Field | Meaning |
|---|---|
type |
event.opened, event.updated, event.resolved or behaviour.changed |
event.kind |
outcome, behaviour, change, exposure, coverage |
event.severity |
critical, high, medium, low, info |
event.status |
open, acknowledged, resolved, dismissed |
event.failureBoundary |
stepIndex (zero-based), stepName, detail; null for Events without a step boundary |
event.location |
Human label of the first failing vantage point, or null |
event.url |
Deep link to the Event in the product application |
event.evidence[] |
kind and app links; thumbnailUrl is null when no preview exists |
Evidence objects are never embedded and the organisation id is never included. Evidence links open the app, and downloads always go through the API with a short-lived token and an access audit row.
Headers
| Header | Value |
|---|---|
X-ScoutSentinel-Event |
The payload type, for routing before you parse the body |
X-ScoutSentinel-Delivery |
Delivery id, for idempotency |
X-ScoutSentinel-Timestamp |
Unix seconds when the signature was computed |
X-ScoutSentinel-Signature |
t=<unix seconds>,v1=<hex HMAC-SHA256>; more than one v1= element may be present |
User-Agent |
ScoutSentinel-Webhooks/1.0 |
The signature is HMAC-SHA256(secret, "<timestamp>.<rawBody>") encoded as lowercase hex, where <timestamp> is the t value from the signature header (identical to X-ScoutSentinel-Timestamp). The Integrations delivery log shows the signature header that was sent with each attempt so you can debug a verifier.
Verifying a signature
- Read the raw request body as bytes exactly as received. Do not re-serialise JSON before verifying.
- Take
tfromX-ScoutSentinel-Signature(fall back toX-ScoutSentinel-Timestamp) and reject the delivery when it is more than 300 seconds from your clock. This is the replay window. - Compute
HMAC-SHA256(secret, t + "." + rawBody)and compare it with eachv1=value using a constant-time comparison. Accept when any one matches. - Only then parse the JSON.
Node.js:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyScoutSentinel(rawBody: string, headers: Headers, secret: string): boolean {
const header = headers.get("x-scoutsentinel-signature") ?? "";
const parts = header.split(",").map((p) => p.trim());
const t = parts.find((p) => p.startsWith("t="))?.slice(2) ?? headers.get("x-scoutsentinel-timestamp") ?? "";
const timestamp = Number.parseInt(t, 10);
if (!Number.isFinite(timestamp) || Math.abs(Math.floor(Date.now() / 1000) - timestamp) > 300) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const expectedBytes = Buffer.from(expected, "hex");
return parts
.filter((p) => p.startsWith("v1="))
.map((p) => p.slice(3))
.some((sig) => /^[0-9a-f]{64}$/i.test(sig) && timingSafeEqual(Buffer.from(sig, "hex"), expectedBytes));
}
Python:
import hashlib
import hmac
import time
def verify_scoutsentinel(raw_body: bytes, headers: dict[str, str], secret: str) -> bool:
header = headers.get("x-scoutsentinel-signature", "")
parts = [p.strip() for p in header.split(",")]
t = next((p[2:] for p in parts if p.startswith("t=")), headers.get("x-scoutsentinel-timestamp", ""))
if not t.isdigit() or abs(int(time.time()) - int(t)) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
candidates = [p[3:] for p in parts if p.startswith("v1=")]
return any(hmac.compare_digest(candidate.lower(), expected) for candidate in candidates)
In a framework that parses JSON for you, read the raw body from the request stream before parsing, or the whitespace and key order will not match what was signed.
Secret rotation
Update the webhook’s secret with PATCH /v1/integrations/{integrationId}; omitting secret keeps the stored value. Verifiers should accept any v1= element in the header, which lets a delivery carry signatures for both secrets while you roll your endpoint over. Configuration changes are recorded in the audit log.
Retries
A response other than 2xx within 10 seconds is retried when it could plausibly succeed later: no response at all (timeout or network error), 408, 425, 429 or any 5xx. Other 4xx responses fail the delivery immediately. The delay before each retry is exponential from 30 seconds with jitter between half and all of the nominal delay, capped at one hour, for a maximum of 8 attempts including the first:
| Attempt | Nominal delay before it |
|---|---|
| 2 | 30 s |
| 3 | 1 min |
| 4 | 2 min |
| 5 | 4 min |
| 6 | 8 min |
| 7 | 16 min |
| 8 | 32 min |
After the eighth attempt the delivery is marked dead_lettered. Every attempt is listed under Integrations, Deliveries (GET /v1/integrations/{integrationId}/deliveries) with status code, latency and a response excerpt of at most 1 KB, and a failed delivery can be resent with POST .../deliveries/{deliveryId}/retry. Notification failures never change the state of the Event itself.
Source addresses
Webhooks are sent from Cloudflare Workers, so source IP addresses are not stable. Verify signatures rather than allowlisting IPs. Endpoints must be public HTTPS hosts; private and loopback addresses are rejected when the integration is created.