Skip to content

回调通知

每笔已确认到账都有独立事件。归集任务结束后,也会生成对应结果事件。平台把加密后的事件通过 HTTP POST 发送到租户配置的回调地址。

到账成功与推送成功是两个独立状态。 推送失败不会撤销到账,也不能据此认为链上转账失败。

回调地址要求

通过设置回调配置API保存并开启Webhook。地址必须满足:

  • 使用公网 HTTPS,端口为 443。
  • URL 不包含用户名、密码或 fragment。
  • 域名解析结果必须全部为允许的公网地址。
  • 接收端直接返回应答,不通过 301、302 等重定向。

例如 https://merchant.example.com/guiji/webhook 中的域名需替换为你的真实接收域名。本地示例监听 127.0.0.1,通过你自己的 HTTPS 反向代理开放给平台。

HTTP 请求格式

http
POST /guiji/webhook HTTP/1.1
Content-Type: application/json

请求体是以下七个字段组成的加密外层对象:

json
{
  "version": 1,
  "algorithm": "AES-256-GCM",
  "app_id": "9001000001",
  "event_id": "9002000001",
  "timestamp": "1788832800",
  "nonce": "NSu9ChIdudU8GvWP",
  "ciphertext": "BASE64_OF_CIPHERTEXT_AND_TAG"
}

上例密文是结构占位符;可直接用于解密测试的完整外层对象见固定测试向量

字段类型说明
versioninteger固定为 1,表示协议格式版本
algorithmstring固定为 AES-256-GCM
app_idstring租户 appID
event_idstring固定事件 ID,所有重试及人工重新推送保持不变
timestampstring本次投递的 Unix 秒级时间戳,不是链上到账时间
noncestring本次加密随机生成的 12 字节 nonce,标准 Base64
ciphertextstring密文与末尾 16 字节认证标签拼接后的标准 Base64

没有额外的回调签名头

当前回调不发送 Bearer 令牌,也不发送 X-SignX-Signature 或其他独立签名头。消息真实性和完整性由 AES-GCM 认证标签与 AAD 校验。只有标签验证成功后,才能把解密内容交给业务处理。

密钥派生

回调直接使用租户当前 appID 和密钥,不需要创建另一套回调密钥:

text
K = HKDF-SHA256(
  IKM    = UTF8(appSecret),
  salt   = UTF8(appID),
  info   = UTF8("guiji/webhook/v1"),
  length = 32
)

appSecret 按原始字符串的 UTF-8 字节传入。即使密钥看起来像十六进制,也不要先做 hex 解码;不要对 appID 做整数二进制编码。得到的 32 字节 K 用作 AES-256-GCM 密钥。

AAD 与认证标签

AAD 按以下内容拼接,其中 \n 是一个 LF 换行字节:

text
guiji/webhook/v1\n{app_id}\n{event_id}\n{timestamp}

最后没有额外换行,也不包含花括号或 JSON 引号。例如:

text
guiji/webhook/v1
9001000001
9002000001
1788832800

解密步骤:

  1. 标准 Base64 解码 nonce,确认长度为 12 字节。
  2. 标准 Base64 解码 ciphertext,确认长度至少为 16 字节。
  3. 取最后 16 字节作为 GCM tag,前面的字节为密文本身。
  4. 使用 K、nonce 和精确的 AAD 验证标签并解密。
  5. 解析解密后的 UTF-8 JSON,确认明文 event_id 等于外层 event_id。

使用 Python 的 AESGCM.decrypt 时可直接传入完整的「密文 + tag」;Node.js 的 createDecipheriv 需要通过 setAuthTag 单独设置最后 16 字节。

Base64 使用标准字母表,可能包含 +/ 和末尾 =。它不是 accessToken 使用的无填充 Base64URL。

网络识别

到账、归集事件的解密正文都包含必填字段 network,取值为 main(主网)或 nile(测试网络)。此字段在加密正文内,外层对象不单独提供网络字段。

三方应先验证认证标签并解密,再根据 network 将资金记录归入对应网络。不能仅根据钱包 ID 或地址推断网络,也不要把未知或缺失的网络值当成某个默认网络处理。按链上事件对账时同时使用网络、交易哈希和日志索引。

解密后的到账事件

