返回博客
Engineering
2026-07-2818 分钟阅读作者:StableOps

稳定币支付对账:如何把链上转账匹配到业务订单

稳定币支付对账需要连接业务订单、支付事件与链上转账。本文讲解如何设计稳定外键、发现 Webhook 缺口,并执行日对账与月对账。

稳定币支付
支付对账
支付运营

钱包余额不等于已支付订单总额,这是正常现象。钱包余额是某一时点的存量,已经包含入账转账、资金归集、退款和无关转账的共同结果;订单报表则是指定期间内的流量。再加上多条链、两种稳定币、地址复用、迟到付款和 Webhook 端点故障,直接比较这两个数字就不再是一种有效的会计方法。

可靠的稳定币支付对账,不会强求某一个数据库回答所有问题。它连接三类记录:业务订单说明客户应付什么,支付层说明订单经历了哪些状态,规范链说明转账是否真实存在。稳定标识符负责连接三者,周期性接口核对则负责发现实时 Webhook 路径之外的缺口。

实际执行时应分成两种节奏:

  • 每天运行一次小型自动对账,发现订单缺失、状态不一致和投递失败;
  • 月末执行一次受控关账,关闭异常、证明余额,并保留完整审计材料。

为什么钱包余额与已支付订单总额不一致

调查差额之前,先确认两边比较的是同一种数字。一个地址的期末余额,并不是某个期间的结算总额。

原因对比较结果的影响
钱包期初余额本期之前收到的资金仍留在期末余额中
资金归集或供应商付款有效入账后来又离开收款地址
退款业务订单可能仍为已支付,但另一笔出账转账减少了钱包余额
多链与多资产Base 上的 10 USDC 与 TRON 上的 10 USDT 是两套账,不能当成一个余额
少付或多付转账到达商户地址,却没有让精确金额订单最终完成
过期后付款订单已经进入终态且地址已经释放后,资金仍可能迟到
地址复用同一个地址随时间可以对应多笔订单,因此地址本身不是订单键
链重组或回执失败已检测或已确认的转账,可能在最终确定前变成 reverted
Webhook 投递失败StableOps 已推进订单,但商户应用没有收到通知,也没有更新自己的账本

应该核对资金变动,而不是只比较余额:

链上期初余额
+ 符合条件的入账转账
+ 未匹配的入账转账
- 资金归集
- 退款及其他出账转账
= 链上期末余额

然后,把符合条件的入账转账与支付订单核对,再把支付订单与业务债权核对。这样可以避免把资金归集误判成销售缺失,也可以避免把未匹配转账误认成收入。

使用三类记录,分别回答不同问题

一笔付款不存在适用于所有问题的“唯一事实来源”。哪个记录具有权威性,取决于当前要回答什么问题。

记录记录内容权威范围
商户订单与账本客户、发票、应付金额、计价单位、履约、退款、人工入账、会计期间商业债权及商户如何进行会计处理
StableOps 订单与事件可用链和资产、分配地址、精确金额、状态变化、投递历史转账是否匹配有效订单并达到要求的最终性
规范区块链记录代币合约或铸币地址、收发地址、最小单位金额、交易哈希、区块、回执转账是否存在于规范链上

区块浏览器只是第三类记录的一种查看工具,并不是会计系统。它不知道 merchantOrderId: invoice_10482 对应哪张客户发票,也不知道一笔多付款中有多少被人工入账、多少已经退款。同样,payment.finalized 事件证明 StableOps 生命周期达到了最终确定,但不能证明你的履约任务已经提交了自己的数据库事务。

对账连接关系应该如下:

商户系统                         StableOps                         区块链

invoice_10482  <------------->  merchant_order_id
业务订单号                       payment_order_id  <------------> 归一化事件
结算状态                         event_id                          tx_hash + log_index
退款或人工入账                   delivery_id                      链 + 代币 + 金额

绝不要只用收款地址连接记录。订单最终完成、回滚、过期或取消后,地址可能再次分配。也不要只按金额连接:不同地址或不同链上可以同时存在金额相等的有效付款。

