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/webhooksRequest headers
| Header | Required | Description |
|---|---|---|
X-API-KEY | yes | API key from the dashboard |
X-Real-IP | yes | An 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
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"}'{
"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:
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
| Method | Path | Action |
|---|---|---|
GET | /apiv2/reports/webhooks | list 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
{
"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"
}| Field | Description |
|---|---|
event | report.ready — routing key for your handler |
delivery_id | Dedup key. Also sent as the X-Netts-Delivery header |
order_id | The order number you were given when you queued the report |
order_type | statement or balance_at_date |
download_url | Path to fetch the file, relative to https://netts.io |
artifact.sha256 | Checksum, so you can verify what you downloaded |
confirmed_at | UTC |
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.
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.
- Deduplicate by
delivery_id. A repeat must be a no-op on your side. - Verify the signature before acting, not after.
- Answer
2xxonly 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.
| HTTP | Meaning |
|---|---|
401 | key missing or invalid, or the source IP is not whitelisted |
404 | no such endpoint on your account |
409 | the requested role is already taken — role primary is already taken |
422 | the URL was rejected, or a PATCH body carried nothing to change |
429 | rate limit exceeded |
A rejected URL comes back as 422 with the reason spelled out, so you can show it to whoever typed it:
{"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.
Related
- Statement files — ordering the report that triggers this notification
- Order webhooks — the separate registry for energy, bandwidth and activation events