json
{
  "id": "9002000001",
  "event_id": "9002000001",
  "event_type": "deposit.confirmed",
  "status": "confirmed",
  "wallet_id": "9003000001",
  "custom_id": "customer-1001",
  "chain": "TRON",
  "network": "nile",
  "asset": "USDT",
  "token_standard": "TRC20",
  "decimals": 6,
  "address": "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC",
  "source": "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb",
  "contract": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf",
  "amount": "100000000",
  "txid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "log_index": 0,
  "block_number": "68192698",
  "block_time": "2030-01-01T00:00:00.000000Z",
  "created_at": "2030-01-01T00:00:01.000000Z",
  "revision": 1,
  "confirmation_type": "solidified",
  "confirmations": null,
  "block_hash": null
}

这是演示数据。amount 为 USDT 最小单位整数字符串,上例表示 100 USDT。钱包 ID、事件 ID 和区块号都是字符串;log_index 是整数。一笔链上交易可能产生多个 Transfer 日志,不要仅按 txid 去重。

到账通知解密正文与到账详情的 data、到账列表的单个 items 共用 24 个字段;同一 id、revision 的内容一致。查询返回当前版本,历史通知保留对应事件版本。id 用于查询到账,event_id 用于查询通知和去重,不能假定两者始终相同;custom_id 用于关联业务。

字段类型必返说明
idstring到账记录 ID,同一交易日志即使撤销后重新确认也保持不变。
event_idstring本次状态事件 ID;重试不变,撤销/重新确认产生新事件 ID。按此字段去重。
event_typestring到账状态事件类型。 deposit.confirmed 表示到账认可;deposit.reverted 表示到账撤销。
statusstring到账状态:confirmed 表示达到租户确认门槛;reverted 表示因链重组撤销。不是推送状态。
wallet_idstring收款钱包 ID。
custom_idstring钱包的租户自定义ID,同一租户内唯一,取到账入库时快照。
chainstring所属区块链。 固定值:"TRON"。
networkstring资产所属网络:main(主网)或 nile(测试网络)。
assetstring资产名称;测试网对应测试代币。资产身份以network+contract为准。 固定值:"USDT"。
token_standardstring代币协议。 固定值:"TRC20"。
decimalsinteger金额精度,展示金额=amount÷10^decimals。使用整数或十进制定点计算。 固定值:6。
addressstring到账收款地址。
sourcestring链上 Transfer 事件的来源地址。
contractstring此笔Transfer的实际代币合约。结合network识别资产;Nile为测试代币,Main为Tether发行的USDT。
amountstring该条到账记录的 USDT 金额,是否有效由 status 决定;最小单位的整数字符串,六位精度,禁止用浮点数处理。
txidstring该条到账对应的交易哈希。
log_indexinteger交易收据内的日志索引;与网络、交易哈希一起标识链上事件。
block_numberstring到账状态对应的区块高度,低确认模式下不代表已固化。
block_timestring链上区块时间;UTC RFC3339,固定6位小数秒,以Z结尾。不是通知发送时间。
created_atstring平台确认并保存到账的时间;UTC RFC3339,固定6位小数秒,以Z结尾。确认或同步存在延迟时,可能晚于 block_time。
revisioninteger同一到账状态版本,从1递增;只应用更高版本,旧版本通知应答成功但不再入账。
confirmation_typestring该笔到账采用的确认规则:block_1、block_3、block_6、block_12、solidified。仅平台开通的确认规则。
confirmationsinteger / null通过最新区块确认时的确认数;按固化结果确认时为 null。
block_hashstring / null本次状态对应的区块哈希;既有历史记录未保存时为null。

到账对象不包含会变化的推送状态、次数和返回正文;用同一event_id调用通知详情查询。重试及人工重推保留原到账正文,只重新加密。

无论先通过查询发现到账,还是先收到通知,都调用同一个业务处理函数:

js
// queried 是到账详情返回的 data。
// decoded.event 是下载版接收示例解密后的正文。
await handleDeposit(queried);
await handleDeposit(decoded.event);
// handleDeposit 内必须把事件去重与业务入账放在同一数据库事务中。

归集结果事件

归集结果类型:

event_type说明
sweep.succeededUSDT 归集成功
sweep.failedUSDT 归集失败
sweep.cancelledUSDT 归集取消

结果事件示例:

