API 参考
接收事件

Webhook 投递与签名校验

UnifyPort 如何通过 HTTP POST 把事件投递到你的接收地址、每次投递携带的 X-Device-* 请求头,以及如何校验 HMAC-SHA256 签名以确认载荷可信。同时说明我们期望的响应、重试与幂等。

投递请求头

  • X-Device-Event-Id

    事件的稳定 id。同一事件的多次重试保持不变——与投递 id 搭配做幂等去重。

  • X-Device-Delivery-Id

    本次单独投递尝试的 id。未单独设置时回退为事件 id。

  • 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 响应会按 retry_policy.max_attempts 重试(默认 3 次)。其它 4xx 不重试,事件进入死信。
  • 投递语义为至少一次(at-least-once)。重试复用同一个 X-Device-Event-Id,请据此去重,不要假设恰好一次。
  • 请对原始请求字节做校验,且在任何 JSON 解析 / 重新序列化之前。重新编码请求体会改变字节、导致签名不匹配。
  • 拒绝 X-Device-Timestamp 与本地时钟相差过大的投递以限制重放——时间戳已被签名覆盖。
  • 签名按端点配置:在创建或更新 webhook 端点时设置 signing_secret 即开启;留空则关闭并不再发送 X-Device-Signature 头。
  • 投递不保证顺序——同一会话的事件可能乱序到达(已读回执可能先于它指向的消息)。请按 occurred_at 排序(以事件 id 作为并列时的次序键)后再应用状态。
  • UnifyPort 不持久化消息历史,也没有消息查询 API——webhook 事件是流量的唯一记录。事件到达时就把需要的内容存下来;漏掉的载荷之后无法回补。