如何通过 webhook 自动确认订单付款

将发票与订单关联,验证 Webhook 签名,并防止重复事件、付款不足和回滚导致错误交付。

更新日期:

要让网站自动将订单标记为已付款,请在 Cryptanio 为其创建发票,并接入 webhook,即服务在付款状态变化时向你的服务器发送的通知。服务器验证消息真伪,找到订单并核对发票状态后,即可为买家开通付费访问权限或将订单交付履行。

下面以使用美元作为计算单位创建账单、由买家在付款页面选择可用加密货币和网络的商店为例。资金进入商店的地址。订单与商品交付仍由商店应用管理,Cryptanio 将发票与转账关联,并通知其状态。该接入需要开发者参与,或具备修改网站服务器代码的条件。

准备项目和付款地址

在 Cryptanio 控制台选择项目,添加属于自己的收款地址,并连接到 Payments。普通地址监控不足以用于发放发票。确认地址已激活且属于所选网络。真实付款需要使用正式运行模式的地址。

如果客户可能同时付款,应提前准备多个地址。地址可能正被未结发票占用,也可能在发票关闭后暂时不可用。没有空闲地址时,应用应显示清晰错误,并允许稍后重试。

创建 API 密钥并保存到商店服务器,用于调用 https://api.cryptanio.com/v1。不得将密钥放入网页 JavaScript、移动应用或公开代码仓库。

设置通知接收地址

打开所选项目的“通知”。如果已启用“使用账户通知设置”,请通过“账户设置”配置 Webhook,或关闭继承并设置项目独立接收地址。

在 Webhook 卡片的“结束点 URL”中填写处理器的 HTTPS 地址,点击“保存”。处理器必须直接接收 POST 请求,不能通过重定向转到其他 URL。在同一卡片获取“签名密钥”,并在服务器上与 API 密钥分开保存。

确认 Webhook 未暂停。在处理器中处理 payment.confirmed 和 payment.rolled_back,如需中间状态,可使用 payment.pending。履行订单前,请检查真实模式及下文其他条件。无需为账单地址单独启用 Webhook 渠道。

为具体订单创建发票

先将订单保存到商店数据库,再由服务器调用 POST /v1/invoices 创建发票。在 reference 中传入自己的订单标识。本例订单价格为 12.50 USD:invoice_currency 和 invoice_amount 指定价格,买家在付款页面选择可用网络和资产。因此,该请求不传入 chain、asset、amount、address 和 tolerance。使用 USD 不意味着接收法定货币,也不意味着将收到的加密货币兑换成美元。

