Webhook Callback Guide
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, or when your credit balance runs low or is exhausted, 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
- Credit balance: Notification when the account's credits run low or are exhausted
Two-Tier Configuration
| Tier | Configuration Location | Priority | Description |
|---|---|---|---|
| Request level | The callback_url parameter in an API request | High (takes precedence) | Can be specified when creating an audio import or a broadcast; may differ per request |
| 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.
API Key for Each Notification
Every notification belongs to one API Key: when no callback_url is specified, it is sent to that key's webhook_url, and it is always signed with that key's webhook_secret (including when it is sent to a callback_url).
| Scenario | API Key | Can callback_url be specified? |
|---|---|---|
| Audio import | The API Key used to create the import | Yes |
| Real-time recording | The API Key used to obtain the connection ticket | No; always sent to that key's webhook_url |
| Broadcast recording | The API Key used to create the broadcast; if a different key starts the broadcast, the key that started it applies once the recording is finalized | Yes (specified when creating the broadcast; still takes precedence over webhook_url) |
| Credit balance notifications | Every active API Key in the account that has a webhook_url | Not applicable (never sent to a callback_url); see credit.low |
- After an API Key is deleted, no further notifications are sent for that key, including recordings and imports that specified a
callback_url.
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 save the URL, the system first sends a signed verification request (event webhook.verify) to that URL, and the URL is saved only if the receiving endpoint responds with 2xx within 8 seconds. The system checks only the response status code and does not check the signature; however, if your receiving endpoint already verifies signatures, it must hold the webhook_secret first, otherwise it rejects the request with a non-2xx response and the URL cannot be saved. The verification request does not follow redirects, and a 3xx response also counts as a failure. We recommend the two-step process below:
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. If you selected "Also generate Webhook Secret" when creating the API Key, the plaintext was shown once when the key was created, and you can skip this step.
Note: 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 Secret" 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 a verification request with this secret, and the URL is saved only if the receiving endpoint responds with 2xx within 8 seconds. If this API Key has no secret yet, clicking "Edit" first generates one automatically and shows the plaintext once; set it on the receiving endpoint before saving the URL.
Because both sides hold the same secret, the receiving endpoint's signature check passes, it responds with 2xx, and the URL is saved.
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, click "Regenerate Secret".
- When a secret is generated: A secret is generated in the following cases:
- You click "Generate Webhook Secret" or "Regenerate Secret"
- You select "Also generate Webhook Secret" when creating the API Key (the plaintext is shown once when the key is created)
- You click "Edit" to set the URL while the key has no secret (generated automatically, and the plaintext is shown once)
- When you set the URL through "Batch webhook settings", keys without a secret get one automatically; if you select "Regenerate each key's webhook secret", every key gets a new one. In both cases the plaintext is not shown; to get the plaintext, open that key's detail page and click "Regenerate 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.) |
credit.low | Credit balance low | After a deduction the balance drops below the threshold (default 50 points); sent at most once every 24 hours per account; see credit.low |
credit.exhausted | Credit exhausted | After a deduction the balance reaches zero (including partial deduction shortfalls), or a recording cannot start or continue because the available credits do not cover one minute and the account balance cannot cover that minute; sent at most once every 24 hours per account |
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. 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": "No audio was received during the recording, so nothing was saved.",
"failure_source": "no_audio"
}
}
data Field Descriptions
| Field | Type | Description |
|---|---|---|
task_id | string | Task ID (UUID) |
error | string | A general description of the failure reason (fixed English text without internal details; provide the task_id for troubleshooting) |
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; no_audio means a real-time recording received no audio at all (v1.18.0). Other sources (system processing failure, automatic timeout 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.A real-time recording that received no audio at all sends this event as soon as the recording ends (
failure_source: "no_audio"); that recording has no transcript or audio file.
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"
}
}
import.failedis sent only once per failed import. Temporary errors during processing are retried automatically first; the event is sent only when retries are exhausted, anderror_codeis the cause of the failure.- After
import.failed, you never receiveimport.completedfor the same import. error_messageis a general description based on the error code, without internal details; provide theimport_idwhen you need troubleshooting. See Error Codes for the list of codes.
credit.low
Sent when the account balance drops below the threshold after a deduction. Both balance and threshold are point strings (one decimal place).
{
"event": "credit.low",
"timestamp": "2026-02-24T12:05:00.000000Z",
"delivery_id": "e5f6a7b8-c9d0-1234-efab-345678901234",
"data": {
"balance": "49.5",
"threshold": "50.0"
}
}
| Field | Type | Description |
|---|---|---|
balance | string | Current remaining points |
threshold | string | Low-balance threshold that triggered the notification |
Repeat rule (counted separately for
credit.lowandcredit.exhausted):
- Sent at most once every 24 hours per account. After 24 hours, it is sent again the next time the condition is met (for example, the balance is still below the threshold after another deduction).
- Re-evaluated after credits are added to the account (for example, a top-up or a refund): it is sent again the next time the condition is met, without waiting for 24 hours.
- These two notifications do not use
callback_url; they are sent to every active API Key in the account that has awebhook_url, one copy per key (each with its owndelivery_id). Keys that share the same URL are not merged into one copy.
credit.exhausted
Sent when the account balance reaches zero after a deduction (including partial-deduction shortfalls). Also sent when a real-time recording cannot start or continue because the available credits do not cover one minute and the account balance cannot cover that minute (in this case balance may be greater than 0.0).
{
"event": "credit.exhausted",
"timestamp": "2026-02-24T12:05:00.000000Z",
"delivery_id": "f6a7b8c9-d0e1-2345-fabc-456789012345",
"data": {
"balance": "0.0"
}
}
| Field | Type | Description |
|---|---|---|
balance | string | Current remaining points (typically 0.0; when a recording cannot start or continue for lack of one minute of credits, this may be a remaining balance smaller than one minute's charge) |
Security Verification
VAS uses HMAC-SHA256 signatures to verify the authenticity of Webhook requests.
Note: Setup order: Signature verification requires that the receiving endpoint and VAS both hold the same
webhook_secret. Follow Step 1: Generate a Webhook Secret to first obtain the secret in the Dashboard and set it on the receiving endpoint, then perform Step 2: Enter the Webhook URL. If the receiving endpoint verifies signatures but does not have this secret yet, it rejects the verification request sent when you save the URL (with a non-2xx response), 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 webhook_secret of the API Key the notification belongs to (see API Key for Each Notification):
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 (including timeouts and 3xx, 4xx, or 5xx responses), VAS automatically retries.
Retry Strategy
Each notification is sent at most 5 times (the first delivery plus 4 retries):
| 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 |
- Uses an exponential backoff strategy
- If the 4th retry also fails, no further deliveries are made and the notification is marked as failed
- Redirects are not followed: a 3xx response counts as a failure and is retried, so configure the final URL directly
- 4xx responses are retried as well
- To have a notification that was marked as failed sent again, contact us with the related
task_idorimport_id
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 });
});
The completion event for a recording may arrive twice: In rare cases,
recording.completedfor the same recording is delivered twice, with a differentdelivery_ideach time, so checkingdelivery_idalone will not catch it. Also checkdata.task_idtogether witheventto decide whether it has already been processed. A repeated delivery does not deduct credits twice.
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.24.1 Last Updated: 2026-10-07