在接受第一笔付款前设计外键

merchantOrderId 是从 StableOps 返回业务对象的主要桥梁。它是必填字段,并且在组织与环境范围内唯一。应使用业务系统生成且长期稳定的标识符,不要使用能够修改或复用的展示编号。

商户侧应保存以下字段:

字段用途
内部业务编号幂等履约与会计入账键
merchantOrderIdStableOps 与业务的连接键,通常由内部编号派生
StableOps 支付订单号直接查询订单,并按订单筛选投递记录
请求金额与实际应付金额自动金额调整可能使返回给付款人的金额不同于业务金额,因此两者都必须保留
结算资产防止意外合并 USDC 与 USDT 总额
事件号Webhook 处理去重
投递号诊断单次投递尝试,不能作为事件去重键
交易引用从检测事件保存的 chain、交易哈希与日志序号
对账状态openmatchedexceptionclosed,以及原因和复核人

metadata 适合保存查询与运营上下文,但不能成为关键外键的唯一副本:

const order = await client.paymentOrders.create(
  {
    merchantOrderId: 'invoice_10482',
    amount: '249.00',
    acceptedAssets: [
      { chain: 'base', asset: 'USDC' },
      { chain: 'tron', asset: 'USDT' },
    ],
    expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
    metadata: {
      customerId: 'cus_781',
      invoiceNumber: 'INV-2026-10482',
      ledgerAccount: 'sales-software',
    },
  },
  { idempotencyKey: 'invoice_10482:create-payment-order' },
)

merchantOrderId 防止第二笔支付订单占用同一个业务引用。Idempotency-Key 则是独立的请求重放键:使用相同键重试完全相同的建单请求,会返回原响应;使用新的幂等键重复提交同一个 merchantOrderId,则会收到冲突响应。

如果元数据包含客户或发票信息,应根据接收端点的实际需要配置 Webhook 元数据脱敏。不要为了方便对账,就把密钥、私钥或受监管数据放入元数据。

在执行副作用之前建立事件账本

Webhook 处理程序应先使用原始请求体验证签名,再把 X-Event-Id 原子写入事件收件表。完成这一步后,才由任务更新业务订单。一条有效的收件记录应包含:

  • 带唯一约束的事件号;
  • 用于诊断单次尝试的投递号;
  • 事件类型与支付订单号;
  • merchant_order_id 与原始载荷;
  • 接收时间、处理状态与处理错误;
  • payment.detected 中存在的交易哈希与日志序号。

必须保留两层幂等边界。事件号唯一约束使重试投递或重放不会重复处理;业务订单或履约操作上的第二个唯一约束,则防止两个不同的有效事件重复发放同一权益。

对账时,不要把“存在一条成功的 Webhook 投递”理解为“商户账本已经更新”。端点可能在持久化收件记录后返回 2xx,而异步任务随后失败。日对账必须比较 StableOps 订单状态与商户账本中真正提交的处理结果。

稳定币支付 Webhook:如何防止重复履约给出了完整的处理模式。

用接口核对建立恢复路径

Webhook 负责低延迟通知,列表接口则提供独立的恢复与审计路径:

  • 列出支付订单返回当前接口密钥所属组织与环境中的订单,按创建时间倒序排列,并支持状态筛选与分页;
  • 列出 Webhook 投递可以按状态、端点或支付订单筛选,并提供尝试次数、响应状态、错误与死信状态;
  • 重放指定投递根据旧记录创建新投递,不会删除原始审计记录;
  • 重放死信投递可以在端点修复后,重新排入限定数量的死信。

为了让这条审计路径保留转账证据,至少应让一个端点订阅 payment.detectedpayment.finalized,并保存经过验签的商户事件收件表。投递列表只能恢复已经订阅的事件,不能代替商户侧长期保存。

下面的 Node.js 脚本对一个已经关闭的会计时间窗执行有边界的核对。输入文件会区分业务金额与返回给付款人的金额,保存 StableOps 支付订单号,并可包含商户已经保存的交易引用:

