# Guide to Vanta Webhooks: Features and Best Practices

Vanta webhooks notify your systems when something changes in your compliance programme: a security questionnaire is created or exported, someone requests access to your Trust Center, a vendor decision is recorded, or, for audit firms, evidence and information requests move during an audit. If you build on Vanta, webhooks let you react to those changes without polling the API.

This guide covers how Vanta webhooks are delivered and signed, the event types and their payloads, the retry schedule, and the best practices for running a Vanta receiver in production.

## What are Vanta webhooks?

Vanta delivers webhooks through [Svix](/webhooks/platforms/guide-to-svix-webhooks-features-and-best-practices). You register an HTTPS endpoint in the Vanta dashboard, choose the event types it should receive, and Vanta sends each event as a `POST` with the event payload as the JSON body.

Every request carries three Svix headers: `svix-id`, `svix-timestamp` and `svix-signature`. The signature is an HMAC-SHA256 over the message ID, the timestamp and the raw body, keyed with the endpoint's `whsec_` signing secret.

Vanta's payloads are deliberately small. They hold the IDs of the objects that changed, and Vanta's guidance is to treat those IDs as opaque strings and fetch the full object from the Manage Vanta API.

## Vanta webhook features

| Feature | Details |
| --- | --- |
| Delivery platform | Svix |
| Configuration | Vanta dashboard, **Settings > Webhooks > Add Endpoint** |
| Endpoint | Must be HTTPS |
| Subscription | Chosen event types per endpoint. An empty selection receives all events |
| Event names | `v1.<domain>.<entity>.<action>` |
| Headers | `svix-id`, `svix-timestamp`, `svix-signature` |
| Scheme | Base64 HMAC-SHA256 over `{svix-id}.{svix-timestamp}.{raw body}` |
| Secret | One per endpoint, starts with `whsec_` |
| Timestamp tolerance | Vanta recommends rejecting timestamps more than 5 minutes from your clock |
| Response window | A `2xx` within 15 seconds |
| Retries | 8 attempts over about 5 days, then marked as failed |
| Recovery | **Retry** and **Bulk Retry** in the dashboard |
| Source IPs | Not published |

## Common events

Vanta documents 24 event types across six domains:

| Domain | Event types | Delivered to |
| --- | --- | --- |
| Questionnaire | `v1.questionnaire.created`, `.deleted`, `.status-changed`, `.export-completed`, `.export-failed` | Your Vanta account |
| Trust Center | `v1.trust-center.access-request.received`, `.approved`, `.denied` | Your Vanta account |
| Vendor | `v1.vendor.decision.created` | Your Vanta account |
| Information Request | `v1.information-request.status-changed`, `.comment.created`, `.comment.updated`, `.comment.deleted`, `.evidence.created`, `.evidence.deleted` | Audit Partner accounts, during an active audit |
| Evidence | `v1.evidence.created`, `.deleted`, `.status-changed`, `.comment.created`, `.comment.updated`, `.comment.deleted` | Audit Partner accounts, during an active audit |
| Control | `v1.control.comment.created`, `.comment.updated`, `.comment.deleted` | Audit Partner accounts, during an active audit |

The version is part of the event name (the `v1.` prefix), not a separate header.

Vanta's reference documents each event's payload as the body, and the payloads are objects of IDs, for example:

```json
{ "questionnaire": { "id": "123" } }

```

None of the documented payloads includes the event type, and the three headers do not carry it either. The top-level key tells you which kind of object changed, but not what happened to it, so `v1.questionnaire.created` and `v1.questionnaire.deleted` look the same. Give event types that need different handling their own endpoints, and inspect a real delivery with the dashboard's **Send Example** before you depend on any field.

## Setting up Vanta webhooks

1. In the Vanta dashboard, go to **Settings > Webhooks** and click **Add Endpoint**.
2. Enter your HTTPS endpoint URL.
3. Select the event types you need, or leave the selection blank to receive every event.
4. Click **Create**, then open the endpoint and copy its **Signing Secret**. It starts with `whsec_`. Store it server-side, for example as `VANTA_WEBHOOK_SIGNING_SECRET`.
5. On the endpoint's **Testing** tab, choose an event type and click **Send Example** to send a test message.

Vanta also reminds you to disable CSRF protection on the webhook route, because webhook requests do not carry CSRF tokens.

## Securing Vanta webhooks

Vanta recommends the official Svix libraries, and documents the manual steps as well:

1. Read the `svix-id`, `svix-timestamp` and `svix-signature` headers.
2. Build the signed content `{svix-id}.{svix-timestamp}.{body}` from the raw request body.
3. Remove the `whsec_` prefix from the signing secret and base64-decode the rest.
4. Compute an HMAC-SHA256 of the signed content with the decoded secret.
5. Base64-encode the result and compare it with each `v1,` entry in `svix-signature`.

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

