Webhooks
Managing endpoints
| Call | Effect |
|---|---|
POST /v1/webhooks | Register. Returns the secret once. |
GET /v1/webhooks | List. Never returns secrets. |
PATCH /v1/webhooks/{id} | Change URL, events, is_active, or swap role. |
POST /v1/webhooks/{id}/rotate-secret | New secret, returned once, effective immediately. |
POST /v1/webhooks/{id}/test | Synthetic delivery; reports what your server answered, including the first 512 bytes of your response body. |
DELETE /v1/webhooks/{id} | Hard delete; undelivered events for it are dropped. |
- Auth: every call takes an API key or the dashboard session. The signup bootstrap token is refused with
401— create the API key first, then register the endpoint. - Two endpoints, not fan-out. One
primaryand onebackup. Every event goes to the primary; the backup is used only after the primary’s retries are exhausted. Never both at once, and it keeps the samedelivery_idthroughout, so a delivery that failed on the primary and succeeded on the backup is still one event for dedup. - Zero-downtime URL change: register the new address as
backup, verify with a test delivery, thenPATCHit torole: "primary". Atomic; you are never without a primary. - Local development: point at a public tunnel — the URL validator rejects loopback and private addresses, so
localhostcannot be registered.
Events
data fields are listed per event; the envelope is identical for all (below).
| Event | Sent when | data fields |
|---|---|---|
order.confirmed | A delegation is confirmed on chain and the receiver’s limit is verified. | 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{performed,hash,amount_sun} |
order.failed | An order could not be delivered. Terminal; any charge has been reversed. | order_id, client_order_id, receiver, resource, amount, tier, failure{code,slug,message}, charged_amount_sun, refunded_amount_sun, refund_complete |
order.expired | A rental window ended and the resource was returned automatically. | as order.reclaimed, with expired_at instead of reclaimed_at |
order.reclaimed | A rental was returned early by POST /v1/orders/{id}/reclaim. | order_id, client_order_id, receiver, resource, amount, reclaim_hash, reclaimed_at, refunded_amount_sun |
order.refunded | A delivered order was reversed and the money credited back — the refunded terminal state. Previously this state had no event and a client learned of a reversal only from balance.credited. | order_id, client_order_id, receiver, resource, amount, delivered_amount, partial, reason, charged_amount_sun, refunded_amount_sun, refund_complete, refunded_at |
batch.completed | Every receiver in a batch reached a terminal state. Once per batch. | batch_id, client_batch_id, status, summary{total,completed,partial,failed,insufficient_funds,cancelled}, charged_amount_sun, finished_at |
subscription.refilled | An auto-refill bought resource for a watched address. | subscription_id, order_id, receiver, resource, amount, tier, trigger_available, threshold_amount, total_amount_sun, refills_today |
subscription.suspended | A subscription stopped because the balance could not cover the next refill. | subscription_id, receiver, reason, required_sun, available_sun |
subscription.paused | A plan subscription stopped delegating (subscriptions.md §3). | subscription_id, receiver, reason ∈ billing_failed, reserve_exhausted, user |
subscription.charged | The daily fee of a plan subscription was charged. | subscription_id, receiver, plan, amount_sun, next_billing_at |
balance.credited | A deposit was confirmed and credited to the ledger. | reason, currency, amount_sun, tx_hash, confirmations, balance_sun, reference_id, asset, usdt_amount, rate, rate_source, spread_bps, txid, sender, block_number |
balance.low | The balance fell below the account’s configured alert threshold. | balance_sun, threshold_sun, estimated_orders_remaining (at the current price). Use it to top up before orders start failing with 4001. |
deposit_address.rotated | Support rotated the account’s deposit address (admin action; customers cannot rotate). | address (new, show this one), retired_address (null on a first issue), retired_credit_until (the retired address still credits until then; after it, a deposit there is not credited automatically), rotated_at |
Semantics not visible in the field lists:
- Failure is an event here, unlike some competitors:
order.failedis delivered. Notifying only successes forces the client to poll for the case that matters most. Route oneventand ignore unknown types — new types will be added and a receiver that throws on one starts failing the day we ship it; no re-registration is needed unless you pinned an expliciteventslist. order.confirmed: checkpartial. When only some allocations landed the same event arrives withpartial: true,delivered_amount < amount, andrefunded_amount_suncarrying the pro-rata refund already issued. The order goesconfirmedthenactiveas usual — nopartialstate and no separate event; partial delivery is represented as a field, not a new state.delegate_hashesis authoritative (an order filled from several stake addresses has several);delegate_hashisdelegate_hashes[0], kept because most callers want one and it is what the CatFee- and Netts-compatible facades map onto. Every hash is verified on chain before the event is sent — delivery is held and re-checked until all hashes are in blocks, so you never receive a hash that does not exist; if a hash never lands the order becomesfailedand you getorder.failed.order.failed:refund_complete: falsemeans the reversal is still running and a laterbalance.creditedcarries it.order.refundedreason∈incident_credit,dispute,partial_delivery,support_adjustment; it is distinct fromorder.failed(never delivered) andorder.reclaimed(resource returned, money not), and the matchingbalance.credited(reason: "refund",reference_id= order id) still arrives — this event is the order-level statement of the same fact.order.expired/order.reclaimednever carry a refund for the rental itself: returning a resource does not undo the payment.batch.completed: receivers still produce their ownorder.confirmed/order.failed, so a batch of 100 generates 101 events — register an endpoint withevents: ["batch.completed"]and read detail fromGET /v1/batches/{id}if that is too much.status: "partial"is normal and must be handled; a batch is not all-or-nothing.subscription.refilled:trigger_availableis the free resource observed on the address when the refill fired — use it to tune a threshold that fires too often or too late.subscription.suspendedreason∈insufficient_funds,daily_limit_reached,price_above_limit; the subscription is not deleted and resumes by itself once the cause clears (a deposit, the next UTC day, a price back under the cap).balance.creditedfor a USDT deposit:asset: "USDT",usdt_amount(decimal string, USDT received),rate(SUN credited per 1 USDT, after the spread),rate_source(fixed_envorsunswap_v3@<block>),spread_bps(5 = 0.05 %);amount_sunis the TRX credited. For TRX these four arenullandassetis"TRX". A held USDT deposit (below 5 USDT, above 2,000 USDT) sends no event until support credits it.balance.creditedis the deposit event: the scanner sends it once per credited TRX or USDT deposit (never on a block re-read). There is no separatedeposit.credited.confirmationsandbalance_sunarenullon a deposit event; readGET /v1/balancefor the balance.deposit_address.rotated: replace the address you show your users withaddress; keep crediting logic forretired_addressuntilretired_credit_until.balance.creditedreason∈deposit,refund,referral,adjustment. For a refund,reference_idis the order being reversed andtx_hashisnull— no chain transaction is involved in an internal credit.
Payload shapes
Envelope, identical for every event:
{ "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": { } }
| Field | Meaning |
|---|---|
event | Type. Your routing key. |
event_id | Dedup key. Also in X-Event-Id. |
event_version | Increments only for a breaking change to data; additive fields do not bump it. |
created_at | When the event happened, not when it was sent. A retried event keeps its original value. |
account_id | Which account. Relevant when one receiver serves several accounts. |
network | mainnet or nile. Guard against a testnet event reaching production logic: the two are separate accounts on separate hosts (api.<brand-domain>, api-nile.<brand-domain>) with separate ledgers and separate endpoint secrets, and Nile does not behave identically to mainnet (see the Testnet section of openapi.yaml). |
test | true only for deliveries from POST /v1/webhooks/{id}/test. Never act on money for a true. |
data | Event-specific, per the table above. |
Four distinct data shapes (order, batch, subscription, balance):
// order.confirmed / .failed / .refunded / .expired / .reclaimed
{ "order_id": "ord_01J9Z5P8T3WQ", "client_order_id": "acme-2026-09-11-000418",
"batch_id": null, "subscription_id": null, "resource": "energy",
"amount": 65000, "delivered_amount": 65000, "partial": false,
"tier": "1h", "duration_seconds": 3600, "receiver": "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE",
"delegate_hash": "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90",
"delegate_hashes": ["a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90"],
"delegated_at": "2026-09-11T18:04:07.900Z", "expires_at": "2026-09-11T19:04:07.900Z",
"total_amount_sun": 1300000, "refunded_amount_sun": 0,
"activation": { "performed": false, "hash": null, "amount_sun": 0 } }
// order.failed carries instead: failure{code,slug,message} e.g. 5002 / delegation_failed /
// "Delegation not confirmed within the wait window", charged_amount_sun 50000000,
// refunded_amount_sun 50000000, refund_complete true
// batch.completed
{ "batch_id": "bat_01J9Z6Q1V8XZ", "client_batch_id": "acme-payout-2026-09-11-01",
"status": "partial",
"summary": { "total": 3, "completed": 2, "partial": 0, "failed": 1,
"insufficient_funds": 0, "cancelled": 0 },
"charged_amount_sun": 2600000, "finished_at": "2026-09-11T18:23:40.000Z" }
// subscription.refilled (subscription.suspended: subscription_id, receiver, reason,
// required_sun 2620000, available_sun 410000)
{ "subscription_id": "sub_01J9Z7R4Y2AB", "order_id": "ord_01J9ZB7K9RSU",
"receiver": "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE", "resource": "energy",
"amount": 131000, "tier": "1h", "trigger_available": 42100, "threshold_amount": 65000,
"total_amount_sun": 2620000, "refills_today": 3 }
// balance.credited (balance.low: balance_sun, threshold_sun, estimated_orders_remaining)
{ "reason": "deposit", "currency": "TRX", "amount_sun": 1000000000,
"tx_hash": "9c1e3b5d7f9a1c3e5b7d9f1a3c5e7b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e",
"confirmations": 19, "balance_sun": 2250400000, "reference_id": null }
Delivery headers
| Header | Value |
|---|---|
Content-Type | application/json; charset=utf-8 |
X-Event-Id | Unique id of this event. Same across retries and across primary/backup. |
X-Event-Type | The event type, mirroring the body’s event. |
X-Event-Version | Payload schema version for this event type. Currently 1 for all. |
X-Delivery-Id | Unique id of this delivery attempt. Differs between retries. |
X-Delivery-Attempt | Attempt number, starting at 1. |
X-API-TIMESTAMP | Unix seconds at send time. |
X-API-SIGN | base64(HMAC_SHA256(endpoint_secret, timestamp + "." + raw_body)) |
User-Agent | tenergy-webhook/1 |
The signature headers deliberately reuse the request-signing names, so a client library has one concept of “signed with the shared secret”. Dedup on X-Event-Id, not X-Delivery-Id.
Signature
Spec: X-API-SIGN = base64(HMAC_SHA256(endpoint_secret, X-API-TIMESTAMP + "." + raw_body)), computed over the raw bytes you received — any reordering or whitespace change breaks it. Replay window ±300 s. Sign with the secret of the endpoint that received the request: primary and backup have separate secrets, so pick by the URL the request arrived at, not by the event.
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)); // replay protection
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 equivalent: base64.b64encode(hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).digest()), compared with hmac.compare_digest, same ±300 s check. Three rules, in order: verify before parsing (no unverified JSON in business logic); compare in constant time (== leaks timing); check the timestamp (a valid signature on a replayed old body is still a replay).
With the SDKs (same checks, raw body as received):
import { verifyWebhookSignature } from "@tenergy/sdk";
const ok = verifyWebhookSignature(secret, req.headers["x-api-timestamp"], rawBody, req.headers["x-api-sign"]);
from tenergy import verify_signature
ok = verify_signature(secret, request.headers, raw_body, tolerance_seconds=300)
The TypeScript helper does not check the timestamp: reject anything more than 300 s from now yourself. Answer 2xx fast, then process; a non-2xx or a timeout is retried on the schedule below with the same X-Event-Id, so dedup on it.
Retry schedule
At-least-once. Respond 2xx to acknowledge; any non-2xx, any connection failure and any response slower than 10 seconds is a failure and triggers a retry.
| Attempt | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|
| Delay after the previous | immediate | 15 s | 30 s | 3 min | 10 min | 20 min | 30 min | 1 h | 3 h | 6 h |
Total window ≈ 11 hours. Short-tier orders (5m) stop after attempt 4 (about 4 minutes) — the rental has expired by then, so late delivery is pointless. If the primary is exhausted and a backup is registered, delivery moves to the backup and the schedule starts over, signed with the backup’s own secret; only when the backup is exhausted too is the delivery dead. A dead delivery is visible in the dashboard, and the order state is always readable from GET /v1/orders/{id}. Test deliveries are never retried and do not appear in delivery history.
Receiver requirements
- Dedup by
event_id. Store it; a repeat is a no-op. At-least-once means you will see duplicates, usually because your2xxwas lost on the way back to us. - Verify the HMAC before any money action. Never release goods on an unverified body.
- Return
2xxonly after durably storing the event. Acknowledging first and processing after turns our correct retry into your lost event. - Do not treat webhooks as your only source of truth. They are an optimisation over polling. Reconcile on a timer against
GET /v1/orders?status=confirmed&created_after=…so a dead delivery, a deploy during an outage or a handler bug cannot leave your records permanently wrong. - Do not infer sequence between orders. Events for one order arrive in the order they happened; no ordering is guaranteed across orders.
- Check
testexplicitly if your handler has side effects — a test delivery must never cause a money action. Answer within 10 s and route onevent, ignoring types you do not handle.