{
  "windowStart": "2026-07-27T00:00:00.000Z",
  "windowEnd": "2026-07-28T00:00:00.000Z",
  "orders": [
    {
      "merchantOrderId": "invoice_10482",
      "paymentOrderId": "po_01...",
      "requestedAmount": "249.00",
      "payableAmount": "249.000001",
      "settlementAsset": "USDC",
      "paymentApplied": true,
      "transfer": {
        "chain": "base",
        "asset": "USDC",
        "chainAmount": "249000001",
        "txHash": "0x...",
        "logIndex": 7
      }
    }
  ]
}

脚本会执行双向核对,从去重后的 payment.detected 事件生成转账证据,并判断失败或进入死信的事件后来是否在同一个端点投递成功。当前列表接口使用偏移量分页,因此只有连续两次完整扫描结果一致时,脚本才接受订单与投递组成的联合数据集。并发变化会让任务失败并等待重试,而不是产生错误的“无差异”结论。

import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'

type TransferRef = {
  chain: string
  asset: string
  chainAmount: string
  txHash: string
  logIndex: number
}

type LocalOrder = {
  merchantOrderId: string
  paymentOrderId: string
  requestedAmount: string
  payableAmount: string
  settlementAsset: string | null
  paymentApplied: boolean
  transfer?: TransferRef
}

type ExportFile = {
  windowStart: string
  windowEnd: string
  orders: LocalOrder[]
}

type PlatformOrder = {
  id: string
  merchant_order_id: string
  amount: string
  requested_amount: string
  settlement_asset?: string
  status: string
  created_at: string
}

type Delivery = {
  id: string
  webhook_endpoint_id: string
  event_id: string
  payment_order_id: string | null
  event_type: string
  status: string
  created_at: string
  payload: Record<string, unknown>
}

type Page<T> = { items: T[]; has_more: boolean }
type TransferEvidence = TransferRef & {
  eventId: string
  paymentOrderId: string
  normalizedEventId: string
}

const apiKey = process.env.STABLEOPS_API_KEY
const baseUrl = process.env.STABLEOPS_API_URL ?? 'https://api.stableops.dev'
if (!apiKey) throw new Error('STABLEOPS_API_KEY is required')

function canonical(value: unknown): unknown {
  if (Array.isArray(value)) return value.map(canonical)
  if (value && typeof value === 'object') {
    const record = value as Record<string, unknown>
    return Object.fromEntries(
      Object.keys(record)
        .sort()
        .map((key) => [key, canonical(record[key])]),
    )
  }
  return value
}

function fingerprint<T extends { id: string }>(items: T[]) {
  const sorted = [...items].sort((a, b) => a.id.localeCompare(b.id))
  return createHash('sha256')
    .update(JSON.stringify(canonical(sorted)))
    .digest('hex')
}

async function listOnce<T extends { id: string }>(
  path: string,
  query: Record<string, string> = {},
) {
  const items = new Map<string, T>()
  for (let offset = 0; ; offset += 200) {
    const search = new URLSearchParams({ ...query, limit: '200', offset: String(offset) })
    const response = await fetch(`${baseUrl}${path}?${search}`, {
      headers: { authorization: `Bearer ${apiKey}` },
    })
    if (!response.ok) throw new Error(`${path}: ${response.status} ${await response.text()}`)
    const page = (await response.json()) as Page<T>
    for (const item of page.items) {
      if (items.has(item.id)) throw new Error(`${path}: pagination changed during the scan`)
      items.set(item.id, item)
    }
    if (!page.has_more) return [...items.values()]
  }
}

async function scanAuditData() {
  const [platform, deliveries] = await Promise.all([
    listOnce<PlatformOrder>('/v1/payment-orders'),
    listOnce<Delivery>('/v1/webhook-deliveries'),
  ])
  return { platform, deliveries }
}

