Docs menu

Webhooks

Managing endpoints

CallEffect
POST /v1/webhooksRegister. Returns the secret once.
GET /v1/webhooksList. Never returns secrets.
PATCH /v1/webhooks/{id}Change URL, events, is_active, or swap role.
POST /v1/webhooks/{id}/rotate-secretNew secret, returned once, effective immediately.
POST /v1/webhooks/{id}/testSynthetic 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 primary and one backup. 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 same delivery_id throughout, 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, then PATCH it to role: "primary". Atomic; you are never without a primary.
  • Local development: point at a public tunnel — the URL validator rejects loopback and private addresses, so localhost cannot be registered.

Events

data fields are listed per event; the envelope is identical for all (below).

EventSent whendata fields
order.confirmedA 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.failedAn 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.expiredA rental window ended and the resource was returned automatically.as order.reclaimed, with expired_at instead of reclaimed_at
order.reclaimedA 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.refundedA 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.completedEvery 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.refilledAn 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.suspendedA subscription stopped because the balance could not cover the next refill.subscription_id, receiver, reason, required_sun, available_sun
subscription.pausedA plan subscription stopped delegating (subscriptions.md §3).subscription_id, receiver, reason ∈ billing_failed, reserve_exhausted, user
subscription.chargedThe daily fee of a plan subscription was charged.subscription_id, receiver, plan, amount_sun, next_billing_at
balance.creditedA 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.lowThe 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.rotatedSupport 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.failed is delivered. Notifying only successes forces the client to poll for the case that matters most. Route on event and 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 explicit events list.
  • order.confirmed: check partial. When only some allocations landed the same event arrives with partial: true, delivered_amount < amount, and refunded_amount_sun carrying the pro-rata refund already issued. The order goes confirmed then active as usual — no partial state and no separate event; partial delivery is represented as a field, not a new state.
  • delegate_hashes is authoritative (an order filled from several stake addresses has several); delegate_hash is delegate_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 becomes failed and you get order.failed.
  • order.failed: refund_complete: false means the reversal is still running and a later balance.credited carries it. order.refunded reason ∈ incident_credit, dispute, partial_delivery, support_adjustment; it is distinct from order.failed (never delivered) and order.reclaimed (resource returned, money not), and the matching balance.credited (reason: "refund", reference_id = order id) still arrives — this event is the order-level statement of the same fact. order.expired/order.reclaimed never carry a refund for the rental itself: returning a resource does not undo the payment.
  • batch.completed: receivers still produce their own order.confirmed/order.failed, so a batch of 100 generates 101 events — register an endpoint with events: ["batch.completed"] and read detail from GET /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_available is 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.suspended reason ∈ 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.credited for a USDT deposit: asset: "USDT", usdt_amount (decimal string, USDT received), rate (SUN credited per 1 USDT, after the spread), rate_source (fixed_env or sunswap_v3@<block>), spread_bps (5 = 0.05 %); amount_sun is the TRX credited. For TRX these four are null and asset is "TRX". A held USDT deposit (below 5 USDT, above 2,000 USDT) sends no event until support credits it.
  • balance.credited is the deposit event: the scanner sends it once per credited TRX or USDT deposit (never on a block re-read). There is no separate deposit.credited. confirmations and balance_sun are null on a deposit event; read GET /v1/balance for the balance.
  • deposit_address.rotated: replace the address you show your users with address; keep crediting logic for retired_address until retired_credit_until.
  • balance.credited reason ∈ deposit, refund, referral, adjustment. For a refund, reference_id is the order being reversed and tx_hash is null — no chain transaction is involved in an internal credit.

Payload shapes

Envelope, identical for every event:

json
{ "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": { } }
FieldMeaning
eventType. Your routing key.
event_idDedup key. Also in X-Event-Id.
event_versionIncrements only for a breaking change to data; additive fields do not bump it.
created_atWhen the event happened, not when it was sent. A retried event keeps its original value.
account_idWhich account. Relevant when one receiver serves several accounts.
networkmainnet 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).
testtrue only for deliveries from POST /v1/webhooks/{id}/test. Never act on money for a true.
dataEvent-specific, per the table above.

Four distinct data shapes (order, batch, subscription, balance):

json
// 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

HeaderValue
Content-Typeapplication/json; charset=utf-8
X-Event-IdUnique id of this event. Same across retries and across primary/backup.
X-Event-TypeThe event type, mirroring the body’s event.
X-Event-VersionPayload schema version for this event type. Currently 1 for all.
X-Delivery-IdUnique id of this delivery attempt. Differs between retries.
X-Delivery-AttemptAttempt number, starting at 1.
X-API-TIMESTAMPUnix seconds at send time.
X-API-SIGNbase64(HMAC_SHA256(endpoint_secret, timestamp + "." + raw_body))
User-Agenttenergy-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.

javascript
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):

ts
import { verifyWebhookSignature } from "@tenergy/sdk";
const ok = verifyWebhookSignature(secret, req.headers["x-api-timestamp"], rawBody, req.headers["x-api-sign"]);
python
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.

Attempt12345678910
Delay after the previousimmediate15 s30 s3 min10 min20 min30 min1 h3 h6 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

  1. Dedup by event_id. Store it; a repeat is a no-op. At-least-once means you will see duplicates, usually because your 2xx was lost on the way back to us.
  2. Verify the HMAC before any money action. Never release goods on an unverified body.
  3. Return 2xx only after durably storing the event. Acknowledging first and processing after turns our correct retry into your lost event.
  4. 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.
  5. Do not infer sequence between orders. Events for one order arrive in the order they happened; no ordering is guaranteed across orders.
  6. Check test explicitly if your handler has side effects — a test delivery must never cause a money action. Answer within 10 s and route on event, ignoring types you do not handle.

Next steps

    ↑ ↓ to move · Enter to open · Esc to close