Webhook
目錄
概述
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.completed | recording.failed |
| 廣播錄音 | recording.completed | recording.failed |
| 音檔匯入 | recording.completed + import.completed | import.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": { ... }
}
| 欄位 | 類型 | 說明 |
|---|---|---|
event | string | 事件類型 |
timestamp | string | 事件發生時間(ISO 8601) |
delivery_id | string | 發送記錄 ID(UUID,可用於除錯和冪等處理) |
data | object | 事件資料(依事件類型不同) |
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與 WebSocketsession_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_id | string | 任務 ID(UUID) |
error | string | 失敗原因描述 |
failure_source | string | 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-Type | application/json |
X-VAS-Event | 事件類型(如 recording.completed) |
X-VAS-Delivery-Id | 發送記錄 ID(UUID) |
X-VAS-Timestamp | Unix 時間戳(秒) |
X-VAS-Signature | HMAC-SHA256 簽名 |
User-Agent | VAS-Webhook/1.0 |
簽名驗證
簽名使用 API Key 的 webhook_secret 計算:
signature_payload = timestamp + "." + raw_body
signature = "sha256=" + HMAC-SHA256(signature_payload, webhook_secret)
驗證步驟:
- 從
X-VAS-Timestamp取得時間戳 - 從
X-VAS-Signature取得簽名 - 將
timestamp + "." + 原始請求 body作為簽名內容 - 使用
webhook_secret計算 HMAC-SHA256 - 比對簽名是否一致
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