async function stableAuditSnapshot() {
  let previous = await scanAuditData()
  for (let attempt = 0; attempt < 3; attempt += 1) {
    const current = await scanAuditData()
    if (
      fingerprint(previous.platform) === fingerprint(current.platform) &&
      fingerprint(previous.deliveries) === fingerprint(current.deliveries)
    )
      return current
    previous = current
  }
  throw new Error('no stable audit snapshot; retry after the cutoff settles')
}

function decimalKey(value: string) {
  if (!/^\d+(?:\.\d+)?$/u.test(value)) throw new Error(`invalid decimal amount: ${value}`)
  const [whole, fraction = ''] = value.split('.')
  return `${BigInt(whole)}.${fraction.replace(/0+$/u, '')}`
}

function inWindow(value: string, start: number, end: number) {
  const time = Date.parse(value)
  return Number.isFinite(time) && time >= start && time < end
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}

function detectedEvidence(delivery: Delivery): TransferEvidence | null {
  if (delivery.event_type !== 'payment.detected' || !isRecord(delivery.payload.data)) return null
  const data = delivery.payload.data
  if (
    !delivery.payment_order_id ||
    typeof data.normalized_event_id !== 'string' ||
    typeof data.chain !== 'string' ||
    typeof data.asset !== 'string' ||
    typeof data.chain_amount !== 'string' ||
    typeof data.tx_hash !== 'string' ||
    typeof data.log_index !== 'number'
  )
    return null
  return {
    eventId: delivery.event_id,
    paymentOrderId: delivery.payment_order_id,
    normalizedEventId: data.normalized_event_id,
    chain: data.chain,
    asset: data.asset,
    chainAmount: data.chain_amount,
    txHash: data.tx_hash,
    logIndex: data.log_index,
  }
}

function transferKey(value: TransferRef) {
  return [value.chain, value.asset, value.chainAmount, value.txHash, value.logIndex].join('|')
}

const file = process.argv[2]
if (!file) throw new Error('usage: npx tsx reconcile.ts business-export.json')

const input = JSON.parse(await readFile(file, 'utf8')) as ExportFile
const start = Date.parse(input.windowStart)
const end = Date.parse(input.windowEnd)
if (!Number.isFinite(start) || !Number.isFinite(end) || start >= end)
  throw new Error('invalid reconciliation window')

const { platform, deliveries } = await stableAuditSnapshot()

const findings: string[] = []
const localByMerchantId = new Map<string, LocalOrder>()
const localByPaymentId = new Map<string, LocalOrder>()
for (const order of input.orders) {
  if (localByMerchantId.has(order.merchantOrderId))
    throw new Error(`duplicate merchantOrderId: ${order.merchantOrderId}`)
  if (localByPaymentId.has(order.paymentOrderId))
    throw new Error(`duplicate paymentOrderId: ${order.paymentOrderId}`)
  localByMerchantId.set(order.merchantOrderId, order)
  localByPaymentId.set(order.paymentOrderId, order)
}

const platformById = new Map(platform.map((order) => [order.id, order]))
const paymentIdsChangedInWindow = new Set(
  deliveries
    .filter((delivery) => delivery.payment_order_id && inWindow(delivery.created_at, start, end))
    .map((delivery) => delivery.payment_order_id as string),
)
const platformInWindow = platform.filter(
  (order) => inWindow(order.created_at, start, end) || paymentIdsChangedInWindow.has(order.id),
)
const relevantPaymentIds = new Set([
  ...localByPaymentId.keys(),
  ...platformInWindow.map((order) => order.id),
])

