使用指南

Webhook 回呼指南

目錄

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

概述

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.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 為同一識別碼。可直接拿來對齊 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_idstring任務 ID(UUID)
errorstring失敗原因的一般說明(固定的英文文字,不含內部細節;需要排查時請提供 task_id)
failure_sourcestring | 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"
  }
}
欄位類型說明
balancestring當前剩餘點數
thresholdstring觸發通知的低餘額門檻

重複通知規則(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"
  }
}
欄位類型說明
balancestring當前剩餘點數(通常為 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-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 計算(見 通知對應的 API Key):

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 狀態碼(包括逾時,以及回 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

Copyright © 2026