PAYATHON 2026

如何配置 Authorize.net ARB 的 Silent Post URL?

技术老齐

结论

Silent Post URL 应指向一个可从公网访问的 HTTPS 接口,用于接收每笔 ARB 扣款的异步交易结果。收到通知后,接口先检查幂等性,再通过 Authorize.net 的交易查询 API 核实交易,确认无误后更新本地账单和用户权限。

Silent Post 主要报告扣款成功、拒绝、处理错误等交易结果,无法完整覆盖订阅取消、暂停、到期等生命周期变化。

Authorize.net 通常也不会提供可直接用于提醒的可靠“下一次扣款时间”。系统需要在创建订阅时保存扣款计划,自行计算预计扣款日。银行卡到期年月同样应在首次收集支付信息时单独保存,不能依赖回调获取。

新系统还可以评估 Authorize.net Webhooks。Silent Post 是较早的通知机制,可用事件和后台入口可能随账户配置而异。

配置 Silent Post URL

登录 Authorize.net Merchant Interface,在账户设置中找到 Silent Post URL。不同版本的后台菜单名称可能不同,一般位于 Account 或 Settings 下的交易通知设置中。

填写完整的 HTTPS 地址,例如:

https://billing.example.com/webhooks/authorize-net/silent-post

配置时应满足以下要求:

  • 分别为 Sandbox 和 Production 环境配置 URL。
  • URL 必须能被 Authorize.net 从公网访问。
  • 接口需要接受 POST 请求和 application/x-www-form-urlencoded 数据。
  • 地址不能要求登录、Cookie 或浏览器交互。
  • 回调不能重定向到其他地址。
  • 成功接收后返回 HTTP 200。
  • 回调处理时间应尽量缩短,耗时业务可交给队列执行。

普通订单的返回地址和用户付款后的跳转地址不能与 Silent Post URL 混用。Silent Post 是服务器之间的通知,不会将用户浏览器带回网站。

Silent Post 会发送什么

ARB 完成扣款后,Silent Post 通常会发送一组以 x_ 开头的表单字段,常用字段包括:

x_response_code
x_response_reason_code
x_response_reason_text
x_trans_id
x_subscription_id
x_subscription_paynum
x_amount

x_response_code 的常见取值如下:

值含义建议处理
1Approved核实交易后确认付款
2Declined记录拒付,进入催款或宽限流程
3Error记录错误,等待重试或人工处理
4Held for Review标记为待审核,不立即开通长期权限

实际出现的字段及其内容取决于交易类型和账户配置,代码不能假定所有字段始终存在。

PHP 接收示例

以下示例读取参数,并完成基本校验和幂等处理。findLocalSubscription()、transactionExists() 等函数代表项目自己的数据访问逻辑。

<?php

declare(strict_types=1);

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit;
}

$payload = $_POST;

$transactionId = trim((string) ($payload['x_trans_id'] ?? ''));
$subscriptionId = trim((string) ($payload['x_subscription_id'] ?? ''));
$responseCode = (string) ($payload['x_response_code'] ?? '');
$reasonCode = (string) ($payload['x_response_reason_code'] ?? '');
$paymentNumber = (string) ($payload['x_subscription_paynum'] ?? '');
$amount = (string) ($payload['x_amount'] ?? '');

if ($transactionId === '' || $subscriptionId === '') {
    http_response_code(400);
    exit;
}

$subscription = findLocalSubscription($subscriptionId);

if ($subscription === null) {
    recordUnmatchedCallback($payload);
    http_response_code(200);
    exit;
}

/*
 * x_trans_id 应设置唯一约束。
 * Authorize.net 重复发送通知时,不应重复延长会员期限或重复记账。
 */
if (transactionExists($transactionId)) {
    http_response_code(200);
    exit;
}

/*
 * 不要仅凭 POST 参数修改用户权限。
 * 应调用 Authorize.net Get Transaction Details API,
 * 核对 transactionId、subscriptionId、amount 和交易状态。
 */
queueTransactionVerification([
    'transaction_id' => $transactionId,
    'subscription_id' => $subscriptionId,
    'response_code' => $responseCode,
    'reason_code' => $reasonCode,
    'payment_number' => $paymentNumber,
    'amount' => $amount,
    'raw_payload' => $payload,
]);

http_response_code(200);

使用 Authorize.net PHP SDK 时,可以通过 GetTransactionDetailsRequest 查询交易。下面是基本调用方式,生产环境还需检查 API 返回码和空值:

<?php

use net\authorize\api\contract\v1 as AnetAPI;
use net\authorize\api\controller as AnetController;
use net\authorize\api\constants\ANetEnvironment;

function getAuthorizeNetTransaction(string $transactionId): ?AnetAPI\TransactionDetailsType
{
    $authentication = new AnetAPI\MerchantAuthenticationType();
    $authentication->setName(getenv('AUTHORIZE_NET_API_LOGIN_ID'));
    $authentication->setTransactionKey(
        getenv('AUTHORIZE_NET_TRANSACTION_KEY')
    );

    $request = new AnetAPI\GetTransactionDetailsRequest();
    $request->setMerchantAuthentication($authentication);
    $request->setTransId($transactionId);

    $controller = new AnetController\GetTransactionDetailsController($request);

    $environment = getenv('APP_ENV') === 'production'
        ? ANetEnvironment::PRODUCTION
        : ANetEnvironment::SANDBOX;

    $response = $controller->executeWithApiResponse($environment);

    if ($response === null) {
        return null;
    }

    $messages = $response->getMessages();

    if (
        $messages === null ||
        $messages->getResultCode() !== 'Ok'
    ) {
        return null;
    }

    return $response->getTransaction();
}

