PAYATHON 2026

使用 Revolut webhook 时如何验证请求来源?

支付老李

明确结论

验证 Revolut 支付 Webhook 的来源时,应使用配置 Webhook 时获得的签名密钥,结合请求头 Revolut-Request-Timestamp 和未经处理的原始请求体重新计算 HMAC-SHA256,再与 Revolut-Signature 比较。

不要只依赖来源 IP、User-Agent 或请求中的订单号。这些信息可能发生变化,也可能被伪造,无法替代签名验证。

验证流程

收到 Webhook 后,按以下顺序处理:

  1. 读取 Revolut-Request-Timestamp。
  2. 获取原始请求体,不要使用重新序列化后的 JSON。
  3. 拼接待签名内容:
{timestamp}.{raw_request_body}
  1. 使用 Webhook signing secret 计算 HMAC-SHA256。
  2. 在十六进制摘要前加上 v1=。
  3. 使用恒定时间比较方法,将结果与 Revolut-Signature 中的签名比较。
  4. 检查时间戳是否在允许范围内,防止合法请求被重放。
  5. 签名验证通过后,再解析 JSON 并更新订单状态。

签名密钥应使用 Revolut 在创建或配置 Webhook 时返回或展示的 signing secret。它不是 API 密钥,也不应写入前端代码或日志。如果当前 Webhook 配置中没有签名密钥,需要根据实际使用的 Revolut Merchant API 版本检查配置或重新创建 Webhook,不能自行用某个 API 密钥代替。

Node.js / Express 示例

这里需要保留原始请求体。下面的路由使用 express.raw(),不要让全局 express.json() 提前处理该路径。

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

const app = express();

const webhookSecret = process.env.REVOLUT_WEBHOOK_SECRET;
const toleranceSeconds = 300;

function safeEqualHex(actualHex, expectedHex) {
  if (!/^[a-f0-9]+$/i.test(actualHex)) {
    return false;
  }

  const actual = Buffer.from(actualHex, 'hex');
  const expected = Buffer.from(expectedHex, 'hex');

  return (
    actual.length === expected.length &&
    crypto.timingSafeEqual(actual, expected)
  );
}

function verifyRevolutWebhook(rawBody, timestamp, signatureHeader) {
  if (!webhookSecret || !timestamp || !signatureHeader) {
    return false;
  }

  const timestampSeconds = Number(timestamp);
  if (!Number.isFinite(timestampSeconds)) {
    return false;
  }

  const nowSeconds = Math.floor(Date.now() / 1000);
  if (Math.abs(nowSeconds - timestampSeconds) > toleranceSeconds) {
    return false;
  }

  const signedPayload = Buffer.concat([
    Buffer.from(`${timestamp}.`, 'utf8'),
    rawBody
  ]);

  const expectedDigest = crypto
    .createHmac('sha256', webhookSecret)
    .update(signedPayload)
    .digest('hex');

  // 兼容请求头中存在多个以逗号分隔的签名值
  const signatures = signatureHeader
    .split(',')
    .map(value => value.trim())
    .filter(value => value.startsWith('v1='))
    .map(value => value.slice(3));

  return signatures.some(signature =>
    safeEqualHex(signature, expectedDigest)
  );
}

app.post(
  '/webhooks/revolut',
  express.raw({ type: '*/*' }),
  async (req, res) => {
    const timestamp = req.get('Revolut-Request-Timestamp');
    const signature = req.get('Revolut-Signature');

    if (!verifyRevolutWebhook(req.body, timestamp, signature)) {
      return res.status(401).send('Invalid webhook signature');
    }

    let event;

    try {
      event = JSON.parse(req.body.toString('utf8'));
    } catch {
      return res.status(400).send('Invalid JSON');
    }

    // 先做幂等检查,再根据 event 更新订单状态
    // await handleRevolutEvent(event);

    return res.sendStatus(204);
  }
);

app.listen(3000);

如果应用已经全局启用 JSON 中间件,应先注册 Webhook 路由:

