# Guide to Voiceflow Webhooks: Features and Best Practices

[Voiceflow](https://www.voiceflow.com) is a platform for building AI agents, organised into projects with environments that teams branch, publish and merge. In a larger organisation you may want those changes reflected elsewhere: an audit log, an observability dashboard, a test run after each publish. Voiceflow's organisation events webhooks send each project and environment change to a URL you choose.

| Aspect | Value |
| --- | --- |
| Method | `POST`, JSON body |
| Signature headers | `svix-id`, `svix-timestamp`, `svix-signature` (Standard Webhooks) |
| Scope | One URL per organisation; every organisation event is sent to it |
| Acknowledgement and retries | Voiceflow states no acknowledgement, timeout or retry policy |

## What are Voiceflow webhooks?

Organisation events webhooks are configured once per organisation. After you set a URL, every organisation event goes to it as a JSON `POST`. Svix delivers and signs them, and Voiceflow's own [documentation page](https://www.voiceflow.com/docs/api-reference/webhooks/org-events) links to Svix's guide for verification.

Voiceflow also has a project-level session lifecycle webhook. It's a separate feature with its own secret, and its docs don't describe this signing scheme, so this guide doesn't cover it.

## Voiceflow webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Settings > Organization**, one URL |
| Event selection | None; every organisation event is sent |
| Signature | Standard Webhooks HMAC-SHA256, via Svix |
| Signature header | `svix-signature` |
| Secret | Generated when you enter the URL. Format not shown |
| Rotation | Old secret stays valid for 24 hours |
| Body | JSON |
| Event routing | Body field `type` |
| Retries and timeout | Not stated |
| Source IPs | Svix's addresses |

## Events and payloads

Every event has the same outer shape:

| Field | Meaning |
| --- | --- |
| `type` | The event name, e.g. `organization.project.created` |
| `data` | Event details: `organizationID`, `projectID`, `projectMetadata.name`, `workspaceID`, plus event-specific fields |
| `resource` | `organization-<organizationID>` |
| `time` | Event time as a Unix timestamp in milliseconds |

Voiceflow sends six event types:

| Event | Triggered When | Key `data` fields |
| --- | --- | --- |
| `organization.project.created` | A new project is created | `createdBy`, `projectID`, `projectMetadata.name` |
| `organization.project.deleted` | A project is deleted | `deletedBy`, `projectID`, `projectMetadata.name` |
| `organization.project.environment.created` | An environment is created within a project | `createdProjectEnvironmentID`, `createdProjectEnvironmentMetadata`, `source.environmentID` (the environment it was branched from) |
| `organization.project.environment.published` | An environment is published | `publishedProjectEnvironmentID`, `publishedVersionIDBefore`, `publishedVersionIDAfter` |
| `organization.project.environment.merged` | One environment is merged into another | `sourceProjectEnvironmentID`, `targetProjectEnvironmentID`, `sourceProjectEnvironmentRemoved` (source deleted as part of the merge) |
| `organization.project.environment.deleted` | An environment is deleted | `deletedProjectEnvironmentID`, `deletedBy` |

The actor field changes name with the event (`createdBy`, `deletedBy`, `publishedBy` or `mergedBy`) and always has the form `{ "type": "user", "userEmail": "..." }`. For `organization.project.environment.published`, `publishedProjectEnvironmentMetadata` includes `alias`, `isLive`, `name` and `trafficPercentage`, so you can tell a publish of the live **Main** environment from one of a branch that takes no traffic.

This is the `organization.project.created` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/voiceflow/latest/organization.project.created.json). `time` is in milliseconds:

```json
{
  "data": {
    "createdBy": {
      "type": "user",
      "userEmail": "user@voiceflow.com"
    },
    "organizationID": "LVkmPy8AE2",
    "projectID": "6a16f4549e4196323d8a49b9",
    "projectMetadata": {
      "name": "My new project"
    },
    "workspaceID": "95kwAZ63nO"
  },
  "resource": "organization-LVkmPy8AE2",
  "time": 1779889237608,
  "type": "organization.project.created"
}

```

## Setting up Voiceflow webhooks

Hookdeck's Voiceflow source supports organisation event webhooks only. The project-level session webhook doesn't document this signing scheme and may fail verification.

