如何集成 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 创建结账页,而是按以下流程操作:
- 使用
client_id和client_secret获取 OAuth access token。 - 创建或获取 Customer。
- 获取并验证 Funding Source。
- 调用
/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 示例。
备注:内容仅供参考。
