代码 PayPal Webhook 验签 FAILURE 排查笔记:签名串、raw body 与验签通过后的掉单

2026-11-03 09:01:23

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,唯一传输 ID
  • paypal-transmission-time:ISO8601 时间
  • paypal-transmission-sig:base64 签名
  • paypal-cert-url:X.509 证书 URL
  • paypal-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 是通知,不是账本。

排查顺序(从便宜到贵)

  1. 打印签名串原文,比对分隔符是否为 |、有无空格、字段顺序是否正确;
  2. 确认 crc32 是十进制;
  3. 确认拿到的是 raw body,长度和哈希与日志里一致;
  4. 确认 webhook_id 属于当前环境;
  5. 确认 cert_url 的 host 在白名单内,证书能下载(curl -I);
  6. 用 openssl 命令行手工验一遍,排除代码问题;
  7. 最后才怀疑 PayPal 侧或代理改写了 body。nginx 的 sub_filter、部分 WAF 会压缩或改写响应体;注意客户端压缩不影响入站请求。

按这个顺序走,多数 FAILURE 在前三步就能定位,剩下的多半是环境配置或 body 被中间件动过。

复制全文 生成海报 支付 PayPal Webhook 接口对接 排障

推荐文章

程序员茄子在线接单