PAYATHON 2026

如何集成 Dwolla payment API 并解决空响应问题

支付小周

结论

最直接的问题是,curl_exec() 失败时会返回 false,而 echo false 不会显示任何内容,所以看起来像是“空响应”。原代码也没有输出 cURL 错误、HTTP 状态码和响应头,真正的失败原因因此没有显现出来。

代码中还有几个需要修正的问题:

  • https://www.dwolla.com/oauth/rest/offsitegateway/checkouts 是旧版 Dwolla Offsite Gateway 接口,不能直接作为当前 Dwolla API 的集成示例。该接口是否还能使用,需要根据对应账户和 Dwolla 当前文档确认。
  • orderItems 应当是商品数组,而不是单个商品对象。
  • "true" 和 "false" 是字符串,不是 JSON 布尔值,应分别改为 true 和 false。
  • Dwolla 无法访问你电脑上的 localhost,真实回调不能使用本地地址。
  • 关闭 CURLOPT_SSL_VERIFYPEER 会带来中间人攻击风险。
  • 代码中暴露了 client_secret。如果这是真实凭证,应立即在 Dwolla 后台撤销并重新生成,不能继续使用。

先把真实错误打印出来

调试时不能只输出响应正文,还要检查 curl_exec() 的返回值、cURL 错误、HTTP 状态码和响应头:

<?php

$postUrl = 'https://www.dwolla.com/oauth/rest/offsitegateway/checkouts';

$postValues = [
    'client_id' => getenv('DWOLLA_CLIENT_ID'),
    'client_secret' => getenv('DWOLLA_CLIENT_SECRET'),
    'callback' => 'https://example.com/dwolla/callback',
    'redirect' => 'https://example.com/dwolla/return',
    'allowGuestCheckout' => true,
    'checkoutWithApi' => false,
    'orderId' => 'foo123',
    'allowFundingSources' => true,
    'additionalFundingSources' => true,
    'purchaseOrder' => [
        'orderItems' => [
            [
                'name' => 'T-Shirt',
                'description' => 'Size: Large - Graphic Tee',
                'quantity' => 1,
                'price' => '15.00',
            ],
        ],
        'destinationId' => '812-134-9523',
        'total' => 15.00,
        'tax' => 0.00,
        'shipping' => 0.00,
        'discount' => 0.00,
        'notes' => 'blahhh',
        'facilitatorAmount' => 0,
        'metadata' => [
            'orderNum' => '363498639',
            'category' => 'thing2',
        ],
    ],
];

$postString = json_encode($postValues, JSON_THROW_ON_ERROR);

$ch = curl_init($postUrl);

curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => $postString,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HEADER => true,
    CURLOPT_CONNECTTIMEOUT => 10,
    CURLOPT_TIMEOUT => 30,
    CURLOPT_HTTPHEADER => [
        'Accept: application/json',
        'Content-Type: application/json',
        'Content-Length: ' . strlen($postString),
    ],
]);

$response = curl_exec($ch);

if ($response === false) {
    $errorNumber = curl_errno($ch);
    $errorMessage = curl_error($ch);
    curl_close($ch);

    throw new RuntimeException(
        "Dwolla request failed: cURL error {$errorNumber}: {$errorMessage}"
    );
}

$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);

$responseHeaders = substr($response, 0, $headerSize);
$responseBody = substr($response, $headerSize);

curl_close($ch);

echo "HTTP status: {$statusCode}\n";
echo "Headers:\n{$responseHeaders}\n";
echo "Body:\n{$responseBody}\n";

这段代码用于查明旧请求失败的具体原因,并不代表旧版 Offsite Gateway 端点目前仍然有效。

可能出现的结果包括:

  • 0:请求没有成功到达服务器,常见原因是 DNS、TLS、代理或网络连接问题。
  • 400:JSON 结构、字段类型或业务参数有误。
  • 401 或 403:凭证错误、权限不足,或者当前已不再支持这种认证方式。
  • 404 或 410:端点有误,或者旧接口已经下线。
  • 422:服务器可以解析请求格式,但业务数据不符合要求。
  • 301 或 302:请求发生了重定向。调试时应检查 Location 响应头,不要直接开启自动跳转。

当前 Dwolla API 的调用方式

现代 Dwolla API 通常不再通过旧的 offsitegateway/checkouts 创建结账页,而是按以下流程操作:

  1. 使用 client_id 和 client_secret 获取 OAuth access token。
  2. 创建或获取 Customer。
  3. 获取并验证 Funding Source。
  4. 调用 /transfers 创建转账。

实际流程会随账户类型、Customer 类型和资金来源验证方式而变化。下面只演示最基本的“获取 token”和“创建 transfer”请求结构。

获取 access token

沙箱环境通常使用 https://api-sandbox.dwolla.com,生产环境使用 https://api.dwolla.com。两个环境的资源 URL 和凭证不能混用。

<?php

