代码 PayPal webhook 验签失败与收不到回调:从四段待签串查到 443 投递

2026-10-21 09:01:17

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

字段来源:

段来源
transmissionIdheader paypal-transmission-id
timeStampheader 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 校验。

四段拼接最容易错的四处

  1. webhookId 来源。它不在 header,也不在 body 的 webhook_event 里,而在订阅该 listener 时生成。从 body 里找 webhook_id 一定拿到空值或错值。simulator 场景下固定为 WEBHOOK_ID。
  2. crc32 必须是十进制。crc32.ChecksumIEEE 的结果直接 %d;写成 %x、或带 0x 前缀,验签必失败。Node 示例里那个 parseInt("0x" + hex) 就是在做十六进制到十进制的转换。
  3. 必须用原始 raw body。把 body 解析成对象再序列化,键顺序、空白、转义、数字格式都可能变化,CRC32 随之改变。日志里打印过「拼出来的串看着一样,就是不过」,基本都是这一条。
  4. 时间戳用 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 关联本地订单,再决定是否发货。这一步漏掉,验签再正确也挡不住金额错配。

复制全文 生成海报 PayPal webhook 验签 支付接口 Go

推荐文章

程序员茄子在线接单