Paddle Webhook 在 Checkout 成功后未返回邮箱或客户信息
结论
使用当前版本的 Paddle Billing 时,支付类 webhook 通常只会提供 customer_id,客户邮箱不会直接出现在 payload 顶层。可以按以下步骤获取邮箱:
- 从
transaction.completed的data.customer_id读取客户 ID。 - 使用服务器端 API key 请求
GET /customers/{customer_id}。 - 从响应的
data.email中读取邮箱。
你也可以订阅 customer.created 和 customer.updated。这两类事件的 data 通常包含客户资料,但业务逻辑不能依赖事件的到达顺序。发放许可证时,建议根据 customer_id 查询 Customers API。
浏览器端的成功跳转或 JavaScript callback 可以用来展示支付结果,但不能作为发放许可证的唯一依据。用户可能提前关闭页面,请求也可能被伪造,因此应由服务器端 webhook 触发许可证发放流程。
为什么 $_POST 是空的
Paddle Billing webhook 使用 JSON 请求体,而 PHP 不会自动把 application/json 解析到 $_POST。因此,$_POST 为空是正常的,需要直接读取原始请求体:
$raw = file_get_contents('php://input');
$event = json_decode($raw, true);
还需要注意字段所在的位置。Paddle Billing webhook 的结构通常如下:
{
"event_id": "evt_...",
"event_type": "transaction.completed",
"data": {
"id": "txn_...",
"customer_id": "ctm_..."
}
}
所以下面的代码一般读不到邮箱:
$email = $data['email'] ?? null;
处理 transaction.completed 事件时,应先读取客户 ID:
$customerId = $event['data']['customer_id'] ?? null;
然后再查询客户资源。
PHP 实现示例
下面的示例适用于 Paddle Billing。生产环境还需要处理签名验证、幂等和失败重试。
<?php
declare(strict_types=1);
$raw = file_get_contents('php://input');
if ($raw === false || $raw === '') {
http_response_code(400);
exit('Empty request body');
}
$event = json_decode($raw, true);
if (!is_array($event)) {
http_response_code(400);
exit('Invalid JSON');
}
$eventType = $event['event_type'] ?? null;
if ($eventType !== 'transaction.completed') {
http_response_code(200);
exit('Event ignored');
}
$transactionId = $event['data']['id'] ?? null;
$customerId = $event['data']['customer_id'] ?? null;
if (!$transactionId || !$customerId) {
http_response_code(400);
exit('Missing transaction_id or customer_id');
}
$apiKey = getenv('PADDLE_API_KEY');
if (!$apiKey) {
http_response_code(500);
exit('PADDLE_API_KEY is not configured');
}
$customer = getPaddleCustomer($customerId, $apiKey, false);
$email = $customer['data']['email'] ?? null;
if (!$email) {
http_response_code(500);
exit('Customer email was not returned');
}
/*
* 在这里保存 transaction_id、customer_id 和 email,
* 然后将许可证生成及邮件发送任务交给队列或后台任务。
*
* 同一个 event_id 或 transaction_id 必须只处理一次。
*/
http_response_code(200);
echo 'OK';
function getPaddleCustomer(
string $customerId,
string $apiKey,
bool $sandbox
): array {
$baseUrl = $sandbox
? 'https://sandbox-api.paddle.com'
: 'https://api.paddle.com';
$url = $baseUrl . '/customers/' . rawurlencode($customerId);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $apiKey,
'Accept: application/json',
],
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 15,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($response === false || $status < 200 || $status >= 300) {
throw new RuntimeException(
'Paddle API request failed. HTTP ' . $status . ': ' . $error
);
}
$decoded = json_decode($response, true);
if (!is_array($decoded)) {
throw new RuntimeException('Invalid JSON returned by Paddle API');
}
return $decoded;
}
测试环境需要使用 https://sandbox-api.paddle.com 和对应的 sandbox API key,生产环境使用 https://api.paddle.com。两个环境中的客户 ID、交易 ID 和凭据不能混用。
必须验证 webhook 签名
收到 endpoint 请求后不能直接发放许可证,因为攻击者也能构造 POST 请求。应使用 webhook endpoint secret 验证 Paddle-Signature。
签名验证必须基于未经修改的原始请求体。完成验证前,不要重新编码 JSON。以下是一个基础实现:
<?php
function verifyPaddleSignature(
string $rawBody,
string $signatureHeader,
string $endpointSecret
): bool {
$timestamp = null;
$signatures = [];
foreach (explode(';', $signatureHeader) as $part) {
[$name, $value] = array_pad(explode('=', trim($part), 2), 2, null);
if ($name === 'ts') {
$timestamp = $value;
} elseif ($name === 'h1' && $value !== null) {
$signatures[] = $value;
}
}
if ($timestamp === null || $signatures === []) {
return false;
}
$signedPayload = $timestamp . ':' . $rawBody;
$expected = hash_hmac('sha256', $signedPayload, $endpointSecret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
$raw = file_get_contents('php://input');
$signature = $_SERVER['HTTP_PADDLE_SIGNATURE'] ?? '';
$endpointSecret = getenv('PADDLE_WEBHOOK_SECRET') ?: '';
if (
!$raw ||
!$signature ||
!$endpointSecret ||
!verifyPaddleSignature($raw, $signature, $endpointSecret)
) {
http_response_code(401);
exit('Invalid webhook signature');
}
还应检查签名中的时间戳,并按照业务允许的时间窗口拒绝过旧请求,降低重放风险。如果已经安装 Paddle 官方 SDK,可以优先使用 SDK 提供的签名验证功能。
处理 billing info
交易事件中的 customer_id、address_id 和 business_id 都是相关资源的引用。需要完整资料时,应分别查询 Customers、Addresses 或 Businesses API,不能假设交易 webhook 会嵌入所有字段。
保存数据时,建议至少记录以下关联:
event_id
transaction_id
customer_id
customer_email
license_status
如果创建 checkout 的方式支持 custom_data,可以在其中保存内部订单号、用户 ID 或待发放产品 ID。不要只根据邮箱判断需要发放哪一个许可证,因为同一个邮箱可能对应多笔交易。
提高发放许可证的可靠性
遇到网络超时,或 endpoint 没有及时返回成功状态时,Paddle 可能会重试 webhook。处理程序应做到以下几点:
- 先验证
Paddle-Signature。 - 使用
event_id或transaction_id进行幂等判断。 - 持久化事件和交易状态后,尽快返回
2xx。 - 通过后台任务查询客户资料、生成许可证并发送邮件。
- API 查询或邮件发送失败后进行重试。
- 日志中不要记录 API key、完整签名、完整客户资料或其他不必要的个人信息。
Dashboard 将 webhook 标记为 successful,只代表 endpoint 返回了 Paddle 接受的 HTTP 状态。它不能证明 payload 中包含邮箱,也不能证明许可证发放流程已经成功完成。
确认使用的是 Paddle Billing 还是 Paddle Classic
上述字段和接口适用于 Paddle Billing。旧版 Paddle Classic 的 alert webhook 可能使用 application/x-www-form-urlencoded,字段结构和签名方式与 Billing 不同。
可以根据请求数据判断所使用的产品:
- 请求体包含
event_type、event_id和嵌套的data时,通常是 Paddle Billing。 - 请求通过表单字段发送,并包含 Classic 专用字段或
p_signature时,通常是 Paddle Classic。
Classic 的 $_POST 示例和 p_signature 验证方式不能与 Billing 的 JSON webhook 和 Paddle-Signature 混用。如果收到的是 Paddle Classic 事件,应按该产品对应的事件字段处理。
备注:内容仅供参考。
