Webhooks — order notifications
Register an HTTPS endpoint to receive a signed webhook the moment one of your orders is fulfilled and verified on-chain. Instead of polling, you continue your flow (e.g. release USDT) as soon as the notification arrives.
Three events are delivered:
| Event | Sent when |
|---|---|
delegation.confirmed | An energy rental (1h / 5m) is confirmed on-chain |
bandwidth.delegated | A bandwidth order is fulfilled |
activation.confirmed | An address activation is executed on-chain |
This page covers the management API (create / list / edit / rotate-secret / delete your endpoints) and the format of the webhooks we deliver to you.
ℹ️ Roles. You manage your endpoints here. Delivery is performed by Netts asynchronously after the order is verified — there is nothing to poll. Only success events are sent; failures and timeouts are never delivered.
🔒 Every hash we send is verified on-chain first. A webhook is dispatched only after each transaction hash in it is found in a block. If a hash is not in a block yet, delivery is held and re-checked every 30 seconds for up to 5 minutes; if it never lands, nothing is sent for that order. You will never receive a hash that does not exist on-chain.
Endpoint base URL
https://netts.io/apiv2/webhooksRequest Headers
| Header | Required | Description |
|---|---|---|
| Content-Type | Yes (for POST/PATCH) | application/json |
| X-API-KEY | Yes | Your API key from the Netts dashboard |
| X-Real-IP | Yes | IP address from your whitelist |
Your user_id is derived from the API key — you never pass it. You can see and modify only your own endpoints.
Primary and backup endpoint
You register at most two endpoints, and each one has a role:
| Role | Purpose |
|---|---|
primary | The address every webhook is delivered to. |
backup | Fallback. Used only when delivery to primary fails after its retries are exhausted. |
A single confirmed order produces a single webhook. It is not fan-out: the same event is never sent to both addresses at once. The backup endpoint exists for resilience — if your primary host is unreachable or keeps returning non-2xx, delivery moves to the backup instead of being dropped.
The first endpoint you create becomes primary, the second becomes backup. You can pass role explicitly, or swap them later with PATCH.
Why not a separate URL per operation type? Because the event type travels inside the body, in the
eventfield. One handler, one signature check, and new event types start arriving without you registering anything new.
Manage endpoints
Create — POST /apiv2/webhooks
Registers a new endpoint and returns a secret shown only once (store it — it signs every webhook you receive).
// request body — role is optional
{
"url": "https://your-server.example/netts/delegation-hook",
"role": "primary"
}If you omit role, the first free one is assigned: primary, then backup.
// response 201
{
"detail": {
"code": 10000,
"status": "created",
"data": {
"id": 1,
"url": "https://your-server.example/netts/delegation-hook",
"secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"role": "primary",
"is_active": true,
"created_at": "2026-01-01T00:00:00"
}
}
}URL requirements (validated on create and on every edit):
- must be
https; - must resolve to a public address — loopback, private (RFC1918), link-local (incl.
169.254.169.254), and other non-routable ranges are rejected; - no credentials in the URL (
user:pass@…); - length up to 2048 chars.
A rejected URL returns 400.
You may have two endpoints — one primary and one backup. A third returns 409 (4090). Asking for a role that is already taken returns 409 (4091) — swap roles with PATCH or delete the existing one first.
curl -X POST https://netts.io/apiv2/webhooks \
-H "Content-Type: application/json" \
-H "X-API-KEY: your_api_key" \
-H "X-Real-IP: your_whitelisted_ip" \
-d '{"url": "https://your-server.example/netts/delegation-hook", "role": "primary"}'List — GET /apiv2/webhooks
Returns your endpoints (the secret is never returned here).
{
"detail": {
"code": 10000,
"status": "ok",
"data": {
"endpoints": [
{
"id": 1,
"url": "https://your-server.example/netts/delegation-hook",
"role": "primary",
"is_active": true,
"created_at": "2026-01-01T00:00:00",
"updated_at": "2026-01-01T00:00:00"
},
{
"id": 2,
"url": "https://backup.example/netts/delegation-hook",
"role": "backup",
"is_active": true,
"created_at": "2026-01-01T00:00:00",
"updated_at": "2026-01-01T00:00:00"
}
],
"count": 2,
"max_endpoints": 2,
"roles": ["primary", "backup"]
}
}
}Get one — GET /apiv2/webhooks/{id}
Same shape as a list item (no secret). A foreign or non-existent id returns 404.
Edit — PATCH /apiv2/webhooks/{id}
Change the url, is_active and/or role. Send any subset; an empty body returns 422. A changed url is re-validated (https / SSRF). A foreign or non-existent id returns 404.
// request body (any subset)
{ "url": "https://your-server.example/netts/new-hook", "is_active": false }// response 200
{
"detail": {
"code": 10000,
"status": "updated",
"data": {
"id": 1,
"url": "https://your-server.example/netts/new-hook",
"role": "primary",
"is_active": false,
"created_at": "2026-01-01T00:00:00",
"updated_at": "2026-01-01T00:00:01"
}
}
}Promoting the backup. Sending {"role": "primary"} to your backup endpoint swaps the two roles in a single transaction — the old primary becomes the backup. You are never left without a primary address, and no separate call is needed for the other endpoint.
curl -X PATCH https://netts.io/apiv2/webhooks/2 \
-H "Content-Type: application/json" \
-H "X-API-KEY: your_api_key" \
-H "X-Real-IP: your_whitelisted_ip" \
-d '{"role": "primary"}'Set
is_active: falseto pause delivery without deleting the endpoint;trueto resume. Pausing yourprimarydoes not promote the backup — delivery still targets the primary. Swap the roles if you want the backup to take over.
Rotate secret — POST /apiv2/webhooks/{id}/rotate-secret
Generates a new secret and returns it once. The new secret takes effect immediately for subsequent deliveries — no further action needed. Each endpoint has its own secret: rotating the primary's secret does not change the backup's.
// response 200
{
"detail": {
"code": 10000,
"status": "rotated",
"data": {
"id": 1,
"secret": "whsec_yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
}
}
}Delete — DELETE /apiv2/webhooks/{id}
Hard-deletes the endpoint and frees its role. Returns 204 (no body); a foreign or non-existent id returns 404.
curl -X DELETE https://netts.io/apiv2/webhooks/1 \
-H "X-API-KEY: your_api_key" -H "X-Real-IP: your_whitelisted_ip"The webhooks we deliver
When one of your orders is fulfilled, Netts sends a POST to your primary endpoint. Every body is application/json (UTF-8); addresses and hashes are always full values.
Fields common to all events:
| Field | Type | Description |
|---|---|---|
event | string | Event type — routing key for your handler |
delivery_id | int | Delivery ID — dedup key on your side. Also sent in the X-Netts-Delivery header. |
order_id | string | Your order ID |
order_type | string | 1h, 5m, bandwidth or activation |
tx_hashes | string[] | All transaction hashes of the operation, each verified on-chain |
confirmed_at | string | UTC ISO-8601 |
delegation.confirmed — energy rental
{
"event": "delegation.confirmed",
"delivery_id": 1,
"order_id": "1Hxxxxxxxxxx",
"order_type": "1h",
"receive_address": "TXXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"energy_amount": 65000,
"tx_hash": "0000000000000000000000000000000000000000000000000000000000000000",
"tx_hashes": ["0000000000000000000000000000000000000000000000000000000000000000"],
"delegation_timestamp": 1700000000000,
"confirmed_at": "2026-01-01T00:00:00Z"
}| Field | Type | Description |
|---|---|---|
order_type | string | 1h or 5m |
receive_address | string | TRON address that received the energy |
energy_amount | int | Energy amount delegated |
tx_hash | string | Legacy field, kept for compatibility: same as tx_hashes[0] |
delegation_timestamp | int? | Optional — present only when confirmed via the Mongo path |
Prefer
tx_hashesin new integrations — an order may in principle be fulfilled by more than one transaction.tx_hashwill keep working.
bandwidth.delegated — bandwidth order
{
"event": "bandwidth.delegated",
"delivery_id": 2,
"order_id": "B1Hxxxxxxxxxxxxx",
"order_type": "bandwidth",
"rental_label": "1h",
"rental_seconds": 3600,
"receive_address": "TXXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"bandwidth_amount": 400,
"fulfillment": "delegated",
"tx_hashes": ["0000000000000000000000000000000000000000000000000000000000000000"],
"confirmed_at": "2026-01-01T00:00:00Z"
}| Field | Type | Description |
|---|---|---|
rental_label / rental_seconds | string / int | Rental duration, e.g. 1h / 3600 |
receive_address | string | TRON address that received the bandwidth |
bandwidth_amount | int | Bandwidth units (net) |
fulfillment | string | How the order was fulfilled — see below |
fulfillment values:
| Value | Meaning | tx_hashes |
|---|---|---|
delegated | Bandwidth delegated from our pool | 1+ hashes |
trx_send | Fulfilled by sending TRX to the address instead of delegating | 1+ hashes |
already_enough | The address already had enough free bandwidth — nothing was sent on-chain | empty |
already_enough is the only case where tx_hashes is empty: the order is closed successfully, but there is no transaction because none was needed.
activation.confirmed — address activation
{
"event": "activation.confirmed",
"delivery_id": 3,
"order_id": "123456",
"order_type": "activation",
"address": "TXXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"activation_type": "ACC_CREATE",
"source": "telegram_bot",
"tx_hashes": ["0000000000000000000000000000000000000000000000000000000000000000"],
"confirmed_at": "2026-01-01T00:00:00Z"
}| Field | Type | Description |
|---|---|---|
order_id | string | Activation order ID (numeric string) |
address | string | TRON address that was activated |
activation_type | string | ACC_CREATE (AccountCreateContract) or DIRECT (TRX transfer) |
source | string | Origin marker. Either a service tag, or the ID of the energy order that required the activation |
Only real activations are delivered. If the address turned out to be already active and no transaction was made, no webhook is sent at all.
An energy order that also required an activation produces two webhooks — one
activation.confirmedand onedelegation.confirmed. They are separate events with separatedelivery_ids; route them by theeventfield.
Headers we send:
| Header | Value |
|---|---|
X-Netts-Event | Event type: delegation.confirmed, bandwidth.delegated or activation.confirmed |
X-Netts-Delivery | delivery_id (dedup) |
X-Netts-Timestamp | unix seconds at send time |
X-Netts-Signature | sha256=<hex>, hex = HMAC_SHA256(secret, "<timestamp>." + raw_body) |
User-Agent | netts-webhook/1.0 |
Verifying the signature
The signature follows the Stripe scheme (timestamp.body), computed over the raw bytes we send. Recompute it with your secret, compare constant-time, and reject if X-Netts-Timestamp is outside a ±5 minute window (replay protection).
Sign with the secret of the endpoint that received the request: primary and backup have separate secrets. If both your addresses are served by the same handler, pick the secret by the URL the request arrived at.
import hmac, hashlib, time
def verify(raw_body: bytes, sig_header: str, ts_header: str, secret: str) -> bool:
# freshness (anti-replay)
if abs(time.time() - int(ts_header)) > 300:
return False
signed = f"{ts_header}.".encode() + raw_body
expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig_header)
# Flask example:
# ok = verify(request.get_data(),
# request.headers["X-Netts-Signature"],
# request.headers["X-Netts-Timestamp"], SECRET)Delivery semantics (important — at-least-once)
Delivery is at-least-once: a dropped response can cause a retry, so you may receive the same event twice. Because the business action (releasing USDT) is money-sensitive:
- Dedup is mandatory — process each event idempotently by
delivery_id(and/ororder_id); a repeat is a no-op. - Verify HMAC before any money action — don't trust the body until the signature matches and
X-Netts-Timestampis fresh. - Return 2xx only after you've durably stored the event — otherwise we (correctly) retry.
Respond 2xx to acknowledge; any non-2xx / timeout triggers a retry.
Order of attempts:
- Retries go to your
primaryendpoint. The window depends on order type:5morders retry for ~1 minute, all other types for ~10 minutes. - If the window is exhausted and you registered a
backup, delivery moves there and the retry schedule starts over — signed with the backup's own secret. - Only after the backup is exhausted too is the delivery marked dead.
The same delivery_id is used throughout, so a message that first failed on the primary and then succeeded on the backup is still one event for your dedup logic.
Error Code Reference
| Code | Description | HTTP Status |
|---|---|---|
10000 | Success (created / ok / updated / rotated) | 200 / 201 |
- | Deleted (no body) | 204 |
4000 | Invalid / unsafe webhook URL (not https, private/loopback, credentials, too long) | 400 |
-1 | Invalid API key / IP not in whitelist | 401 |
-1 | Endpoint not found (or not yours) | 404 |
4090 | Endpoint limit reached (max 2: primary, backup) | 409 |
4091 | Requested role is already taken — swap with PATCH or delete the existing endpoint | 409 |
4220 | Nothing to update (PATCH with empty body) | 422 |
5003 | Failed to create endpoint (try again) | 503 |
Rate Limits
Limited per API key (header X-API-KEY):
| Period | Limit |
|---|---|
| 1 second | 5 requests |
| 1 minute | 150 requests |
Rate Limit Exceeded (429)
{ "message": "API rate limit exceeded" }Notes
- Secret is shown once — on create and on rotate. It is never returned by
GET/LIST. Lost it? rotate to get a new one. - Two endpoints, not fan-out: one
primaryand onebackup. Each confirmed order produces one webhook, delivered to the primary; the backup is used only if the primary is exhausted. - Zero-downtime URL change: register the new address as
backup, verify it, thenPATCHit toprimary— the swap is atomic. - Pausing:
PATCH … {"is_active": false}stops delivery without losing the endpoint. - Success events only:
delegation.confirmed,bandwidth.delegated,activation.confirmed. There is no failure event — a failed or timed-out order produces no webhook. - New event types may be added over time. Route by the
eventfield and ignore types you do not handle yet — you never need to register anything new to start receiving them. - Hashes are verified on-chain before delivery (see the note at the top): a webhook either carries hashes that are all in a block, or is not sent at all.
- URLs are validated for SSRF safety at registration and on every edit; the delivery side re-validates at send time.