如何修复Node.js中PayPal Webhook签名验证失败问题
问题描述
在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-algopaypal-cert-urlpaypal-transmission-idpaypal-transmission-sigpaypal-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、确认请求头等方案,未解决问题。
疑问
- 是否遗漏了PayPal Webhook验证的常见配置或陷阱?
- 问题是否源于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

