PayPal webhook 验签失败与收不到回调:从四段待签串查到 443 投递
线上订单出现两种表现:一部分 PAYMENT.CAPTURE.COMPLETED 正常入库;另一部分订单在 PayPal 侧已扣款,服务端日志里却没有对应回调。另外,把收到的 header 和 body 原样交给 postback 接口,返回 verification_status: FAILURE。这两类现象经常同时出现,但它们属于不同环节:前者是投递问题,后者是验签问题。下面按订阅配置、自校验、投递、事件与生命周期逐项排查。
参考文档:Integrate webhooks、Webhook event names、Payment Lifecycle。
订阅、Webhook ID 与投递前提
订阅有两种方式:在 Application management 里给 App 配置 listener URL,或调用 Webhooks Management API。订阅完成后,每个 listener URL 对应一个 Webhook ID,验签必须用到它。
投递侧有三条硬约束:
- listener 必须是 HTTPS,且只投 443 端口;
- 每次投递都必须返回 2xx;
- 无响应、连不上、404、500 都算失败,PayPal 会在 3 天内最多重试 25 次。
订阅时应显式列出事件类型,不要选通配。通配会让大量不关心的事件打进来,也会掩盖「某个事件类型根本没订阅」这种低级问题。
Mock 事件的两个特殊点
Webhooks simulator 生成的事件不属于任何 App。因此:
- mock 事件的
webhook_id是固定字符串WEBHOOK_ID,不是你自己配置里那个 ID; - postback 验签对 mock 事件不支持。
用 mock 事件联调时只能走自校验,且待签串里的 webhookId 要填 WEBHOOK_ID。这一点如果漏了,本地永远验不过,但生产环境可能是正常的。
自校验:Go 实现
待签串是四段拼接:
transmissionId | timeStamp | webhookId | crc32
字段来源:
| 段 | 来源 |
|---|---|
| transmissionId | header paypal-transmission-id |
| timeStamp | header paypal-transmission-time |
| webhookId | 订阅配置处,不在 header 也不在 body |
| crc32 | 原始 body 的 CRC32 校验和,十进制 |
签名在 header paypal-transmission-sig(base64),算法由 paypal-auth-algo 给出,常见值为 SHA256withRSA。证书地址在 paypal-cert-url,从中取公钥。
func verifyWebhook(r *http.Request, rawBody []byte, webhookID string, certPEM []byte) error {
transmissionID := r.Header.Get("paypal-transmission-id")
timestamp := r.Header.Get("paypal-transmission-time")
sigB64 := r.Header.Get("paypal-transmission-sig")
// crc32.ChecksumIEEE 返回 uint32,%d 即十进制
crc := crc32.ChecksumIEEE(rawBody)
message := fmt.Sprintf("%s|%s|%s|%d", transmissionID, timestamp, webhookID, crc)
sig, err := base64.StdEncoding.DecodeString(sigB64)
if err != nil {
return fmt.Errorf("decode sig: %w", err)
}
block, _ := pem.Decode(certPEM)
if block == nil {
return errors.New("bad cert pem")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return fmt.Errorf("parse cert: %w", err)
}
pub, ok := cert.PublicKey.(*rsa.PublicKey)
if !ok {
return errors.New("cert public key is not RSA")
}
h := sha256.New()
h.Write([]byte(message))
return rsa.VerifyPKCS1v15(pub, crypto.SHA256, h.Sum(nil), sig)
}
保留原始 body 是关键。Go 里要在 handler 里先 io.ReadAll(r.Body) 存下来,再 json.Unmarshal。官方 Node 示例的对等写法是 Express 用 express.raw({type:'application/json'}),待签串中的 crc 用 parseInt("0x" + crc32(event).toString('hex')) 得到十进制数,再用 crypto.createVerify('SHA256') 配合 base64 解码后的 paypal-transmission-sig 校验。
四段拼接最容易错的四处
- webhookId 来源。它不在 header,也不在 body 的
webhook_event里,而在订阅该 listener 时生成。从 body 里找webhook_id一定拿到空值或错值。simulator 场景下固定为WEBHOOK_ID。 - crc32 必须是十进制。
crc32.ChecksumIEEE的结果直接%d;写成%x、或带0x前缀,验签必失败。Node 示例里那个parseInt("0x" + hex)就是在做十六进制到十进制的转换。 - 必须用原始 raw body。把 body 解析成对象再序列化,键顺序、空白、转义、数字格式都可能变化,CRC32 随之改变。日志里打印过「拼出来的串看着一样,就是不过」,基本都是这一条。
- 时间戳用 header 原串。不要
time.Parse再格式化,时区或格式一变,待签串就变了。
另外分隔符是竖线 |,不带空格。
cert_url 白名单与缓存
下载证书前先校验 paypal-cert-url 的域名属于 PayPal 的证书域名,不满足就直接拒绝,避免被伪造 URL 打 SSRF。证书下载后本地缓存,按 URL 或证书指纹做 key,不要在每次回调里都拉一遍远程证书——高并发下这是显著的延迟来源。
u, err := url.Parse(certURL)
if err != nil || u.Scheme != "https" || !strings.HasSuffix(u.Host, ".paypal.com") {
return errors.New("cert_url rejected")
}
// 缓存命中则跳过下载;TTL 与失效策略未实测
可选:postback 验签
不自校验时,可以把整套 header 和事件体回传给 PayPal:
curl -sS https://api.sandbox.paypal.com/v1/notifications/verify-webhook-signature \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"transmission_id": "
",
"transmission_time": "
",
"cert_url": "
",
"auth_algo": "SHA256withRSA",
"transmission_sig": "
",
"webhook_id": "",
"webhook_event": { "原样回传,不要重新格式化" }
}'
webhook_event 必须原样回传。解析后重新序列化就可能失败,与自校验里 raw body 的问题是同一个根因。生产域名把 sandbox 换成 api.paypal.com。
收不到回调:先分清有没有状态码
官方 Troubleshooting delivery problems 把问题分成两类:
- PayPal 连不上 listener、拿不到任何状态码:最常见是 443 入站被防火墙规则阻断,或者域名被 url filtering 服务标记为 malware / phishing / abuse。可以用
urlfiltering.paloaltonetworks.com查询域名是否被归类。 - 返回了非 2xx(404、500 等):问题在 listener 应用或 web server 配置,看服务端访问日志。
前一类在业务日志里完全静默,只能从入站网络和域名信誉方向查。
事件名与双重生命周期
常用事件:PAYMENT.CAPTURE.COMPLETED、PAYMENT.CAPTURE.PENDING、PAYMENT.CAPTURE.DENIED、PAYMENT.CAPTURE.REFUNDED、PAYMENT.CAPTURE.REVERSED、PAYMENT.REFUND.PENDING(eCheck 退款要几天)、PAYMENT.REFUND.FAILED、PAYMENT.AUTHORIZATION.CREATED、PAYMENT.AUTHORIZATION.VOIDED(授权 30 天有效期到期或被 void)。
Order 与 Capture 是两个对象、两条独立生命周期:
- Order:
PENDING_APPROVAL(付款人已完成动作,等 PayPal 确认,可能持续几小时)→COMPLETED(订单完成,Capture 生命周期开始)。APM 支付还要看 capture 状态。 - Capture:
PENDING(等待变为COMPLETED或DECLINED,这期间不要发货,原因看status_details,监听PAYMENT.CAPTURE.COMPLETED/PAYMENT.CAPTURE.DENIED)→COMPLETED(钱可用,发货)→DECLINED(未扣到钱,清理订单,PayPal 把钱退回付款人);退款态为PARTIALLY_REFUNDED/REFUNDED。
PAYMENT.CAPTURE.COMPLETED 事件体里 resource 的关键字段:id(capture id)、status、amount.value + amount.currency_code、final_capture、seller_receivable_breakdown(gross_amount / paypal_fee / net_amount)、supplementary_data.related_ids.order_id、create_time、links(self / refund / up)。外层还有 id(WH- 开头)、event_type、resource_type、resource_version、create_time。
验签通过之后还要做的两件事
幂等:同一事件可能重复投递,用事件(webhook_message)的 id 做去重键,落库加唯一索引,而不是用订单号或 capture id 当幂等键。
金额核对:验签只证明请求来自 PayPal,不证明金额与订单一致。落库前用 amount.value + currency_code 与本地订单应付金额比对,用 supplementary_data.related_ids.order_id 关联本地订单,再决定是否发货。这一步漏掉,验签再正确也挡不住金额错配。