json
{
  "event_id": "9004000001",
  "event_type": "sweep.succeeded",
  "network": "nile",
  "task_id": "9004000001",
  "wallet_id": "9003000001",
  "amount": "100000000",
  "destination": "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC",
  "txid": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
  "status": "succeeded",
  "reason": "",
  "actual_fee": "1000000"
}

归集结果使用同一结构,始终包含以下 11 个字段,不返回 null:

字段类型必有 / 空值格式与意义
event_idstring必有,非空正整数十进制字符串;结果事件 ID,与外层 event_id 一致;当前结果事件使用任务 ID
event_typestring必有,非空上表中的 sweep.*;后缀与 status 一致
networkstring必有,非空该事件实际发生的网络:mainnile
task_idstring必有,非空正整数十进制字符串;平台归集任务 ID
wallet_idstring必有,非空正整数十进制字符串;归集的转出钱包
amountstring必有,可以为 "0"任务记录中的最小单位整数字符串;归集为 USDT;任务在金额确定前失败时可为 0
destinationstring必有,非空TRON Base58Check 目标地址;本任务的归集目标
txidstring必有,可以为 ""已生成时为 64 位十六进制交易哈希;签名前失败或取消可为空
statusstring必有,非空succeededfailedcancelled
reasonstring必有,可以为 ""失败或取消原因;没有记录原因时为空字符串,不是 null
actual_feestring必有,可以为 "0"已结算链上实际手续费,TRX 最小单位整数字符串;没有产生已确认费用时为 0

USDT 和 TRX 在此都按六位精度解释。actual_fee: "1000000" 表示 1 TRX。失败事件的 amount 是任务记录金额,不能当作已经到账的金额;是否成功以 status 和对应业务流水为准。

去重、业务事务与成功应答

平台采用至少一次投递。接收端按 appID + event_id 建立持久化唯一约束,并将以下操作放进同一数据库事务:

  1. 检查事件是否已经处理;已成功处理的相同事件直接返回成功。
  2. 首次处理时执行你的入账记录、归集结果更新等业务写入。
  3. 保存事件 ID 和处理结果。
  4. 提交事务,然后应答。

唯一成功应答:

http
HTTP/1.1 200 OK
Content-Type: application/json

{"code":"SUCCESS"}

数字 0、字符串 "success"、纯文本 SUCCESS、HTTP 201/204 都不算成功。JSON 不得包含重复键或尾随数据,响应正文不得超过 64 KiB。

不要先返回成功再异步执行未持久化的业务。若你的业务处理需要跨服务事务,应先完成可靠的持久化接收和业务调度设计,再据此决定何时应答。

可运行接收服务

下载:

示例先验证 GCM 标签并解密,再用 SQLite 事务同时保存事件去重记录和到账/归集结果。金额和 ID 以 TEXT 保存,不使用浮点数;进程重启后仍能去重。

设置环境变量,数据库文件必须使用固定的持久路径:

bash
export GUIJI_APP_ID='YOUR_APP_ID'
export GUIJI_APP_SECRET='YOUR_APP_SECRET'
export GUIJI_WEBHOOK_DB='/absolute/persistent/path/guiji-webhooks.sqlite'
export GUIJI_WEBHOOK_PORT='9000'
# GUIJI_NETWORK 按平台提供的资产网络设置为 main 或 nile。
: "${GUIJI_NETWORK:?请先设置约定的资产网络}"
bash
node --experimental-sqlite webhook.mjs
bash
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/python webhook.py

Node.js 示例只使用内置模块;该版本的 SQLite 模块需要上面的启动选项。Python 请求签名客户端不依赖额外包,回调解密示例固定使用 cryptography==46.0.3

示例保存的是最小的商户到账和转账结果记录,便于直接运行和检查事务。接入实际业务时,把其中的投影写入替换为你的业务事务;不要把内存 Set 或单独提交的“已接收”标记当作完整去重实现。多实例部署应让所有实例共享同一份事务数据库。

重试与人工重新推送

规则当前行为
第一轮起点到账/结果事件入库时开始计算,首次立即进入待推送
单轮期限最长 10 分钟
尝试次数最多 20 次
重试间隔两次请求的实际开始时间至少间隔 30 秒
单次时限最长 15 秒,同时不能超过本轮剩余时间
结束条件成功则停止;次数耗尽或到期则标记推送异常
投递中断后恢复继续未到期轮次,已过期轮次不集中补发
重新推送仅异常事件可调用重新推送API,以accessToken+逐请求签名及request_id开启新轮次