function verifySvixSignature(rawBody, headers, secret) {
  const msg_id = headers['svix-id'];
  const msg_timestamp = headers['svix-timestamp'];
  const signature_header = headers['svix-signature'];
  if (!msg_id || !msg_timestamp || !signature_header) return false;
  if (typeof secret !== 'string' || !secret.startsWith('whsec_')) return false;

  // The HMAC key is the base64-decoded part of the secret after "whsec_".
  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  const signed_content = Buffer.concat([Buffer.from(`${msg_id}.${msg_timestamp}.`, 'utf8'), rawBody]);
  const expected = Buffer.from(
    crypto.createHmac('sha256', key).update(signed_content).digest('base64'),
    'utf8'
  );

  // The header can hold several space-separated "v1,<signature>" entries.
  return signature_header.split(' ').some((entry) => {
    const [version, signature] = entry.split(',');
    if (version !== 'v1' || !signature) return false;
    const received = Buffer.from(signature, 'utf8');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/vanta', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.VANTA_WEBHOOK_SIGNING_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifySvixSignature(req.body, req.headers, secret)) {
    return res.sendStatus(400);
  }

  // Optional: Vanta recommends rejecting timestamps more than 5 minutes old.
  // const age = Math.abs(Date.now() / 1000 - Number(req.get('svix-timestamp')));
  // if (age > 300) return res.sendStatus(400);

  const payload = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on req.get('svix-id'), queue the event, then respond.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac
import os

def verify_svix_signature(raw_body: bytes, headers, secret: str) -> bool:
    msg_id = headers.get("svix-id")
    msg_timestamp = headers.get("svix-timestamp")
    signature_header = headers.get("svix-signature")
    if not (msg_id and msg_timestamp and signature_header):
        return False
    if not secret or not secret.startswith("whsec_"):
        return False

    key = base64.b64decode(secret[len("whsec_"):])
    signed_content = f"{msg_id}.{msg_timestamp}.".encode("utf-8") + raw_body
    expected = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest())

    for entry in signature_header.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature.encode("utf-8"), expected):
            return True
    return False

# In FastAPI: raw_body = await request.body()
# verify_svix_signature(raw_body, request.headers, os.environ["VANTA_WEBHOOK_SIGNING_SECRET"])

```

To test your verifier, Svix publishes this example: secret `whsec_plJ3nmyCDGBKInavdOK15jsl`, message ID `msg_loFOjxBNrRLzqYUf`, timestamp `1731705121` and body `{"event_type":"ping","data":{"success":true}}` should produce the signature `v1,rAvfW3dJ/X/qxhsaXPOyyCGmRKsaKWcsNccKXlIktD0=`. The timestamp is old, so leave any freshness check off while you test.

> **Make Vanta webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies Vanta's Svix signatures, acknowledges each delivery inside the 15-second window, and queues every event until your service has processed it.

Hookdeck's Vanta source type verifies the three `svix-*` headers with the `whsec_` signing secret you enter. Hookdeck does not apply a timestamp window, so a delayed retry still verifies. If you want Vanta's 5-minute check, apply it in your own receiver.

## Vanta webhook limitations and pain points

### Payloads don't say what happened

**The Problem:** Your handler receives `{"questionnaire": {"id": "123"}}` and can't tell whether the questionnaire was created, deleted or exported.

**Why It Happens:** Vanta's documented payloads contain only the IDs of the affected objects, and no documented header carries the event type.

**Workarounds:**

* Give each event type that needs different handling its own endpoint, so the URL tells you the type.
* Fetch the object from the Manage Vanta API using the ID, and act on its current state.

**How Hookdeck Can Help:** Several Hookdeck sources can deliver to the same service with different paths, so each Vanta endpoint keeps its own event type while your code handles them in one place.

### Raw body handling breaks verification

**The Problem:** Every signature check fails even though the secret is right.

**Why It Happens:** The signature covers the exact body bytes. Frameworks that parse JSON before your handler runs, such as `express.json()`, change the bytes, and Vanta's troubleshooting guide names this as the cause.

**Workarounds:**

* Use `express.raw()` on the webhook route and parse after verification.
* Check that you copied the signing secret for the right endpoint.

**How Hookdeck Can Help:** Hookdeck verifies against the bytes Vanta sent, so the services behind it don't each need raw-body handling.

### A 15-second timeout

**The Problem:** Handlers that call the Manage Vanta API inline time out and get retried.

**Why It Happens:** Vanta marks a delivery as failed if it doesn't get a `2xx` within 15 seconds, and retries it.

**Workarounds:**

* Return `200` once the event is stored, then fetch from the API in a background job.

**How Hookdeck Can Help:** Hookdeck acknowledges Vanta immediately and delivers to your service with a timeout you configure.

### Failed messages are dropped after about five days

**The Problem:** A long outage loses events once the retry schedule runs out.

**Why It Happens:** Vanta retries at 5 seconds, 5 minutes, 30 minutes, 2 hours, 8 hours, 1 day and 2 days after the first attempt, then marks the message as failed.

**Workarounds:**

* Use **Retry** or **Bulk Retry** in **Settings > Webhooks** to replay failed messages for a time range after recovery.

**How Hookdeck Can Help:** Hookdeck stores every delivery as it arrives, so an outage on your side doesn't burn Vanta's retries, and you can replay from Hookdeck whenever your service is back.

## Best practices

### Verify against the raw body

Hash the exact bytes received. Use `express.raw()` in Express, `Buffer.from(await request.arrayBuffer())` in a Next.js route handler, and `await request.body()` in FastAPI.

### Deduplicate on `svix-id`

Delivery is at least once. Vanta's docs say to use `svix-id` to deduplicate, and Svix keeps the same ID when it resends a message. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

Return a `2xx` straight after verification and handle the event in a background job. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Treat IDs as opaque and fetch current state

Don't parse or validate IDs. Use them to fetch the full object from the Manage Vanta API, which also protects you from acting on a stale event that arrived after a retry.

### Test with Send Example

The **Testing** tab sends a real, signed example for any event type. Use it to confirm verification and to see the exact body before you rely on its shape.

## Conclusion

Vanta sends small, ID-only payloads through Svix, signed with base64 HMAC-SHA256 over `svix-id`, `svix-timestamp` and the raw body. Verify every request, deduplicate on `svix-id`, answer within 15 seconds, and fetch the full object from the Manage Vanta API before you act.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, verifies Vanta's signatures, and holds each event until your service has processed it.

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