function getDwollaAccessToken(
    string $clientId,
    string $clientSecret,
    string $baseUrl
): string {
    $ch = curl_init($baseUrl . '/token');

    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => http_build_query([
            'grant_type' => 'client_credentials',
        ]),
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 10,
        CURLOPT_TIMEOUT => 30,
        CURLOPT_USERPWD => $clientId . ':' . $clientSecret,
        CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
        CURLOPT_HTTPHEADER => [
            'Accept: application/json',
            'Content-Type: application/x-www-form-urlencoded',
        ],
    ]);

    $response = curl_exec($ch);

    if ($response === false) {
        $message = curl_error($ch);
        curl_close($ch);
        throw new RuntimeException('Dwolla token request failed: ' . $message);
    }

    $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    $data = json_decode($response, true, 512, JSON_THROW_ON_ERROR);

    if ($statusCode < 200 || $statusCode >= 300) {
        throw new RuntimeException(
            "Dwolla token request returned HTTP {$statusCode}: {$response}"
        );
    }

    if (empty($data['access_token'])) {
        throw new RuntimeException('Dwolla response does not contain access_token');
    }

    return $data['access_token'];
}

$baseUrl = 'https://api-sandbox.dwolla.com';

$accessToken = getDwollaAccessToken(
    getenv('DWOLLA_CLIENT_ID'),
    getenv('DWOLLA_CLIENT_SECRET'),
    $baseUrl
);

创建 transfer

source 和 destination 必须使用 Dwolla 返回的真实资源 URL,例如 Funding Source URL。下面的占位值需要替换:

<?php

function createDwollaTransfer(
    string $accessToken,
    string $baseUrl,
    string $sourceUrl,
    string $destinationUrl,
    string $amount,
    string $currency = 'USD'
): array {
    $payload = [
        '_links' => [
            'source' => [
                'href' => $sourceUrl,
            ],
            'destination' => [
                'href' => $destinationUrl,
            ],
        ],
        'amount' => [
            'currency' => $currency,
            'value' => $amount,
        ],
    ];

    $json = json_encode($payload, JSON_THROW_ON_ERROR);

    $ch = curl_init($baseUrl . '/transfers');

    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => $json,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HEADER => true,
        CURLOPT_CONNECTTIMEOUT => 10,
        CURLOPT_TIMEOUT => 30,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer ' . $accessToken,
            'Accept: application/vnd.dwolla.v1.hal+json',
            'Content-Type: application/vnd.dwolla.v1.hal+json',
        ],
    ]);

    $response = curl_exec($ch);

    if ($response === false) {
        $message = curl_error($ch);
        curl_close($ch);
        throw new RuntimeException('Dwolla transfer request failed: ' . $message);
    }

    $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);

    $headers = substr($response, 0, $headerSize);
    $body = substr($response, $headerSize);

    curl_close($ch);

    if ($statusCode !== 201) {
        throw new RuntimeException(
            "Dwolla transfer request returned HTTP {$statusCode}: {$body}"
        );
    }

    preg_match('/^Location:\s*(.+)$/mi', $headers, $matches);

    return [
        'status' => $statusCode,
        'location' => isset($matches[1]) ? trim($matches[1]) : null,
        'body' => $body,
    ];
}

$result = createDwollaTransfer(
    $accessToken,
    'https://api-sandbox.dwolla.com',
    'https://api-sandbox.dwolla.com/funding-sources/SOURCE_ID',
    'https://api-sandbox.dwolla.com/funding-sources/DESTINATION_ID',
    '15.00'
);

var_dump($result);

资源创建成功时,Dwolla 可能返回 201 Created,并将资源地址放在 Location 响应头中,此时响应正文可能为空。因此,正文为空不一定表示请求失败,还要结合 HTTP 状态码和响应头判断。

回调地址不能使用 localhost

下面的地址只能从本机访问:

http://localhost/WC/christian/register/dwolla2

Dwolla 的服务器无法请求这个地址。开发时可以使用带有 HTTPS 公网地址的测试环境,也可以通过安全的隧道服务暂时公开本地端口。生产环境必须使用稳定的 HTTPS 地址。

处理回调时还要注意:

  • 不能只根据浏览器重定向结果判断支付是否成功。
  • 应通过 Dwolla API 查询资源状态,或者按照当前文档验证 webhook。
  • webhook 处理程序需要支持重复通知,以免重复入账。
  • 收到 webhook 后应尽快返回成功状态,耗时业务再异步处理。

安全与金额处理注意事项

  • 立即撤销问题中出现的 client_secret,并生成新凭证。
  • 将凭证保存在环境变量或密钥管理服务中,不要写入源码或提交到 Git。
  • 不要设置 CURLOPT_SSL_VERIFYPEER => false。如果证书验证失败,应更新服务器的 CA 证书包。
  • 金额建议使用字符串,例如 "15.00",不要依赖浮点数处理财务计算。
  • 分别处理 HTTP 2xx、4xx 和 5xx,同时记录 Dwolla 返回的错误正文及相关请求标识。
  • 沙箱和生产环境的凭证、Customer、Funding Source 及资源 URL 互不通用。
  • 集成方式应以当前 Dwolla 开发者文档和账户已启用的产品为准,不要继续照搬旧版 Offsite Gateway 示例。

备注:内容仅供参考。