Guides

Webhook Callback Guide

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, 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

TierConfiguration LocationPriorityDescription
Request levelThe callback_url parameter in an API requestHigh (takes precedence)Can be specified when creating an audio import or a broadcast; may differ per request
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.

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).

ScenarioAPI KeyCan callback_url be specified?
Audio importThe API Key used to create the importYes
Real-time recordingThe API Key used to obtain the connection ticketNo; always sent to that key's webhook_url
Broadcast recordingThe API Key used to create the broadcast; if a different key starts the broadcast, the key that started it applies once the recording is finalizedYes (specified when creating the broadcast; still takes precedence over webhook_url)
Credit balance notificationsEvery active API Key in the account that has a webhook_urlNot 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

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

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.)
credit.lowCredit balance lowAfter a deduction the balance drops below the threshold (default 50 points); sent at most once every 24 hours per account; see credit.low
credit.exhaustedCredit exhaustedAfter 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

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. 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

FieldTypeDescription
task_idstringTask ID (UUID)
errorstringA general description of the failure reason (fixed English text without internal details; provide the task_id for troubleshooting)
failure_sourcestring | undefinedFailure 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 include failure_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.failed is sent only once per failed import. Temporary errors during processing are retried automatically first; the event is sent only when retries are exhausted, and error_code is the cause of the failure.
  • After import.failed, you never receive import.completed for the same import.
  • error_message is a general description based on the error code, without internal details; provide the import_id when 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"
  }
}
FieldTypeDescription
balancestringCurrent remaining points
thresholdstringLow-balance threshold that triggered the notification

Repeat rule (counted separately for credit.low and credit.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 a webhook_url, one copy per key (each with its own delivery_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"
  }
}
FieldTypeDescription
balancestringCurrent 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:

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 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:

  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 (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):

AttemptWait TimeCumulative Time
1st retry10 seconds10 seconds
2nd retry30 seconds40 seconds
3rd retry90 seconds2 min 10 sec
4th retry270 seconds6 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_id or import_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.completed for the same recording is delivered twice, with a different delivery_id each time, so checking delivery_id alone will not catch it. Also check data.task_id together with event to 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)

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.24.1 Last Updated: 2026-10-07

Copyright © 2026