# Velt Webhooks

[Velt](https://velt.dev) is an SDK for in-app collaboration: comments, huddles, CRDT sync, recordings and
review workflows. Its [Advanced (V2) webhooks](https://docs.velt.dev/webhooks/advanced), available on
Enterprise only, are delivered through Svix and signed with
Standard Webhooks headers: `webhook-id`,
`webhook-timestamp` and `webhook-signature`. The event name is the top-level `event` field of a JSON body
shaped `{actionType, data, event, platform, source, webhookId}`.

> **Requirement.** Hookdeck's configuration alert for this source reads: "Only Velt Advanced (V2) webhooks, available on Enterprise, are signed. Basic (V1) webhooks send an auth token instead and fail verification."
> Basic (V1) webhooks only send an optional auth token in the `Authorization` header as `Basic <token>`.

## When to Use This Skill

* How do I receive Velt webhooks?
* How do I verify Velt webhook signatures (`webhook-signature`, `whsec_` secret)?
* How do I handle `comment_annotation.add`, `comment.add`, `crdt.update_data` or `recorder.done` events?
* Why is my Velt webhook signature verification failing, or rejecting Hookdeck retries?
* Why do Velt Basic (V1) webhooks fail signature verification?

## Verification (core)

Signed content is `{webhook-id}.{webhook-timestamp}.{raw body}`. Velt signs it with HMAC-SHA256 using the
base64-decoded secret (the part after `whsec_`), base64-encodes the result, and sends one or more
space-separated `v1,<signature>` entries. Use a Standard Webhooks library (`standardwebhooks` on npm and
PyPI), which compares in constant time. Read the `webhook-*` headers, and when `webhook-id` is absent fall
back to the `svix-*` headers (`svix-id`, `svix-timestamp`, `svix-signature`), as Hookdeck does.

```javascript
const { Webhook } = require('standardwebhooks');

const prefix = req.get('webhook-id') ? 'webhook' : 'svix';
const headers = {
  'webhook-id': req.get(`${prefix}-id`),
  'webhook-timestamp': req.get(`${prefix}-timestamp`),
  'webhook-signature': req.get(`${prefix}-signature`),
};
// rawBody: the raw Buffer/string, never re-serialized JSON
const event = new Webhook(process.env.VELT_WEBHOOK_SECRET).verify(rawBody, headers);
// throws on a bad signature or a timestamp outside the window; returns the parsed body

```

```python
from standardwebhooks.webhooks import Webhook, WebhookVerificationError

wh = Webhook(os.environ["VELT_WEBHOOK_SECRET"])  # "whsec_..."
event = wh.verify(raw_body, {"webhook-id": id_, "webhook-timestamp": ts, "webhook-signature": sig})

```

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/velt-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/velt-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/velt-webhooks/examples/fastapi/).

Each endpoint has its own secret in the Velt console. It starts with `whsec_`. Keep the full value in
`VELT_WEBHOOK_SECRET`. Handlers fail closed: with the variable unset they return 500 instead of accepting anything. A missing header or a bad
signature returns 400. The raw body is required, so in Express use `express.raw({ type: '*/*' })` and
parse the body only after the check.

### Hookdeck retries and replays hit the 5-minute window

The Standard Webhooks libraries always enforce a fixed 5-minute timestamp window, and it can't be
configured. Velt re-signs each attempt with a fresh `webhook-timestamp`, but Hookdeck retries and
replays resend the **original** timestamp, so the library rejects any retry or replay that happens more
than 5 minutes after the original delivery. If you verify behind Hookdeck (or replay old events), use the
manual verifier in [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/velt-webhooks/references/verification.md), where the window can be
dropped or widened. Deduplicate on `webhook-id` instead of relying on the timestamp.

## Common Event Types

33 events are documented, and the list may not be exhaustive. The name is `event` in the JSON body. The
examples handle all of these:

| 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 |
| `comment_annotation.priority_change` | A thread's priority changes |
| `comment_annotation.custom_list_change` | A custom list item is added or changed on a thread |
| `comment_annotation.subscribe` / `.unsubscribe` | A user subscribes to or leaves a thread |
| `comment_annotation.accept` / `.reject` / `.approve` | A moderator acts on a thread (Moderator Mode only) |
| `comment_annotation.suggestion_accept` / `.suggestion_reject` | A suggestion is accepted or rejected (opt-in) |
| `comment.add` / `.update` / `.delete` | A comment is added, edited or removed |
| `comment.reaction_add` / `.reaction_delete` | A reaction is added or removed |
| `huddle.create` / `huddle.join` | A huddle is created or joined |
| `crdt.update_data` | CRDT data is updated (5-second debounce) |
| `recorder.done` | A recording finishes processing |
| `execution.dispatched` / `.completed` / `.failed` / `.cancelled` | Review workflow execution lifecycle |
| `step.awaiting-approval` / `.completed` / `.failed` / `.breached` / `.cancelled` | Review workflow step lifecycle |
| `group.quorum-met`, `loop.iteration-started`, `loop.exhausted` | Review workflow groups and loops |

`data` holds `actionUser`, the feature object (`commentAnnotation`, `crdtData`, ...) and `metadata`
(`apiKey`, `document`, `organization`, `pageInfo`). Encoded (Base64) or encrypted deliveries are opt-in and
have no top-level `event`. See [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/velt-webhooks/references/overview.md) for the full list and shape.

## Environment Variables

```bash
# Signing secret of the Advanced (V2) webhook endpoint (Standard Webhooks format, starts with whsec_)
VELT_WEBHOOK_SECRET=whsec_xxxxx

```

## Local Development

```bash
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 velt --path /webhooks/velt

```

Add the tunnel URL as an endpoint in the Velt console, then send an example event from its Testing tab.

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/velt-webhooks/references/overview.md) - Velt webhook concepts, all 33 events, payload shape, encoded and encrypted payloads
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/velt-webhooks/references/setup.md) - Add an endpoint in the Velt console, get the secret, test, delivery and retries
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/velt-webhooks/references/verification.md) - Standard Webhooks verification details, header fallback, manual verifier, timestamp window