Webhooks
TEnergy поддерживает отправку асинхронных уведомлений о событиях на ваш сервер через Webhooks. Состояния заказов совпадают с API и кабинетом: created → paid → allocating → delegated → confirmed → active → expired | reclaimed, с терминальными ветками ошибок failed и refunded. Частичное исполнение не является отдельным статусом, а выражается флагом partial: true и объемом delivered_amount.
Управление эндпоинтами
| Метод API | Действие |
|---|---|
POST /v1/webhooks | Регистрация нового эндпоинта. Секрет подписи возвращается ровно один раз. |
GET /v1/webhooks | Список эндпоинтов. Никогда не возвращает секреты. |
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 без простоя: зарегистрируйте новый URL с ролью
backup, проверьте тестовой отправкой, а затем вызовитеPATCHи установитеrole: "primary". - Локальная разработка: используйте публичный туннель (например, ngrok) — валидатор URL отклоняет локальные и приватные IP, поэтому зарегистрировать
localhostнапрямую нельзя.
Типы событий
| Событие | Условие отправки | Поля объекта data |
|---|---|---|
order.confirmed | Делегирование подтверждено в блокчейне TRON и проверено на адресе получателя. | 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. Уведомление только об успехах вынуждает клиентов опрашивать API для выявления сбоев. Маршрутизируйте логику поeventи игнорируйте неизвестные типы — добавление новых событий не должно ломать ваш сервис. order.confirmed: обязательно проверяйтеpartial. При частичном исполнении приходит это же событие с флагомpartial: true, объемомdelivered_amount < amountи суммой возвратаrefunded_amount_sun.delegate_hashes: массив транзакций в сети (при наборе объема из нескольких кошельков). Каждый хэш верифицируется в блокчейне перед отправкой вебхука, отправка несуществующих хэшей исключена.
Структура полезной нагрузки (Payload)
Общая структура для всех событий:
{
"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 | Уникальный ID события для дедупликации. Также передается в заголовке X-Event-Id. |
event_version | Версия схемы данных, в настоящее время 1. |
created_at | Точное время возникновения события (UTC). При ретраях сохраняется исходное время. |
account_id | ID аккаунта платформы. |
network | Сеть: mainnet или nile. Защищает от попадания тестовых событий в боевой контур. |
test | Равно true только для тестовых доставок из POST /v1/webhooks/{id}/test. Никогда не выполняйте реальных действий с товаром или деньгами при test: true. |
data | Специфичный для события объект с данными. |
Заголовки доставки
| Заголовок | Значение |
|---|---|
Content-Type | application/json; charset=utf-8 |
X-Event-Id | Уникальный ID события. Не меняется при ретраях и при переходе primary/backup. |
X-Event-Type | Тип события, дублирует поле event. |
X-Event-Version | Версия схемы полезной нагрузки. |
X-Delivery-Id | Уникальный ID конкретной попытки доставки. Меняется при каждом ретрае. |
X-Delivery-Attempt | Номер попытки, начиная с 1. |
X-API-TIMESTAMP | Метка времени отправки (Unix seconds). |
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)), вычисляется строго над сырыми байтами тела запроса в том виде, в котором они были получены — любое изменение форматирования JSON ломает подпись. Допустимое окно рассинхронизации часов: ±300 секунд.
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);
}
Три правила проверки:
- Сначала проверка подписи, затем парсинг: не передавайте непроверенный JSON в бизнес-логику.
- Сравнение за константное время: используйте
crypto.timingSafeEqualилиhmac.compare_digestдля защиты от атак по времени. - Проверка метки времени: отклоняйте запросы с разницей более 300 секунд для защиты от атак повторного воспроизведения.
Расписание повторных попыток
Доставка строится по принципу At-least-once (как минимум один раз). Ваш сервер должен ответить статусом 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: сохраняйте полученные идентификаторы; повторный запрос должен возвращать 200 без повторного выполнения бизнес-логики. - Проверяйте HMAC перед отгрузкой: никогда не исполняйте заказ клиента до успешной проверки подписи вебхука.
- Возвращайте
2xxтолько после надежного сохранения события: подтверждение до сохранения превратит наш ретрай в утерянное вами событие. - Не полагайтесь исключительно на вебхуки: периодически выполняйте сверку по расписанию через
GET /v1/orders?status=confirmed&created_after=…. - Отвечайте в течение 10 секунд: принимайте событие, сохраняйте в очередь и сразу возвращайте 200; тяжелую обработку выполняйте в фоне.