查询完成后,至少核对以下内容:

  • transId 是否与回调中的 x_trans_id 一致。
  • 交易是否属于预期的商户账户和环境。
  • 订阅 ID 是否与本地记录匹配。
  • 金额和币种是否符合本期账单。
  • 交易最终状态是否允许确认付款。

如何升级或降级用户

支付状态、订阅状态和业务权限应分别保存,不要将 Authorize.net 的状态直接写入用户等级字段。

可以按以下方式处理:

switch ($verifiedResponseCode) {
    case '1':
        markInvoicePaid($invoiceId, $transactionId);
        extendUserAccess($userId, $paidThroughDate);
        break;

    case '2':
        markInvoicePastDue($invoiceId, $reasonCode);
        startDunningProcess($userId);
        break;

    case '3':
        markInvoicePaymentError($invoiceId, $reasonCode);
        scheduleReconciliation($transactionId);
        break;

    case '4':
        markInvoiceUnderReview($invoiceId);
        break;

    default:
        recordUnknownPaymentState($transactionId, $verifiedResponseCode);
        break;
}

扣款成功后,可以升级用户或延长有效期。扣款失败后是否立即降级,应由业务规则决定。常见做法是先设置宽限期,让用户有时间更新银行卡,避免第一次拒付就关闭服务。

订阅处于 suspended、canceled、expired 或 terminated 状态,与单笔交易失败是两类情况。例如,订阅取消后,用户可能仍处于已经支付的服务期内。因此,降级时间通常应以本地保存的 paid_through 日期为准。

Silent Post 无法覆盖所有订阅状态变化。系统还需要定期调用 ARB 订阅查询接口进行对账,也可以使用账户支持的 Webhook 订阅相关事件。

如何获得银行卡到期时间

Silent Post 不适合获取银行卡到期时间。回调中的支付信息通常已经过掩码处理,也不应尝试从回调中恢复完整卡片数据。

如果系统通过支付页面直接收集到期年月,可以在创建订阅时保存:

card_expiry_month = 08
card_expiry_year  = 2028

到期年月通常不属于完整银行卡号或 CVV,但仍应按照支付安全要求限制访问和日志输出。不要保存 CVV。除非系统具备相应的 PCI DSS 合规能力,也不要自行保存完整银行卡号。

使用 Accept Hosted、Accept.js 或其他令牌化方案时,应用可能只能取得掩码后的支付信息。能否取得到期年月,取决于具体集成方式和 API 返回内容。如果查询结果只有掩码值,需要在绑卡时单独记录年月,或者让用户进入安全的支付资料更新页面。

银行卡到期提醒应按“到期月份”处理。例如,可以在到期月份前 30 天和 7 天发送更新提醒,不要推断一个并不存在的精确到期日。

如何计算下一次 billing date

创建 ARB 订阅时,应用已经提交了支付计划,通常包括:

startDate
interval.length
interval.unit
totalOccurrences
trialOccurrences

这些信息应保存在本地。每次确认扣款成功后,再按照支付周期推进下一次预计扣款日期。

<?php

function calculateNextBillingDate(
    DateTimeImmutable $currentBillingDate,
    int $intervalLength,
    string $intervalUnit
): DateTimeImmutable {
    return match ($intervalUnit) {
        'days' => $currentBillingDate->modify(
            sprintf('+%d days', $intervalLength)
        ),
        'months' => $currentBillingDate->modify(
            sprintf('+%d months', $intervalLength)
        ),
        default => throw new InvalidArgumentException(
            'Unsupported interval unit'
        ),
    };
}

这段代码只表达基本计算思路。按月计费时,月末日期需要单独处理。例如,从 1 月 31 日增加一个月,不同语言的日期库可能得到不同结果,也可能与支付网关的排期规则不一致。更安全的方案是在创建订阅时生成并保存计划日期,明确短月份的处理方式,再根据实际交易通知校正。

系统还要区分两个日期:

  • next_scheduled_billing_date:按订阅计划预计发起扣款的日期。
  • next_retry_date:扣款失败后预计再次尝试的日期。

如果 Authorize.net 的订阅查询响应没有明确返回下一次处理日期,就不能根据订阅状态猜测。可以结合本地计划、已成功期数和已处理交易计算,再通过定期对账修正。

给用户的通知可以写成:

预计将于 2026-11-15 从尾号 1234 的银行卡扣款 ¥99.00。

使用“预计”更准确,因为银行拒付、账户暂停、时区和处理批次都可能影响最终结果。日期应按业务时区展示,数据库中则保留明确的时区或 UTC 时间。

实现时容易忽略的问题

  • 为 x_trans_id 建立唯一索引,避免重复通知造成重复记账或重复开通权益。
  • 回调中的金额、订阅 ID 和交易状态都需要通过服务端 API 复核。
  • 不要在日志中记录完整的 POST 数据,其中可能包含姓名、地址或掩码支付信息。
  • Sandbox 和 Production 使用不同的凭据、API 环境和回调配置。
  • Silent Post、Webhook 和定时对账可能重复报告同一笔交易,各个处理入口应共用一套幂等逻辑。
  • 保存原始事件时,需要限制访问并设置合理的保留期限。
  • 对账任务应查找“网关已成功、本地未入账”和“本地等待中、网关已有最终结果”的记录。
  • 权益变更应以核实后的交易和 paid_through 日期为依据,不能只看某一次回调是否到达。

备注:内容仅供参考。