使用指南

Webhook

目錄

  1. 概述
  2. 設定方式
  3. Webhook 事件
  4. Payload 格式
  5. 安全驗證
  6. 重試機制
  7. 最佳實踐
  8. 完整範例
  9. 相關文件

概述

Webhook 讓您的伺服器在錄音處理完成或失敗時收到即時通知,無需輪詢 API。VAS 會向您指定的 URL 發送 HTTP POST 請求,附帶事件資料和 HMAC-SHA256 簽名。

適用場景

  • 音檔匯入:長時間音檔處理完成後自動通知
  • 即時錄音:錄音上傳並處理完成後通知
  • 廣播:廣播錄音處理完成後通知

雙層設定

層級設定位置優先順序說明
請求級API 請求的 callback_url 參數高(優先)每次請求可指定不同的回呼 URL
API Key 級API Key 的 webhook_url 設定低(備用)作為所有請求的預設回呼 URL

若兩層都有設定,以請求級 callback_url 為準。


設定方式

方式一:請求級 callback_url(推薦)

在每次 API 請求中指定 callback_url 參數。

音檔匯入:

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"

建立廣播:

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"
  }'

方式二:API Key 級 webhook_url

在 API Key 設定中指定 webhook_url,作為所有請求的預設回呼位址。

由於設定 URL 時系統會立即送出簽名版 probe 驗證可達性與簽章一致性,接收端必須先持有 webhook_secret 才能通過驗證。請依下列兩步流程設定:

步驟 1:產生 Webhook Secret

於 Dashboard 開啟對應 API Key 詳情頁,於「Webhook 設定」區塊點擊 「產生 Webhook Secret」。系統會產生一個 64 字元的隨機 secret,並一次性顯示明文供您複製。

⚠️ Secret 僅在剛產生時顯示一次。關閉視窗後僅顯示遮罩,無法再取得明文。如遺失請點「重新產生」覆蓋。

將取得的 secret 設定到接收端(例如 .env 中的 VAS_WEBHOOK_SECRET),啟用 HMAC-SHA256 簽名驗證後重啟接收端服務。

步驟 2:填入 Webhook URL

回到 Dashboard,於「Webhook 設定」區塊點「編輯」,填入接收端 URL 後儲存。系統會用既有 secret 簽名送出 webhook.verify probe(必須回 2xx 才會儲存)。

由於雙方持有相同 secret,probe 簽章驗證會直接通過。

Webhook Secret 生命週期

  • 一次性顯示:明文僅在剛產生時透過 Dashboard 顯示一次。
  • 重生會立即失效舊簽名:執行重生後,使用舊 secret 簽名的 webhook 將被接收端拒絕。建議的 rotation 流程是先在接收端短暫接受新舊兩把 secret,等 dashboard 重生完成、所有 in-flight webhook 處理完畢後再下架舊 secret。
  • 與 URL 解耦:清空 webhook URL 不會清除 secret;若要重置 secret 請呼叫「重新產生」。
  • Lazy 產生:建立 API Key 時不會自動產生 webhook secret,僅在主動點擊「產生 Webhook Secret」時才會建立。

API Key 級設定僅限透過 Dashboard 操作。


Webhook 事件

事件觸發時機說明
recording.completed錄音處理完成即時錄音、廣播、音檔匯入的錄音處理完成
recording.failed錄音處理失敗處理過程中發生錯誤
import.completed音檔匯入完成音檔匯入關聯的錄音處理完成
import.failed音檔匯入失敗匯入過程中發生錯誤(格式錯誤、轉換失敗等)

事件觸發對應

場景成功事件失敗事件
即時錄音recording.completedrecording.failed
廣播錄音recording.completedrecording.failed
音檔匯入recording.completed + import.completedimport.failed(匯入階段)或 recording.failed(處理階段)

音檔匯入成功時會收到兩個事件:先是 recording.completed,再是 import.completed


Payload 格式

共通結構

{
  "event": "recording.completed",
  "timestamp": "2026-02-24T12:00:00.000000Z",
  "delivery_id": "550e8400-e29b-41d4-a716-446655440000",
  "data": { ... }
}
欄位類型說明
eventstring事件類型
timestampstring事件發生時間(ISO 8601)
delivery_idstring發送記錄 ID(UUID,可用於除錯和冪等處理)
dataobject事件資料(依事件類型不同)

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": "會議錄音",
    "duration_ms": 3600000,
    "type_source": "realtime",
    "transcription_languages": ["zh-TW"],
    "translation_languages": ["en-US"]
  }
}
欄位說明
task_id任務 ID(可用於 Tasks API 查詢詳細資料)
name錄音名稱
duration_ms錄音時長(毫秒)
type_source來源類型:realtime(即時錄音)/ import(音檔匯入)
transcription_languages轉錄語言
translation_languages翻譯語言

ID 對齊提示data.task_id 與 WebSocket session_started 事件中的 task_id 為同一識別碼(V1.4.1 之前 WS 端命名為 recording_id,現仍為同值別名)。可直接拿來對齊 WS 會話與 Webhook 任務。

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 欄位說明

