案例 Airwallex webhook 验签实操:签名是 timestamp + raw body 的 HMAC,SDK 没有现成 helper

2026-11-14 21:05:48

Airwallex webhook 验签实操:签名是 HMAC-SHA256(secret, timestamp + raw body),SDK 里没有现成 helper

Airwallex 的 webhook 签名不是 Standard Webhooks / Svix 那一套:没有 webhook-id / webhook-signature 头,没有 whsec_ 前缀要解码,node-sdk 也不带入站验签 helper。签名需要自己算。文档见 Airwallex Webhooks。

头字段与签名算法

Airwallex 只在请求头里提供两项用于验签的信息:

  • x-timestamp:Unix 毫秒时间戳,例如 1357872222592。
  • x-signature:HMAC-SHA256 的 hex digest,仅当该 webhook 配置了 secret 时发送。

待签字符串是 x-timestamp(按字符串原样)拼上原始 JSON body(按字符串原样),即:

value_to_digest = x-timestamp + raw_body
signature       = hex(HMAC-SHA256(secret, value_to_digest))

拼接顺序不能反,timestamp 必须在前。secret 以该 notification URL 的配置为准,每个订阅各自独立,在 Airwallex Web 控制台里配置。

先验签,再解析 body

最常踩的坑是拿解析后又重新序列化的 JSON 去算 HMAC。JSON.parse 再 JSON.stringify 会改变空格、键顺序、转义形式,算出来的签名必然对不上。同理,任何中间件只要读走并重写了 body,验签就会失败。

正确顺序是:

  1. 拿 x-timestamp、x-signature 两个 header;
  2. 用原始 body 字符串计算 HMAC-SHA256;
  3. 常数时间比较签名;
  4. 比较通过后,再检查时间戳与当前时间的差值是否在容差内;
  5. 全部通过才 JSON.parse 处理业务。

其他失败原因还有:时间戳被转成了 Date 或做了格式化、用错 secret(拿的是另一个 URL 的)、拼接时把 timestamp 放到了后面。

各框架怎么拿 raw body

  • Express:express.raw({ type: 'application/json' }),req.body 是 Buffer。不要挂 express.json() 到这条路由上。
  • Next.js App Router:const raw = await req.text()。
  • FastAPI:raw = await request.body(),得到 bytes。
  • PHP:file_get_contents('php://input')。

Node 会把 header 名统一小写,所以取的是 req.headers['x-timestamp'] 和 req.headers['x-signature'];大小写敏感的运行时按 x-timestamp / x-signature 取。

Node 实现

const crypto = require('crypto');
const express = require('express');
const app = express();

app.post(
  '/webhooks/airwallex',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const timestamp = req.headers['x-timestamp'];
    const signature = req.headers['x-signature'];
    const secret = process.env.AIRWALLEX_WEBHOOK_SECRET;

    if (!timestamp || !signature) return res.status(401).end();

    const raw = req.body.toString('utf8'); // 原始 body,未经解析

    const expected = crypto
      .createHmac('sha256', secret)
      .update(timestamp + raw)
      .digest('hex');

    // 常数时间比较,长度不同直接拒绝
    const a = Buffer.from(expected, 'utf8');
    const b = Buffer.from(signature, 'utf8');
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).end();
    }

    // 官方未规定重放窗口,这里自选 5 分钟
    const skew = Math.abs(Date.now() - Number(timestamp));
    if (skew > 5 * 60 * 1000) return res.status(401).end();

    const event = JSON.parse(raw);
    // 用 event.id 做幂等去重,然后入队/异步处理
    res.status(200).end();
  }
);

PHP 实现

$timestamp = $_SERVER['HTTP_X_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$secret    = getenv('AIRWALLEX_WEBHOOK_SECRET');
$raw       = file_get_contents('php://input');

if ($timestamp === '' || $signature === '') {
    http_response_code(401);
    exit;
}

$expected = hash_hmac('sha256', $timestamp . $raw, $secret);

// hash_equals 是常数时间比较
if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit;
}

$nowMs = (int) round(microtime(true) * 1000);
if (abs($nowMs - (int) $timestamp) > 5 * 60 * 1000) {
    http_response_code(401);
    exit;
}

$event = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
// 按 $event['id'] 去重后处理
http_response_code(200);

应答、重试与幂等

Airwallex 要求端点返回 200 OK,超时或任何其他状态码都算失败并会重试。所以应当先回 200,再异步处理业务,避免处理逻辑把响应拖到超时。

每次重试都是同一事件,每个事件有稳定的 id。用这个 id 在库里做唯一约束或去重表,就能安全应对重试。

端点与控制台侧

  • 端点必须是 HTTPS。
  • 服务端需要放行 Airwallex 的 webhook IP,生产与 Sandbox 各有自己的 IP 列表。
  • 每个订阅独立 secret,在 Web 应用里配置。
  • 测试事件的签名由测试 payload 中 client-secret-key 头提供的 secret 生成,不要拿生产 secret 去验测试事件。
复制全文 生成海报 支付 Webhook 接口对接

推荐文章

程序员茄子在线接单