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:唯一的传输 IDPAYPAL-TRANSMISSION-TIME:RFC3339 时间PAYPAL-TRANSMISSION-SIG:base64 编码的非对称签名PAYPAL-CERT-URL:X.509 公钥证书下载地址PAYPAL-AUTH-ALGO:签名算法,例如SHA256withRSAPAYPAL-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-IDtimeStamp取PAYPAL-TRANSMISSION-TIMEwebhookId不在 header,也不在 body 里,来自你在 PayPal 后台配置 webhook 时生成的 IDcrc=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.APPROVEDPAYMENT.CAPTURE.COMPLETEDPAYMENT.CAPTURE.DENIEDPAYMENT.CAPTURE.REFUNDED
跨境收款场景里,PAYMENT.CAPTURE.COMPLETED 与 PAYMENT.CAPTURE.REFUNDED 是账务侧必须落库的两类,PAYMENT.CAPTURE.DENIED 通常需要告警而不是记账。