Skip to content

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:

EventSent when
delegation.confirmedAn energy rental (1h / 5m) is confirmed on-chain
bandwidth.delegatedA bandwidth order is fulfilled
activation.confirmedAn 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/webhooks

Request Headers

HeaderRequiredDescription
Content-TypeYes (for POST/PATCH)application/json
X-API-KEYYesYour API key from the Netts dashboard
X-Real-IPYesIP 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:

RolePurpose
primaryThe address every webhook is delivered to.
backupFallback. 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 event field. 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).

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

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

bash
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).

json
{
    "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.

json
// request body (any subset)
{ "url": "https://your-server.example/netts/new-hook", "is_active": false }
json
// 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.

bash
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: false to pause delivery without deleting the endpoint; true to resume. Pausing your primary does 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.

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

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

FieldTypeDescription
eventstringEvent type — routing key for your handler
delivery_idintDelivery ID — dedup key on your side. Also sent in the X-Netts-Delivery header.
order_idstringYour order ID
order_typestring1h, 5m, bandwidth or activation
tx_hashesstring[]All transaction hashes of the operation, each verified on-chain
confirmed_atstringUTC ISO-8601

delegation.confirmed — energy rental

json
{
    "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"
}
FieldTypeDescription
order_typestring1h or 5m
receive_addressstringTRON address that received the energy
energy_amountintEnergy amount delegated
tx_hashstringLegacy field, kept for compatibility: same as tx_hashes[0]
delegation_timestampint?Optional — present only when confirmed via the Mongo path

Prefer tx_hashes in new integrations — an order may in principle be fulfilled by more than one transaction. tx_hash will keep working.

bandwidth.delegated — bandwidth order

json
{
    "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"
}
FieldTypeDescription
rental_label / rental_secondsstring / intRental duration, e.g. 1h / 3600
receive_addressstringTRON address that received the bandwidth
bandwidth_amountintBandwidth units (net)
fulfillmentstringHow the order was fulfilled — see below

fulfillment values:

ValueMeaningtx_hashes
delegatedBandwidth delegated from our pool1+ hashes
trx_sendFulfilled by sending TRX to the address instead of delegating1+ hashes
already_enoughThe address already had enough free bandwidth — nothing was sent on-chainempty

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

json
{
    "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"
}
FieldTypeDescription
order_idstringActivation order ID (numeric string)
addressstringTRON address that was activated
activation_typestringACC_CREATE (AccountCreateContract) or DIRECT (TRX transfer)
sourcestringOrigin 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.confirmed and one delegation.confirmed. They are separate events with separate delivery_ids; route them by the event field.

Headers we send:

HeaderValue
X-Netts-EventEvent type: delegation.confirmed, bandwidth.delegated or activation.confirmed
X-Netts-Deliverydelivery_id (dedup)
X-Netts-Timestampunix seconds at send time
X-Netts-Signaturesha256=<hex>, hex = HMAC_SHA256(secret, "<timestamp>." + raw_body)
User-Agentnetts-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.

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

  1. Dedup is mandatory — process each event idempotently by delivery_id (and/or order_id); a repeat is a no-op.
  2. Verify HMAC before any money action — don't trust the body until the signature matches and X-Netts-Timestamp is fresh.
  3. 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:

  1. Retries go to your primary endpoint. The window depends on order type: 5m orders retry for ~1 minute, all other types for ~10 minutes.
  2. 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.
  3. 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

CodeDescriptionHTTP Status
10000Success (created / ok / updated / rotated)200 / 201
-Deleted (no body)204
4000Invalid / unsafe webhook URL (not https, private/loopback, credentials, too long)400
-1Invalid API key / IP not in whitelist401
-1Endpoint not found (or not yours)404
4090Endpoint limit reached (max 2: primary, backup)409
4091Requested role is already taken — swap with PATCH or delete the existing endpoint409
4220Nothing to update (PATCH with empty body)422
5003Failed to create endpoint (try again)503

Rate Limits

Limited per API key (header X-API-KEY):

PeriodLimit
1 second5 requests
1 minute150 requests

Rate Limit Exceeded (429)

json
{ "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 primary and one backup. 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, then PATCH it to primary — 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 event field 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.