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,验签就会失败。
正确顺序是:
- 拿
x-timestamp、x-signature两个 header; - 用原始 body 字符串计算 HMAC-SHA256;
- 常数时间比较签名;
- 比较通过后,再检查时间戳与当前时间的差值是否在容差内;
- 全部通过才
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 去验测试事件。