Gareth Wilson Gareth Wilson

Guide to Velt Webhooks: Features and Best Practices

Published


Velt 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, 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.

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

FeatureDetails
SignatureStandard Webhooks HMAC-SHA256
Signature headerwebhook-signature
BodyJSON
Event routingBody field event
PlanAdvanced (V2) webhooks: Enterprise
Delivery platformSvix
Secretwhsec_ + base64, one per endpoint
Response window2xx within 15 seconds; 3xx is a failure
RetriesImmediately, 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 10 h
Optional body formatsBase64-encoded, or AES-256-CBC encrypted
Source IPsPublished
TransformationsJavaScript per endpoint

Events and payloads

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

EventTriggered When
comment_annotation.addA comment thread is created with its first comment
comment_annotation.assignA thread is assigned to a user
comment_annotation.status_changeA thread's status changes (open, in progress, resolved)
comment_annotation.priority_changeA thread's priority changes
comment_annotation.custom_list_changeA custom list item is added to or changed on a thread
comment_annotation.subscribeA user subscribes to a thread's notifications
comment_annotation.unsubscribeA user unsubscribes from a thread's notifications
comment_annotation.acceptA moderator accepts a thread (Moderator Mode only)
comment_annotation.rejectA moderator rejects a thread (Moderator Mode only)
comment_annotation.approveA moderator approves a thread (Moderator Mode only)
comment_annotation.suggestion_acceptA suggestion is accepted (opt-in, disabled by default)
comment_annotation.suggestion_rejectA suggestion is rejected (opt-in, disabled by default)
comment.addA comment is added to a thread
comment.updateA comment's content or metadata is modified
comment.deleteA comment is permanently removed from a thread
comment.reaction_addA user adds an emoji reaction to a comment
comment.reaction_deleteA user removes their reaction from a comment
huddle.createA user creates a huddle
huddle.joinA user joins a huddle
crdt.update_dataCRDT data is updated (enabled by default, 5-second debounce)
recorder.doneA recording finishes processing, with or without transcription (enabled by default)
execution.dispatchedA review workflow execution is created and its first step(s) are scheduled
execution.completedAll steps are completed with no unhandled failures
execution.failedA blocking step ends failed or breached with no recovery edge
execution.cancelledAn execution is cancelled via API or full-execution rollback
step.awaiting-approvalA step enters waiting (human, running agent or async webhook step)
step.completedA step completes
step.failedA step fails (retry budget exhausted)
step.breachedA step exceeds its SLA before completing
step.cancelledA step is cancelled via API or by a quorum-met side effect
group.quorum-metA parallel group's approval threshold is first satisfied
loop.iteration-startedA rejected iteration spawns the next one below the maxIterations cap
loop.exhaustedA loop region hits its maxIterations cap

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

Event Payload Structure

FieldMeaning
eventThe event type, for example comment.add
actionTypeThe verb, for example add or updateData
sourceThe feature that produced the event, for example comment or crdt
platformWhere it came from, for example sdk
webhookIdAn id carried in the body. Use the webhook-id header for deduplication
data.actionUserThe user who acted
data.commentAnnotation, data.crdtData, ...The feature object. Comment events carry the thread and its comments
data.metadataapiKey, 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. Use its fields to explore the payload; it is a documentation example.

{
  "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:

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:

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:

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 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 and 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 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 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 gives Velt a fast endpoint and gives you routing and retries behind it. Sign up and add a Velt source as an Advanced webhook endpoint.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.