Webhooks 回调
TEnergy 支持通过 Webhook 实时向您的服务推送异步事件通知。订单状态流转与 API 和控制台保持完全一致:created → paid → allocating → delegated → confirmed → active → expired | reclaimed,异常时流转至终止状态 failed 或 refunded。部分交付并不是一个独立状态,而是通过 partial: true 标记及实际交付量 delivered_amount 体现。
管理 Webhook 端点
| API 接口 | 作用说明 |
|---|---|
POST /v1/webhooks | 注册新端点。签名密钥 secret 仅在创建时返回一次。 |
GET /v1/webhooks | 查询已注册的端点列表。绝不返回 secret。 |
PATCH /v1/webhooks/{id} | 修改 URL、监听事件列表、启用状态 is_active 或切换角色 role。 |
POST /v1/webhooks/{id}/rotate-secret | 轮换密钥,新密钥立即生效并仅展示一次。 |
POST /v1/webhooks/{id}/test | 发送模拟测试事件;返回您的接收服务器的响应详情(包含前 512 字节的响应体)。 |
DELETE /v1/webhooks/{id} | 删除端点;该端点未完成的投递任务将被直接丢弃。 |
- 主备双端点机制(非广播扇出): 支持配置一个
primary(主)和一个backup(备用)端点。所有事件优先投递至主端点;仅在主端点所有重试耗尽后才转至备用端点。同一事件在主备流转过程中保持唯一的delivery_id,便于业务排重。 - 零停机平滑更换 URL: 先将新地址注册为
backup,通过测试事件验证可用性,然后调用PATCH将其切换为role: "primary"即可无缝切换。 - 本地调试: 请使用公网隧道(如 ngrok)—— 系统校验器会自动拒绝回环与私有内网 IP,因此无法直接注册
localhost。
事件类型列表
| 事件名称 | 触发时机 | 核心数据字段 (data) |
|---|---|---|
order.confirmed | 链上委托已成功确认且接收地址额度已验证。 | order_id, client_order_id, batch_id, subscription_id, resource, amount, delivered_amount, partial, tier, duration_seconds, receiver, delegate_hash, delegate_hashes[], delegated_at, expires_at, total_amount_sun, refunded_amount_sun, activation |
order.failed | 订单无法完成交付。终态;所有款项已全额原路退还。 | order_id, client_order_id, receiver, resource, amount, tier, failure{code,slug,message}, charged_amount_sun, refunded_amount_sun, refund_complete |
order.expired | 能量租用时长自然到期,链上资源已自动回收。 | 同 order.reclaimed,携带 expired_at 字段。 |
order.reclaimed | 租用能量通过 POST /v1/orders/{id}/reclaim 被主动提前赎回回收。 | order_id, client_order_id, receiver, resource, amount, reclaim_hash, reclaimed_at, refunded_amount_sun |
order.refunded | 已交付的订单被执行了退款操作(退款终态)。 | order_id, client_order_id, receiver, resource, amount, delivered_amount, partial, reason, charged_amount_sun, refunded_amount_sun, refund_complete, refunded_at |
batch.completed | 批量订单中的每一个接收地址均到达终态(每个批次仅推送一次)。 | batch_id, client_batch_id, status, summary{total,completed,partial,failed,insufficient_funds,cancelled}, charged_amount_sun, finished_at |
subscription.refilled | 自动监控地址触发补水,成功买入并委托了能量。 | subscription_id, order_id, receiver, resource, amount, tier, trigger_available, threshold_amount, total_amount_sun, refills_today |
subscription.suspended | 自动订阅因余额不足等原因被系统挂起。 | subscription_id, receiver, reason, required_sun, available_sun |
subscription.paused | 套餐订阅停止委托。 | subscription_id, receiver, reason ∈ billing_failed, reserve_exhausted, user |
subscription.charged | 自动订阅扣除了每日套餐费用。 | subscription_id, receiver, plan, amount_sun, next_billing_at |
balance.credited | 充值到账已确认并计入账户账本。 | reason, currency, amount_sun, tx_hash, confirmations, balance_sun, reference_id, asset, usdt_amount, rate |
balance.low | 可用余额低于账户设定的预警阈值。 | balance_sun, threshold_sun, estimated_orders_remaining |
deposit_address.rotated | 支持人员为账户轮换了新的充值地址。 | address (新地址), retired_address (废弃的旧地址), retired_credit_until (旧地址停止计入的时间), rotated_at |
- 失败也是正式事件: 与部分同行不同,我们推送
order.failed事件。仅推送成功事件会迫使客户端通过轮询去检查最重要的失败情况。请按event分发路由,并忽略未知的事件类型 —— 新增事件类型不应导致您的服务异常。 order.confirmed中必须检查partial字段:部分交付时仍会发送此事件,附带partial: true、delivered_amount < amount以及已自动原路退回的差额refunded_amount_sun。delegate_hashes为权威数组(当自营多个质押地址共同撮合一单时会有多笔交易);delegate_hash取第一笔。每个 Hash 在推送前都经过链上实际确认,绝不提前虚报不存在的交易哈希。
载荷通用格式
外层通用信封结构对所有事件完全一致:
{
"event": "order.confirmed",
"event_id": "evt_01J9ZB3F5HJK",
"event_version": 1,
"created_at": "2026-09-11T18:04:08.100Z",
"account_id": "acc_01J9Z4K2M7Q8",
"network": "mainnet",
"test": false,
"data": { }
}
| 字段 | 含义说明 |
|---|---|
event | 事件类型名称,用于您服务端的逻辑路由。 |
event_id | 事件唯一标识,用于幂等去重。亦包含在 X-Event-Id 请求头中。 |
event_version | 数据格式版本号,当前统一为 1。 |
created_at | 事件真实发生的时间(UTC 时间戳)。重试推送保持初始发生时间不变。 |
account_id | 归属的平台账户 ID。 |
network | 所在网络:mainnet(主网)或 nile(测试网)。请据此防范测试网事件误入生产业务系统。 |
test | 仅在由 POST /v1/webhooks/{id}/test 触发时为 true。当 test === true 时切勿执行真实发货。 |
data | 事件专属数据载荷。 |
投递请求头
| 请求头 | 说明 |
|---|---|
Content-Type | application/json; charset=utf-8 |
X-Event-Id | 该事件的全局唯一 ID。重试或主备流转保持不变。 |
X-Event-Type | 事件类型,与请求体内的 event 一致。 |
X-Event-Version | 载荷版本号,目前统一为 1。 |
X-Delivery-Id | 本次投递尝试的唯一 ID。每次重试都会生成新的 ID。 |
X-Delivery-Attempt | 投递尝试计数,从 1 开始递增。 |
X-API-TIMESTAMP | 发送时的 Unix 秒级时间戳。 |
X-API-SIGN | base64(HMAC_SHA256(endpoint_secret, timestamp + "." + raw_body)) |
User-Agent | tenergy-webhook/1 |
请务必根据 X-Event-Id(或请求体中的 event_id)进行业务去重,切勿使用 X-Delivery-Id 去重。
签名验证
签名算法规范:X-API-SIGN = base64(HMAC_SHA256(endpoint_secret, X-API-TIMESTAMP + "." + raw_body)),其中 raw_body 必须为接收到的原始未经解析的字节串 —— 任何 JSON 序列化变更或多余空格都会导致签名校验失败。时间漂移窗口为 ±300 秒。请使用接收该请求的端点对应的 Secret(主端点与备用端点拥有独立的 Secret)。
import crypto from 'node:crypto';
const MAX_SKEW_SECONDS = 300;
export function verifyWebhook(rawBody, headers, secret) {
const timestamp = headers['x-api-timestamp'], signature = headers['x-api-sign'];
if (!timestamp || !signature) return false;
const skew = Math.abs(Date.now() / 1000 - Number(timestamp)); // 防重放校验
if (!Number.isFinite(skew) || skew > MAX_SKEW_SECONDS) return false;
const signed = Buffer.concat([Buffer.from(`${timestamp}.`), rawBody]);
const expected = crypto.createHmac('sha256', secret).update(signed).digest('base64');
const a = Buffer.from(expected), b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Python 验证逻辑:使用 hmac.compare_digest 比较 base64.b64encode(hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).digest())。
三大验证守则:
- 先验签再解析: 绝不在业务逻辑中处理未经验签的 JSON。
- 常量时间比对: 必须使用
crypto.timingSafeEqual或hmac.compare_digest防范时序侧信道攻击。 - 校验时间戳: 验证时间戳与当前时间是否在 ±300 秒以内,防止重放攻击。
重试机制与时间表
投递采用 At-least-once(至少一次) 语义。您的服务必须返回 HTTP 2xx 状态码以表示成功签收;任何非 2xx 响应、连接超时或响应时间超过 10 秒 均被视为投递失败并触发阶梯重试。
| 尝试次数 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|
| 与上一轮的间隔 | 立即 | 15 秒 | 30 秒 | 3 分钟 | 10 分钟 | 20 分钟 | 30 分钟 | 1 小时 | 3 小时 | 6 小时 |
整个重试周期约为 11 小时。超短时订单(5m、15m)在第 4 次尝试(约 4 分钟)后自动停止重试 —— 此时租用已结束,延迟交付已无实际意义。若主端点重试耗尽且配置了备用端点,任务将切换至备用端点并重新从第 1 次时间表开始;仅当备用端点也耗尽重试后,投递才彻底宣告失败。您可在控制台中查看投递历史;测试事件从不重试。
接收端最佳实践
- 基于
event_id去重: 将处理过的 ID 存入缓存或数据库;遇到重复请求直接响应 200。 - 先验签再履约: 务必在通过 HMAC 校验后再对下游用户发货。
- 先持久化再应答 2xx: 确保事件已落库后再向我们返回 2xx。
- 不要将 Webhook 视为唯一数据源: Webhook 是轮询的优化方案。建议配置定时对账任务,比对
GET /v1/orders?status=confirmed&created_after=…。 - 在 10 秒内快速应答: 收到请求后快速确认存储并返回 200,复杂的业务履约应放入异步队列中处理。