Guide to Velt Webhooks: Features and Best Practices
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
Authorizationheader asBasic <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
| 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_ | A thread is assigned to a user |
comment_ | A thread's status changes (open, in progress, resolved) |
comment_ | A thread's priority changes |
comment_ | A custom list item is added to or changed on a thread |
comment_ | A user subscribes to a thread's notifications |
comment_ | A user unsubscribes from a thread's notifications |
comment_ | A moderator accepts a thread (Moderator Mode only) |
comment_ | A moderator rejects a thread (Moderator Mode only) |
comment_ | A moderator approves a thread (Moderator Mode only) |
comment_ | A suggestion is accepted (opt-in, disabled by default) |
comment_ | 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. 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
- In the Velt console, open the Advanced webhooks section and add an endpoint, for example
https://webhooks.example.com/webhooks/velt. - Select event types. With none selected the endpoint receives everything; Velt suggests narrowing that once testing is done.
- Copy the endpoint's signing secret,
whsec_followed by base64, intoVELT_WEBHOOK_SECRET, prefix included. Secrets are per endpoint. - 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_dataif 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.