---
title: "Event Subscriptions"
source: "https://docs.vertesiahq.com/events/subscriptions"
markdown: "https://docs.vertesiahq.com/llms/events/subscriptions.md"
---

# Event Subscriptions

Event subscriptions let a project react to platform events. A subscription matches events by category, action,
resource type, and an optional JSONLogic condition, then delivers each matching event to a workflow, agent, process, or
webhook.

Use subscriptions for integrations such as:

- notifying an external system when a document is created or updated
- starting a workflow, agent, or process when a content object changes
- routing content events differently for documents, images, audio, or video
- reacting to events pushed in from external systems (Salesforce, GitHub, ...) through an [ingest channel](#inbound-external-event-channels-incoming-webhooks)
- building lightweight project automations without polling

## Where to manage subscriptions

In Studio, open **Store** and select **Event Bus**. The page has these main views:

- **Subscriptions**: create, edit, duplicate, disable, or delete user-managed subscriptions.
- **Webhooks**: the subset of subscriptions whose target is an outbound webhook.
- **Ingest Channels**: create [inbound channels](#inbound-external-event-channels-incoming-webhooks) that let external systems push events into the bus.
- **Deliveries**: inspect matched events and delivery state for workflow, agent, process, and webhook targets.

System subscriptions are shown for visibility when relevant, but protected system subscriptions cannot be edited or deleted.
Projects that were migrated from legacy customized system rules may show a protected **Override** subscription. That
override replaces the Vertesia-provided system subscription for the project instead of running beside it.

## Event model

Every event has a stable envelope:

```json
{
  "event_id": "01HF7YAT00ABCDEFGHJKMNPQRS",
  "event_category": "content",
  "action": "create",
  "resource_type": "content_object",
  "resource_id": "object-id",
  "account_id": "account-id",
  "project_id": "project-id",
  "tenant_id": "tenant-id",
  "timestamp": "2026-05-23T00:00:00.000Z",
  "source": "zeno-server",
  "resource_data": {
    "metadata": {
      "type": "document"
    }
  }
}
```

The main event categories are:

| Category | Typical use |
| --- | --- |
| `content` | content object create, update, revision, delete, type changes |
| `workflow` | workflow lifecycle events |
| `security` | authentication, authorization, and permission events |
| `billing` | inference, embedding, and image generation usage events |
| `system` | platform maintenance and system events |
| `external` | events pushed in from outside Vertesia through an [ingest channel](#inbound-external-event-channels-incoming-webhooks) |

## API reference

The subscription management and delivery inspection APIs are generated from the OpenAPI spec:

- `GET /api/v1/events/subscriptions`
- `POST /api/v1/events/subscriptions`
- `GET /api/v1/events/subscriptions/{subscriptionId}`
- `PUT /api/v1/events/subscriptions/{subscriptionId}`
- `DELETE /api/v1/events/subscriptions/{subscriptionId}`
- `POST /api/v1/events/deliveries/search`

Ingest channels (inbound webhooks) are managed under the same topic:

- `GET /api/v1/events/channels`
- `POST /api/v1/events/channels`
- `GET /api/v1/events/channels/{channelId}`
- `PUT /api/v1/events/channels/{channelId}`
- `DELETE /api/v1/events/channels/{channelId}`

The public ingest endpoint external systems POST to — `POST /webhooks/events/{accountId}/{projectId}/{channelId}` — is token/signature authenticated and is **not** part of the authenticated Vertesia API; its request shape is documented in the [ingest channels](#inbound-external-event-channels-incoming-webhooks) section.

See [Event Subscriptions in the API reference](/api/openapi#tag/Event-Subscriptions). The outbound webhook request body is not a callable Vertesia API endpoint, so its payload shape is documented in the webhook section below.

## Create a webhook subscription in Studio

1. Open **Store > Event Bus > Subscriptions**.
2. Select **Create subscription**.
3. Enter a name and optional description.
4. Choose `project` scope. Account-scoped subscriptions are reserved for a future account-wide automation model.
5. Set the filter.
6. Set **Target type** to `webhook`.
7. Enter the webhook URL and timeout.
8. Save the subscription.

The webhook URL must use `https`. Local development can allow `http` only when the server is explicitly running with local-development SSRF allowances. Private/internal hosts and dangerous ports are blocked.

## Webhook payload

For the default `event_envelope` payload mode, Vertesia sends:

```json
{
  "event": {
    "event_id": "01HF7YAT00ABCDEFGHJKMNPQRS",
    "event_category": "content",
    "action": "create",
    "resource_type": "content_object",
    "resource_id": "object-id",
    "account_id": "account-id",
    "project_id": "project-id",
    "tenant_id": "tenant-id",
    "timestamp": "2026-05-23T00:00:00.000Z",
    "source": "zeno-server",
    "resource_data": {}
  },
  "delivery": {
    "id": "delivery-intent-id",
    "subscription_id": "subscription-id",
    "attempt": 1
  }
}
```

Vertesia delivers webhooks through a durable workflow. If the target is temporarily unavailable, the delivery is retried and its status is visible in the **Deliveries** tab.

## Webhook headers and signing

User-managed webhook subscriptions support only safe plaintext headers:

- `accept`
- `content-type`
- `user-agent`
- `x-request-id`
- `x-correlation-id`
- `x-vertesia-correlation-id`

Credential-bearing headers such as `Authorization`, `x-api-key`, cookies, and auth tokens are rejected.

Webhook signing is optional. New webhook subscriptions are unsigned unless signing is enabled. Legacy subscriptions
migrated from workflow rules also remain unsigned unless you enable signing later.

When signing is enabled, Vertesia creates a per-subscription signing secret and shows it once when the subscription is
created or rotated. Store that value in your webhook receiver. Vertesia does not show the existing secret again.

Signed webhook requests include these headers:

| Header | Value |
| --- | --- |
| `X-Vertesia-Event-Id` | The platform event ID |
| `X-Vertesia-Delivery-Id` | The delivery intent ID, stable across retries |
| `X-Vertesia-Timestamp` | Unix timestamp in seconds, generated when the webhook delivery workflow starts |
| `X-Vertesia-Signature` | `v1=` |
| `X-Vertesia-Event-Category` | Event category, such as `content` |
| `X-Vertesia-Event-Action` | Event action, such as `create` |

The signature is HMAC-SHA256 over:

```text
<timestamp>.<delivery_id>.<raw request body>
```

Use the exact raw HTTP request body bytes when verifying the signature. Reject requests with timestamps outside your
accepted replay window. Vertesia's shared verifier uses a 15-minute replay window by default. Deduplicate by
`X-Vertesia-Delivery-Id` or by the pair
`event.event_id` + `delivery.subscription_id`.

## Define filters

A subscription filter can match:

- `event_category`: array of categories, or `["*"]`
- `exclude_event_category`: categories to exclude
- `action`: event actions
- `resource_type`: resource types
- `condition`: a JSONLogic object evaluated against the event envelope
- `semantic_condition`: an optional LLM-evaluated natural-language predicate (see below)

Example: match created or updated content objects:

```json
{
  "event_category": ["content"],
  "resource_type": ["content_object"],
  "action": ["create", "update"]
}
```

Example: match only document content objects:

```json
{
  "event_category": ["content"],
  "resource_type": ["content_object"],
  "action": ["create", "update", "revision_created"],
  "condition": {
    "==": [
      { "var": "resource_data.metadata.type" },
      "document"
    ]
  }
}
```

Example: match updates where the source changed:

```json
{
  "event_category": ["content"],
  "resource_type": ["content_object"],
  "action": ["update", "revision_created"],
  "condition": {
    "==": [
      { "var": "details.dirty.source" },
      true
    ]
  }
}
```

### Semantic conditions (optional LLM filtering)

A `semantic_condition` is a natural-language predicate evaluated by an LLM **after** all structural filters
(`event_category`, `action`, `resource_type`, `condition`) have already matched. Use it when the routing decision
cannot be expressed as a structural rule — for example "the document looks like a signed contract amendment".

```json
{
  "event_category": ["content"],
  "resource_type": ["content_object"],
  "action": ["create", "update"],
  "semantic_condition": {
    "instruction": "the document appears to be a signed contract amendment",
    "mode": "enforce",
    "on_error": "fail_closed",
    "evaluator": {
      "type": "interaction",
      "enrich_with_content": true
    }
  }
}
```

- **`evaluator.type`**:
  - `interaction` (default) — a single LLM classification call. Set `enrich_with_content: true` to include an excerpt
    of the content object's text, and optionally `interaction_ref` to use a stored classifier interaction.
  - `agent` — a non-interactive agent run that may use tools (fetch documents, inspect processes) to decide. Slower and
    more expensive; the delivery sits in the `evaluating` state until the agent finishes.
- **`mode`**: `enforce` (default) skips delivery on a negative verdict; `shadow` records the verdict on the delivery
  without ever blocking it — useful for trialing a condition before enforcing it.
- **`on_error`**: `fail_closed` (default) does **not** deliver when evaluation errors out; `fail_open` delivers anyway.

Verdicts are visible per delivery in the **Deliveries** tab.

## Create a webhook subscription with the CLI

Create a JSON file:

```json
{
  "name": "Notify CRM on document changes",
  "description": "Sends document create/update events to the CRM ingestion endpoint.",
  "scope": "project",
  "enabled": true,
  "priority": "normal",
  "run_as_role": "automation",
  "filter": {
    "event_category": ["content"],
    "resource_type": ["content_object"],
    "action": ["create", "update"],
    "condition": {
      "==": [
        { "var": "resource_data.metadata.type" },
        "document"
      ]
    }
  },
  "target": {
    "type": "webhook",
    "url": "https://example.com/vertesia/events",
    "signing_mode": "signed",
    "payload_mode": "event_envelope",
    "timeout_ms": 30000,
    "headers": {
      "content-type": "application/json"
    }
  }
}
```

Apply it:

```sh
vertesia events subscriptions apply --file crm-document-webhook.json
```

Omit `signing_mode` or set it to `legacy_unsigned` for an unsigned webhook.

Useful commands:

```sh
vertesia events subscriptions list
vertesia events subscriptions get <subscription-id>
vertesia events subscriptions update <subscription-id> --file subscription.json
vertesia events subscriptions delete <subscription-id>
```

## Create a workflow subscription

A workflow target starts a named workflow for each matching event. The workflow receives the event reference and any configured variables.

```json
{
  "name": "Run contract analysis",
  "scope": "project",
  "run_as_role": "automation",
  "filter": {
    "event_category": ["content"],
    "resource_type": ["content_object"],
    "action": ["create"],
    "condition": {
      "==": [
        { "var": "resource_data.metadata.type" },
        "document"
      ]
    }
  },
  "target": {
    "type": "workflow",
    "endpoint": "AnalyzeContract",
    "workflow_class": "contract-analysis",
    "vars": {
      "analysis_mode": "standard"
    }
  }
}
```

`run_as_role` is **required** on every subscription. It sets the project service token the delivery runs as, so a subscription always has an explicit run-as identity and never falls back to running as the event's originating user. User-managed subscriptions currently support:

- `automation`
- `executor`
- `reader`

## Create an agent subscription

An agent target starts an autonomous agent run for each matching event. The target can reference an interaction by ID,
app ref, or system ref. If `interaction_ref` is omitted, Vertesia uses the general-purpose system agent.

The target `data` object is passed to the interaction and can contain event templates:

```json
{
  "name": "Review changed policy documents",
  "scope": "project",
  "run_as_role": "automation",
  "filter": {
    "event_category": ["content"],
    "resource_type": ["content_object"],
    "action": ["update"],
    "condition": {
      "==": [
        { "var": "resource_data.metadata.type" },
        "document"
      ]
    }
  },
  "target": {
    "type": "agent",
    "interaction_ref": "sys:GeneralAgent",
    "data": {
      "message": "Review {{ event.resource_id }} and summarize the policy impact.",
      "event": "$event",
      "event_ref": "$event_ref",
      "object_id": "$event.resource_id",
      "touched_fields": "$event.details.dirty"
    },
    "max_iterations": 12
  }
}
```

Event subscription agent runs are stored with `source_type: "event_subscription"` and include the event subscription ID
and event reference.

## Create a process subscription

A process target starts a process run for each matching event. The target can reference a stored process or provide an
inline process definition.

```json
{
  "name": "Start renewal process",
  "scope": "project",
  "run_as_role": "automation",
  "filter": {
    "event_category": ["content"],
    "resource_type": ["content_object"],
    "action": ["create"]
  },
  "target": {
    "type": "process",
    "process_ref": "contract-renewal",
    "run_type": "programmatic",
    "data": {
      "message": "Start renewal review for {{ event.resource_id }}.",
      "event_ref": "$event_ref",
      "object_id": "$event.resource_id"
    }
  }
}
```

Process target `data` is merged into the process initial context, so the target process definition must accept the fields
you pass.

## IDP document events

When IDP completes, Vertesia publishes a `document_processed` content event in addition to the generic workflow
lifecycle event. It is scoped to the processed content object, so webhook, workflow, agent, and process subscriptions can
filter it directly without inspecting a workflow result.

The event contains operational aggregates and references only. Extracted data, citations, bounding boxes, prompts, and
page images remain in artifact storage.

```json
{
  "event_category": "content",
  "action": "document_processed",
  "resource_type": "content_object",
  "resource_id": "document-id",
  "meters": [
    { "category": "processing", "type": "documents", "quantity": 1 },
    { "category": "processing", "type": "pages", "quantity": 75 },
    { "category": "processing", "type": "ocr_pages", "quantity": 4 },
    { "category": "processing", "type": "vision_pages", "quantity": 6 },
    { "category": "extraction", "type": "properties", "quantity": 14 },
    { "category": "extraction", "type": "citations", "quantity": 218 },
    { "category": "verification", "type": "digitally_verified", "quantity": 211 },
    { "category": "verification", "type": "ai_verified", "quantity": 5 },
    { "category": "verification", "type": "unverified", "quantity": 2 },
    { "category": "review", "type": "issues", "quantity": 1 }
  ],
  "details": {
    "schema_version": 1,
    "pipeline": "grounded_extraction",
    "page_count": 75,
    "ocr_page_count": 4,
    "vision_page_count": 6,
    "property_count": 14,
    "citation_count": 218,
    "verification": {
      "total": 218,
      "digitally_verified": 211,
      "ai_verified": 5,
      "unverified": 2
    },
    "confidence": 0.94,
    "coverage_min": 0.72,
    "hardness": 0.32,
    "escalated": false,
    "reviewed": true,
    "review_issue_count": 1,
    "verdict": "needs_review",
    "verdict_reason": "Two values could not be verified against the source.",
    "models_used": [
      {
        "role": "extraction",
        "run_id": "interaction-run-id",
        "model": "model-id",
        "environment_id": "environment-id",
        "provider": "provider-id"
      }
    ],
    "result_path": "magic-pdf/document-id/grounded-extraction.json",
    "workflow_id": "grounded-workflow-id",
    "workflow_run_id": "temporal-run-id",
    "workflow_type": "PdfGroundedExtractionWorkflow"
  }
}
```

Inference audit events produced by direct extraction and review calls carry the same `details.workflow_run_id`. Join on
that field to analyze document outcomes by the actual inference model and token usage without duplicating accounting data
in the document event. Large-document review agents are identified by `details.review_agent_run_id` when present.

For operational dashboards, `POST /api/v1/audit-trail/aggregate` accepts bounded, typed aggregations over event counts and
meters. The server always applies the authenticated account scope and forces the authenticated project for project-scoped
principals; client-provided scope never replaces those predicates. For example, the following query returns daily document
and page throughput:

```json
{
  "projectId": "project-id",
  "from": "2026-07-01T00:00:00.000Z",
  "to": "2026-08-01T00:00:00.000Z",
  "filter": {
    "actions": ["document_processed"],
    "details": [
      { "field": "pipeline", "values": ["grounded_extraction"] }
    ]
  },
  "groupBy": [
    { "dimension": "time", "resolution": "day" }
  ],
  "metrics": [
    { "id": "documents", "operation": "count" },
    {
      "id": "pages",
      "operation": "sum_meter",
      "meterCategory": "processing",
      "meterType": "pages"
    }
  ]
}
```

The following subscription sends IDP outcomes that need review to a signed webhook:

```json
{
  "name": "Notify on IDP review",
  "scope": "project",
  "run_as_role": "automation",
  "filter": {
    "event_category": ["content"],
    "resource_type": ["content_object"],
    "action": ["document_processed"],
    "condition": {
      "==": [
        { "var": "details.verdict" },
        "needs_review"
      ]
    }
  },
  "target": {
    "type": "webhook",
    "url": "https://example.com/vertesia/idp-events",
    "signing_mode": "signed",
    "payload_mode": "event_envelope",
    "timeout_ms": 30000
  }
}
```

The generic `workflow_completed` and `workflow_failed` events are still published for workflow lifecycle automation.
IDP workflow lifecycle events also target the content object and identify the workflow in `details.workflow_type`.

## Inbound external event channels (incoming webhooks)

Subscriptions deliver events **out** of Vertesia. An **ingest channel** is the reverse: a per-channel public URL that
external systems (Salesforce, GitHub, Slack, ...) POST to in order to push events **into** the bus. Each ingested event
is published with `event_category: "external"` and `source: "external:"`, then matched by subscriptions exactly
like any internal event — so you react to it with a workflow, agent, process, or webhook the same way.

> Vertesia has no other generic "incoming webhook". To push data in without a channel, use the REST API directly.

### Create a channel in Studio

1. Open **Store > Event Bus > Ingest Channels**.
2. Select **Create channel**.
3. Enter a name and a `source` label (stamped on events as `external:`, e.g. `salesforce`).
4. Optionally set a default action / resource type, a [transform](#transforms), and [signature verification](#signature-verification).
5. Save. The **ingest token** (and the **signing secret**, if signature verification is enabled) are shown **once** — copy them now; Vertesia never shows them again.

The channel's ingest URL is:

```text
POST https://<store-base-url>/webhooks/events/<accountId>/<projectId>/<channelId>
```

### Authenticating inbound requests

A channel authenticates senders one of two ways:

- **Ingest token** (default). Pass the token as `Authorization: Bearer `, an `x-vertesia-ingest-token` header, or a `?token=` query parameter (for senders that cannot set headers).
- **HMAC signature** (see [below](#signature-verification)). When signature verification is configured the sender signs the request body instead, and the ingest token becomes optional.

### Request body

The body is JSON. The full raw body is always preserved under the event's `details.payload`. Top-level fields, when
present, set the corresponding event fields directly:

```json
{
  "action": "received",
  "resource_type": "lead",
  "resource_id": "00Q5g00000ABCDEments",
  "idempotency_key": "evt-12345",
  "timestamp": "2026-05-23T00:00:00.000Z",
  "payload": { "any": "domain data" },
  "details": { "extra": "merged into event.details" }
}
```

All fields are optional. When omitted, the channel's `transform` and defaults apply. Sending the same `idempotency_key`
twice produces the same `event_id`, so retries are de-duplicated. A successful POST returns `202` with `{ "event_id": "..." }`.

### Transforms

Most providers send their own payload shape and cannot wrap it in the envelope above. A **transform** declaratively maps
dot-paths in the raw body to event fields (array indices supported, e.g. `commits.0.id`):

```json
{
  "transform": {
    "action_path": "action",
    "resource_type_path": "resource",
    "resource_id_path": "pull_request.id",
    "idempotency_key_path": "delivery_id",
    "timestamp_path": "created_at",
    "static_details": { "provider": "github" }
  }
}
```

Extracted values override the channel defaults; the raw body is still kept under `details.payload`.

### Signature verification

For senders that sign their requests (GitHub, Stripe, a Salesforce Apex callout), enable optional HMAC verification on
the channel instead of relying on the ingest token. The server recomputes `HMAC(algorithm, signing_secret, rawBody)` and
compares it (timing-safe) to the header value after stripping the configured prefix.

```json
{
  "signature": {
    "header": "x-hub-signature-256",
    "algorithm": "sha256",
    "encoding": "hex",
    "prefix": "sha256="
  }
}
```

- `header` — request header carrying the signature.
- `algorithm` — `sha256` (default) or `sha1`.
- `encoding` — `hex` (default) or `base64`.
- `prefix` — optional literal stripped from the header value before comparison.

The **signing secret** is generated by Vertesia and shown once on create or rotation; configure the sender to HMAC the
**raw request body** with it. When signature verification is configured, the ingest token is not required (providers
authenticate by signature alone). Rotate the secret by updating the channel with `rotate_signing_secret: true`.

### Create a channel with the API

Ingest channels are managed through the REST API under `/api/v1/events/channels` (`POST` to create, `GET` to
list, `GET`/`PUT`/`DELETE` by id). Send the channel definition as the request body:

```json
{
  "name": "Salesforce leads",
  "source": "salesforce",
  "default_action": "received",
  "default_resource_type": "lead",
  "transform": { "resource_id_path": "lead.id" },
  "signature": { "header": "x-vertesia-signature", "algorithm": "sha256", "encoding": "hex" }
}
```

```sh
curl -X POST "$VERTESIA_API/api/v1/events/channels" \
  -H "authorization: Bearer $VERTESIA_TOKEN" \
  -H "content-type: application/json" \
  --data @salesforce-leads.json
```

Then create a subscription that matches the external events and runs your automation:

```json
{
  "name": "Qualify inbound leads",
  "scope": "project",
  "run_as_role": "automation",
  "filter": {
    "event_category": ["external"],
    "resource_type": ["lead"],
    "action": ["received"]
  },
  "target": { "type": "agent", "interaction_ref": "sys:GeneralAgent" }
}
```

## Event templates

Agent and process target data supports event templates:

| Template | Result |
| --- | --- |
| `"$event.resource_id"` | Inserts the referenced value while preserving its original JSON type |
| `"{{ event.resource_id }}"` | Inserts the referenced value while preserving its original JSON type |
| `"Document {{ event.resource_id }} changed"` | Replaces the template inline as text |
| `"$event"` | Passes the complete event object |
| `"$event_ref"` | Passes the event reference |
| `"$delivery.id"` | Passes the delivery intent ID |

If a template references a missing field, the delivery fails because retrying the same event cannot resolve the missing
field.

## Delivery behavior

Event subscriptions are at-least-once. Delivery can be retried after transient failures, so webhook handlers should be idempotent. Use `event.event_id` and `delivery.subscription_id` as stable identifiers when deduplicating work.

For all delivery target types, Vertesia starts the underlying Temporal workflow with a stable workflow identifier based on
the event and subscription, so repeated delivery attempts do not intentionally start duplicate work for the same
event/subscription pair.

## Troubleshooting

Use **Store > Event Bus > Deliveries** to inspect:

- whether an event matched the subscription
- delivery status: pending, running, succeeded, retrying, failed, or cancelled
- workflow IDs and run IDs for started deliveries
- recent errors and retry state

If no delivery appears:

- check that the subscription is enabled
- confirm the subscription is in the same project as the event
- check `event_category`, `action`, and `resource_type`
- test the JSONLogic condition against the event shape
- confirm the event source includes the fields referenced by the condition

If webhook delivery fails:

- confirm the URL is reachable from Vertesia
- keep the timeout below 50 seconds
- return a successful HTTP status quickly
- deduplicate using `event_id`
- inspect the delivery error in the **Deliveries** tab