Skip to content

POST /apiv2/reports/webhooks

Register a URL and NETTS will call it when a report is ready, instead of you polling for the status.

These endpoints are separate from the order webhooks. Registering there does not subscribe you to report notifications, and the other way round. The wire format — signature, headers, retry behaviour — is identical, so a handler written for one works for the other.

Endpoint base URL

https://netts.io/apiv2/reports/webhooks

Request headers

HeaderRequiredDescription
X-API-KEYyesAPI key from the dashboard
X-Real-IPyesAn address from the key whitelist

Primary and backup

Up to two endpoints per account. primary receives everything. backup is used only after delivery to the primary has run out of attempts — and it is signed with its own secret, not the primary's.

Register

bash
curl -s -X POST 'https://netts.io/apiv2/reports/webhooks' \
  -H 'X-API-KEY: your-api-key' \
  -H 'X-Real-IP: 203.0.113.10' \
  -H 'Content-Type: application/json' \
  -d '{"url": "https://example.com/netts/reports", "role": "primary"}'
json
{
  "status": "success",
  "code": 10000,
  "data": {
    "id": 1,
    "url": "https://example.com/netts/reports",
    "role": "primary",
    "is_active": true,
    "created_at": "2026-09-06 17:05:12+00:00",
    "updated_at": "2026-09-06 17:05:12+00:00",
    "secret": "whsec_<64 hex characters>"
  }
}

The secret is shown once, here. It is never returned again — not by the list, not by the read endpoint. Store it when you receive it. If it is lost, issue a new one:

bash
curl -s -X POST 'https://netts.io/apiv2/reports/webhooks/1/rotate-secret' \
  -H 'X-API-KEY: your-api-key' -H 'X-Real-IP: 203.0.113.10'

Rotation takes effect immediately and the old secret stops verifying, so deploy the new value first if you cannot tolerate a gap.

Manage

MethodPathAction
GET/apiv2/reports/webhookslist yours, without secrets
GET/apiv2/reports/webhooks/{id}read one
PATCH/apiv2/reports/webhooks/{id}change url, or pause with is_active: false
DELETE/apiv2/reports/webhooks/{id}remove it

The URL must be public HTTPS. Loopback, private and link-local addresses are rejected, as are credentials inside the URL. Anything rejected comes back as 422 with the reason. The check runs again immediately before every delivery, so an endpoint that later resolves to a private address stops receiving.

What we send

json
{
  "event": "report.ready",
  "delivery_id": 4,
  "order_id": "REPxxxxxxxxxxxx",
  "order_type": "statement",
  "client_request_id": "stmt-2026-09-usdt",
  "status": "done",
  "format": "csv",
  "download_url": "/apiv2/reports/REPxxxxxxxxxxxx/download",
  "expires_at": "2026-10-06 15:48:04+00:00",
  "artifact": { "sha256": "…", "size_bytes": 696 },
  "confirmed_at": "2026-09-06T15:48:04Z"
}
FieldDescription
eventreport.ready — routing key for your handler
delivery_idDedup key. Also sent as the X-Netts-Delivery header
order_idThe order number you were given when you queued the report
order_typestatement or balance_at_date
download_urlPath to fetch the file, relative to https://netts.io
artifact.sha256Checksum, so you can verify what you downloaded
confirmed_atUTC

All timestamps are UTC.

Verifying the signature

X-Netts-Signature: sha256=<hex>
X-Netts-Timestamp: <unix seconds>
X-Netts-Delivery:  <delivery_id>

The signature is HMAC-SHA256 over "<timestamp>." + raw body, computed with the secret of the endpoint that received the request. Compare in constant time and reject anything whose timestamp falls outside a ±5 minute window.

python
import hmac, hashlib, time

def verify(raw_body: bytes, sig_header: str, ts_header: str, secret: str) -> bool:
    if abs(time.time() - int(ts_header)) > 300:      # anti-replay
        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)

Sign with the secret of the URL the request arrived at: primary and backup have different secrets.

Delivery is at-least-once

A dropped response causes a retry, so the same event can arrive twice.

  1. Deduplicate by delivery_id. A repeat must be a no-op on your side.
  2. Verify the signature before acting, not after.
  3. Answer 2xx only once you have stored the event. Anything else, or a timeout, is treated as a failure and retried.

Retries to one endpoint go at 1 minute, 5 minutes, 15 minutes, 1 hour, 6 hours and 24 hours — six attempts in all, spanning a little over 31 hours. When they are exhausted and you registered a backup, the delivery moves there and the schedule starts over with the backup's own secret. The delivery_id stays the same throughout, so an event that failed on the primary and succeeded on the backup is still one event.

Redirects are not followed.

Rate limits

10 requests per second per endpoint, shared across all clients.

Errors

Registering answers 201, deleting answers 204 with no body, everything else 200.

HTTPMeaning
401key missing or invalid, or the source IP is not whitelisted
404no such endpoint on your account
409the requested role is already taken — role primary is already taken
422the URL was rejected, or a PATCH body carried nothing to change
429rate limit exceeded

A rejected URL comes back as 422 with the reason spelled out, so you can show it to whoever typed it:

json
{"status": "error", "code": -4, "msg": "url: Value error, invalid webhook_url: resolved address 127.0.0.1 is not public"}

The wordings are only https:// URLs are allowed, credentials in URL are not allowed, and resolved address <ip> is not public. The last one is resolved at registration time and again immediately before every delivery, so a hostname that later points at a private address stops receiving.

  • Statement files — ordering the report that triggers this notification
  • Order webhooks — the separate registry for energy, bandwidth and activation events