# Guide to Velt Webhooks: Features and Best Practices

[Velt](https://velt.dev) is an SDK for adding collaboration to your own app: comment threads, huddles, CRDT-synced data, screen and voice recordings, and multi-step review workflows. Webhooks let your backend react to what users do with those features, such as sending an email when someone is assigned a thread or storing a transcript when a recording is processed.

Velt has two kinds:

* **Advanced (V2)** webhooks, [documented here](https://docs.velt.dev/webhooks/advanced), are available to
  Enterprise customers only. They are delivered through Svix and signed with
  Standard Webhooks headers (`webhook-id`,
  `webhook-timestamp`, `webhook-signature`). You add endpoints in the Velt console and choose event types.
* **Basic (V1)** webhooks are not signed. They send an optional auth token in the `Authorization` header
  as `Basic <token>`, so Hookdeck's Velt source reports that they fail verification.

This guide is about Advanced (V2) webhooks. Deliveries are JSON `POST` requests by default.

It covers Velt's events and payload shape, encoded and encrypted bodies, signature checks, and the console settings that can break verification if you're not expecting them.

## What are Velt webhooks?

Advanced webhooks run on Svix: Velt's console offers Svix Play for temporary URLs, and the retry schedule, rate limits and transformations it describes match Svix's. Unlike most Svix-based vendors, Velt prints the Standard Webhooks header names, `webhook-id`, `webhook-timestamp` and `webhook-signature`, and explains the signature itself on its [Advanced webhooks page](https://docs.velt.dev/webhooks/advanced).

Velt adds some options of its own. Payloads can be Base64-encoded, which helps when an endpoint's security policy rejects bodies containing HTML, or encrypted with your RSA public key. Velt publishes a fixed list of source IPs, and the console can attach Basic Auth or custom headers to each request.

## Velt webhook features

| Feature | Details |
| --- | --- |
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | `webhook-signature` |
| Body | JSON |
| Event routing | Body field `event` |
| Plan | Advanced (V2) webhooks: Enterprise |
| Delivery platform | Svix |
| Secret | `whsec_` + base64, one per endpoint |
| Response window | `2xx` within 15 seconds; `3xx` is a failure |
| Retries | Immediately, 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 10 h |
| Optional body formats | Base64-encoded, or AES-256-CBC encrypted |
| Source IPs | Published |
| Transformations | JavaScript per endpoint |

## Events and payloads

Velt documents 33 events, and the list may not be complete. The top-level `event` field carries the name.

| Event | Triggered When |
| --- | --- |
| `comment_annotation.add` | A comment thread is created with its first comment |
| `comment_annotation.assign` | A thread is assigned to a user |
| `comment_annotation.status_change` | A thread's status changes (open, in progress, resolved) |
| `comment_annotation.priority_change` | A thread's priority changes |
| `comment_annotation.custom_list_change` | A custom list item is added to or changed on a thread |
| `comment_annotation.subscribe` | A user subscribes to a thread's notifications |
| `comment_annotation.unsubscribe` | A user unsubscribes from a thread's notifications |
| `comment_annotation.accept` | A moderator accepts a thread (Moderator Mode only) |
| `comment_annotation.reject` | A moderator rejects a thread (Moderator Mode only) |
| `comment_annotation.approve` | A moderator approves a thread (Moderator Mode only) |
| `comment_annotation.suggestion_accept` | A suggestion is accepted (opt-in, disabled by default) |
| `comment_annotation.suggestion_reject` | A suggestion is rejected (opt-in, disabled by default) |
| `comment.add` | A comment is added to a thread |
| `comment.update` | A comment's content or metadata is modified |
| `comment.delete` | A comment is permanently removed from a thread |
| `comment.reaction_add` | A user adds an emoji reaction to a comment |
| `comment.reaction_delete` | A user removes their reaction from a comment |
| `huddle.create` | A user creates a huddle |
| `huddle.join` | A user joins a huddle |
| `crdt.update_data` | CRDT data is updated (enabled by default, 5-second debounce) |
| `recorder.done` | A recording finishes processing, with or without transcription (enabled by default) |
| `execution.dispatched` | A review workflow execution is created and its first step(s) are scheduled |
| `execution.completed` | All steps are completed with no unhandled failures |
| `execution.failed` | A blocking step ends `failed` or `breached` with no recovery edge |
| `execution.cancelled` | An execution is cancelled via API or full-execution rollback |
| `step.awaiting-approval` | A step enters `waiting` (human, running agent or async webhook step) |
| `step.completed` | A step completes |
| `step.failed` | A step fails (retry budget exhausted) |
| `step.breached` | A step exceeds its SLA before completing |
| `step.cancelled` | A step is cancelled via API or by a quorum-met side effect |
| `group.quorum-met` | A parallel group's approval threshold is first satisfied |
| `loop.iteration-started` | A rejected iteration spawns the next one below the `maxIterations` cap |
| `loop.exhausted` | A loop region hits its `maxIterations` cap |

Velt also sends `message.attempt.exhausted` when a message has used all its retries.

### Event Payload Structure

| Field | Meaning |
| --- | --- |
| `event` | The event type, for example `comment.add` |
| `actionType` | The verb, for example `add` or `updateData` |
| `source` | The feature that produced the event, for example `comment` or `crdt` |
| `platform` | Where it came from, for example `sdk` |
| `webhookId` | An id carried in the body. Use the `webhook-id` header for deduplication |
| `data.actionUser` | The user who acted |
| `data.commentAnnotation`, `data.crdtData`, ... | The feature object. Comment events carry the thread and its `comments` |
| `data.metadata` | `apiKey`, `document`, `organization` and `pageInfo` |

Two additions depend on your setup. If you've configured notification settings, the payload includes per-user preferences in `usersOrganizationNotificationsConfig` or `usersDocumentNotificationsConfig`. Comment events for private comments carry a `data.visibility` object with `userIds` or `organizationIds`; public comments have none. Velt checks visibility on its side before sending and says to treat the object as information, never as a reason to forward a notification to more people.

### Encoded and encrypted payloads

Both are off by default. With Base64 encoding enabled, the body follows Velt's `WebhookV2PayloadEncoded` model. With encryption enabled, the body is `{ "encryptedData", "encryptedKey", "iv" }`: the payload is encrypted with AES-256-CBC, and the symmetric key is encrypted with your RSA public key using OAEP padding and SHA-256. Velt recommends a 2048-bit key and wants only the base64 key string, without PEM headers.

In both cases the signature covers the body as delivered. Verify first, then decode or decrypt.

### Published payload example

This is the `crdt.update_data` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/velt/latest/crdt.update_data.json). Use its fields to explore the payload; it is a documentation example.

```json
{
  "actionType": "updateData",
  "data": {
    "actionUser": {
      "clientUserName": "Michael Scott",
      "color": "#ffcb00",
      "email": "michael.scott@dundermifflin.com",
      "initial": "M",
      "isAdmin": false,
      "name": "Michael Scott",
      "organizationId": "crdt-array-demo-org1",
      "type": "signedIn",
      "userId": "michael"
    },
    "crdtData": {
      "data": [
        {
          "completed": false,
          "id": "seed-1",
          "text": "Welcome Todo - Edit me!"
        },
        {
          "completed": false,
          "id": "bmg6tpj7we",
          "text": "Todo1"
        },
        {
          "completed": false,
          "id": "dt0vhs2t3os",
          "text": "Todo2"
        }
      ],
      "id": "crdt-array-demo-todos-1",
      "lastUpdate": "2026-03-17T06:23:24.514Z",
      "lastUpdatedBy": "michael",
      "sessionId": "tvLupvP0L2jiztlba4P0"
    },
    "metadata": {
      "apiKey": "VKabul7W2DJhLu1BiMaj",
      "document": {
        "documentId": "crdt-array-demo-doc-1",
        "documentName": "CRDT Array Demo"
      },
      "organization": {
        "organizationId": "crdt-array-demo-org1"
      },
      "pageInfo": {
        "baseUrl": "http://localhost:5225",
        "path": "/",
        "queryParams": "",
        "title": "CRDT Array Demo",
        "url": "http://localhost:5225/"
      }
    }
  },
  "event": "crdt.update_data",
  "platform": "sdk",
  "source": "crdt",
  "webhookId": "-OnuSfG_ffGIwNkodE2a"
}

```

## Setting up Velt webhooks

1. In the Velt console, open the Advanced webhooks section and add an endpoint, for example `https://webhooks.example.com/webhooks/velt`.
2. Select event types. With none selected the endpoint receives everything; Velt suggests narrowing that once testing is done.
3. Copy the endpoint's signing secret, `whsec_` followed by base64, into `VELT_WEBHOOK_SECRET`, prefix included. Secrets are per endpoint.
4. Use the **Testing** tab to send example events, then open a message to see its payload and attempts. Velt keeps sample schemas and payloads for most events in this section of the console rather than in its docs.

Optional settings on the endpoint include a messages-per-second **rate limit**, **Basic Auth** in the URL or **custom headers**, payload encoding and encryption, and **transformations** under the Advanced tab. If your endpoint sits behind a firewall, allow Velt's published source IPs.

To receive test events locally, start the [Hookdeck CLI](/docs/cli):

```bash
npx hookdeck-cli listen 3000 velt --path /webhooks/velt

```

**With Hookdeck.** Add a **Velt** source's URL as an Advanced webhook endpoint and give the source that endpoint's `whsec_` secret. Only Velt Advanced (V2) webhooks, available on Enterprise, are signed. Basic (V1) webhooks send an auth token instead and fail verification. The source verifies the `webhook-*` headers, accepts only `POST` and replies `200` within Velt's 15 seconds; a request that fails verification is logged as failed and isn't forwarded.

## Securing Velt webhooks

Velt's instructions follow Standard Webhooks: sign `{webhook-id}.{webhook-timestamp}.{raw body}` with HMAC-SHA256, using the base64-decoded part of the secret after `whsec_` as the key, and compare the base64 result with each version-prefixed signature in `webhook-signature`. In code:

```javascript
const crypto = require('node:crypto');

function decodeBase64(value) {
  if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64');
  return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
    ? bytes : null;
}

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const prefix = headers['webhook-id'] !== undefined ? 'webhook' : 'svix';
  const id = headers[`${prefix}-id`];
  const timestamp = headers[`${prefix}-timestamp`];
  const signature = headers[`${prefix}-signature`];
  if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
      !/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
  const key = decodeBase64(secret.replace(/^whsec_/, ''));
  if (!key) return false;
  const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
  const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    const received = decodeBase64(candidate);
    if (!received) return false;
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

Velt warns that any change to the body before verifying breaks the signature. A handler for the comment and recording events:

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

// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
  try {
    const value = JSON.parse(rawBody.toString('utf8'));
    return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
  } catch {
    return null;
  }
}

const app = express();

app.post('/webhooks/velt', express.raw({ type: '*/*' }), async (req, res) => {
  const secret = process.env.VELT_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500);
  if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

  const payload = parseJsonObject(req.body);
  if (!payload) return res.sendStatus(400); // signed, but not a JSON object
  const { event, data } = payload;
  if (typeof event !== 'string' || !data || typeof data !== 'object') return res.sendStatus(400);

  const message_id = req.headers['webhook-id'];
  try {
    if (await handled.has(message_id)) return res.sendStatus(200);

    switch (event) {
      case 'comment_annotation.assign':
        await notifications.assigned(data.commentAnnotation, data.actionUser);
        break;
      case 'comment.add':
        if (!data.metadata) return res.sendStatus(400);
        await notifications.newComment(data.commentAnnotation, data.metadata.document, data.visibility);
        break;
      case 'recorder.done':
        await recordings.store(data);
        break;
      default:
        break;
    }
    await handled.add(message_id);
  } catch {
    return res.sendStatus(503); // not processed, so don't acknowledge
  }
  return res.sendStatus(200);
});

```

`handled`, `notifications` and `recordings` stand for your own code. If you turn on encoding or encryption, decode or decrypt `req.body` after `verifySignature` and before `parseJsonObject`.

Velt recommends checking `webhook-timestamp` against your own clock within a tolerance you choose. That works for a direct receiver. Behind Hookdeck, which applies no tolerance and keeps the original timestamp when it retries or replays, a five-minute check such as the Svix or Standard Webhooks libraries apply would reject those deliveries, so the function above doesn't check age.

> **Make Velt webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) answers Velt inside 15 seconds, smooths bursts of comment and CRDT events, and routes review workflow events to the service that runs approvals.

## Velt webhook limitations and pain points

### Signed webhooks need Enterprise

**The Problem:** Teams on other plans can't verify that a webhook came from Velt.

**Why It Happens:** Only Advanced (V2) webhooks are signed, and they're Enterprise-only. Basic (V1) webhooks send an optional token in `Authorization: Basic <token>` and use different `actionType` values.

**Workarounds:**

* On Basic webhooks, check the token on every request and keep the URL private.
* Move to Advanced webhooks before relying on webhooks for anything sensitive.

**How Hookdeck Can Help:** Hookdeck's Velt source verifies Advanced webhooks only. For Basic webhooks, leave verification off on the source and check the token in your service.

### Transformations can break delivery or verification

**The Problem:** Requests stop arriving, fail verification, or vanish while the console shows success.

**Why It Happens:** A transformation can change the method to `PUT`, rewrite the URL or body, or cancel a message, and Velt says cancelled messages appear as successful dispatches.

**Workarounds:**

* Keep transformations to routing where possible, and test each change from the Testing tab.
* Always verify the body exactly as it arrives.

**How Hookdeck Can Help:** Hookdeck's Velt source accepts `POST` only, so a transformation that switches to `PUT` is refused rather than silently delivered. For routing, Hookdeck [filters](/docs/filters) and [transformations](/docs/transformations) run after verification, without touching what Velt signed.

### Most payload schemas live in the console

**The Problem:** You can't write handlers for every event from the public docs alone.

**Why It Happens:** Velt's docs point to the console for sample schemas and payloads, and only a couple of events have full public examples.

**Workarounds:**

* Copy samples from the console, or send test events and record what arrives.
* Parse defensively and ignore fields you don't use.

**How Hookdeck Can Help:** Hookdeck stores every request it receives, so real payloads for each event are easy to find.

### Bursts of CRDT and comment events

**The Problem:** A busy document produces a steady stream of `crdt.update_data` and comment events.

**Why It Happens:** CRDT webhooks are enabled by default with only a 5-second debounce, and every comment action is its own event.

**Workarounds:**

* Set a rate limit on the endpoint in the console.
* Unsubscribe from `crdt.update_data` if you don't need it.

**How Hookdeck Can Help:** Hookdeck accepts the burst and [delivers](/docs/destinations) it to your service at the rate you set.

## Best practices

### Deduplicate on `webhook-id`

It stays the same when Velt resends a message, while the timestamp reflects each attempt. Store it with the effect it produced; our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency) explains the approach.

### Respect comment visibility

When `data.visibility` is present, send notifications only to the users and organisations it names.

### Verify before decoding

With encoding or encryption on, the signature is over the encoded or encrypted body. Verify, then decode.

### Handle `message.attempt.exhausted`

This event tells you a message has run out of retries. Alert on it and use the console's **Recover** option to resend failed messages from a given date.

## Conclusion

Velt's Advanced webhooks cover a lot of ground, from comment threads to multi-step review workflows, and they follow Standard Webhooks closely. Watch for the Enterprise requirement, transformations that change what you receive, payload schemas kept in the console, and high-volume CRDT events.

[Hookdeck Event Gateway](https://hookdeck.com) gives Velt a fast endpoint and gives you routing and retries behind it. [Sign up](https://dashboard.hookdeck.com/signup) and add a Velt source as an Advanced webhook endpoint.