app.post(
  '/webhooks/revolut',
  express.raw({ type: '*/*' }),
  revolutWebhookHandler
);

app.use(express.json());

请求体一旦被解析,再通过 JSON.stringify() 生成,其中的空格、换行或字段格式就可能发生变化,导致签名始终无法匹配。

Python / Flask 示例

在 Flask 中,应先调用 request.get_data() 获取原始字节:

import hashlib
import hmac
import json
import os
import time

from flask import Flask, request, Response

app = Flask(__name__)

WEBHOOK_SECRET = os.environ["REVOLUT_WEBHOOK_SECRET"]
TOLERANCE_SECONDS = 300


def verify_revolut_webhook(raw_body, timestamp, signature_header):
    if not timestamp or not signature_header:
        return False

    try:
        timestamp_seconds = int(timestamp)
    except ValueError:
        return False

    if abs(int(time.time()) - timestamp_seconds) > TOLERANCE_SECONDS:
        return False

    signed_payload = timestamp.encode("utf-8") + b"." + raw_body

    expected_digest = hmac.new(
        WEBHOOK_SECRET.encode("utf-8"),
        signed_payload,
        hashlib.sha256,
    ).hexdigest()

    signatures = [
        item.strip()[3:]
        for item in signature_header.split(",")
        if item.strip().startswith("v1=")
    ]

    return any(
        hmac.compare_digest(signature, expected_digest)
        for signature in signatures
    )


@app.post("/webhooks/revolut")
def revolut_webhook():
    raw_body = request.get_data(cache=True)

    timestamp = request.headers.get("Revolut-Request-Timestamp")
    signature = request.headers.get("Revolut-Signature")

    if not verify_revolut_webhook(raw_body, timestamp, signature):
        return Response("Invalid webhook signature", status=401)

    try:
        event = json.loads(raw_body)
    except json.JSONDecodeError:
        return Response("Invalid JSON", status=400)

    # 在这里执行幂等检查并处理 event
    return Response(status=204)

还需要注意什么

防止重放攻击

签名正确只能说明请求由持有密钥的一方生成,不能证明它是第一次到达。还需要采取以下措施:

  • 限制 Revolut-Request-Timestamp 与服务器时间的差值,例如不超过 5 分钟;
  • 记录已经处理过的事件 ID;
  • 同一个事件重复回调时返回成功,但不要重复发货、退款或记账。

允许的时间窗口应根据 Revolut 当前文档和系统时钟误差确定,同时确保服务器时间保持同步。

不要只相信事件内容

即使签名验证通过,在执行发货、入账等重要操作前,仍应使用事件中的订单标识查询或核对本地订单,确认:

  • 订单属于当前商户;
  • 金额和币种符合预期;
  • 状态转换合法;
  • 该事件尚未处理。

必要时,可以再调用 Revolut API 查询订单的当前状态,但查询接口不能完全代替 Webhook 签名验证。

密钥管理

Webhook signing secret 应保存在服务端的环境变量或密钥管理系统中。不要:

  • 提交到 Git 仓库;
  • 返回给浏览器;
  • 输出到应用日志;
  • 与 API token 混用;
  • 在签名失败日志中记录完整请求头和敏感支付数据。

如果签名密钥可能已经泄露,应轮换密钥或重建 Webhook。过渡期间,按照 Revolut 当前支持的方式处理新旧签名。

不要把 IP 白名单作为唯一验证手段

除非 Revolut 针对所使用的产品和环境明确发布并维护固定出口 IP 列表,否则不要用 IP 白名单验证身份。代理、网络架构或出口地址都可能调整。IP 限制可以作为附加防护,但签名验证仍是主要验证方式。

如果收到的请求中没有 Revolut-Signature 或 Revolut-Request-Timestamp,应先确认当前使用的 Revolut 产品、API 版本和 Webhook 创建方式。不同产品或历史接口可能采用不同机制。在没有对应签名规范的情况下,不能声称仅凭请求本身就能可靠证明其来源。

备注:内容仅供参考。