#!/usr/bin/env bash
set -euo pipefail
: "${CRYPTANIO_API_KEY:?Set CRYPTANIO_API_KEY}"
: "${CRYPTANIO_OPERATION_ID:?Set CRYPTANIO_OPERATION_ID}"
: "${CRYPTANIO_PROJECT_ID:?Set CRYPTANIO_PROJECT_ID}"
json_string() {
  local text="$1" char code index output='"'
  for ((index=0; index<${#text}; index++)); do
    char="${text:index:1}"
    case "$char" in
      '"'|'\') output+="\\$char" ;;
      *) printf -v code '%d' "'$char"
         if ((code < 32)); then printf -v char '\\u%04x' "$code"; fi
         output+="$char" ;;
    esac
  done
  printf '%s"' "$output"
}
response_file="$(mktemp)"
trap 'rm -f "$response_file"' EXIT
transfer_status=0
http_status="$(curl \
  --silent --show-error --max-time 30 --request POST \
  --header "Authorization: Bearer $CRYPTANIO_API_KEY" \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/json' \
  --header "Idempotency-Key: $CRYPTANIO_OPERATION_ID" \
  --data-binary '{"project":'"$(json_string "$CRYPTANIO_PROJECT_ID")"',"invoice_currency":"USD","invoice_amount":"12.50","reference":"order-42"}' \
  "https://api.cryptanio.com/v1/invoices" \
  --output "$response_file" --write-out '%{http_code}')" || transfer_status=$?
if [[ "$http_status" == 2?? ]]; then
  cat "$response_file"
  exit "$transfer_status"
fi
printf 'HTTP %s\n' "$http_status" >&2
cat "$response_file" >&2
exit 1

下载示例

运行前,在服务器设置 CRYPTANIO_API_KEY、CRYPTANIO_PROJECT_ID 和 CRYPTANIO_OPERATION_ID。使用具有发票创建权限的正式 API 密钥;CRYPTANIO_PROJECT_ID 应指向已准备好的 Payments 项目。CRYPTANIO_OPERATION_ID 是为该订单保存的创建操作标识,由 16 至 80 个拉丁字母、数字、连字符或下划线组成。示例通过 Idempotency-Key 请求头传入它,而不是 JSON 字段。新订单应生成不同值,重试同一请求则保留原值。

创建成功后,API 返回 HTTP 201。将发票 id 和返回的 checkout_url 与订单一起保存,并引导买家打开该链接。选择付款方式前,网络、地址和加密货币金额尚未确定。买家在付款页面选择可用方式,获取当前收款信息和付款期限。如果在自己的界面显示收款信息,应使用具体付款尝试的数据、amount 中的准确金额和 expires_at 截止时间;不要四舍五入,也不要用 asked 替换金额。后续 GET 请求和发票列表中的链接仅供查看。创建响应中的可交互付款链接应单独保存,只交给对应买家。

如果发送请求后连接中断,使用相同的 Idempotency-Key 重发相同正文。成功重试将返回 HTTP 201 和同一个 id。使用已用过的密钥修改参数,会得到 HTTP 409。reference 用于关联发票与订单,但不能代替保证单次创建的幂等键。不要仅因超时就更换键值。

验证收到的 webhook

Cryptanio 会发送 X-Timestamp、X-Signature、X-Event-Id、X-Event-Type 和 X-Attempt。签名计算方式如下:

hex(HMAC-SHA256(secret, timestamp + "." + raw_body))

raw_body 是请求的原始字节,应在解析 JSON 前取得。重新序列化可能改变空格或字段顺序,导致验证失败。限制允许的正文大小,并拒绝没有有效签名的请求。

下面示例将时间有效窗口设为五分钟。这是接收处理器自己的配置,正常工作依赖服务器时钟同步。

import hashlib
import hmac
import re
import time

def verify_signature(secret, timestamp, raw_body, signature):
    if not secret or not timestamp or not re.fullmatch(r"[0-9]{1,12}", timestamp):
        return False
    if not signature or not re.fullmatch(r"[0-9a-f]{64}", signature):
        return False
    if abs(time.time() - int(timestamp)) > 300:
        return False
    signed = timestamp.encode("ascii") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed,
                        hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

该函数验证签名及请求时间。验证成功后再解析 JSON,检查 schema_version="v1"、预期的 project_id、mode="live" 和 sandbox=false。

业务判断应使用已签名正文中的 event_id 和 event_type。可以将同名请求头与正文对照,但这些请求头本身不受签名保护。只有预期的 payment 对象且方向为 in 时,才执行付款逻辑。

区分转账确认与订单付款完成

商店主要需要处理以下事件:

  • payment.pending:已检测到转账,仍在等待确认;
  • payment.confirmed:转账达到所需确认数;
  • payment.rolled_back:此前检测到的转账因区块链变化被撤回。

payment.confirmed 不代表某个订单已全额付款。检查 invoice 对象,将 invoice.id、invoice.reference 与保存的订单核对。未关联发票的消息,不能只因金额相似就自动归属于某次购买。

交付前调用 GET /v1/invoices/{id} 获取当前状态。核对 id、project_id、reference、invoice_currency 和 invoice_amount 中的订单价格,以及 sandbox=false。检查网络、资产、地址及所选付款尝试;存在多次尝试时,应考虑 settled_attempt_id 和 settled_attempt。成功付款对应发票状态 paid;pending、seen 和 partial 尚不允许交付。买家返回网站或打开付款链接,都不能确认付款。

不足付款使用 underpaid,等待补足的部分付款使用 partial,逾期到账使用 late。超额付款时,项目策略可能让发票保持 paid,也可能设置为带有 needs_decision=true 的 overpaid。underpaid、overpaid 和 late 都需要在履行订单前单独决定如何处理。amount_raw 和 paid 字段以字符串返回。比较时应将其解析为精确整数,单位为资产的最小单位;decimals 指定缩放比例。不要使用二进制浮点数。应提前制定差额处理规则;这并不意味着会自动退款。

处理重复投递和回滚

投递可能重复。将已验证事件保存到持久存储,使用 event_id 作为唯一键。已接收事件再次到达时,应返回成功响应,不得重复交付商品。

一笔转账会经历不同状态,因此只使用交易标识过滤所有后续消息并不足够。对于 chain、tx_id、index、address 和资产标识的组合,保存最近应用的 payment.revision。只有 revision 大于已处理值时,才应用状态转换。

订单修改与交付任务登记应一致提交,并防止并发重复处理。任务应具有订单唯一键。实际执行前,再次确认订单仍允许交付。收到回滚后应更新状态,停止尚未执行的交付;对已提供的访问权限,则准备复核或暂停流程。

检查完整流程

真实收款前,请用准备好的数据检查正文解析、签名和防重复处理。服务事件 webhook.test 不能确认购买,也不能替代完整流程检查。

然后用自己的验证订单,检查发票创建、转账、确认和订单更新的完整过程。使用准备好的数据,分别检查事件重复、不足付款和回滚。只有可靠保存已接收消息后才返回 HTTP 2xx;如果暂时无法保存,应返回服务器错误,让投递能够重试。

将控制台中的事件和投递日志与应用记录核对。只有确认订单恰好履行一次,而中间状态、其他订单或重复事件不会触发错误交付,检查才算完成。

参考:API v1 发票、webhook 签名验证。