Skip to content

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" }
}
  • id is stable for the fact it describes, not for the HTTP request. The same approval decision emitted twice produces the same id, so deduplicate on id. It is also sent as X-Preloop-Event-Id.
  • occurred_at is 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 data is not a version bump. Removing or renaming one is, and would arrive as version: "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.finished event the evidence_receipt fields are copied from storage. integrity_verified reflects 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 id can 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-letter and 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.