欄位類型說明
task_idstring任務 ID(UUID)
errorstring失敗原因描述
failure_sourcestring | undefined失敗來源標籤;user_forced 代表使用者透過 POST /api/v1/tasks/{taskId}/force-fail 主動標記失敗;其他來源(Job 執行失敗、CheckStaleRecordings 自動清理)目前未帶此欄位

訂閱者提示:若要區分使用者手動操作 vs 系統自動偵測,請以 data.failure_source === 'user_forced' 判斷;舊有來源為求向後相容不帶 failure_source

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": "會議錄音",
    "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": "音檔格式轉換失敗"
  }
}

安全驗證

VAS 使用 HMAC-SHA256 簽名驗證 Webhook 請求的真實性。

⚠️ Bootstrap 提醒:簽名驗證的前提是接收端與 VAS 雙方持有同一把 webhook_secret。請務必依照 步驟 1:產生 Webhook Secret 流程,先在 Dashboard 取得 secret 並設定到接收端,再執行 步驟 2:填入 Webhook URL。否則 probe 將因簽章不符而失敗,URL 無法儲存。

HTTP Headers

每次 Webhook 請求包含以下 Headers:

Header說明
Content-Typeapplication/json
X-VAS-Event事件類型(如 recording.completed
X-VAS-Delivery-Id發送記錄 ID(UUID)
X-VAS-TimestampUnix 時間戳(秒)
X-VAS-SignatureHMAC-SHA256 簽名
User-AgentVAS-Webhook/1.0

簽名驗證

簽名使用 API Key 的 webhook_secret 計算:

signature_payload = timestamp + "." + raw_body
signature = "sha256=" + HMAC-SHA256(signature_payload, webhook_secret)

驗證步驟:

  1. X-VAS-Timestamp 取得時間戳
  2. X-VAS-Signature 取得簽名
  3. timestamp + "." + 原始請求 body 作為簽名內容
  4. 使用 webhook_secret 計算 HMAC-SHA256
  5. 比對簽名是否一致

Node.js 驗證範例:

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; // 原始請求 body 字串

  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 驗證範例:

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)

防重放攻擊

建議檢查 X-VAS-Timestamp 與當前時間的差距,若超過 5 分鐘則拒絕請求:

const MAX_AGE_SECONDS = 300; // 5 分鐘
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' });
}

重試機制

若您的伺服器未回應 2xx 狀態碼,VAS 會自動重試。

重試策略

次數等待時間累計時間
第 1 次重試10 秒10 秒
第 2 次重試30 秒40 秒
第 3 次重試90 秒2 分 10 秒
第 4 次重試270 秒6 分 40 秒
第 5 次重試810 秒20 分 10 秒
  • 最多重試 5 次(含首次發送共 6 次嘗試)
  • 使用指數退避策略
  • 重試耗盡後標記為失敗

回應要求

  • 回應 2xx 狀態碼表示接收成功
  • 回應必須在 15 秒內返回
  • 逾時或非 2xx 回應會觸發重試

最佳實踐

1. 快速回應,非同步處理

// 推薦:先回應 200,再非同步處理
app.post('/webhooks/vas', async (req, res) => {
  // 立即回應
  res.status(200).json({ received: true });

  // 非同步處理事件
  processWebhookEvent(req.body).catch(console.error);
});

2. 冪等處理

使用 delivery_id 避免重複處理:

app.post('/webhooks/vas', async (req, res) => {
  const { delivery_id } = req.body;

  // 檢查是否已處理過
  if (await isDeliveryProcessed(delivery_id)) {
    return res.status(200).json({ received: true, duplicate: true });
  }

  // 標記為已處理
  await markDeliveryProcessed(delivery_id);

  // 處理事件...
  res.status(200).json({ received: true });
});

3. 驗證簽名

務必驗證 X-VAS-Signature 確保請求來自 VAS:

app.post('/webhooks/vas', (req, res) => {
  if (!verifyWebhookSignature(req, WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }
  // 處理事件...
});

完整範例

Node.js (Express)

const express = require('express');
const crypto = require('crypto');

const app = express();
const WEBHOOK_SECRET = 'your_webhook_secret_here';

// 保留原始 body 用於簽名驗證
app.use('/webhooks/vas', express.json({
  verify: (req, res, buf) => { req.rawBody = buf.toString(); }
}));

app.post('/webhooks/vas', (req, res) => {
  // 1. 驗證簽名
  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. 立即回應
  res.status(200).json({ received: true });

  // 3. 處理事件
  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`);
      // 載入逐字稿、通知使用者等...
      break;

    case 'recording.failed':
      console.log(`Recording failed: task_id=${data.task_id}, error=${data.error}`);
      // 通知使用者處理失敗...
      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. 驗證簽名
    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. 處理事件
    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}")
        # 載入逐字稿、通知使用者等...

    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)

相關文件

文件說明
認證機制API Key 設定與認證說明
Imports API音檔匯入 API(含 callback_url 參數)
Broadcasts API廣播 API(含 callback_url 參數)
錯誤碼參考完整錯誤碼一覽表

版本:V1.5.7 最後更新:2026-05-20

Copyright © 2026