龍騰 AI 非選批閱平台 開發者文件中心
事件與通知

接收 Webhook

註冊端點、事件目錄、驗證簽章,以及零停機的密鑰輪換

若需要對平台事件進行即時記錄,可以訂閱 Webhook 事件,生命週期事件發生時,將推送至客戶端註冊的 HTTPS 端點,使客戶端系統無須輪詢。

Loading diagram...

註冊端點

請在 Studio 中註冊 Webhook 端點。

註冊端點時,需要填寫或勾選

  • 目的地網址:必須是 HTTPS,且建立後不可變更。
  • 訂閱的事件

建立完成後,建立頁面 Studio 將顯示該端點的第一把簽章密鑰。該簽章密鑰僅顯示一次,關閉視窗後將沒有任何方式重新讀取,請立即存入祕密管理系統。

客戶端可以註冊多個端點,並各自具備自己的事件過濾與簽章密鑰。

接收端目的地規格要求

項目要求
通訊協定HTTPS,且憑證須由公開受信任的 CA 簽發(不接受自簽或私有 CA)
可及性需可自公開網際網路連線
重導3xx 不會被跟隨,會被視為一次失敗
用戶端憑證目前不支援 mTLS,也不支援自訂認證標頭

Webhook 事件來源不提供固定的來源 IP 或網段。因此請勿以來源 IP 作為信任依據。 請改以驗證簽章確認來源事件的安全性。

事件目錄

事件名稱均以 {resource}.{event} 格式命名。

事件觸發來源掛載資源
submission.created提交被受理、批閱排入佇列sub_
submission.completed每一筆作答都到達終態sub_
submission.failed提交整體失敗sub_
response.grading_started單筆作答開始批閱resp_
response.grading_completed單筆作答完成批閱resp_
response.grading_failed單筆作答批閱失敗resp_
response.evaluation_revised一次人工覆核產生了新的批閱結果版本resp_

多數整合應只需要 submission.completed,建議由此開始,有明確需求時再額外增加。不同事件之間產生的流量差異可能很大,請視需求訂閱並逐步測試。

遞送內容

POST /webhooks/norma HTTP/1.1
Host: api.norma.terathinker.com
Content-Type: application/json
X-Norma-Signature: sha256=5f37d0b0e2249171d8b323b1c8dbb1a582b70a83f9a72c2b64b5b6c9c9d3e8f1
X-Norma-Timestamp: 1768905001
X-Norma-Event-Type: submission.completed
X-Norma-Delivery-Id: del_8c31a2f9

{
  "event_id": "evt_a1b2c3d4e5f6",
  "event_type": "submission.completed",
  "sequence": 2,
  "resource_id": "sub_def456",
  "resource_type": "submission",
  "idempotency_key": "sub_def456:completed:2",
  "occurred_at": "2026-01-20T10:30:00Z",
  "delivered_at": "2026-01-20T10:30:01Z",
  "payload": { "…": "…" }
}

各事件 payload 的完整欄位請見 Webhook 事件參考

標頭說明
X-Norma-Signature本次遞送的簽章。每把有效簽章密鑰會產生一組 sha256={hex},多把時以空白分隔
X-Norma-Timestamp該次嘗試的 Unix 秒數,同時也是簽章的 {timestamp} 輸入
X-Norma-Event-Type該次事件的種類
X-Norma-Delivery-Id該次遞送嘗試的識別碼(del_…)(僅用於記錄與技術支援,不可作為去重依據。正確的去重方法請參考 event_id

驗證簽章

每次遞送時,都將以端點的有效簽章密鑰進行簽章。每個端點同時持有一至兩把簽章密鑰,每一把密鑰對應遞送標頭中的一組 X-Norma-Signature 簽章。有多把密鑰時,以空白分隔:

X-Norma-Signature: sha256={hex_a}                 # 一把有效密鑰(穩定狀態)
X-Norma-Signature: sha256={hex_a} sha256={hex_b}  # 兩把(輪換期間)

hex = HMAC-SHA256(secret, "{timestamp}.{raw_body}")

請以持有的密鑰計算期望的正確簽章,只要在標頭的任一個值中存在即可接受。

  1. 讀取 X-Norma-SignatureX-Norma-Timestamp
  2. |now − timestamp| > 300 秒則拒絕。
  3. 對持有的每把密鑰,以收到的原始位元組計算期望的正確簽章 HMAC-SHA256(secret, "{timestamp}.{raw_body}")切勿使用解析後 JSON 的重新序列化結果,因空白與鍵值順序的差異將使簽章有所差異。
  4. X-Norma-Signature 依空白切分,以定時比較(constant-time)檢查期望的正確簽章,是否等於其中任一個;皆不符則拒絕。
  5. 通過之後即可解析與處理內容。
import crypto from 'node:crypto';

// rawBody:收到的原始請求位元組(例如 express.raw({ type: 'application/json' }))。
// X-Norma-Signature 每把有效密鑰對應一個 "sha256={hex}",以空白分隔;
// 持有的密鑰只要符合其中任一個即接受。
function verifyWebhook(headers, rawBody, secret) {
  const header = headers['x-norma-signature'] ?? '';
  const timestamp = headers['x-norma-timestamp'] ?? '';

  // 1. 重放保護:拒絕過期的時間戳(5 分鐘窗口)
  const now = Math.floor(Date.now() / 1000);
  if (!timestamp || Math.abs(now - Number(timestamp)) > 300) {
    throw new Error('Timestamp outside tolerance');
  }

  // 2. 以持有的密鑰重新計算期望簽章
  const hmac = crypto.createHmac('sha256', secret);
  hmac.update(`${timestamp}.`);
  hmac.update(rawBody);
  const expected = `sha256=${hmac.digest('hex')}`;

  // 3. 標頭中任一個符合即接受(定時比較)
  const b = Buffer.from(expected);
  const matched = header
    .split(/\s+/)
    .filter(Boolean)
    .some((sig) => {
      const a = Buffer.from(sig);
      return a.length === b.length && crypto.timingSafeEqual(a, b);
    });
  if (!matched) throw new Error('Invalid signature');

  return JSON.parse(rawBody);
}

每次遞送嘗試都將以全新的時間戳獨立簽章,內容中的 delivered_at 亦為新值,因此各次嘗試的簽章不同,每次收到遞送均請重新計算期望的正確簽章,不得將該簽章暫存或直接比對。

輪換簽章密鑰

您可以在 Studio 中自行進行簽章密鑰的輪換作業。

在 Studio 新增第二把密鑰

新密鑰的值只會顯示一次,請立即保存。

自此之後,每次遞送都同時以兩把密鑰簽章,標頭將帶有兩個 sha256=… 值。

更新客戶端驗證程式

使驗證程式認得新密鑰。

在 Studio 停用舊密鑰

舊密鑰在停用前將持續有效。請記得停用舊密鑰,遞送將停止以該密鑰簽章,回到單一簽章。

每個端點最多同時持有兩把有效密鑰,已有兩把時無法再新增,請先退役一把。

相關

本頁內容