回调通知
每笔已确认到账都有独立事件。归集任务结束后,也会生成对应结果事件。平台把加密后的事件通过 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"
}上例密文是结构占位符;可直接用于解密测试的完整外层对象见固定测试向量。
| 字段 | 类型 | 说明 |
|---|---|---|
version | integer | 固定为 1,表示协议格式版本 |
algorithm | string | 固定为 AES-256-GCM |
app_id | string | 租户 appID |
event_id | string | 固定事件 ID,所有重试及人工重新推送保持不变 |
timestamp | string | 本次投递的 Unix 秒级时间戳,不是链上到账时间 |
nonce | string | 本次加密随机生成的 12 字节 nonce,标准 Base64 |
ciphertext | string | 密文与末尾 16 字节认证标签拼接后的标准 Base64 |
没有额外的回调签名头
当前回调不发送 Bearer 令牌,也不发送 X-Sign、X-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解密步骤:
- 标准 Base64 解码 nonce,确认长度为 12 字节。
- 标准 Base64 解码 ciphertext,确认长度至少为 16 字节。
- 取最后 16 字节作为 GCM tag,前面的字节为密文本身。
- 使用 K、nonce 和精确的 AAD 验证标签并解密。
- 解析解密后的 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 用于关联业务。
| 字段 | 类型 | 必返 | 说明 |
|---|---|---|---|
| id | string | 是 | 到账记录 ID,同一交易日志即使撤销后重新确认也保持不变。 |
| event_id | string | 是 | 本次状态事件 ID;重试不变,撤销/重新确认产生新事件 ID。按此字段去重。 |
| event_type | string | 是 | 到账状态事件类型。 deposit.confirmed 表示到账认可;deposit.reverted 表示到账撤销。 |
| status | string | 是 | 到账状态:confirmed 表示达到租户确认门槛;reverted 表示因链重组撤销。不是推送状态。 |
| wallet_id | string | 是 | 收款钱包 ID。 |
| custom_id | string | 是 | 钱包的租户自定义ID,同一租户内唯一,取到账入库时快照。 |
| chain | string | 是 | 所属区块链。 固定值:"TRON"。 |
| network | string | 是 | 资产所属网络:main(主网)或 nile(测试网络)。 |
| asset | string | 是 | 资产名称;测试网对应测试代币。资产身份以network+contract为准。 固定值:"USDT"。 |
| token_standard | string | 是 | 代币协议。 固定值:"TRC20"。 |
| decimals | integer | 是 | 金额精度,展示金额=amount÷10^decimals。使用整数或十进制定点计算。 固定值:6。 |
| address | string | 是 | 到账收款地址。 |
| source | string | 是 | 链上 Transfer 事件的来源地址。 |
| contract | string | 是 | 此笔Transfer的实际代币合约。结合network识别资产;Nile为测试代币,Main为Tether发行的USDT。 |
| amount | string | 是 | 该条到账记录的 USDT 金额,是否有效由 status 决定;最小单位的整数字符串,六位精度,禁止用浮点数处理。 |
| txid | string | 是 | 该条到账对应的交易哈希。 |
| log_index | integer | 是 | 交易收据内的日志索引;与网络、交易哈希一起标识链上事件。 |
| block_number | string | 是 | 到账状态对应的区块高度,低确认模式下不代表已固化。 |
| block_time | string | 是 | 链上区块时间;UTC RFC3339,固定6位小数秒,以Z结尾。不是通知发送时间。 |
| created_at | string | 是 | 平台确认并保存到账的时间;UTC RFC3339,固定6位小数秒,以Z结尾。确认或同步存在延迟时,可能晚于 block_time。 |
| revision | integer | 是 | 同一到账状态版本,从1递增;只应用更高版本,旧版本通知应答成功但不再入账。 |
| confirmation_type | string | 是 | 该笔到账采用的确认规则:block_1、block_3、block_6、block_12、solidified。仅平台开通的确认规则。 |
| confirmations | integer / null | 是 | 通过最新区块确认时的确认数;按固化结果确认时为 null。 |
| block_hash | string / null | 是 | 本次状态对应的区块哈希;既有历史记录未保存时为null。 |
到账对象不包含会变化的推送状态、次数和返回正文;用同一event_id调用通知详情查询。重试及人工重推保留原到账正文,只重新加密。
无论先通过查询发现到账,还是先收到通知,都调用同一个业务处理函数:
js
// queried 是到账详情返回的 data。
// decoded.event 是下载版接收示例解密后的正文。
await handleDeposit(queried);
await handleDeposit(decoded.event);
// handleDeposit 内必须把事件去重与业务入账放在同一数据库事务中。归集结果事件
归集结果类型:
| event_type | 说明 |
|---|---|
sweep.succeeded | USDT 归集成功 |
sweep.failed | USDT 归集失败 |
sweep.cancelled | USDT 归集取消 |
结果事件示例:
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_id | string | 必有,非空 | 正整数十进制字符串;结果事件 ID,与外层 event_id 一致;当前结果事件使用任务 ID |
event_type | string | 必有,非空 | 上表中的 sweep.*;后缀与 status 一致 |
network | string | 必有,非空 | 该事件实际发生的网络:main 或 nile |
task_id | string | 必有,非空 | 正整数十进制字符串;平台归集任务 ID |
wallet_id | string | 必有,非空 | 正整数十进制字符串;归集的转出钱包 |
amount | string | 必有,可以为 "0" | 任务记录中的最小单位整数字符串;归集为 USDT;任务在金额确定前失败时可为 0 |
destination | string | 必有,非空 | TRON Base58Check 目标地址;本任务的归集目标 |
txid | string | 必有,可以为 "" | 已生成时为 64 位十六进制交易哈希;签名前失败或取消可为空 |
status | string | 必有,非空 | succeeded、failed 或 cancelled |
reason | string | 必有,可以为 "" | 失败或取消原因;没有记录原因时为空字符串,不是 null |
actual_fee | string | 必有,可以为 "0" | 已结算链上实际手续费,TRX 最小单位整数字符串;没有产生已确认费用时为 0 |
USDT 和 TRX 在此都按六位精度解释。actual_fee: "1000000" 表示 1 TRX。失败事件的 amount 是任务记录金额,不能当作已经到账的金额;是否成功以 status 和对应业务流水为准。
去重、业务事务与成功应答
平台采用至少一次投递。接收端按 appID + event_id 建立持久化唯一约束,并将以下操作放进同一数据库事务:
- 检查事件是否已经处理;已成功处理的相同事件直接返回成功。
- 首次处理时执行你的入账记录、归集结果更新等业务写入。
- 保存事件 ID 和处理结果。
- 提交事务,然后应答。
唯一成功应答:
http
HTTP/1.1 200 OK
Content-Type: application/json
{"code":"SUCCESS"}数字 0、字符串 "success"、纯文本 SUCCESS、HTTP 201/204 都不算成功。JSON 不得包含重复键或尾随数据,响应正文不得超过 64 KiB。
不要先返回成功再异步执行未持久化的业务。若你的业务处理需要跨服务事务,应先完成可靠的持久化接收和业务调度设计,再据此决定何时应答。
可运行接收服务
下载:
- Node.js:webhook.mjs
- Python:webhook.py、requirements.txt
示例先验证 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.mjsbash
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/python webhook.pyNode.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。
接收方必须在同一数据库事务中处理事件去重、到账状态及业务账务:
- 按 appID + event_id 去重;相同事件重推不得重复入账。
- 按 appID + id 找到到账业务记录,比较 revision。旧版本即使晚到也不能覆盖新版本,处理完返回 SUCCESS。
- 新版本 confirmed 将该记录设为有效到账;新版本 reverted 将其设为已撤销并撤销对应的入账额度。金额始终为正的原始转账金额,不能把撤销事件当作另一笔充值。
- 撤销通知可能先于原确认通知到达,仍须保存较高版本。随后收到低版本确认时忽略账务变更。
- 撤销不等于退款交易,无法自动追回已发货、已提现等外部业务;选择低确认类型前应确认自身业务能够承担这个风险。
通知查询的 payload 保留该事件产生时的快照;到账详情返回同一字段结构的最新状态。旧版本历史回调保留原始正文,不改写已投递事件。实际确认规则以到账记录的 confirmation_type 为准。
延迟到账与记录核对
同步恢复或完整性核验可能补录较早区块的到账。不要按区块时间距离当前时间的长短拒绝有效通知。已有到账的恢复保持原到账 ID;同一事件及版本不得重复记账。具体规则见到账核对与延迟处理。