代码 PayPal webhook 验签总 FAILURE:raw body、crc32、webhook_id 三处根因与重复发货点

2026-11-09 09:01:49

PayPal webhook 验签总 FAILURE:raw body、crc32、webhook_id 三处根因与重复发货点

国内渠道的回调通常是「收到通知 → 验签 → 更新订单」,PayPal 这套 REST 的差异集中在两处:验签对象是原始字节;订单(order)和付款(capture)是两个资源,事件到达顺序不保证。下面按排查顺序记录。

前提与边界

  • 适用:Orders v2 + Webhooks REST,服务端 PHP 或 Go,已完成 create order / capture 的 sandbox 联调。
  • 不适用:Braintree、Subscriptions、Advanced Checkout 的 webhook 结构不同;PayPal 后台 Webhooks simulator 生成的 mock 事件不能回发验签。
  • 示例基于 PHP 7.4/8.x(64 位)与 Go 1.20+。文中标注「未实测」的是文档与社区结论,本轮只在 sandbox 验证了其中一部分。

官方文档:

  • 验签接口:https://developer.paypal.com/api/webhooks/v1/verify-webhook-signature-post
  • 自签规则:https://developer.paypal.com/api/rest/webhooks/rest/
  • 投递与重试:https://developer.paypal.com/api/rest/webhooks
  • 幂等:https://developer.paypal.com/reference/guidelines/idempotency
  • Orders 错误排查:https://developer.paypal.com/api/rest/integration/orders-api/troubleshooting
  • capture 接口:https://developer.paypal.com/api/orders/v2/orders-capture

一、验签 FAILURE:先确认送进去的是同一串字节

POST /v1/notifications/verify-webhook-signature 需要七个字段,全部来自请求头和后台配置:

{
  "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": "后台该订阅 URL 的 Webhook ID",
  "webhook_event": { "原样回传的事件体": true }
}

返回 {"verification_status":"SUCCESS"} 或 FAILURE。三个高频根因:

1)raw body 被重新序列化。 这是最常见的一处。Laravel 的 $request->all()、Gin 的 c.ShouldBindJSON 都会把 body 解析成对象,再序列化回去,字段顺序、空白、转义(/ 变 \/、Unicode 转义)全部变化,webhook_event 已不是同一串字节。必须拿原始输入:PHP 用 file_get_contents('php://input'),Go 用 io.ReadAll(r.Body)。注意 body 只能读一次,读完要重新塞回去给后续 handler。

2)crc32 用了十六进制。 自签路径的待签串是:

transmissionId|timeStamp|webhookId|crc32(rawBody)

其中 crc32 是原始 body 的 CRC32 校验和,十进制无符号形式,不是 dechex() 出来的十六进制,也不是 CRC32C。PHP 在 32 位下 crc32() 可能返回负数,统一用 sprintf('%u', crc32($raw));Go 的 crc32.ChecksumIEEE([]byte(raw)) 是 uint32,fmt.Sprint 输出即十进制。

3)webhook_id 取错。 它来自开发者后台里该订阅 URL 对应的 Webhook ID,不是 client_id,不是 app id,也不是 merchant id。sandbox 与 live 各自一套,混用必然 FAILURE。另一处容易出错的地方:Webhooks simulator 生成的 mock 事件不属于任何 App,其 webhook_id 字面量就是 "WEBHOOK_ID",而且 mock 事件不支持回发验签,只能走自签(该字面量来自文档与社区,未单独实测)。

二、自签实现(PHP)

不额外调用验签接口,省一次往返,代价是要自己管证书缓存。PAYPAL-CERT-URL 指向的证书下载到本地后缓存复用;下载前必须校验 URL 是 https 且 host 属于 api.paypal.com / api.sandbox.paypal.com,避免被伪造头引成 SSRF。

$raw = file_get_contents('php://input');            // 原始字节,只读一次
$h   = array_change_key_case(getallheaders(), CASE_LOWER);

$expected = $h['paypal-transmission-id'] . '|'
          . $h['paypal-transmission-time'] . '|'
          . $webhookId . '|'                         // 后台 Webhook ID
          . sprintf('%u', crc32($raw));              // 十进制

$pubKey = openssl_pkey_get_public(file_get_contents($certCachePath));
$ok = openssl_verify(
    $expected,
    base64_decode($h['paypal-transmission-sig']),
    $pubKey,
    OPENSSL_ALGO_SHA256
) === 1;

用验签接口时,最保真的做法是手工拼 JSON,把 raw event 字符串原样嵌进 webhook_event,避免 json.Marshal 重排字段或压缩空白:

payload := fmt.Sprintf(
    `{"auth_algo":%q,"cert_url":%q,"transmission_id":%q,"transmission_sig":%q,"transmission_time":%q,"webhook_id":%q,"webhook_event":%s}`,
    hAuthAlgo, hCertURL, hTransmissionID, hTransmissionSig, hTransmissionTime, webhookID, rawEvent,
)

拼接方式对含大量转义字符的事件体未穷举实测,先用一两条真实事件对照 SUCCESS 再上量。

三、事件顺序不保证,重复发货多半出在这里

PAYMENT.CAPTURE.COMPLETED 可能比 CHECKOUT.ORDER.APPROVED 先到。不要把「必须先收到 APPROVED 才允许发货」写成状态机,发货判断放在 PAYMENT.CAPTURE.COMPLETED,并且用对字段:

  • 订单号:resource.supplementary_data.related_ids.order_id
  • capture id:resource.id