1. In the [Voiceflow dashboard](https://creator.voiceflow.com), click **Settings** in the bottom left corner, then select **Organization**.
2. Enter the URL to send events to, for example `https://example.com/webhooks/voiceflow`.
3. Copy the webhook secret that appears and store it in `VOICEFLOW_WEBHOOK_SECRET`. Voiceflow doesn't show an example value; Svix secrets start with `whsec_`, so keep the whole string.

From then on, every organisation event is sent to that URL. If you'd rather not write code, Voiceflow suggests a no-code tool such as Make to receive them, in which case verification is up to that tool.

If your endpoint sits behind a restrictive firewall, allow Svix's IP addresses; events don't come from Voiceflow's.

To use Hookdeck, create a source with the **Voiceflow** type, paste the secret, and enter the source URL in Voiceflow's settings. Forward it during development with the [Hookdeck CLI](/docs/cli):

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

```

Creating a throwaway project in the organisation sends `organization.project.created`, and deleting it sends `organization.project.deleted`.

## Securing Voiceflow webhooks

Voiceflow points to Svix's manual verification steps: sign `svix-id`, a dot, `svix-timestamp`, another dot and the raw body with HMAC-SHA256, using the base64-decoded secret after `whsec_`, and compare with each `v1,` entry in `svix-signature`. This function does that and also accepts `webhook-*` header names:

```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['svix-id'] ? 'svix' : 'webhook';
  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);
  });
}

```

After you regenerate the secret with the refresh button on the settings page, the old one stays valid for 24 hours. During that period a delivery can carry a `v1,` entry for each secret, and the function accepts either, so roll out the new value before the 24 hours end.

Voiceflow sets no timestamp tolerance. The Svix libraries use five minutes, but Hookdeck has no tolerance and keeps the original `svix-timestamp` on retries and replays, so behind Hookdeck the age check is left out.

The receiver records each change keyed by `svix-id`:

```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;
  }
}

// orgAuditLog.write(messageId, event) records the change once and resolves when durable.
function createVoiceflowReceiver(orgAuditLog) {
  const app = express();
  app.post('/webhooks/voiceflow', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.VOICEFLOW_WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

    const event = parseJsonObject(req.body);
    if (!event) return res.sendStatus(400); // signed, but not a JSON object
    try {
      await orgAuditLog.write(req.headers['svix-id'] || req.headers['webhook-id'], event);
    } catch {
      return res.sendStatus(503);
    }
    return res.sendStatus(200);
  });
  return app;
}

```

A Hookdeck Voiceflow source replies `200` regardless of the signature. A request that fails is marked as failed verification and isn't delivered.

> **Make Voiceflow webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) splits Voiceflow's single organisation feed by event type, keeps a history of every publish and merge, and retries your services.

## Voiceflow webhook limitations and pain points

### One URL, every event

**The Problem:** Your publish-triggered test runner also receives project deletions and environment merges.

**Why It Happens:** Voiceflow allows one URL per organisation and sends every organisation event to it, with no per-event selection.

**Workarounds:**

* Switch on `type` in the receiver and acknowledge the events you don't need.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) on `type` send `organization.project.environment.published` to one service and everything else to another.

### No stated delivery policy

**The Problem:** You don't know whether events sent during an outage will be retried.

**Why It Happens:** Voiceflow states no acknowledgement window, timeout or retry policy. Svix delivers the events, but Voiceflow doesn't say which Svix settings apply.

**Workarounds:**

* Return `2xx` quickly after storing the event, and compare your records with the projects in Voiceflow after an outage.

**How Hookdeck Can Help:** Hookdeck accepts every event as it arrives and [retries](/docs/retries) your service on a schedule you control.

### Session webhooks are different

**The Problem:** Requests from a project's session webhook fail verification.

**Why It Happens:** The project-level session lifecycle webhook has its own secret, and its docs don't describe the Svix scheme.

**Workarounds:**

* Use separate endpoints for organisation events and session webhooks, and only apply this verifier to organisation events.

**How Hookdeck Can Help:** Point session webhooks at a separate Hookdeck source without Voiceflow verification, so they don't mix with signed organisation events.

### Secret format isn't shown

**The Problem:** You can't tell from the docs whether the secret should be base64-decoded.

**Why It Happens:** Voiceflow doesn't print an example secret. Svix secrets start with `whsec_`, but Voiceflow doesn't confirm it.

**Workarounds:**

* Check the value on the settings page before configuring a manual verifier.

**How Hookdeck Can Help:** Hookdeck flags failed verification on the first delivery, so a wrong secret is obvious straight away.

## Best practices

### Track what each publish changed

Store `publishedVersionIDBefore` and `publishedVersionIDAfter` so you can see exactly which version went live.

### Distinguish live publishes

Check `publishedProjectEnvironmentMetadata.isLive` before triggering production checks, and use `trafficPercentage` to see how much traffic the environment serves.

### Handle merges that remove environments

When `sourceProjectEnvironmentRemoved` is `true`, the merged environment no longer exists. Clean up anything you keyed on it.

### Make processing idempotent

Use `svix-id` as the key. [Implementing webhook idempotency](/webhooks/guides/implement-webhook-idempotency) explains the storage pattern.

## Conclusion

Voiceflow's organisation events webhooks give you an audit trail of how agents are created, published and merged. Everything goes to one URL, so filter by `type` before acting.

[Hookdeck Event Gateway](https://hookdeck.com) filters, records and retries Voiceflow's organisation events. [Sign up and start free](https://dashboard.hookdeck.com/signup).