You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何修复Node.js中PayPal Webhook签名验证失败问题

集成PayPal Webhook时Postman模拟请求出现401签名验证失败问题

问题描述

在Node.js应用中集成PayPal Webhook时,使用Postman向http://localhost:3001/paypal/subscriptions/webhook发送POST请求,返回401 Unauthorized错误,提示信息为"Webhook signature verification failed"。

背景

为模拟Webhook事件,通过Webhook.site捕获PayPal的真实Webhook调用,提取以下请求头并手动添加到Postman请求中:

  • paypal-auth-algo
  • paypal-cert-url
  • paypal-transmission-id
  • paypal-transmission-sig
  • paypal-transmission-time
    已确认请求头与PayPal真实调用一致,但签名验证始终失败。

代码实现

paypalRouter.post("/subscriptions/webhook", async (req, res) => {

  console.log("Received webhook event", req.body); 

  try {
   
    console.log('Headers:', {
      'paypal-auth-algo': req.headers['paypal-auth-algo'],
      'paypal-cert-url': req.headers['paypal-cert-url'],
      'paypal-transmission-id': req.headers['paypal-transmission-id'],
      'paypal-transmission-sig': req.headers['paypal-transmission-sig'],
      'paypal-transmission-time': req.headers['paypal-transmission-time'],
    });
    const webhookEvent = req.body;

    console.log("Webhook event received:", webhookEvent.event_type);


    const verification = {
      auth_algo: req.headers['paypal-auth-algo'],
      cert_url: req.headers['paypal-cert-url'],
      transmission_id: req.headers['paypal-transmission-id'],
      transmission_sig: req.headers['paypal-transmission-sig'],
      transmission_time: req.headers['paypal-transmission-time'],
      webhook_id: process.env.WEBHOOK_ID,
      webhook_event: webhookEvent,
    };
    console.log('Final Verification Request Payload:', JSON.stringify(verification, null, 2));

    console.log('Verification Payload:', verification);

    const params = new URLSearchParams();
    params.append("grant_type", "client_credentials");

    const authResponse = await axios.post(
      "https://api-m.sandbox.paypal.com/v1/oauth2/token",
      params,
      {
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
        },
        auth: {
          username: process.env.PAYPAL_CLIENT_ID,
          password: process.env.PAYPAL_SECRET,
        },
      }
    );

    const accessToken = authResponse.data.access_token;

    const verifyResponse = await axios.post(
      "https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature",
      verification,
      {
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${accessToken}`
        },
      }
    );

    if (verifyResponse.data.verification_status === "SUCCESS") {
      if (webhookEvent.event_type === "BILLING.SUBSCRIPTION.ACTIVATED") {
        const userId = webhookEvent.resource.custom_id;
        const subscriptionId = webhookEvent.resource.id;

        console.log(`Attempting to confirm subscription for user ${userId} with subscription ID ${subscriptionId}`);

        try {
          const updatedUser = await User.findOneAndUpdate(
              { _id: userId, subscriptionId: subscriptionId }, 
              { $set: { subscriptionStatus: 'confirmed' }},
              { new: true }
          );

          if (updatedUser) {
              console.log("Subscription confirmed for user:", userId);
          } else {
              console.log("No matching user document to update or subscription ID mismatch.");
          }

          return res.status(200).send('Subscription confirmed');
        } catch (error) {
          console.error("Error confirming subscription:", error);
          return res.status(500).send("Error updating subscription status.");
        }
      }
    } else {
      console.log("Failed to verify webhook signature:", verifyResponse.data);
      return res.status(401).send('Webhook signature verification failed');
    }
  } catch (error) {
    console.error("Webhook processing error:", error);
    return res.status(500).send("Webhook processing failed");
  }
});

控制台日志输出

Received webhook event: {}

Headers: {
  'paypal-auth-algo': 'SHA...',
  'paypal-cert-url': 'https://api.sandbox.paypal.com/v1/notifications/certs/CERT...',
  'paypal-transmission-id': 'a5d...',
  'paypal-transmission-sig': 'Rrwi...',
  'paypal-transmission-time': '2024....'
}
Webhook event received: undefined
Final Verification Request Payload: {
  "auth_algo": "SHA-....",
  "cert_url": "https://api.sandbox.paypal.com/v1/notifications/certs/CERT-...",
  "transmission_id": "a5d....",
  "transmission_sig": "Rrw...",
  "transmission_time": "2024....",
  "webhook_id": "string",
  "webhook_event": {}
}
Failed to verify webhook signature: { verification_status: 'FAILURE' }

已尝试的解决方法

  • 核对请求头与PayPal真实Webhook调用完全一致;
  • 确认环境变量中的PayPal客户端ID、密钥及WEBHOOK_ID配置正确;
  • 查阅Stack Overflow相关问题,尝试检查环境URL、确认请求头等方案,未解决问题。

疑问

  1. 是否遗漏了PayPal Webhook验证的常见配置或陷阱?
  2. 问题是否源于Postman请求头的设置方式,或是对PayPal验证流程的误解?

问题排查与解决方案

1. 核心问题:请求体为空导致签名不匹配

从日志可见Received webhook event: {}和webhook_event: {},PayPal的签名是基于原始请求体的哈希值生成的,空请求体或与原始请求体不一致,必然导致签名验证失败。

解决:

  • 从Webhook.site捕获PayPal发送的完整请求体,复制到Postman的raw -> JSON格式请求体中,确保与原始内容完全一致(包括空格、换行符等细节);
  • 检查Node.js服务是否配置了express.json()中间件,否则req.body会解析为空对象:
    app.use(express.json());
    

2. Webhook ID配置错误

日志中"webhook_id": "string"说明环境变量WEBHOOK_ID未正确加载,或值为占位符。PayPal验证签名时需要匹配后台创建Webhook时生成的真实ID。

解决:

  • 登录PayPal开发者后台,找到对应Webhook的真实ID,替换环境变量中的占位值;
  • 重启Node.js服务确保环境变量生效。

3. 请求头大小写不匹配

尽管HTTP头理论上不区分大小写,但PayPal的验证逻辑可能严格匹配头名称格式。

解决:

  • 确保Postman中的请求头名称与Webhook.site捕获的完全一致(包括大小写,比如paypal-auth-algo不能写成驼峰格式)。

4. 签名时效性过期

paypal-transmission-time是PayPal发送请求的时间,签名验证会检查该时间是否在5分钟有效期内,旧的请求头会因过期导致验证失败。

解决:

  • 重新捕获最新的PayPal Webhook调用,获取新鲜的请求头和请求体后立即测试。

5. 环境URL不匹配

当前验证API使用沙箱环境地址https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature,需确保Webhook是在沙箱环境创建的,而非生产环境。

解决:

  • 核对PayPal后台Webhook的环境(沙箱/生产)与代码中的验证API URL一致。

内容的提问来源于stack exchange,提问作者user23826131

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.27 03:28:11