PayPal Webhook 验签 FAILURE 排查笔记:签名串、raw body 与验签通过后的掉单
验签失败的典型现象是 openssl_verify() 返回 0,或 postback 接口返回 verification_status: FAILURE。多数情况不是密钥错,而是签名串拼错、body 被框架改过,或 webhook_id 拿错了环境。下面按排查成本从低到高整理。
与微信支付、支付宝的根本差异
微信支付和支付宝是 HMAC + 共享密钥(APIv3 key、商户私钥)那一套:本地用同一份密钥算摘要,再比对。PayPal 不是。它用 PayPal 自己的私钥对每条通知做 RSA-SHA256 签名,算法写在请求头 paypal-auth-algo 里,值为 SHA256withRSA;验证用的公钥证书由 paypal-cert-url 在每次投递时给出。所以本地没有“算 HMAC 再比”的动作,只有验签。
投递侧有几个硬约束:回调地址必须 HTTPS 443;监听端必须回 2xx,否则 PayPal 最多重试 25 次、持续 3 天;之后事件标记为 Failed,可以在 Webhook Events 面板手动 resend。每个 app 最多绑定 10 个 webhook URL。
五个请求头都要参与或辅助验签:
paypal-transmission-id:UUID,唯一传输 IDpaypal-transmission-time:ISO8601 时间paypal-transmission-sig:base64 签名paypal-cert-url:X.509 证书 URLpaypal-auth-algo:SHA256withRSA
自验签:签名串怎么拼
签名串由四段用 | 连接:
transmissionId | timeStamp | webhookId | crc32(rawBody)
crc32 是原始 HTTP body 的 CRC32,十进制无符号整数。webhookId 既不在 header 也不在 body,是注册 webhook 时拿到的那个 ID,需要自己配置。
PHP 版本:
$raw = file_get_contents('php://input'); // 必须原始字节
$crc = sprintf('%u', crc32($raw)); // 十进制;32 位环境下 crc32() 可能返回负数
$msg = implode('|', [
$_SERVER['HTTP_PAYPAL_TRANSMISSION_ID'],
$_SERVER['HTTP_PAYPAL_TRANSMISSION_TIME'],
getenv('PAYPAL_WEBHOOK_ID'),
$crc,
]);
$sig = base64_decode($_SERVER['HTTP_PAYPAL_TRANSMISSION_SIG']);
$cert = fetch_cert_cached($_SERVER['HTTP_PAYPAL_CERT_URL']);
$pubkey = openssl_pkey_get_public($cert);
$ok = openssl_verify($msg, $sig, $pubkey, OPENSSL_ALGO_SHA256) === 1;
Node 版本:
const crc = parseInt("0x" + crc32(rawBody).toString('hex')); // 再转回十进制
const message = `${transmissionId}|${timeStamp}|${WEBHOOK_ID}|${crc}`;
const verifier = crypto.createVerify('SHA256');
verifier.update(message);
verifier.verify(certPem, Buffer.from(sigHeader, 'base64'));
四个高发错误
1. crc32 用了十六进制
crc32 库默认常返回十六进制字符串,直接拼进签名串必然 FAILURE。签名串里要的是十进制无符号整数。PHP 用 sprintf('%u', crc32($raw)) 兜住 32 位下可能出现的负数;Node 按上面代码先拿 hex 再 parseInt 回十进制;Python 的 zlib.crc32 需要 & 0xFFFFFFFF 才有无符号语义。
2. raw body 被框架重新序列化
Laravel/Symfony 里用 $request->all()、Express 里用 express.json() 之后,再 JSON.stringify 拼 body:键序、空格、数字格式(500.0 与 500.00)、转义方式都可能变,CRC32 跟着全变。必须取原始字节:
- PHP:
file_get_contents('php://input') - Express:
express.raw({ type: 'application/json' }) - FastAPI:
await request.body()
验签通过之前,body 不能被任何中间件解析。
3. webhook_id 沙箱与生产混用
沙箱 webhook 有自己的 ID,生产是另一个。拿沙箱 ID 去验生产事件,同样是 FAILURE,而且从日志上看和密钥错误没有任何区别。建议 PAYPAL_WEBHOOK_ID 按环境注入,不要写死一个全局值。
4. cert_url 没做域名白名单
如果直接 fetch 请求头里的 cert_url 拿证书,密码学验证是能过的——因为证书可能来自攻击者自己的域名。必须校验 host 等于 paypal.com 或以 .paypal.com 结尾,且协议是 https,再发起请求。
证书缓存与轮换
不要每个请求都下载证书:多一次外网 RTT,PayPal 抖动时还会导致验签集体失败。按 cert_url 做缓存,PayPal 轮换证书时 URL 会变,缓存 miss 自动拉新,旧 key 随 TTL 淘汰。
反例是把证书内容写死在配置文件里。轮换之后全站验签失败,现象和密钥错误一模一样,很难第一时间定位到证书。
Postback 校验的两个限制
不想自己拼签名串,可以调 POST /v1/notifications/verify-webhook-signature,body 带 auth_algo、cert_url、transmission_id、transmission_sig、transmission_time、webhook_id、webhook_event。返回字段 verification_status 的值是 SUCCESS / FAILURE,不是 VALID,别照抄其他网关的判断逻辑。
限制一:webhook_event 必须原样字节塞进去。用 JSON.stringify(JSON.parse(body)) 会因格式差异被判 FAILURE,要么做字符串拼接,要么全程保留 raw body。
限制二:Webhooks Simulator 发出的 mock 事件不支持 postback 校验。mock 事件验签时 webhook_id 固定为字符串 "WEBHOOK_ID"。也就是说,联调阶段想验通验签,只能走真实沙箱支付,模拟器只能看事件结构。
验签通过之后仍然掉单
验签只是入口,掉单通常发生在后面。
2xx 与重试:非 2xx 或超时都会触发重试。有些实现验签通过后同步跑发货逻辑,中间调第三方超时返回 500,于是同一事件被反复投递。官方只说明 3 天内最多 25 次,没有公布具体间隔,不要依赖固定时间表。正确做法是落库后立刻回 2xx,异步处理业务。
事件乱序:PAYMENT.CAPTURE.REFUNDED 可能早于 PAYMENT.CAPTURE.COMPLETED 到达。如果状态机假设 COMPLETED 一定先到,遇到乱序就会卡住,后续事件全被丢弃。
事件类型语义:
CHECKOUT.ORDER.APPROVED只代表买家批准,钱没动,必须再调 capture;PAYMENT.CAPTURE.COMPLETED才是已收款;PAYMENT.CAPTURE.PENDING(eCheck 未清算等)不能发货,要等 COMPLETED 或主动查 capture 状态;PAYMENT.CAPTURE.DENIED是失败。
幂等:用 event.id 落表去重,同时把 resource.id(capture id)做成业务唯一键,两者都要。只做其中一个,重试或乱序时仍可能重复发货。
对账兜底:不要只靠 webhook。独立跑对账任务,按 create_time 拉 /v1/reporting/transactions,或逐单查 /v2/payments/captures/{capture_id},比对本地订单状态。webhook 是通知,不是账本。
排查顺序(从便宜到贵)
- 打印签名串原文,比对分隔符是否为
|、有无空格、字段顺序是否正确; - 确认 crc32 是十进制;
- 确认拿到的是 raw body,长度和哈希与日志里一致;
- 确认
webhook_id属于当前环境; - 确认
cert_url的 host 在白名单内,证书能下载(curl -I); - 用 openssl 命令行手工验一遍,排除代码问题;
- 最后才怀疑 PayPal 侧或代理改写了 body。nginx 的
sub_filter、部分 WAF 会压缩或改写响应体;注意客户端压缩不影响入站请求。
按这个顺序走,多数 FAILURE 在前三步就能定位,剩下的多半是环境配置或 body 被中间件动过。