代码 PayPal Webhook 验签:手工拼 CRC32 签名串的两个坑,和 PHP 为什么只能调官方接口

2026-10-07 09:01:24

PayPal Webhook 验签:手工签名串拼装与 verify-webhook-signature API 两种走法

信源:developer.paypal.com/api/rest/webhooks/rest、/v1/notifications/verify-webhook-signature 参考文档、PayPal-PHP-SDK 的 sample/notifications/ValidateWebhookEvent.php。

PayPal 发过来的请求长什么样

PayPal 收到订阅事件后,向后台配置的 notify_url 发一个 POST,HTTP 头里带这些字段:

  • PAYPAL-TRANSMISSION-ID:唯一的传输 ID
  • PAYPAL-TRANSMISSION-TIME:RFC3339 时间
  • PAYPAL-TRANSMISSION-SIG:base64 编码的非对称签名
  • PAYPAL-CERT-URL:X.509 公钥证书下载地址
  • PAYPAL-AUTH-ALGO:签名算法,例如 SHA256withRSA
  • PAYPAL-AUTH-VERSION

body 是 JSON 事件,包含 id(形如 WH-xxxx)、event_type(如 PAYMENT.CAPTURE.COMPLETED)、resource 等字段。id 是事件 ID,不是配置 webhook 时那个 webhook ID,两者不要混。

方式一:手工验签

签名串怎么拼

签名串 = `${transmissionId}|${timeStamp}|${webhookId}|${crc}`

四个部分的取值:

  • transmissionId 取 PAYPAL-TRANSMISSION-ID
  • timeStamp 取 PAYPAL-TRANSMISSION-TIME
  • webhookId 不在 header,也不在 body 里,来自你在 PayPal 后台配置 webhook 时生成的 ID
  • crc = CRC32(原始 HTTP body) 的十进制值

crc 这一项最容易翻车:必须用原始 raw body 计算,不能把 body 反序列化成对象、再重新序列化后去算。JSON 的 key 顺序、空格、转义、Unicode 处理只要有一点不同,CRC32 就对不上,验签必挂。

拼串示意(PHP):

$headers = array_change_key_case($headers, CASE_UPPER);

$transmissionId = $headers['PAYPAL-TRANSMISSION-ID'];
$timeStamp      = $headers['PAYPAL-TRANSMISSION-TIME'];

$crc = sprintf('%u', crc32($rawBody)); // 注意用 raw body

$signature = "{$transmissionId}|{$timeStamp}|{$webhookId}|{$crc}";

sprintf('%u', ...) 是为了规避 32 位平台上 crc32() 返回负数的问题——负值拼进签名串,PayPal 那边算不出来。

用证书里的公钥验签

拿 PAYPAL-CERT-URL 下载 X.509 证书,从证书里取出公钥,对 PAYPAL-TRANSMISSION-SIG(base64 解码后)做 SHA256withRSA 验签。

证书建议下载后缓存复用,不要每个 webhook 都去拉一次证书:一是每次请求多一次外网往返,二是 cert_url 这个域名一旦成为热点,很容易变成超时来源。缓存按 cert_url 作为 key 即可。

这也是 Go 侧比较容易走通的一条路,标准库对 X.509 证书链的处理比较完整。

cert_url 是从请求头来的,必须先校验

cert_url 来自攻击者可控的请求头。如果直接拿它去下载证书、再用下载到的公钥验签,攻击者只要把 cert_url 指到自己的服务器、放一张自己的证书,用自己的私钥签名,验签就会「通过」。这不是理论问题。

手工验签必须校验 cert_url 的 host 属于 paypal,例如 api.paypal.com / api-m.sandbox.paypal.com。host 白名单要在下载证书之前做,不能先下载再判断。

另外实践上还要确认收到的是 HTTPS 请求、下载证书本身也走 HTTPS,否则中间人替换证书链同样能伪造通过。

方式二:调 verify-webhook-signature API

POST {api}/v1/notifications/verify-webhook-signature
Authorization: Bearer

