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, result_schema_valid (true/false when the flow sets a result schema, else null), evidence_receipt, platform_review (what Preloop posted on the pull request when the flow opted in, else null; see restricted CI) |
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_undeliverable |
A session ends before delivery, or its spool drops the claimed block | note identity, target, author, undeliverable_at, undeliverable_reason (session_ended, spool_dropped); one event per note |
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 |
ai_model.credential_rejected |
A provider first rejects a customer model API key for authentication or billing | ai_model_id, model_name, reference_id, provider, code (upstream_auth_rejected, upstream_billing_rejected), sanitized message, first_rejected_at, remedy |
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.
Restricted CI completion¶
A completion subscription owned by a restricted CI identity (see
Completion subscriptions)
receives flow.execution.finished with a narrower data block, built from
the execution's persisted state immediately before each attempt rather than
from the emitter:
| Field | Meaning |
|---|---|
execution_id, flow_id, project_id |
The owned execution and its bound flow and project |
repository_identifier, repository_slug, tracker_type |
The bound provider repository |
pr_number, provider_pr_id |
The reviewed pull request |
head_sha |
The exact head commit the review checked out |
base_branch, base_sha |
The target branch and the base commit the provider reported when the review was accepted (base_sha is null on executions accepted before it was recorded) |
status |
The terminal status |
result_ready |
Whether a result is stored |
result_sha256 |
SHA-256 of the canonical JSON of the result that GET .../result returns to the CI key (sorted keys, compact separators, UTF-8), or null |
Preloop records no separate merge-base or diff digest; head_sha plus
base_sha identify the reviewed snapshot.
The result is stored with the terminal status before the event is queued, so
result_ready: true means the result can be read now. An execution that is
not yet terminal is never delivered. If an enqueue fails, the execution still
completes and the failure is logged at error level; nothing retries it, so a
publisher that must not miss a review should also handle a missing event, for
example by reading the execution once after its own timeout.
Delivery follows the same outbox, retry, dead-letter and circuit-breaker
rules as every other endpoint (below). A delivery whose identity or grant was
revoked after queueing is marked dead without being sent. Generic replay and
test sends are refused for these endpoints. Receivers deduplicate on the
envelope id and on execution_id plus head_sha.
GitHub repository_dispatch delivery¶
A CI completion subscription can deliver as a GitHub repository_dispatch
to the identity's bound repository instead of an HTTP POST, so a GitHub
Actions workflow wakes up with no receiver of your own. GitHub passes no
request headers to the workflow, so the signature travels inside
client_payload:
{
"event": "<the envelope, exactly the bytes that were signed>",
"signature": "t=1767225600,v1=<hex HMAC-SHA256 of t.event>",
"event_id": "<envelope id>",
"delivery_id": "<delivery id>",
"attempt": 1
}
Verify signature against event with the subscription secret using the
same scheme as X-Preloop-Signature, then parse event. Allow for the time
a dispatch waits in GitHub's queue before the workflow starts; the example
publisher accepts 30 minutes and deduplicates its publication per execution,
so a replay inside that window publishes nothing new. GitHub answers 204
on success; any other status is a failed attempt and is retried as above.
The payload stays far below GitHub's 10 top-level key and 64 KB limits; a
delivery that would exceed them fails instead of being truncated. Setup is
in Completion by GitHub repository_dispatch.
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; with the target policy on, the connect budget covers resolution and up to three checked addresses |
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, site-local, reserved or otherwise non-global 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. Registration and every delivery
attempt check all resolved addresses; any non-public answer rejects the target.
Delivery connects to a checked address while preserving the original HTTP Host
and TLS hostname verification. Retries resolve and check again, and redirects
are not followed. With this policy enabled, the delivery worker connects
directly, without environment proxies, so a proxy cannot independently resolve
an unchecked destination.