PayPal webhook 验签总失败:raw body、CRC-32 符号、webhook_id 环境这三处
Webhook 是 PayPal 在你的应用订阅的事件发生时,向你的服务器发起的 HTTPS POST,可以当成一次反向 API 调用:以前是你的系统调 PayPal,现在是 PayPal 回调你的服务器。每个应用最多订阅 10 个 webhook URL,每个 URL 可以订阅具体事件类型,也可以用通配 * 订阅全部。只有与该应用关联的事件才会收到——同一账号下另一个 REST app 处理的支付,不会给你这个 app 发事件。
接收端返回 HTTP 200 或其它 2xx 才算接收成功。任何非 2xx 都会让 PayPal 在 3 天内重试最多 25 次,直到拿到 2xx;3 天后仍然失败会被标记为 Failed,之后可以在 Webhook Events 面板手动重发。这一切的前提是 PayPal 能连上你的端点。
下面记的是验签环节。以下机制、头名、字段、接口路径均来自 PayPal 官方开发者文档(Webhooks / Integrate webhooks / verify-webhook-signature / 官方博客);文中的拼接和代码片段没有在真实 live 链路上逐条跑过,只按文档字段对齐,抄之前先确认自己的字节来源。
两种验签方式
PayPal 要求所有 webhook 都验签,否则无法确认消息确实来自 PayPal。两种方式二选一。
自验签,在自己服务器上做。用收到的头计算,涉及这几个头:
paypal-transmission-id:本次投递的传输 ID,UUID 形式paypal-transmission-time:PayPal 生成事件的 ISO8601 时间paypal-transmission-sig:Base64 编码的 RSA-SHA256 签名paypal-cert-url:PayPal 公钥证书(PEM)下载地址,域名必须在 paypal.compaypal-auth-algo:签名算法,当前只有SHA256withRSA(RSA + SHA-256,PKCS#1 v1.5 padding)
待签原文是四段用 | 拼接:transmissionId|timeStamp|webhookId|crc32(rawBody)。注意 webhookId 既不在头里也不在 body 里,它是你配置监听 URL 时拿到的 webhook ID。crc32 是原始请求体的标准 CRC-32 校验和,无符号十进制整数——不是 hex,也不是有符号数。最后用 paypal-cert-url 里那本证书的公钥,对 transmission-sig(先 Base64 解码)做 RSA-SHA256 验签。
Postback 验签,把校验交给 PayPal。POST 到 /v1/notifications/verify-webhook-signature,body 传 auth_algo、cert_url、transmission_id、transmission_sig、transmission_time、webhook_id、webhook_event。成功返回 {"verification_status":"SUCCESS"},部分文档/社区实现也判 VALID。这里有个坑:只有真实事件支持 postback,模拟器发出的 mock 事件过不了 postback 验证。此接口需要 OAuth2 Bearer token(client_credentials)。
官方示例 curl:
curl -X POST "https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature" \
-H "Content-Type: application/json" -H "Authorization: Bearer ACCESS-TOKEN" \
-d '{"transmission_id":"...","transmission_time":"2024-05-16T05:19:23Z","cert_url":"https://api.sandbox.paypal.com/v1/notifications/certs/CERT-...","auth_algo":"SHA256withRSA","transmission_sig":"...","webhook_id":"0NH55953DH663215D","webhook_event":{...}}'
事件示例:event_type 为 PAYMENT.CAPTURE.COMPLETED,resource 是 capture,resource.supplementary_data.related_ids.order_id 关联到原始 order,resource.status=COMPLETED。
坑一:解析后的 JSON 不是 raw body
CRC-32 是字节敏感的,必须用 PayPal 发来的原始请求体字节。把 body 解析成对象再 JSON.stringify 重新序列化,空格和键顺序都会变,验签一定失败。
对应到框架:
- Express 用
express.raw() - Next.js App Router 用
request.text() - FastAPI 用
await request.body()
坑二:CRC-32 要无符号十进制
部分库返回的是有符号 32 位整数,PayPal 要的是无符号形式:例如 3632233996,不是 -662733300。
Node 用 zlib.crc32(buf),返回无符号,需要 Node ≥ 22。Python 用 zlib.crc32(...) & 0xFFFFFFFF 做掩码。
拼串和验签(未实测,仅按文档字段构造):
// Node(未实测)
const zlib = require('zlib');
const crypto = require('crypto');
const id = req.headers['paypal-transmission-id'];
const time = req.headers['paypal-transmission-time'];
const sig = Buffer.from(req.headers['paypal-transmission-sig'], 'base64');
const certUrl = req.headers['paypal-cert-url'];
const crc = zlib.crc32(req.body); // req.body 是 Buffer
const message = `${id}|${time}|${WEBHOOK_ID}|${crc}`;
const ok = crypto.verify(
'sha256',
Buffer.from(message),
{ key: pem, padding: crypto.constants.RSA_PKCS1_PADDING },
sig
);
# Python(未实测)
import base64, zlib
from cryptography.x509 import load_pem_x509_certificate
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
crc = zlib.crc32(raw_body) & 0xFFFFFFFF
message = f"{transmission_id}|{transmission_time}|{webhook_id}|{crc}".encode()
cert = load_pem_x509_certificate(pem)
cert.public_key().verify(
base64.b64decode(transmission_sig),
message,
padding.PKCS1v15(),
hashes.SHA256(),
)
坑三:webhook_id 要对应环境,cert-url 要校验域名
sandbox 和 live 的 webhook ID 不同,用错环境验签直接失败。同样地,sandbox 走 api-m.sandbox.paypal.com,live 走 api-m.paypal.com。
cert-url 的域名要校验是 paypal.com,避免被诱导去请求任意地址(SSRF)。postback 时要把头里的字段原样回传,自验签时不要改动头值。
证书按 URL 缓存,减少延迟。PayPal 换证书是通过换 URL 实现的:URL 变了就是新证书,缓存 miss 一次拉一次即可。
另外一个历史问题:PayPal 在 2023 年更新了验签端点,旧的 webhook 验证方式已 deprecated,新接入直接用 verify-webhook-signature。
调试时先打这四样:四个 paypal-* 头、raw body 前 200 字节、算出来的 CRC-32、拼接后的 message 字符串。多数验签失败看一眼这四行就能定位。
处理顺序
社区通行的做法是这样的顺序:
读 4 个头 → 校验 cert-url 主机以 .paypal.com 结尾 → 拉取并缓存证书 → 计算 raw body 的 CRC-32(无符号)→ 拼 ${id}|${time}|${webhookId}|${crc32} → RSA-SHA256 验签 → 业务处理(存 event id 做幂等)→ 返回 200。
幂等那一步别省,PayPal 的重试机制决定了同一事件可能被投递多次。
文档
- Webhooks overview:https://developer.paypal.com/api/rest/webhooks/
- Integrate webhooks:https://developer.paypal.com/api/rest/webhooks/rest
- Verify webhook signature:https://developer.paypal.com/api/webhooks/v1/verify-webhook-signature-post
- 官方博客(验签端点更新):https://developer.paypal.com/community/blog/paypal-has-updated-its-webhook-verification-endpoint/
信源:PayPal 官方开发者文档(Webhooks / Integrate webhooks / verify-webhook-signature / 官方博客)。