Guide to Voiceflow Webhooks: Features and Best Practices
Voiceflow 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 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. time is in milliseconds:
{
"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.
- In the Voiceflow dashboard, click Settings in the bottom left corner, then select Organization.
- Enter the URL to send events to, for example
https://example.com/webhooks/voiceflow. - Copy the webhook secret that appears and store it in
VOICEFLOW_WEBHOOK_SECRET. Voiceflow doesn't show an example value; Svix secrets start withwhsec_, 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:
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:
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:
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 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
typein the receiver and acknowledge the events you don't need.
How Hookdeck Can Help: Hookdeck 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
2xxquickly 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 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 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 filters, records and retries Voiceflow's organisation events. Sign up and start free.