Webhook
Table of Contents
- Overview
- Configuration
- Webhook Events
- Payload Format
- Security Verification
- Retry Mechanism
- Best Practices
- Complete Examples
- Related Documents
Overview
Webhooks let your server receive instant notifications when a recording finishes processing or fails, without polling the API. VAS sends an HTTP POST request to your specified URL, including the event data and an HMAC-SHA256 signature.
Use Cases
- Audio import: Automatic notification after a long audio file finishes processing
- Real-time recording: Notification after a recording is uploaded and processed
- Broadcast: Notification after a broadcast recording finishes processing
Two-Tier Configuration
| Tier | Configuration Location | Priority | Description |
|---|---|---|---|
| Request level | The callback_url parameter in an API request | High (takes precedence) | Each request can specify a different callback URL |
| API Key level | The webhook_url setting on the API Key | Low (fallback) | Serves as the default callback URL for all requests |
If both tiers are configured, the request-level
callback_urltakes precedence.
Configuration
Option 1: Request-level callback_url (recommended)
Specify the callback_url parameter in each API request.
Audio import:
curl -X POST "https://vas-poc.vurbo.ai/api/v1/imports" \
-H "X-API-Key: YOUR_API_KEY" \
-F "file=@meeting.mp3" \
-F 'transcription_languages=["zh-TW"]' \
-F "recognition_mode=multi_speaker" \
-F "callback_url=https://your-server.com/webhooks/vas"
Create a broadcast:
curl -X POST "https://vas-poc.vurbo.ai/api/v1/broadcasts" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"transcription_language": "zh-TW",
"translation_languages": ["en-US"],
"callback_url": "https://your-server.com/webhooks/vas"
}'
Option 2: API Key-level webhook_url
Specify a webhook_url in the API Key settings to serve as the default callback address for all requests.
When you set the URL, the system immediately sends a signed probe to verify reachability and signature consistency, so the receiving endpoint must already hold the webhook_secret to pass verification. Follow the two-step process below to configure it:
Step 1: Generate a Webhook Secret
Open the detail page of the corresponding API Key in the Dashboard, and in the "Webhook Settings" section click "Generate Webhook Secret". The system generates a random 64-character secret and displays the plaintext once for you to copy.
⚠️ The secret is shown only once, right after it is generated. After you close the window, only a masked value is displayed and the plaintext can no longer be retrieved. If you lose it, click "Regenerate" to overwrite it.
Set the secret you obtained on the receiving endpoint (for example, VAS_WEBHOOK_SECRET in .env), enable HMAC-SHA256 signature verification, and restart the receiving service.
Step 2: Enter the Webhook URL
Return to the Dashboard, click "Edit" in the "Webhook Settings" section, enter the receiving endpoint URL, and save. The system signs and sends a webhook.verify probe using the existing secret (it must return 2xx before the URL is saved).
Because both sides hold the same secret, the probe signature verification passes immediately.
Webhook Secret Lifecycle
- Shown once: The plaintext is shown only once in the Dashboard, right after it is generated.
- Regenerating immediately invalidates old signatures: After you regenerate, webhooks signed with the old secret are rejected by the receiving endpoint. The recommended rotation process is to briefly accept both the new and old secrets on the receiving endpoint, then remove the old secret only after the Dashboard regeneration is complete and all in-flight webhooks have been processed.
- Decoupled from the URL: Clearing the webhook URL does not clear the secret; to reset the secret, use "Regenerate".
- Lazy generation: A webhook secret is not generated automatically when you create an API Key; it is created only when you actively click "Generate Webhook Secret".
API Key-level configuration is only available through the Dashboard.
Webhook Events
| Event | Trigger | Description |
|---|---|---|
recording.completed | Recording finished processing | Recording processing completed for real-time recording, broadcast, or audio import |
recording.failed | Recording processing failed | An error occurred during processing |
import.completed | Audio import completed | The recording associated with an audio import finished processing |
import.failed | Audio import failed | An error occurred during import (invalid format, conversion failure, etc.) |
Event Trigger Mapping
| Scenario | Success Event | Failure Event |
|---|---|---|
| Real-time recording | recording.completed | recording.failed |
| Broadcast recording | recording.completed | recording.failed |
| Audio import | recording.completed + import.completed | import.failed (import stage) or recording.failed (processing stage) |
On a successful audio import you receive two events: first
recording.completed, thenimport.completed.
Payload Format
Common Structure
{
"event": "recording.completed",
"timestamp": "2026-02-24T12:00:00.000000Z",
"delivery_id": "550e8400-e29b-41d4-a716-446655440000",
"data": { ... }
}
| Field | Type | Description |
|---|---|---|
event | string | Event type |
timestamp | string | When the event occurred (ISO 8601) |
delivery_id | string | Delivery record ID (UUID; useful for debugging and idempotent processing) |
data | object | Event data (varies by event type) |
recording.completed
{
"event": "recording.completed",
"timestamp": "2026-02-24T12:00:00.000000Z",
"delivery_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"data": {
"task_id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Meeting Recording",
"duration_ms": 3600000,
"type_source": "realtime",
"transcription_languages": ["zh-TW"],
"translation_languages": ["en-US"]
}
}
| Field | Description |
|---|---|
task_id | Task ID (usable to query details via the Tasks API) |
name | Recording name |
duration_ms | Recording duration (milliseconds) |
type_source | Source type: realtime (real-time recording) / import (audio import) |
transcription_languages | Transcription languages |
translation_languages | Translation languages |
ID alignment tip:
data.task_idis the same identifier astask_idin the WebSocketsession_startedevent (before V1.4.1 the WS side named itrecording_id, which is still kept as an alias of the same value). You can use it directly to align the WS session with the Webhook task.
recording.failed
{
"event": "recording.failed",
"timestamp": "2026-02-24T12:05:00.000000Z",
"delivery_id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"data": {
"task_id": "550e8400-e29b-41d4-a716-446655440000",
"error": "STT processing timeout",
"failure_source": "job_failed"
}
}
data Field Descriptions
| Field | Type | Description |
|---|---|---|
task_id | string | Task ID (UUID) |
error | string | Description of the failure reason |
failure_source | string | undefined | Failure source label; user_forced means the user actively marked the task as failed via POST /api/v1/tasks/{taskId}/force-fail; other sources (job execution failure, CheckStaleRecordings automatic cleanup) currently do not include this field |
Subscriber tip: To distinguish a manual user action from automatic system detection, check whether
data.failure_source === 'user_forced'; legacy sources do not includefailure_source, for backward compatibility.
import.completed
{
"event": "import.completed",
"timestamp": "2026-02-24T12:00:00.000000Z",
"delivery_id": "c3d4e5f6-a7b8-9012-cdef-123456789012",
"data": {
"import_id": "660e8400-e29b-41d4-a716-446655440001",
"task_id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Meeting Recording",
"duration_ms": 3600000
}
}
import.failed
{
"event": "import.failed",
"timestamp": "2026-02-24T12:05:00.000000Z",
"delivery_id": "d4e5f6a7-b8c9-0123-defa-234567890123",
"data": {
"import_id": "660e8400-e29b-41d4-a716-446655440001",
"error_code": "import_conversion_failed",
"error_message": "Audio conversion failed"
}
}
Security Verification
VAS uses HMAC-SHA256 signatures to verify the authenticity of Webhook requests.
⚠️ Bootstrap reminder: Signature verification requires that the receiving endpoint and VAS both hold the same
webhook_secret. Be sure to follow the Step 1: Generate a Webhook Secret process to first obtain the secret in the Dashboard and set it on the receiving endpoint, then perform Step 2: Enter the Webhook URL. Otherwise the probe will fail due to a signature mismatch and the URL cannot be saved.
HTTP Headers
Each Webhook request includes the following headers:
| Header | Description |
|---|---|
Content-Type | application/json |
X-VAS-Event | Event type (e.g., recording.completed) |
X-VAS-Delivery-Id | Delivery record ID (UUID) |
X-VAS-Timestamp | Unix timestamp (seconds) |
X-VAS-Signature | HMAC-SHA256 signature |
User-Agent | VAS-Webhook/1.0 |
Signature Verification
The signature is computed using the API Key's webhook_secret:
signature_payload = timestamp + "." + raw_body
signature = "sha256=" + HMAC-SHA256(signature_payload, webhook_secret)
Verification steps:
- Get the timestamp from
X-VAS-Timestamp - Get the signature from
X-VAS-Signature - Use
timestamp + "." + raw request bodyas the signing content - Compute HMAC-SHA256 using
webhook_secret - Compare whether the signatures match
Node.js verification example:
const crypto = require('crypto');
function verifyWebhookSignature(req, webhookSecret) {
const timestamp = req.headers['x-vas-timestamp'];
const signature = req.headers['x-vas-signature'];
const body = req.rawBody; // raw request body string
const signaturePayload = `${timestamp}.${body}`;
const expected = 'sha256=' + crypto
.createHmac('sha256', webhookSecret)
.update(signaturePayload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
Python verification example:
import hmac
import hashlib
def verify_webhook_signature(timestamp, body, signature, webhook_secret):
signature_payload = f"{timestamp}.{body}"
expected = "sha256=" + hmac.new(
webhook_secret.encode(),
signature_payload.encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
Replay Attack Prevention
We recommend checking the difference between X-VAS-Timestamp and the current time, and rejecting the request if it exceeds 5 minutes:
const MAX_AGE_SECONDS = 300; // 5 minutes
const timestamp = parseInt(req.headers['x-vas-timestamp']);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > MAX_AGE_SECONDS) {
return res.status(401).json({ error: 'Timestamp too old' });
}
Retry Mechanism
If your server does not respond with a 2xx status code, VAS automatically retries.
Retry Strategy
| Attempt | Wait Time | Cumulative Time |
|---|---|---|
| 1st retry | 10 seconds | 10 seconds |
| 2nd retry | 30 seconds | 40 seconds |
| 3rd retry | 90 seconds | 2 min 10 sec |
| 4th retry | 270 seconds | 6 min 40 sec |
| 5th retry | 810 seconds | 20 min 10 sec |
- Up to 5 retries (6 attempts total, including the first delivery)
- Uses an exponential backoff strategy
- Marked as failed after retries are exhausted
Response Requirements
- A 2xx status code indicates successful receipt
- The response must return within 15 seconds
- A timeout or a non-2xx response triggers a retry
Best Practices
1. Respond Quickly, Process Asynchronously
// Recommended: respond with 200 first, then process asynchronously
app.post('/webhooks/vas', async (req, res) => {
// Respond immediately
res.status(200).json({ received: true });
// Process the event asynchronously
processWebhookEvent(req.body).catch(console.error);
});
2. Idempotent Processing
Use delivery_id to avoid duplicate processing:
app.post('/webhooks/vas', async (req, res) => {
const { delivery_id } = req.body;
// Check whether it has already been processed
if (await isDeliveryProcessed(delivery_id)) {
return res.status(200).json({ received: true, duplicate: true });
}
// Mark as processed
await markDeliveryProcessed(delivery_id);
// Process the event...
res.status(200).json({ received: true });
});
3. Verify the Signature
Always verify X-VAS-Signature to ensure the request comes from VAS:
app.post('/webhooks/vas', (req, res) => {
if (!verifyWebhookSignature(req, WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Process the event...
});
Complete Examples
Node.js (Express)
const express = require('express');
const crypto = require('crypto');
const app = express();
const WEBHOOK_SECRET = 'your_webhook_secret_here';
// Keep the raw body for signature verification
app.use('/webhooks/vas', express.json({
verify: (req, res, buf) => { req.rawBody = buf.toString(); }
}));
app.post('/webhooks/vas', (req, res) => {
// 1. Verify the signature
const timestamp = req.headers['x-vas-timestamp'];
const signature = req.headers['x-vas-signature'];
const signaturePayload = `${timestamp}.${req.rawBody}`;
const expected = 'sha256=' + crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signaturePayload)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature || ''), Buffer.from(expected))) {
return res.status(401).json({ error: 'Invalid signature' });
}
// 2. Respond immediately
res.status(200).json({ received: true });
// 3. Process the event
const { event, data, delivery_id } = req.body;
console.log(`Webhook received: ${event} (${delivery_id})`);
switch (event) {
case 'recording.completed':
console.log(`Recording completed: task_id=${data.task_id}, duration=${data.duration_ms}ms`);
// Load the transcript, notify the user, etc...
break;
case 'recording.failed':
console.log(`Recording failed: task_id=${data.task_id}, error=${data.error}`);
// Notify the user that processing failed...
break;
case 'import.completed':
console.log(`Import completed: import_id=${data.import_id}, task_id=${data.task_id}`);
break;
case 'import.failed':
console.log(`Import failed: import_id=${data.import_id}, error=${data.error_message}`);
break;
}
});
app.listen(3000, () => console.log('Webhook server running on port 3000'));
Python (Flask)
import hmac
import hashlib
import json
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = "your_webhook_secret_here"
@app.route("/webhooks/vas", methods=["POST"])
def handle_webhook():
# 1. Verify the signature
timestamp = request.headers.get("X-VAS-Timestamp", "")
signature = request.headers.get("X-VAS-Signature", "")
raw_body = request.get_data(as_text=True)
signature_payload = f"{timestamp}.{raw_body}"
expected = "sha256=" + hmac.new(
WEBHOOK_SECRET.encode(),
signature_payload.encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(signature, expected):
return jsonify({"error": "Invalid signature"}), 401
# 2. Process the event
payload = request.get_json()
event = payload["event"]
data = payload["data"]
delivery_id = payload["delivery_id"]
print(f"Webhook received: {event} ({delivery_id})")
if event == "recording.completed":
task_id = data["task_id"]
print(f"Recording completed: {task_id}")
# Load the transcript, notify the user, etc...
elif event == "recording.failed":
print(f"Recording failed: {data['error']}")
elif event == "import.completed":
print(f"Import completed: import_id={data['import_id']}, task_id={data['task_id']}")
elif event == "import.failed":
print(f"Import failed: {data['error_message']}")
return jsonify({"received": True}), 200
if __name__ == "__main__":
app.run(port=3000)
Related Documents
| Document | Description |
|---|---|
| Authentication | API Key configuration and authentication details |
| Imports API | Audio import API (includes the callback_url parameter) |
| Broadcasts API | Broadcast API (includes the callback_url parameter) |
| Error Code Reference | Complete error code listing |
Version: V1.5.7 Last Updated: 2026-05-20