Guides

Webhook

Table of Contents

  1. Overview
  2. Configuration
  3. Webhook Events
  4. Payload Format
  5. Security Verification
  6. Retry Mechanism
  7. Best Practices
  8. Complete Examples
  9. 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

TierConfiguration LocationPriorityDescription
Request levelThe callback_url parameter in an API requestHigh (takes precedence)Each request can specify a different callback URL
API Key levelThe webhook_url setting on the API KeyLow (fallback)Serves as the default callback URL for all requests

If both tiers are configured, the request-level callback_url takes precedence.


Configuration

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

EventTriggerDescription
recording.completedRecording finished processingRecording processing completed for real-time recording, broadcast, or audio import
recording.failedRecording processing failedAn error occurred during processing
import.completedAudio import completedThe recording associated with an audio import finished processing
import.failedAudio import failedAn error occurred during import (invalid format, conversion failure, etc.)

Event Trigger Mapping

ScenarioSuccess EventFailure Event
Real-time recordingrecording.completedrecording.failed
Broadcast recordingrecording.completedrecording.failed
Audio importrecording.completed + import.completedimport.failed (import stage) or recording.failed (processing stage)

On a successful audio import you receive two events: first recording.completed, then import.completed.


Payload Format

Common Structure

{
  "event": "recording.completed",
  "timestamp": "2026-02-24T12:00:00.000000Z",
  "delivery_id": "550e8400-e29b-41d4-a716-446655440000",
  "data": { ... }
}
FieldTypeDescription
eventstringEvent type
timestampstringWhen the event occurred (ISO 8601)
delivery_idstringDelivery record ID (UUID; useful for debugging and idempotent processing)
dataobjectEvent 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"]
  }
}
FieldDescription
task_idTask ID (usable to query details via the Tasks API)
nameRecording name
duration_msRecording duration (milliseconds)
type_sourceSource type: realtime (real-time recording) / import (audio import)
transcription_languagesTranscription languages
translation_languagesTranslation languages

ID alignment tip: data.task_id is the same identifier as task_id in the WebSocket session_started event (before V1.4.1 the WS side named it recording_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

FieldTypeDescription
task_idstringTask ID (UUID)
errorstringDescription of the failure reason
failure_sourcestring | undefinedFailure 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 include failure_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:

HeaderDescription
Content-Typeapplication/json
X-VAS-EventEvent type (e.g., recording.completed)
X-VAS-Delivery-IdDelivery record ID (UUID)
X-VAS-TimestampUnix timestamp (seconds)
X-VAS-SignatureHMAC-SHA256 signature
User-AgentVAS-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:

  1. Get the timestamp from X-VAS-Timestamp
  2. Get the signature from X-VAS-Signature
  3. Use timestamp + "." + raw request body as the signing content
  4. Compute HMAC-SHA256 using webhook_secret
  5. 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

AttemptWait TimeCumulative Time
1st retry10 seconds10 seconds
2nd retry30 seconds40 seconds
3rd retry90 seconds2 min 10 sec
4th retry270 seconds6 min 40 sec
5th retry810 seconds20 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)

DocumentDescription
AuthenticationAPI Key configuration and authentication details
Imports APIAudio import API (includes the callback_url parameter)
Broadcasts APIBroadcast API (includes the callback_url parameter)
Error Code ReferenceComplete error code listing

Version: V1.5.7 Last Updated: 2026-05-20

Copyright © 2026