# Guide to Vellum Webhooks: Features and Best Practices

Vellum webhooks stream what happens in your Vellum workspace to your own systems: a workflow starts, finishes or fails, Vellum works out the token usage and cost of a run, or a metric finishes evaluating. If you run LLM workflows in Vellum, webhooks let you alert on failures, track spend and keep an audit log in the tools you already use.

This guide covers how Vellum's monitoring webhooks are set up, the events and envelope they use, how to authenticate them with a static credential, where Vellum's optional HMAC signature fits, and what to plan for given how little Vellum documents about delivery.

## What are Vellum webhooks?

Vellum's webhooks are part of its monitoring features. You configure one **Webhook Integration** for the organisation, choose which events it sends, and Vellum sends each event to your URL as JSON.

Authentication is a credential that Vellum adds to every request. Vellum offers two kinds:

* **API Key Header**: a header name and value that you choose.
* **Bearer Token**: a token sent in the `Authorization` header as `Bearer <token>`.

Vellum can also sign outgoing requests with HMAC. Once you provide an HMAC token in your workspace settings, Vellum adds `X-Vellum-Timestamp` and `X-Vellum-Signature` headers to webhooks and to API node requests. The signature covers the request URL as well as the body, which matters if a gateway sits in front of your service (more on that below).

## Vellum webhook features

| Feature | Details |
| --- | --- |
| Configuration | Organisation settings, **Monitoring Integration** section, **Webhook Integration** |
| Events | Six selectable workflow, usage and metric events |
| Payload | JSON envelope with `id`, `name`, `timestamp`, `parent` and `body` |
| Authentication | API Key Header (custom header) or Bearer Token |
| Signature | Optional HMAC-SHA256 in `X-Vellum-Signature`, over timestamp, method, URL and body |
| Method | Not documented |
| Retries | Not documented. Vellum tells you to expect retries and duplicates |
| Timeout | Not documented |
| API version | `2024-10-25` in each event's `api_version` |

## Common events

| Category | Event | Sent when |
| --- | --- | --- |
| Workflow execution | `workflow.execution.initiated` | A workflow starts |
|  | `workflow.execution.fulfilled` | A workflow completes successfully |
|  | `workflow.execution.rejected` | A workflow execution fails |
| Usage calculation | `workflow_execution.usage_calculation.fulfilled` | Usage for a run is calculated |
|  | `workflow_execution.usage_calculation.rejected` | Usage calculation fails |
| Metrics | `metric.execution.fulfilled` | A metric execution completes successfully |

Every event uses the same envelope. Here is a failed workflow run:

```json
{
  "id": "7c1d2a4e-3b9f-4f6e-9a51-2d8c0e4b6f10",
  "timestamp": "2026-10-26T09:14:03.512Z",
  "api_version": "2024-10-25",
  "trace_id": "0b6c3f1e-8d2a-4c7b-a5e9-4f1d2c3b6a70",
  "span_id": "e4a9b2c1-6d3f-4e8a-b7c5-1f2e3d4c5b60",
  "parent": {
    "deployment_id": "a3f8c2d1-5e6b-4a7c-9d8e-0f1a2b3c4d50",
    "deployment_name": "support-triage",
    "release_tag_name": "PRODUCTION",
    "external_id": "ticket-48213"
  },
  "name": "workflow.execution.rejected",
  "body": {
    "workflow_class": { "name": "SupportTriageWorkflow", "module": "support_triage", "bases": [] },
    "error": { "code": "NODE_EXECUTION", "message": "Upstream model timed out" }
  }
}

```

Dispatch on `name`. `parent` tells you which deployment and release tag the run came from, and `external_id` can carry your own identifier for the run, which is the easiest way to tie an event back to your own records. `initiated` events carry the workflow's `inputs`, `fulfilled` events carry its `outputs`, and usage events carry token and character counts, compute time and a `cost` in USD.

