API 參考
接收事件

Webhook 投遞與簽章驗證

UnifyPort 如何透過 HTTP POST 把事件投遞到你的接收位址、每次投遞攜帶的 X-Device-* 請求標頭,以及如何驗證 HMAC-SHA256 簽章以確認載荷可信。並說明我們預期的回應、重試與冪等。

投遞請求標頭

  • X-Device-Event-Id

    同一已解析事件在重試期間不變的關聯 id。HistorySync 可能跨批次重用此值,不能當作歷史訊息的全域唯一鍵。

  • X-Device-Delivery-Id

    投遞關聯 id;未另行設定時會回退為事件 id,因此不保證每次 HTTP 嘗試都唯一。

  • X-Device-Timestamp

    本次投遞簽章時的 RFC 3339 UTC 時間(如 2026-06-08T12:34:56Z)。它是簽章字串的一部分,也可用於拒絕過期投遞。

  • X-Device-Signature

    對 "<X-Device-Timestamp>" + "." + "<原始請求內文>" 計算的十六進位 HMAC-SHA256。僅當端點設定了 signing_secret 時存在;關閉簽章時不傳送。

  • Content-Type

    一律為 application/json。

驗證簽章

Node.js (Express)

import crypto from 'crypto';
import express from 'express';

const app = express();
const SECRET = process.env.WEBHOOK_SIGNING_SECRET;

// express.raw keeps the body as the exact bytes we signed — never re-serialize.
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  let timestamp = req.get('X-Device-Timestamp');
  if (!timestamp) {
    timestamp = '';
  }
  let signature = req.get('X-Device-Signature');
  if (!signature) {
    signature = '';
  }

  const hmac = crypto.createHmac('sha256', SECRET);
  hmac.update(timestamp + '.');
  hmac.update(req.body); // raw Buffer
  const expected = hmac.digest('hex');

  const valid =
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  if (!valid) return res.status(401).end();

  const event = JSON.parse(req.body.toString('utf8'));
  // handle event.type ...
  res.status(200).end(); // any 2xx acknowledges the delivery
});

Python (Flask)

import hmac, hashlib, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["WEBHOOK_SIGNING_SECRET"].encode()

@app.post("/webhook")
def webhook():
    timestamp = request.headers.get("X-Device-Timestamp", "")
    signature = request.headers.get("X-Device-Signature", "")
    body = request.get_data()  # raw bytes, exactly as delivered

    expected = hmac.new(
        SECRET, timestamp.encode() + b"." + body, hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(signature, expected):
        abort(401)

    event = request.get_json()
    # handle event["type"] ...
    return "", 200

備註

  • 以任意 2xx 狀態確認。回應內文會被讀取並丟棄;非 2xx 視為投遞失敗。
  • 連線錯誤與 408 / 429 / 5xx 會在首次投遞後依 max_attempts 重試;預設 3 次重試,即最多 4 次 HTTP 請求。目前沒有 backoff,也不讀取 Retry-After。
  • 投遞語意為至少一次。一般事件可用 X-Device-Event-Id 抵禦同一事件重試;HistorySync 應依 provider + account_id + conversation.id + messages[].id 合併。
  • 請對原始請求位元組做驗證,且在任何 JSON 解析 / 重新序列化之前。重新編碼內文會改變位元組、導致簽章不符。
  • 拒絕 X-Device-Timestamp 與本地時鐘相差過大的投遞以限制重放——時間戳已被簽章涵蓋。
  • 簽章依端點設定:在建立或更新 webhook 端點時設定 signing_secret 即開啟;留空則關閉並不再傳送 X-Device-Signature 標頭。
  • 投遞不保證順序——同一會話的事件可能亂序到達(已讀回執可能先於它指向的訊息)。請按 occurred_at 排序(以事件 id 作為並列時的次序鍵)後再套用狀態。
  • UnifyPort 沒有 REST 訊息歷史查詢,也不保證重放漏掉的事件;呼叫端仍應持久化 Webhook。WhatsApp 的 best-effort HistorySync 僅供近期啟動/重連銜接,不能當作完整封存。