# Webhooks

Source: https://tenergy.me/docs/webhooks
Last updated: 2026-09-11

## 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 `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).

| 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.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": { } }
```

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

```jsonc
// 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.

```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](#retry-schedule) 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

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

- [Webhooks reference](https://tenergy.me/docs/api/webhooks) — register, rotate the secret, send a test delivery.
- [Errors & rate limits](https://tenergy.me/docs/errors) — what `failure.code` in `order.failed` means.
- [TypeScript SDK](https://tenergy.me/docs/sdk/typescript) — `verifyWebhookSignature` does the check below.
