接收 Webhook
註冊端點、事件目錄、驗證簽章,以及零停機的密鑰輪換
若需要對平台事件進行即時記錄,可以訂閱 Webhook 事件,生命週期事件發生時,將推送至客戶端註冊的 HTTPS 端點,使客戶端系統無須輪詢。
註冊端點
請在 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}")請以持有的密鑰計算期望的正確簽章,只要在標頭的任一個值中存在即可接受。
- 讀取
X-Norma-Signature與X-Norma-Timestamp。 - 若
|now − timestamp| > 300秒則拒絕。 - 對持有的每把密鑰,以收到的原始位元組計算期望的正確簽章
HMAC-SHA256(secret, "{timestamp}.{raw_body}")。切勿使用解析後 JSON 的重新序列化結果,因空白與鍵值順序的差異將使簽章有所差異。 - 將
X-Norma-Signature依空白切分,以定時比較(constant-time)檢查期望的正確簽章,是否等於其中任一個;皆不符則拒絕。 - 通過之後即可解析與處理內容。
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 停用舊密鑰
舊密鑰在停用前將持續有效。請記得停用舊密鑰,遞送將停止以該密鑰簽章,回到單一簽章。
每個端點最多同時持有兩把有效密鑰,已有兩把時無法再新增,請先退役一把。
相關
- 遞送、重試與去重 — 重試排程、斷路器、死信佇列,以及消費端應遵守的規則
- Webhook 事件參考 — 各事件的 payload 欄位
- 在 Studio 取得金鑰 — 端點與簽章密鑰的管理位置