{
"auth_algo": ...,
"cert_url": ...,
"transmission_id": ...,
"transmission_sig": ...,
"transmission_time": ...,
"webhook_id": ...,
"webhook_event": { ... }
}

响应里的 verification_status 为 SUCCESS / FAILURE。

域名:Sandbox 是 api-m.sandbox.paypal.com,生产是 api-m.paypal.com。

取 token:

curl -s -X POST https://api-m.sandbox.paypal.com/v1/oauth2/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d "grant_type=client_credentials"

也就是 POST /v1/oauth2/token,grant_type=client_credentials,用 -u CLIENT_ID:CLIENT_SECRET 做 Basic 认证。

调 API 验签:

curl -s -X POST https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"auth_algo": "'"$PAYPAL_AUTH_ALGO"'",
"cert_url": "'"$PAYPAL_CERT_URL"'",
"transmission_id": "'"$PAYPAL_TRANSMISSION_ID"'",
"transmission_sig": "'"$PAYPAL_TRANSMISSION_SIG"'",
"transmission_time": "'"$PAYPAL_TRANSMISSION_TIME"'",
"webhook_id": "'"$WEBHOOK_ID"'",
"webhook_event": '"$RAW_BODY"'
}'

webhook_event 传的就是那个 JSON 事件体。注意别把外层 header 字段混进 webhook_event 里。

PHP 的一个硬限制

PayPal-PHP-SDK 的示例注释写得很直白:

PHP Currently does not support certificate chain validation, that is necessary to validate webhook directly, from received data

也就是说,PHP 侧要独立完成手工验签(从收到数据出发校验证书链)走不通,一般只能调 verify-webhook-signature API。这条路换来了 PHP 侧的实现简单,代价是每个 webhook 都要多调一次 PayPal、多取一次 token。

如果一个进程要处理大量 webhook,token 复用和 API 调用的并发/超时都要单独设计。

还有一个小坑:文档里 header key 是全大写的,实际收到可能是首字母大写(Paypal-Transmission-Id 这种形式),取值前先规整:

$headers = array_change_key_case($headers, CASE_UPPER);

两种方式的取舍

手工验签:不依赖 PayPal 的可用性,没有额外网络往返和 token 管理;但必须自己处理证书下载缓存、cert_url 白名单、raw body 保真、CRC32 的负数问题。Go 侧可行;PHP 侧受证书链限制,不建议硬上。

API 验签:实现短,官方语义明确;但每次验签依赖外网、依赖 token、依赖对方接口可用性,QPS 高或网络不稳时是故障点。

前提与不适用场景

  • 无论哪种方式,都必须能拿到未经中间件改写的原始 body。如果框架的 body parser 先消费了 php://input,或者 nginx/网关对 body 做了压缩、编码、重写,crc32 就对不上,只能改走 API 验签(但仍需保证 webhook_event 与原始事件一致)。
  • webhook_id 必须提前从后台配置拿到并落到配置中心,运行时无法从请求里反推。
  • 手工验签要求运行环境能出网拉取 cert_url;纯内网、出网白名单收得很紧的环境不适用。
  • PHP 侧不做证书链校验,就不要尝试手工验签作为主路径。

应答与重试

处理成功返回 200,表示已确认。否则 PayPal 会重试。具体的重试窗口和次数这里没有实测过,接入时建议按幂等处理:用事件 id 做去重键,重试到达时不重复落账。

常见事件

  • CHECKOUT.ORDER.APPROVED
  • PAYMENT.CAPTURE.COMPLETED
  • PAYMENT.CAPTURE.DENIED
  • PAYMENT.CAPTURE.REFUNDED

跨境收款场景里,PAYMENT.CAPTURE.COMPLETED 与 PAYMENT.CAPTURE.REFUNDED 是账务侧必须落库的两类,PAYMENT.CAPTURE.DENIED 通常需要告警而不是记账。

复制全文 生成海报 PayPal Webhook 验签 跨境收款 接口对接

推荐文章

程序员茄子在线接单