for (const order of input.orders) {
  const remote = platformById.get(order.paymentOrderId)
  if (!remote) {
    findings.push(`${order.merchantOrderId}: payment order ${order.paymentOrderId} is missing`)
    continue
  }
  if (remote.merchant_order_id !== order.merchantOrderId)
    findings.push(`${order.paymentOrderId}: merchantOrderId mapping differs`)
  if (decimalKey(order.requestedAmount) !== decimalKey(remote.requested_amount))
    findings.push(`${order.merchantOrderId}: requested amount differs`)
  if (decimalKey(order.payableAmount) !== decimalKey(remote.amount))
    findings.push(`${order.merchantOrderId}: payable amount differs`)
  if (order.settlementAsset !== (remote.settlement_asset ?? null))
    findings.push(`${order.merchantOrderId}: settlement asset differs`)
  if (order.paymentApplied && remote.status !== 'finalized')
    findings.push(
      `${order.merchantOrderId}: merchant applied payment but StableOps is ${remote.status}`,
    )
  if (!order.paymentApplied && remote.status === 'finalized')
    findings.push(`${order.merchantOrderId}: finalized but merchant is not settled`)
}

for (const remote of platformInWindow) {
  if (!localByMerchantId.has(remote.merchant_order_id))
    findings.push(`${remote.id}: StableOps order has no business order in this window`)
}

const deliveryGroups = new Map<string, Delivery[]>()
for (const delivery of deliveries) {
  if (!delivery.payment_order_id || !relevantPaymentIds.has(delivery.payment_order_id)) continue
  const key = `${delivery.webhook_endpoint_id}:${delivery.event_id}`
  deliveryGroups.set(key, [...(deliveryGroups.get(key) ?? []), delivery])
}
for (const group of deliveryGroups.values()) {
  if (group.some((delivery) => delivery.status === 'succeeded')) continue
  const sample = group[0]
  if (group.some((delivery) => delivery.status === 'dead_letter'))
    findings.push(`${sample.payment_order_id}: ${sample.event_type} remains dead-lettered`)
  else if (group.some((delivery) => delivery.status === 'failed'))
    findings.push(`${sample.payment_order_id}: ${sample.event_type} delivery is still failing`)
}

const evidenceByEvent = new Map<string, TransferEvidence>()
for (const delivery of deliveries) {
  if (!delivery.payment_order_id || !relevantPaymentIds.has(delivery.payment_order_id)) continue
  const evidence = detectedEvidence(delivery)
  if (!evidence) {
    if (delivery.event_type === 'payment.detected')
      findings.push(`${delivery.id}: malformed payment.detected evidence`)
    continue
  }
  const existing = evidenceByEvent.get(evidence.eventId)
  if (existing && transferKey(existing) !== transferKey(evidence))
    findings.push(`${evidence.eventId}: inconsistent replay payload`)
  else evidenceByEvent.set(evidence.eventId, evidence)
}

const evidenceByPaymentId = new Map<string, TransferEvidence[]>()
for (const evidence of evidenceByEvent.values()) {
  evidenceByPaymentId.set(evidence.paymentOrderId, [
    ...(evidenceByPaymentId.get(evidence.paymentOrderId) ?? []),
    evidence,
  ])
}
for (const order of input.orders) {
  const evidence = evidenceByPaymentId.get(order.paymentOrderId) ?? []
  const remote = platformById.get(order.paymentOrderId)
  if (evidence.length > 1)
    findings.push(`${order.merchantOrderId}: multiple detected transfers are associated`)
  if (remote?.status === 'finalized' && evidence.length === 0)
    findings.push(`${order.merchantOrderId}: finalized without retained transfer evidence`)
  if (order.transfer && evidence[0] && transferKey(order.transfer) !== transferKey(evidence[0]))
    findings.push(`${order.merchantOrderId}: merchant and StableOps transfer references differ`)
  if (!order.transfer && evidence[0])
    findings.push(`${order.merchantOrderId}: merchant ledger is missing the transfer reference`)
}

console.log(
  JSON.stringify(
    {
      window: { start: input.windowStart, end: input.windowEnd },
      checkedLocalOrders: input.orders.length,
      checkedPlatformOrders: platformInWindow.length,
      transfers: [...evidenceByEvent.values()],
      findings,
    },
    null,
    2,
  ),
)
process.exitCode = findings.length === 0 ? 0 : 1

