Outbound event webhooks¶
Editions: OSS, Cloud, Enterprise. Unless stated otherwise, everything on this page ships in OSS.
Preloop can POST signed governance events to a URL you own: an approval was raised or decided, a policy denied a call, a runtime session closed, spend crossed a budget, a flow execution finished, a CRA audit found a reportable vulnerability. This is the integration path for a SIEM, a GRC platform or any internal audit collector.
This page is about events Preloop sends. For webhooks Preloop receives (flow triggers, tracker callbacks) see webhook triggers.
Register an endpoint¶
Console: Settings > Webhooks > Add endpoint. API:
curl -X POST https://<your-preloop>/api/v1/event-webhooks/endpoints \
-H "Authorization: Bearer $PRELOOP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://collector.example.com/preloop",
"description": "SIEM",
"event_types": ["approval.decided", "policy.denied"]
}'
The response carries secret once. It is stored encrypted and there is no
endpoint that reads it back; if you lose it, rotate by creating a new endpoint
or setting a new URL and re-registering. Later reads show only secret_hint,
the last four characters.
An empty event_types list means every v1 event. An unknown event type is
rejected at create time rather than silently never firing. An account may
register up to 20 endpoints.
GET /api/v1/event-webhooks/catalogue returns the subscribable event types
with one-line descriptions, plus the signature header name, the verification
tolerance and the retry schedule this deployment is running. Read the contract
from there rather than hard-coding it.
The envelope¶
Every request body is one JSON object with exactly these keys:
{
"id": "0f2f4b8e-2a6a-5c3f-9b71-6c0f2e4a51d3",
"type": "approval.decided",
"version": "1",
"occurred_at": "2026-09-08T11:04:22.481000+00:00",
"account_id": "6c1c2a30-9b0e-4d9a-9f52-3a2b1c0d4e5f",
"data": { "...": "event specific" }
}
idis stable for the fact it describes, not for the HTTP request. The same approval decision emitted twice produces the sameid, so deduplicate onid. It is also sent asX-Preloop-Event-Id.occurred_atis when the fact happened, not when the POST was made. A delivery retried 40 minutes later still carries the original time.- Adding a key inside
datais not a version bump. Removing or renaming one is, and would arrive asversion: "2"alongside a new event type family. - Bodies are capped at 64 KB. A payload over the cap is replaced with
{"truncated": true, "reason": "..."}rather than dropped.
Events (v1)¶
| Type | Fires when | Notable data fields |
|---|---|---|
approval.created |
An approval request is raised for a tool call | approval_request_id, tool_name, summary, managed_agent_name, requested_at, expires_at |
approval.decided |
The request reaches approved, declined, expired or cancelled |
decision, resolved_at, actor (kind: user, ai, bypass, system, plus id), rule (the rule or policy that decided it), comment |
policy.denied |
A policy rule denies a tool call | tool_name, rule_description, condition_matched, execution_id, user_id, correlation_id |
session.ended |
A runtime session closes | runtime_session_id, reason (execution_finished, idle, operator), runtime_principal_*, duration_seconds |
budget.threshold |
Spend crosses a configured soft limit | scope, scope_id, period, limit_amount, spent_amount, percent_used, threshold_percent |
budget.exceeded |
Spend passes a configured hard limit | same as above |
flow.execution.finished |
A flow execution reaches a terminal status | execution_id, flow_id, flow_name, status, failure_category, evidence_receipt |
agent.note_sent |
An operator note is accepted for a running agent | note_id, managed_agent_id, runtime_session_id, text, author (user_id, display, auth_method), created_at, expires_at |
agent.note_delivered |
That note reaches the agent at a turn boundary | same fields plus delivered_at, delivery_channel (gateway, hook, claude_channel, claude_message), turn_index |
cra.reportable_vulnerability |
A CRA audit found an actively exploited vulnerability that its own evidence says affects the product | cve, actively_exploited, exploited_evidence, affected, vex_status, discovered_at, deadlines, status, assessment, kev_snapshot_date, kev_source_url |
agent.discovered |
Opt-in workstation discovery reports an agent tool that was not reported before | candidate_id, agent_kind, agent_version, workstation_fingerprint (salted hash), config_path_hash (salted hash), mcp_server_count, enrolled, os_family, status, first_seen_at |
agent.onboarded |
A managed-agent enrollment is validated for the first time | agent_id, agent_name, agent_kind, source_type (discovered, custom), outcome (created, relinked, merged), enrollment_id, owner_user_id, actor_user_id, gateway_routed, mcp_rewritten, mcp_server_count |
agent.discovered fires once per new candidate row; a workstation that reports
the same tool again only moves last_seen_at. It carries no user name,
hostname, clear path or MCP detail. See
agent discovery reporting.
Operator note payloads do carry the note text, unlike approval payloads.
The text is the fact, and a receiver mirroring notes into a ticket or a
chat room has nothing without it. See operator notes.
cra.reportable_vulnerability fires once per reportable candidate, not once
per execution, because each candidate carries its own clock: deadlines holds
the CRA Article 14 early warning (24 h), notification (72 h) and final report
(14 d) as absolute UTC timestamps computed from discovered_at. The event id
is deterministic on (execution, CVE), and occurred_at is discovered_at
rather than the send time, so a delivery retried the next day still says when
the clock started. The payload carries not_a_legal_determination: true and
filing_is_manufacturer_responsibility: true: Preloop has no client for the
ENISA single reporting platform and does not file anything. Routing this event
into a ticket queue automates a notification, never a filing. The block behind
it is documented in
security audit presets.
agent.onboarded fires when an enrollment first reaches validated, not
when the enrollment row is created: a config that was written but never
checked is not governed yet. (A row created through the API already in
validated status counts as validated at creation and emits then.) The event id is deterministic on the enrollment,
so validating the same enrollment again does not produce a second event.
outcome says how the agent relates to agents Preloop already governed:
created for its first onboarding, relinked when an earlier enrollment of
the same agent was onboarded before (for example after a restore, or on a new
workstation), and merged when a duplicate was merged into the agent since its
last onboarding. The payload carries no hostname, OS user name, config path,
MCP server URL or credential; mcp_server_count is a count only.
{
"id": "1d7c0b9e-4f0a-5d43-9a7e-3b2f6c1e8a21",
"type": "agent.onboarded",
"version": "1",
"occurred_at": "2026-10-03T09:15:02.118000+00:00",
"account_id": "00000000-0000-4000-8000-000000000001",
"data": {
"agent_id": "00000000-0000-4000-8000-0000000000a1",
"agent_name": "Codex workspace",
"agent_kind": "codex",
"source_type": "discovered",
"outcome": "created",
"enrollment_id": "00000000-0000-4000-8000-0000000000e1",
"owner_user_id": "00000000-0000-4000-8000-0000000000c1",
"actor_user_id": "00000000-0000-4000-8000-0000000000c1",
"gateway_routed": true,
"mcp_rewritten": true,
"mcp_server_count": 2
}
}
Approval payloads deliberately omit tool arguments. Those routinely carry the payload the approval exists to guard, and a webhook target is not the audit log. Fetch the request over the API if you need them.
evidence_receipt is present only when the execution captured one. It mirrors
the stored receipt (status, transport, artifact_id, sha256,
manifest_sha256, size_bytes, retention_hours, object_lock,
legal_hold, integrity_verified). What those fields do and do not prove is
covered in evidence storage; the webhook repeats
the receipt, it does not add an assurance the receipt did not already carry.
Test sends use type webhook.test and are delivered to the endpoint you
tested regardless of its filter.
Verifying the signature¶
Every request carries:
| Header | Value |
|---|---|
X-Preloop-Signature |
t=<unix seconds>,v1=<hex hmac> |
X-Preloop-Event-Id |
Event id, stable across retries and replays |
X-Preloop-Event-Type |
The event type |
X-Preloop-Delivery-Id |
This delivery row; differs per endpoint and per replay |
X-Preloop-Attempt |
1-based attempt number |
User-Agent |
Preloop-Webhook/1 |
The v1 value is HMAC-SHA256(secret, f"{t}.{raw_body}") in lowercase hex.
Sign the raw bytes you received; do not parse and re-serialize the JSON
first. Reject a request whose t is more than 300 seconds from your clock.
Preloop stamps a fresh t on every attempt, so a delivery retried an hour
later still lands inside that window.
Parse the header by splitting on commas and taking the parts you know: a
future v2= may be added alongside v1=, and a receiver that ignores unknown
parts keeps working.
Python¶
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(
part.strip().split("=", 1) for part in header.split(",") if "=" in part
)
timestamp = parts.get("t")
signature = parts.get("v1")
if not timestamp or not signature:
return False
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode("utf-8"),
timestamp.encode("ascii") + b"." + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)
FastAPI or Flask: read the body with await request.body() or
request.get_data(), never request.json.
Node¶
const crypto = require("crypto");
const TOLERANCE_SECONDS = 300;
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.trim().split("="))
);
const { t, v1 } = parts;
if (!t || !v1) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(Buffer.concat([Buffer.from(`${t}.`), rawBody]))
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
With Express, mount express.raw({ type: "application/json" }) on the webhook
route so req.body is the original buffer.
What the signature does not prove¶
The signature proves that whoever produced the request held your endpoint secret at that timestamp, and that the body was not altered in transit. That is all. Specifically:
- The audit log is not signed. These events are a notification of a fact, not an attested copy of Preloop's record of it. Nothing here is a signed extract of the audit trail, and the audit trail carries no counter-signature you can check against a delivered event.
- The signature is not a receipt for the evidence bundle. For a
flow.execution.finishedevent theevidence_receiptfields are copied from storage.integrity_verifiedreflects a check Preloop ran, attested by Preloop; the HMAC does not independently confirm it. - The secret is symmetric. Anyone holding it, including Preloop, can mint a valid signature. It authenticates the channel; it is not a non-repudiable signature by a key only Preloop holds.
- Ordering is not guaranteed. Retries mean a later event can arrive first.
Order on
occurred_at, not on arrival. - Delivery is at-least-once. The same
idcan arrive more than once. Deduplicate.
Delivery, retries and dead letters¶
Events are written to a database outbox inside the transaction that produced them, and posted by a background worker. Nothing is sent on the request path, so a slow receiver never slows an approval or a tool call.
- Up to 6 attempts: immediately, then after roughly 10 s, 1 m, 5 m, 15 m and 40 m, each multiplied by jitter between 0.8 and 1.2. Total window is about one hour.
- Any 2xx is success. Everything else, including a timeout after 10 s, is a failed attempt. Response bodies are not stored, only the status code and a short error string.
- After the last attempt the delivery becomes dead. It stays visible at
GET /api/v1/event-webhooks/deliveries/dead-letterand in the console. - After 10 consecutive failures an endpoint's circuit breaker opens and it stops being attempted for 15 minutes, after which one probe is allowed through. Editing the URL or re-enabling the endpoint closes the breaker immediately.
- Undelivered rows are bounded at 10,000 per account. Past that, new events are refused and the reason is stamped on the endpoint, so a dead receiver cannot grow the outbox without limit.
- Delivered and dead rows are purged after 14 days.
Replay one event to every endpoint that already received it:
curl -X POST \
https://<your-preloop>/api/v1/event-webhooks/deliveries/<event-id>/replay \
-H "Authorization: Bearer $PRELOOP_TOKEN"
A replay is a new generation of the same event id. The original rows are left
as they are, so a dead-lettered attempt stays on the record, and the receiver
sees the same X-Preloop-Event-Id with a new X-Preloop-Delivery-Id.
Approval workflow webhooks¶
An approval workflow sends its approval requests to a webhook configured in
channel_configs (webhook, slack or mattermost, each with a url), or
in the older approval_config.webhook_url. Either way the request goes
through the outbox, so it is signed and retried, with the headers and
signature described above.
The signing secret is generated when you create the workflow through the API
and returned once, in webhook_secret on that response. Reads show only
webhook_secret_hint. To get a new one (for a workflow created by policy
apply, or a secret you did not keep), rotate it:
curl -X POST "https://preloop.example.com/api/v1/approval-workflows/$WORKFLOW_ID/webhook-secret/rotate" \
-H "Authorization: Bearer $PRELOOP_API_KEY"
The response carries the new webhook_secret; the next delivery is signed
with it. You can also set approval_config.webhook_secret yourself to choose
the secret. These endpoints appear in the console list read-only, so you can
see one failing, and are edited by changing the approval workflow.
webhook_posted_at on the approval request is stamped when a receiver
actually accepted the delivery, and a receiver that is down does not lose the
notification.
Settings¶
| Setting | Default | Effect |
|---|---|---|
WEBHOOK_DELIVERY_ENABLED |
true |
Off records events in the outbox but posts nothing |
WEBHOOK_DELIVERY_POLL_SECONDS |
5 |
Outbox poll interval |
WEBHOOK_DELIVERY_BATCH_SIZE |
50 |
Deliveries claimed per pass |
WEBHOOK_DELIVERY_CONCURRENCY |
8 |
Concurrent POSTs per pass |
WEBHOOK_DELIVERY_TIMEOUT_SECONDS |
10 |
Per-attempt HTTP timeout |
WEBHOOK_MAX_PENDING_PER_ACCOUNT |
10000 |
Outbox bound per account |
WEBHOOK_CIRCUIT_FAILURE_THRESHOLD |
10 |
Consecutive failures that open the breaker |
WEBHOOK_CIRCUIT_COOLDOWN_SECONDS |
900 |
How long the breaker stays open |
WEBHOOK_DELIVERY_RETENTION_DAYS |
14 |
Retention for delivered and dead rows |
WEBHOOK_BLOCK_PRIVATE_TARGETS |
false |
Refuse URLs resolving to loopback, link-local, private or reserved space |
WEBHOOK_BLOCK_PRIVATE_TARGETS is off by default because a self-hosted
deployment posting to a collector on the same private network is the normal
case. Turn it on for multi-tenant hosting. The check resolves the hostname
when the endpoint is registered, not on every attempt, so a name that later
re-resolves into private space is not caught by it.