Webhook 回呼指南
目錄
概述
Webhook 讓您的伺服器在錄音處理完成或失敗、點數餘額偏低或耗盡時收到即時通知,無需輪詢 API。VAS 會向您指定的 URL 發送 HTTP POST 請求,附帶事件資料和 HMAC-SHA256 簽名。
適用場景
- 音檔匯入:長時間音檔處理完成後自動通知
- 即時錄音:錄音上傳並處理完成後通知
- 廣播:廣播錄音處理完成後通知
- 點數餘額:帳戶點數偏低或耗盡時通知
雙層設定
| 層級 | 設定位置 | 優先順序 | 說明 |
|---|---|---|---|
| 請求級 | API 請求的 callback_url 參數 | 高(優先) | 建立音檔匯入、廣播時可指定,每次請求可不同 |
| API Key 級 | API Key 的 webhook_url 設定 | 低(備用) | 作為所有請求的預設回呼 URL |
若兩層都有設定,以請求級
callback_url為準。
通知對應的 API Key
每則通知都屬於一把 API Key:沒有指定 callback_url 時送到這把 Key 的 webhook_url,簽章也一律使用這把 Key 的 webhook_secret(送到 callback_url 時也一樣)。
| 場景 | 對應的 API Key | 可否指定 callback_url |
|---|---|---|
| 音檔匯入 | 建立匯入時使用的 API Key | 可以 |
| 即時錄音 | 取得連線 Ticket 時使用的 API Key | 不可以,一律送到該 Key 的 webhook_url |
| 廣播錄音 | 建立廣播時使用的 API Key;開播用了另一把 Key 時,錄音收尾後改以開播的那一把為準 | 可以(建立廣播時指定,仍優先於 webhook_url) |
| 點數餘額通知 | 帳戶內每一把啟用中、且已設定 webhook_url 的 API Key | 不適用(不會送到 callback_url),見 credit.low |
- API Key 刪除後,不再送出這把 Key 的任何通知;已指定
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 時,系統會先對該 URL 送出一個帶簽名的驗證請求(事件為 webhook.verify),接收端在 8 秒內回 2xx 才會儲存。系統只看接收端回應的狀態碼,不會檢查簽章;但若接收端已啟用簽章驗證,就必須先持有 webhook_secret,否則接收端會因驗簽失敗而回非 2xx,URL 也就無法儲存。驗證請求不跟隨轉址,回 3xx 也算失敗。建議依下列兩步流程設定:
步驟 1:產生 Webhook Secret
於管理後台開啟對應 API Key 詳情頁,於「Webhook 設定」區塊點擊 「產生 Webhook Secret」。系統會產生一個 64 字元的隨機 secret,並一次性顯示明文供您複製。建立 API Key 時若已勾選「同時產生 Webhook Secret」,建立完成時就會顯示一次明文,可略過這一步。
注意:Secret 僅在剛產生時顯示一次。關閉視窗後僅顯示遮罩,無法再取得明文。如遺失請點「重新產生 Secret」覆蓋。
將取得的 secret 設定到接收端(例如 .env 中的 VAS_WEBHOOK_SECRET),啟用 HMAC-SHA256 簽名驗證後重啟接收端服務。
步驟 2:填入 Webhook URL
回到管理後台,於「Webhook 設定」區塊點「編輯」,填入接收端 URL 後儲存。系統會用這把 secret 簽名送出驗證請求,接收端在 8 秒內回 2xx 才會儲存。若這把 API Key 還沒有 secret,點「編輯」時系統會先自動產生一把並顯示一次明文,請先設定到接收端再儲存 URL。
由於雙方持有相同 secret,接收端驗簽會通過並回 2xx,URL 即可儲存。
Webhook Secret 生命週期
- 一次性顯示:明文僅在剛產生時於管理後台顯示一次。
- 重生會立即失效舊簽名:執行重生後,使用舊 secret 簽名的 webhook 將被接收端拒絕。建議的 rotation 流程是先在接收端短暫接受新舊兩把 secret,等管理後台重新產生完成、所有 in-flight webhook 處理完畢後再下架舊 secret。
- 與 URL 解耦:清空 webhook URL 不會清除 secret;若要重置 secret 請點「重新產生 Secret」。
- 產生時機:以下情況會產生 secret:
- 點「產生 Webhook Secret」或「重新產生 Secret」
- 建立 API Key 時勾選「同時產生 Webhook Secret」(建立完成時顯示一次明文)
- 點「編輯」設定 URL 時這把 Key 還沒有 secret(自動產生,並顯示一次明文)
- 透過「批次 Webhook 設定」設定 URL 時,還沒有 secret 的 Key 會自動產生;勾選「重新產生每把 Key 的 Webhook Secret」則每把都換新。這兩種情況都不會顯示明文,需要明文請到該 Key 詳情頁點「重新產生 Secret」
API Key 級設定僅限透過管理後台操作。
Webhook 事件
| 事件 | 觸發時機 | 說明 |
|---|---|---|
recording.completed | 錄音處理完成 | 即時錄音、廣播、音檔匯入的錄音處理完成 |
recording.failed | 錄音處理失敗 | 處理過程中發生錯誤 |
import.completed | 音檔匯入完成 | 音檔匯入關聯的錄音處理完成 |
import.failed | 音檔匯入失敗 | 匯入過程中發生錯誤(格式錯誤、轉換失敗等) |
credit.low | 點數餘額偏低 | 扣點後餘額低於門檻(預設 50 點);同一帳戶 24 小時內最多通知一次,見 credit.low |
credit.exhausted | 點數已耗盡 | 扣點後餘額歸零(含部分扣抵後不足),或錄音因可用點數不足一分鐘而無法開始或繼續、且帳戶餘額不足以支付該分鐘;同一帳戶 24 小時內最多通知一次 |
事件觸發對應
| 場景 | 成功事件 | 失敗事件 |
|---|---|---|
| 即時錄音 | 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為同一識別碼。可直接拿來對齊 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": "No audio was received during the recording, so nothing was saved.",
"failure_source": "no_audio"
}
}
data 欄位說明
| 欄位 | 類型 | 說明 |
|---|---|---|
task_id | string | 任務 ID(UUID) |
error | string | 失敗原因的一般說明(固定的英文文字,不含內部細節;需要排查時請提供 task_id) |
failure_source | string | undefined | 失敗來源標籤:user_forced 代表使用者透過 POST /api/v1/tasks/{taskId}/force-fail 主動標記失敗;no_audio 代表整場即時錄音沒有收到任何音訊(v1.18.0)。其他來源(系統處理失敗、逾時自動清理)目前不帶此欄位 |
訂閱者提示:若要區分使用者手動操作 vs 系統自動偵測,請以
data.failure_source === 'user_forced'判斷;舊有來源為求向後相容不帶failure_source。整場沒有收到音訊的即時錄音,會在錄音結束時立即送出本事件(
failure_source: "no_audio"),這筆錄音沒有逐字稿與音檔。
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": "音檔格式轉換失敗"
}
}
- 同一筆匯入失敗只會送出一次
import.failed。處理中遇到暫時性的錯誤會先自動重試,重試用盡才送出,error_code為造成失敗的原因。 - 送出
import.failed之後,不會再收到同一筆匯入的import.completed。 error_message是依錯誤碼提供的一般說明,不含內部細節;需要排查時請提供import_id。錯誤碼一覽見 錯誤碼參考。
credit.low
扣點後帳戶餘額低於門檻時送出。balance 與 threshold 均為點數字串(一位小數)。
{
"event": "credit.low",
"timestamp": "2026-02-24T12:05:00.000000Z",
"delivery_id": "e5f6a7b8-c9d0-1234-efab-345678901234",
"data": {
"balance": "49.5",
"threshold": "50.0"
}
}
| 欄位 | 類型 | 說明 |
|---|---|---|
balance | string | 當前剩餘點數 |
threshold | string | 觸發通知的低餘額門檻 |
重複通知規則(
credit.low、credit.exhausted各自計算):
- 同一帳戶 24 小時內只送一次;過了 24 小時,之後再次符合條件時(例如再扣點後餘額仍低於門檻)會再送一次。
- 帳戶點數增加(例如加值、退點)後重新計算:之後再次符合條件時,不必等滿 24 小時就會再送。
- 這兩種通知不使用
callback_url,會送到帳戶內每一把啟用中、且已設定webhook_url的 API Key,每把各一份(各有自己的delivery_id);多把 Key 設定相同網址也不會合併成一份。
credit.exhausted
扣點後帳戶餘額歸零(含部分扣抵後不足)時送出。即時錄音因可用點數不足一分鐘而無法開始或繼續,且帳戶餘額不足以支付該分鐘時,也會送出(此時 balance 可能大於 0.0)。
{
"event": "credit.exhausted",
"timestamp": "2026-02-24T12:05:00.000000Z",
"delivery_id": "f6a7b8c9-d0e1-2345-fabc-456789012345",
"data": {
"balance": "0.0"
}
}
| 欄位 | 類型 | 說明 |
|---|---|---|
balance | string | 當前剩餘點數(通常為 0.0;錄音因點數不足一分鐘而無法開始或繼續時,可能是不足一分鐘費用的剩餘點數) |
安全驗證
VAS 使用 HMAC-SHA256 簽名驗證 Webhook 請求的真實性。
注意:設定順序:簽名驗證的前提是接收端與 VAS 雙方持有同一把
webhook_secret。請依照 步驟 1:產生 Webhook Secret 先在管理後台取得 secret 並設定到接收端,再執行 步驟 2:填入 Webhook URL。若接收端已啟用驗簽卻還沒有這把 secret,會拒絕儲存 URL 時送出的驗證請求(回非 2xx),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 計算(見 通知對應的 API Key):
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 狀態碼(包括逾時,以及回 3xx、4xx、5xx),VAS 會自動重試。
重試策略
同一則通知最多送出 5 次(首次發送加上重試 4 次):
| 次數 | 等待時間 | 累計時間 |
|---|---|---|
| 第 1 次重試 | 10 秒 | 10 秒 |
| 第 2 次重試 | 30 秒 | 40 秒 |
| 第 3 次重試 | 90 秒 | 2 分 10 秒 |
| 第 4 次重試 | 270 秒 | 6 分 40 秒 |
- 使用指數退避策略
- 第 4 次重試仍失敗就不再送出,這則通知標記為失敗
- 不跟隨轉址:回 3xx 視為失敗並重試,請直接設定最終網址
- 回 4xx 同樣會重試
- 需要重送已標記為失敗的通知,請聯絡我們,並提供相關的
task_id或import_id
回應要求
- 回應 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 });
});
同一錄音的完成事件可能送達兩次:極少數情況下,同一錄音的
recording.completed會送達兩次,兩次的delivery_id不同,只看delivery_id擋不住。請再以data.task_id加event判斷是否已處理過。重複送達不會重複扣點。
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.24.1 最後更新:2026-10-07