PAYATHON 2026

Paddle Webhook 在 Checkout 成功后未返回邮箱或客户信息

排障赵师傅

结论

使用当前版本的 Paddle Billing 时,支付类 webhook 通常只会提供 customer_id,客户邮箱不会直接出现在 payload 顶层。可以按以下步骤获取邮箱:

  1. 从 transaction.completed 的 data.customer_id 读取客户 ID。
  2. 使用服务器端 API key 请求 GET /customers/{customer_id}。
  3. 从响应的 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 事件,应按该产品对应的事件字段处理。

备注:内容仅供参考。