Two things to watch. First, the event list names the usage events `workflow_execution.usage_calculation.*`, but Vellum's example payload for the same event shows `workflow.usage_calculation.fulfilled`, so accept both spellings until you've seen a real one. Second, `inputs` and `outputs` are whatever your workflow received and produced, which can include prompts, customer messages and model output. Treat the webhook stream as sensitive data.

## Setting up Vellum webhooks

Generate a long random secret first, for example with `openssl rand -hex 32`, and store it server-side.

1. In Vellum, open your organisation settings page.
2. Scroll to the **Monitoring Integration** section and click **Webhook Integration**, or **Edit** if one already exists.
3. Enter your HTTPS endpoint as the URL.
4. Turn on authentication and choose one option:
  
  * **API Key Header**: enter a header name, such as `X-Vellum-Webhook-Secret`, and your secret as the value.
  * **Bearer Token**: enter your secret as the token. Vellum sends it as `Authorization: Bearer <token>`.
5. Choose the events to send. Starting with `workflow.execution.rejected` gives you failure alerts straight away.
6. Save the integration.

**With Hookdeck.** Vellum is a supported source in Hookdeck. Create a source with the Vellum type and use the Hookdeck source URL as the webhook URL in Vellum. Hookdeck's setup note says to turn on authentication and choose API Key Header, or Bearer Token. For API Key Header, enter the same header name as the **API Key Header** and the secret as the **API Key**. For Bearer Token, enter `Authorization` as the **API Key Header** and `Bearer <token>` as the **API Key**, with the `Bearer ` prefix included. The HMAC signature option isn't supported, so the source checks only the header. The source pre-selects `POST` without managing the method. Vellum's docs don't say which method its webhooks use, so if the request log shows Vellum using something else, allow that method on the source as well, because the source only accepts the methods you allow. A request without the right header still gets a `200` from Hookdeck, but Hookdeck marks it as failed verification and doesn't deliver it to your service.

## Securing Vellum webhooks

Compare the credential header with the stored value in constant time. Look the header up case-insensitively, compare the whole value, including `Bearer ` if you use a bearer token, and check lengths before calling `crypto.timingSafeEqual`.

```javascript
const crypto = require('crypto');
const express = require('express');

function safeEqual(a, b) {
  const x = Buffer.from(String(a), 'utf8');
  const y = Buffer.from(String(b), 'utf8');
  return x.length === y.length && crypto.timingSafeEqual(x, y);
}

function verifyVellumRequest(headers) {
  const expected = process.env.VELLUM_WEBHOOK_SECRET; // e.g. "Bearer <token>" or the raw API key
  if (!expected) return false; // fail closed when unconfigured
  const name = (process.env.VELLUM_WEBHOOK_HEADER || 'Authorization').toLowerCase();
  const received = headers[name];
  return typeof received === 'string' && safeEqual(received, expected);
}

const app = express();

app.post('/webhooks/vellum', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifyVellumRequest(req.headers)) {
    return res.sendStatus(401);
  }

  let event;
  try {
    event = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }
  // Queue the event, keyed on event.id, then dispatch on event.name.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

def verify_vellum_request(headers) -> bool:
    expected = os.environ.get("VELLUM_WEBHOOK_SECRET")
    if not expected:
        return False
    name = os.environ.get("VELLUM_WEBHOOK_HEADER", "Authorization")
    received = headers.get(name)  # case-insensitive in Starlette and Flask
    if not received:
        return False
    return hmac.compare_digest(received.encode("utf-8"), expected.encode("utf-8"))

```

A static credential proves the sender knows the secret, but it doesn't prove the body is unchanged, and it gives no replay protection. Use HTTPS, keep the secret long and random, and rotate it if it leaks.

Vellum's HMAC signature closes those gaps when Vellum calls your service directly. The signed message is the timestamp, the method, the URL and the body, joined by newlines, and Vellum's docs tell you to reject timestamps older than 60 seconds. Behind a gateway it's harder to use. The URL Vellum signed is the gateway's URL, not your endpoint's, so your check would need to rebuild the message with the Hookdeck source URL. And any delivery that reaches you more than 60 seconds after Vellum sent it, such as a retry or a replay, fails Vellum's timestamp check. If you route Vellum through Hookdeck, rely on the header credential that the source checks.