关闭时间窗时,应为最终性规则、异步任务和最终一致的列表读取留出足够缓冲。第一次发现差异后,应再次运行核对,再决定是否升级处理。脚本根据时间窗内创建的支付订单,以及时间窗内投递的支付事件发现反向孤立订单,因此端点必须订阅需要审计的支付事件。

对于高流量生产账户,应把经过验签的商户收件表作为主要事件数据集,并根据本地保存的支付订单号逐笔查询。带保护的全量扫描只用于周期性发现孤立订单。如果并发写入导致无法取得两次一致快照,任务必须保持失败,不能降级成“第一页没有差异”。

生成的 transfers 数组是链上证据的连接索引,并不是一次新的区块链查询。对于异常项目与月末抽样,应通过规范区块浏览器或链服务商再次核对链、代币合约或铸币地址、目标地址、最小单位金额、交易哈希与日志序号、回执状态和当前区块哈希,并把核对时间与区块引用保存在对账结果中。

这个脚本只发现结构性差异,不会自动结算或退款。每一项差异仍需要证据与经过审批的处理决定。

按原因分派每项差异

应把比较结果变成受控异常队列,而不是不断编辑记录,直到总额碰巧相等。

差异可能原因安全处理方式
存在业务订单,但支付订单缺失建单请求失败、环境错误或映射从未保存检查幂等键与接口日志;安全重试,或按原规则创建订单
存在支付订单,但业务订单缺失业务数据被删除或导入错误,或提交了错误的 merchantOrderId隔离履约;恢复业务对象,或记录孤立订单
StableOps 已最终完成,商户仍未入账收件任务失败、事件未处理或映射错误检查事件与投递记录,再重试商户任务或以幂等方式重放传输
商户已入账,StableOps 尚未最终完成乐观履约、人工入账或错误状态变化停止后续履约;核对规范链状态,并记录或撤销人工决定
存在死信投递端点、鉴权、超时或下游故障先修复接收端,再执行重放;不要持续向仍故障的端点重放
金额不一致折扣漂移、自动金额调整、导出错误或人工入账分别比较请求、返回、实收、入账和退款金额,不覆盖任何原值
链上存在转账,但没有匹配订单金额、链、资产、地址有效期错误,或转账迟到进入付款不匹配处理流程

每项异常都应保存处理人、处理时间、原因码、相关事件和交易引用、人工会计分录以及退款交易。“调整至相符”不是可审计的原因。

稳定币支付日对账清单

可以把下面这份清单直接放入运营手册:

  • 固定核对时间窗,并记录组织、沙盒或生产环境、时区及结算资产。
  • 导出时间窗内新建、变化、履约、退款或人工入账的业务订单。
  • 拉取 StableOps 订单与投递组成的联合快照;重复扫描不一致时让任务失败。
  • merchantOrderId 连接,并确认本地已经保存 StableOps 支付订单号。
  • 分别比较 requested_amount、返回的 amount、结算资产与终态。
  • 双向检查没有支付订单的业务订单,以及没有业务订单的支付订单。
  • 确认每笔本地结算恰好对应一条幂等履约记录。
  • 把每笔最终完成订单与保存的链、资产、交易哈希、日志序号和最小单位金额对应起来。
  • 找出 StableOps 已 finalized、但商户账本尚未入账的订单。
  • 找出商户已入账、但 StableOps 状态不是 finalized 的订单。
  • 按端点与事件归并投递;只有同组重放已经成功时,才把历史死信视为已解决。
  • 检查尚未解决的 faileddead_letter 投递,以及收件任务自身的失败记录。
  • 检查已过期、已回滚、已取消和仍未关闭的订单,不要把它们排除在报表之外。
  • 把未匹配和迟到转账送入异常队列,绝不按地址或金额强制匹配。
  • 为每项差异指定负责人、原因码、处理时限与不可变证据链接。
  • 按链与资产保存笔数和总额,并保存任务时间窗与已完成页数。

日对账报表应足够小,才能及时处理。如果其中持续出现数百项异常,应修复制造异常的接入或规则,而不是围绕症状扩大人工团队。