两者不同,拿 resource.id 当订单号更新订单会写错行。落一张 webhook_events 表,对 event id 建唯一索引做第一层幂等;业务表再对 capture_id 建唯一索引兜底。处理放在异步队列里,避免同一事件被重投时并发写。

事件类型要显式订阅,不用通配符:CHECKOUT.ORDER.APPROVED、PAYMENT.CAPTURE.COMPLETED、PAYMENT.CAPTURE.PENDING、PAYMENT.CAPTURE.DENIED、PAYMENT.CAPTURE.REFUNDED、PAYMENT.AUTHORIZATION.CREATED、PAYMENT.AUTHORIZATION.VOIDED,争议是 CUSTOMER.DISPUTE.CREATED。漏订阅就永远收不到(后台补订阅后历史事件能否补发,未实测)。

四、capture 要幂等:PayPal-Request-Id

POST /v2/checkout/orders/{id}/capture,body 为空或只带 payment_source。幂等靠 PayPal-Request-Id 头:值需在同一 API 类型内唯一,建议 UUID(满足 38 个单字节字符限制);带同一 request id 重试返回该请求的最新状态而不是重复执行;两个同时发出的相同 request id,第二个可能直接失败。

所以 request id 必须可复现,不要每次重试新生成:

req.Header.Set("PayPal-Request-Id", orderID+"-capture") // 或落库后复用同一个 UUID

并发下只靠它不够,本地仍要有唯一约束或行锁。

五、ORDER_ALREADY_CAPTURED 按成功处理

intent=CAPTURE 时一个订单只能 capture 一次。重试时拿到 ORDER_ALREADY_CAPTURED,含义是前一次已经成功,多半只是响应丢了。处理方式是 GET /v2/checkout/orders/{id} 取 capture id,按成功继续发货,不要当异常重试。需要多次扣款改 intent=AUTHORIZE。

其余几个会错账的响应码:

  • ORDER_ALREADY_AUTHORIZED:资金已授权,直接 capture;授权超过 3 天可 reauthorize 后再 capture。
  • ORDER_NOT_APPROVED:买家未批准,重定向到 create order 返回的 rel:approve 链接。
  • PAYER_ACTION_REQUIRED:需要跳 rel:payer-action,并订阅 webhook 才能在异步买家动作后 capture。
  • DECIMAL_PRECISION / DECIMALS_NOT_SUPPORTED:金额小数位与币种不符。JPY、HUF 等零小数位币种不能写 "1000.00",要写 "1000"。金额用整数最小单位或字符串传输,别用浮点。
  • DUPLICATE_INVOICE_ID:invoice_id 重复;INSTRUMENT_DECLINED:支付工具被拒;CONTINGENCY_NOT_SUCCESSFUL:3DS 未通过;PAYEE_BLOCKED_TRANSACTION:风控拦截。

PAYMENT.CAPTURE.PENDING 不是成功,只是受理,等 COMPLETED 再发货;DENIED 不发货。PENDING 可能挂数天(如 eCheck),要有超时对账任务。

手续费不要自己乘费率:capture 响应的 seller_receivable_breakdown 里有 gross_amount / paypal_fee / net_amount,直接读,按 net_amount 对账。

六、投递规则:25 次、3 天、30 秒

listener 必须是 HTTPS 443;每次投递必须回 2xx,否则最多重试 25 次、跨 3 天,仍失败标记 Failed(可在 Webhook Events 面板手动重发);处理超过 30 秒没返回 200 也会触发重复投递。所以入口只做三件事:验签、落库、返回 200,业务全部异步。

补单接口:

# 列事件
GET /v1/notifications/webhooks-events?event_type=PAYMENT.CAPTURE.COMPLETED
# 重投
POST /v1/notifications/webhooks-events/{event_id}/resend
# 主动核对
GET /v2/payments/captures/{capture_id}
GET /v2/checkout/orders/{order_id}

环境域名:sandbox 是 api-m.sandbox.paypal.com,live 是 api-m.paypal.com(旧写法 api.sandbox.paypal.com 仍出现在部分示例里)。webhook_id、client_id、secret 全部按环境隔离。

上线自检清单

  • webhook 入口读的是 raw body,全链路没有「解析后再序列化」。
  • body 被多次读取时,已重新塞回 r.Body / 已缓存变量。
  • 自签串里 crc32 输出为十进制无符号;待签串用 | 拼接,顺序为 transmissionId、timeStamp、webhookId、crc32。
  • webhook_id 取自后台订阅配置,与当前环境(sandbox/live)一致,不是 client_id。
  • cert_url 校验了 https 与允许的 host,证书有本地缓存与更新策略。
  • 已显式订阅所需事件类型,未使用通配。
  • webhook 入口 3 秒内返回 2xx,业务异步处理,event id 唯一索引去重。
  • capture_id 在业务表有唯一索引,发货前先判重。
  • PayPal-Request-Id 可复现(订单派生或落库复用),并发路径有本地锁。
  • ORDER_ALREADY_CAPTURED 走「GET 订单取 capture id 后按成功」分支,不重试 capture。
  • 只在 PAYMENT.CAPTURE.COMPLETED 发货,PENDING 不发货,DENIED 关闭订单。
  • 订单号取 related_ids.order_id,capture id 取 resource.id,未混用。
  • 金额按币种小数位校验(JPY/HUF 等零小数位),无浮点运算。
  • 手续费取 seller_receivable_breakdown,对账用 net_amount。
  • 有定时对账任务,用 GET /v2/checkout/orders/{id} 与 GET /v2/payments/captures/{id} 补漏单。
复制全文 生成海报 PayPal 支付对接 Webhook 签名验签 出海

推荐文章

程序员茄子在线接单