> **Make Vellum webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks your Vellum credential on every request, stores each workflow, usage and metric event, and retries delivery to your service on a schedule you control.

## Vellum webhook limitations and pain points

### Delivery behaviour isn't documented

**The Problem:** You can't tell from the docs how long Vellum waits for a response, how often it retries, or when it gives up.

**Why It Happens:** Vellum's webhook page covers configuration, events and authentication. Its only guidance on delivery is to "handle occasional failures or retries" and to make handlers idempotent "in case of duplicate events".

**Workarounds:**

* Return `2xx` quickly and do the work asynchronously, so an unknown timeout doesn't matter.
* Assume a failed delivery may not come back, and reconcile important runs from Vellum's own execution history.

**How Hookdeck Can Help:** Hookdeck answers Vellum as soon as a request arrives and stores it, then delivers it to your service with [automatic retries](/docs/retries) on a schedule you set. [Issues](/docs/issues) alert you when deliveries fail, and you can replay stored events after a fix.

### Duplicate events

**The Problem:** The same event can arrive more than once and trigger the same alert or cost record twice.

**Why It Happens:** Vellum says to expect duplicates, and any sender that retries will sometimes resend an event your service already processed.

**Workarounds:**

* Record each event's `id` and skip ones you've already handled.

**How Hookdeck Can Help:** [Deduplication](/docs/deduplication) can drop repeat requests before they reach your service, for example on the body's `id` field.

### HMAC breaks behind a gateway

**The Problem:** Turning on Vellum's HMAC token doesn't add protection you can use once Hookdeck sits in front of your service.

**Why It Happens:** The signature covers the URL Vellum called, and the docs ask you to reject signatures older than 60 seconds. Hookdeck's Vellum source doesn't check the signature, so the check falls to your service, where the URL is different and retries arrive late.

**Workarounds:**

* Use the API Key Header or Bearer Token credential, and check it at the gateway and at your service.
* If you need HMAC, send Vellum directly to your service and verify it there.

**How Hookdeck Can Help:** The Vellum source checks the header credential on every request and keeps a log of each one, including those that fail verification, so you can see when a credential is wrong.

### One integration, many event types

**The Problem:** Failure alerts, cost tracking and metric results all arrive at the same URL, and each needs different handling.

**Why It Happens:** There is one Webhook Integration per organisation, and the event name sits in the body, not in the URL.

**Workarounds:**

* Dispatch on `name` in a single handler.
* Split by `parent.deployment_name` or `parent.release_tag_name` to keep staging traffic out of production alerts.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can route on `name` or `parent.release_tag_name`, so `workflow.execution.rejected` goes to your alerting service and usage events go to your billing pipeline.

## Best practices

### Start with failures

Send `workflow.execution.rejected` first and alert on it. `initiated` and `fulfilled` events double your volume for every run, so add them when you have a use for them.

### Use the external ID

If you run deployments with an external ID, look for it in `parent.external_id`. It ties each event to your own ticket, user or job without a lookup.

### Accept both usage event spellings

Handle `workflow_execution.usage_calculation.fulfilled` and `workflow.usage_calculation.fulfilled` until a real event confirms which one Vellum sends.

### Build idempotency in

Key on the event `id`. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

Check the credential, store the event, return `200`, then do the work. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Vellum sends monitoring webhooks for workflow runs, usage calculations and metrics, each in a JSON envelope with an `id`, a `name` and the deployment it came from. Authenticate them with an API key header or bearer token compared in constant time over HTTPS, dedupe on `id`, and dispatch on `name`. Vellum's optional HMAC signature is useful when it calls your service directly, but it covers the URL and a 60-second window, which doesn't suit a gateway.

[Hookdeck Event Gateway](https://hookdeck.com) checks the credential, answers Vellum straight away, and retries delivery to your service, which fills the gaps in Vellum's undocumented delivery behaviour.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Vellum webhooks reliably in minutes.