回调未配置或关闭会明确记录异常原因。每次尝试会保存 HTTP 状态、响应正文或网络错误;正文最多保存 64 KiB,超出会标记截断并判定失败。崩溃造成结果未知的尝试仍计入次数,因此“最多 20 次”不等于接收方必然观察到 20 个请求。

同一轮次固定回调 URL 和业务内容。人工新轮次读取当前 URL,重新获得 10 分钟、最多 20 次额度,event_id 和历史记录保留。

密钥轮换与时间戳

每次投递读取当前租户密钥,重新生成 nonce 加密,所以同一事件重试的密文通常不同。重置密钥时,同步更新接收端的密钥配置,不能继续用旧密钥解密之后的投递。

外层 timestamp 是本次投递时间,并受 AAD 认证。协议没有另行规定回调 timestamp 的拒绝窗口,示例也没有强加请求签名使用的 300 秒窗口。人工重推可以发生在原始到账很久之后;应通过固定 event_id 去重,不能仅因为原始到账时间较早就拒绝有效通知。

本地自测

示例说明提供完整运行方式。自测覆盖:

  • 同一组测试密文在 Node.js、Python 中解密一致。
  • appID、event_id、timestamp、认证标签或密钥被修改时拒绝处理。
  • 同一事件重复投递及接收服务重启后不会重复写入。
  • 业务写入失败时,去重记录与业务数据一并回滚。

自测只使用明确的演示凭据与本地临时 SQLite,不发送真实业务请求。

查询投递情况

调用通知列表详情轮次尝试记录可取得当前次数、截止时间以及三方原始应答。使用重新推送时保留request_id重试,避免一个请求创建多个轮次;返回的是新轮次,不是投递成功结果。

运行回调示例前,将 GUIJI_NETWORK 设置为平台为你开通的资产网络(main 或 nile)。示例解密后核对 network,避免把不同网络的资产记入同一账户。该变量只用于你本地的回调校验,不是 API 请求参数。

租户确认类型与到账撤销

每笔到账的 confirmation_type 表示本次采用的确认门槛,由平台为账户开通。三方 API 不提供修改入口。请按该笔到账返回的规则判断,规则调整不改变已发现到账的确认门槛。

  • block_1:成功入块即认可;block_3、block_6、block_12:包括交易所在区块,达到对应确认数才认可。
  • solidified:仅认可节点已固化的交易。所有模式遇到已固化的交易都可认可,无需额外等待块数。
  • 低确认模式的 deposit.confirmed 表示平台按约定门槛认可到账,不保证最终固化。余额同步与归集链上结果仍使用固化数据。
  • 区块重组撤销已认可到账时发送 deposit.reverted。同一到账 id 不变,event_id 更新,revision 递增。重新入块达到原门槛后会发送更高版本的 deposit.confirmed。

接收方必须在同一数据库事务中处理事件去重、到账状态及业务账务:

  1. 按 appID + event_id 去重;相同事件重推不得重复入账。
  2. 按 appID + id 找到到账业务记录,比较 revision。旧版本即使晚到也不能覆盖新版本,处理完返回 SUCCESS。
  3. 新版本 confirmed 将该记录设为有效到账;新版本 reverted 将其设为已撤销并撤销对应的入账额度。金额始终为正的原始转账金额,不能把撤销事件当作另一笔充值。
  4. 撤销通知可能先于原确认通知到达,仍须保存较高版本。随后收到低版本确认时忽略账务变更。
  5. 撤销不等于退款交易,无法自动追回已发货、已提现等外部业务;选择低确认类型前应确认自身业务能够承担这个风险。

通知查询的 payload 保留该事件产生时的快照;到账详情返回同一字段结构的最新状态。旧版本历史回调保留原始正文,不改写已投递事件。实际确认规则以到账记录的 confirmation_type 为准。

延迟到账与记录核对

同步恢复或完整性核验可能补录较早区块的到账。不要按区块时间距离当前时间的长短拒绝有效通知。已有到账的恢复保持原到账 ID;同一事件及版本不得重复记账。具体规则见到账核对与延迟处理