月末对账清单

月末流程在每日运营核对之外增加财务关账:

  • 重新运行所有日对账时间窗,证明每次接口扫描都取得了两次一致的完整快照。
  • 按链和资产,从期初余额经过入账转账、资金归集和退款,滚动核对至期末余额。
  • 分开统计精确匹配收款、未匹配收款、人工入账、退款、网络手续费和资金调拨。
  • 确认所有最终完成订单只入账一次,并进入正确的会计期间与科目。
  • 确认所有人工入账和退款都具有审批记录与交易引用。
  • 关闭每项异常或记录账龄;未解决项目应明确结转,不能隐藏在净额调整中。
  • 检查全部死信、重放,以及已经被收件表接受但未被任务完成的事件。
  • 把业务记录、StableOps 订单与投递记录、链上证据、异常决定和对账汇总导出为只读审计包。
  • 由编制人以外的人员复核并签署本期结果。

在会计规则执行有记录的估值之前,应始终按资产和链分别保存总额。把不同网络上的 USDC 与 USDT 原始数字相加,可以用于部分运营观察,但不能代替会计本位币与估值时点。

重放修复投递,但不会改写历史

StableOps 会保留 Webhook 投递尝试。按端点事件、指定投递或死信执行重放时,系统会创建一条新的投递记录,并保留原记录。这正是审计所需的行为。

这也意味着重放事件仍具有相同的事件身份。处理程序必须按 X-Event-Id 去重,不能按投递号去重。如果事件已经持久化到收件表,只是商户任务执行失败,那么简单重放可能只会走到收件表的空操作分支;正确恢复方式通常是重试商户侧失败的任务。如果事件从未到达收件表,重放才是正确的传输恢复方式。

按下重放之前,必须先判断哪个边界发生了故障:

  1. StableOps 无法投递 HTTP 请求;
  2. 商户端点在持久化接收前拒绝或超时;
  3. 端点已经接受事件,但异步处理失败;
  4. 处理已经成功,但后续会计导出或连接错误。

只有前两类故障能通过投递重放修复。

常见问题

什么是稳定币支付对账?

稳定币支付对账,是连接商户业务债权、支付订单与事件生命周期、规范链转账,并证明每笔最终完成收款只入账一次、每项异常都有解释的过程。把钱包余额与销售总额进行比较,只是余额核对,并不是完整对账。

能否只根据区块链数据完成稳定币支付对账?

不能。区块链数据可以证明转账、代币身份、金额、地址和规范状态,但其中没有你的客户、发票、履约、退款规则或会计期间。必须使用 merchantOrderId 这样的稳定业务外键,并用事件账本保留支付订单关系。

Webhook 与轮询接口,哪个应该作为事实来源?

用 Webhook 及时处理状态变化,用列表接口执行周期性恢复与审计。它们通过不同传输路径呈现同一套支付生命周期。处理程序仍需按事件去重;使用偏移量分页的审计扫描只有在连续两次完整快照一致时才能成功。对账还必须比较平台状态与商户账本中真正提交的副作用。

把对账纳入支付设计

不要等到第一次月末关账时,才发现交易哈希、事件号和业务引用散落在保留周期各不相同的日志中。先理解支付订单模型,把支付订单列表Webhook 投递列表实现为恢复路径,并在接收生产资金前,在沙盒环境运行日对账清单。只有既能解释正常路径,也能解释每一项差异,支付接入才算完整。

相关文章

稳定币少付、多付、发错网络或过期后到账,都不应让订单被悄悄完成。本文说明如何发现、处理与预防各类支付不匹配。

稳定币支付没有唯一的最佳链:比较付款人侧费用、最终性与钱包分布,接受一组链,并把每笔转账精确匹配到订单。

把 x402 用在 HTTP 原生的智能体支付上,但商户侧的订单状态、策略、最终性、事件通知和审计仍要单独保留。

用周期性账单、付款人主动结账和已验证的订阅结算事件构建 USDC 订阅。