PayPal webhook listener 收到 HTTP 302 并触发重试,如何排查?
结论
PayPal 日志中的 http_status: 302 表示那次投递收到了重定向响应。Redirects are not allowed 说明 PayPal 没有继续访问重定向目标,因此判定投递失败,并可能再次发送通知。
http_response_code(200) 只设置 PHP 当前的响应状态。后续 PHP 代码可能将它改为 302,Web 服务器、反向代理或 CDN 也可能改变最终对外返回的响应。
先检查 listener URL 末尾的 /:
$listenerURL = "https://example.com/php/mylistener.php/";
它与 https://example.com/php/mylistener.php 是两个不同的路径。如果服务器会将带斜杠的地址重定向到不带斜杠的地址,就应直接注册后者。是否会发生这种重定向取决于服务器配置,仅凭这段代码还无法确定原因。
为什么验签成功,投递仍然失败?
验签和投递确认是两个独立步骤:
- postback 或 X.509 certificate 验证用于判断通知是否可信。
- listener 返回给 PayPal 的 HTTP 响应用于确认本次投递是否成功。
即使 listener 已收到请求并完成验签,后续代码仍可能跳转到登录页、成功页或其他地址,代理层也可能替换应用的响应。
HTTP 重定向适用于各种 HTTP 客户端。浏览器通常会自动跟随重定向,所以在浏览器中最终看到的 200,可能是另一个 URL 的响应。
所示 PHP cURL 代码用于创建 webhook 订阅。创建成功说明订阅已建立,但通知投递时,该 URL 是否直接返回 200,还需要单独检查。
先检查原始 URL 返回的第一份响应
从公网环境向已注册的 URL 发起 POST 请求,查看状态码和 Location 响应头:
curl --include \
--request POST \
--header 'Content-Type: application/json' \
--data '{}' \
'https://example.com/php/mylistener.php/'
不要加 -L 或 --location,否则 cURL 会跟随重定向,容易把最终响应误认为原始 URL 的响应。也不要只用 curl -I,它发送的是 HEAD 请求,处理路径可能与 webhook 的 POST 请求不同。
例如,以下响应就能确认原始 URL 返回了重定向:
HTTP/1.1 302 Found
Location: https://example.com/php/mylistener.php
实际排查时,以服务器返回的内容为准。接着向不带末尾 / 的地址发送同样的 POST 请求,检查它是否还会继续跳转。
测试中的 {} 不带真实的 PayPal 签名,正常的 listener 可能返回验签失败的 4xx。这项测试主要检查 URL 和入口层的重定向,完整投递仍需用有效的 sandbox 通知验证。
根据 Location 和日志定位来源
根据 Location 中的跳转目标,可以缩小排查范围:
| 跳转目标或变化 | 优先检查 |
|---|---|
删除或添加路径末尾的 / | 路径规范化、Apache/Nginx 配置、框架路由 |
主域名与 www 域名互换 | 域名跳转规则、CDN 配置 |
| 跳转到登录页、验证页 | 登录中间件、访问控制、WAF 或机器人验证 |
| 跳转到首页、错误页、成功页 | PHP 后续代码、异常处理、框架重定向 |
按请求经过的顺序,检查 CDN/WAF、负载均衡、Web 服务器和 PHP 应用,重点查看:
- Apache 的
.htaccess、Redirect或带重定向标志的RewriteRule。 - Nginx 的
return 302、rewrite ... redirect等规则。 - PHP 中的
header('Location: ...'),以及框架的 redirect 调用。 - listener 路由继承的登录检查、会话检查或 CSRF 中间件。
内部 rewrite 不一定会产生 HTTP 重定向,需要确认规则是否向客户端返回了 3xx。
如果入口代理按协议强制跳转,还要检查应用是否正确识别了代理转发的 HTTPS 信息。协议识别错误也可能导致重复跳转。
如果大多数投递失败、部分成功,需要逐次对照日志。用事件 ID、投递时间和已记录的 PAYPAL-TRANSMISSION-ID 关联各层记录,检查失败请求是否进入 PHP、由哪个节点处理,以及最终对外返回的状态。某一次 listener 验签成功,并不能说明其他失败尝试也经过了相同路径。
修正注册地址和 listener 响应
如果已确认不带末尾 / 的地址能直接处理通知,可将创建代码中的地址改为:
$listenerURL = "https://example.com/php/mylistener.php";
$url = "https://api-m.sandbox.paypal.com/v1/notifications/webhooks";
$payload = json_encode([
"url" => $listenerURL,
"event_types" => [
[
"name" => "PAYMENT.CAPTURE.COMPLETED"
]
]
]);
API URL 两侧也应使用合法的 PHP 字符串引号,不能使用弯引号。
修改本地变量不会改变已经创建的 webhook,还需更新已有订阅,并核对 PayPal 实际保存的 URL。可通过 GET /v1/notifications/webhooks/{webhook_id} 查看对应配置。
listener 应在签名校验通过、事件已可靠保存或处理成功后,直接结束响应:
// 放在签名校验通过、事件已可靠保存或处理成功的分支末尾。
// 此前不要输出响应正文。
http_response_code(200);
header('Content-Type: text/plain; charset=utf-8');
echo 'OK';
exit;
这段代码只能控制执行到这里的 PHP 请求。如果重定向发生在 PHP 执行前,或者代理层在响应离开应用后将其改写,就需要修正对应配置。
还要检查 http_response_code(200) 后面的代码。例如,后续调用:
header('Location: /login');
仍可能将响应改为 302,因为开头设置的 200 不会锁定最终状态码。
为什么还会重试,如何确认修复?
PayPal 收到重定向响应后,未能确认投递成功,因此可能重试。listener 内部验签成功,不能替代 HTTP 响应确认。
仅凭 FAIL_SOFT、FAIL_HARD 和两次尝试,无法确定完整的重试规则,也无法推断所有事件都会在第二次尝试后停止。需要逐条查看 transmissions 中的实际状态码和失败原因。
修复后,用有效的 sandbox 通知重新验证:
- PayPal 中保存的是可直接访问的最终 URL。
- 该 URL 接收 POST 后直接返回
200,不经过重定向。 - PayPal 对应投递记录显示成功,各层访问日志与之吻合。
创建 webhook 时所用 cURL 的 CURLOPT_FOLLOWLOCATION 只影响创建订阅的请求。调整它无法改变 PayPal 投递通知时的行为,也就无法解决这里的重定向问题。
listener 还应按 webhook 事件 id 做幂等处理,并对支付业务操作设置必要的唯一性约束。即使业务处理已经完成,只要 PayPal 没有确认成功响应,同一通知仍可能再次到达。
备注:内容仅供参考。
