NetCTL Customer API v1
Base URL: https://netctl.test/api/v1
Authentication
All API requests require a Bearer token in the Authorization header:
Authorization: Bearer {your-api-token}
API tokens are generated by administrators in the NetCTL UI (Customers page). The plain-text token is shown once at generation time and cannot be retrieved afterwards.
Requests without a valid token receive a 401 Unauthorized response:
{
"message": "Authentication required."
}
Rate Limiting
Each customer has a configurable rate limit (default: 60 requests per minute). When exceeded, the API returns 429 Too Many Requests:
{
"message": "Too many requests.",
"retry_after": 45
}
Rate limit headers are included on all responses:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 58
Authorization
Customers can only access their own subnets and IPs. Attempting to access another customer's subnet returns 403 Forbidden. Attempting to enable/disable another customer's IP returns 404 Not Found (IPs outside your account are invisible).
Idempotency
Enable and disable operations are idempotent. Enabling an already-enabled IP returns a success response without making changes. This makes API calls safe to retry.
Synchronous Mode
By default, IP changes are queued and pushed to routers in batches (see Pending Changes and Deployment). Customers with the allow_synchronous flag enabled can pass "synchronous": true on any enable, disable, or bulk request to push changes to routers immediately within the API request.
- Opt-in: The
allow_synchronousflag must be enabled by an administrator on your customer account. - Parameter: Add
"synchronous": trueto the request body. The parameter is optional and defaults tofalse. - Behavior: When
synchronousistrue, the API creates a CommitSet and pushes prefix-lists to all routers in the affected region(s) before returning the response. - Response: The
metaobject includes"synchronous": trueand a"commit_status"field (success,partial_failure, orfailed). - Rejection: If your account does not have synchronous mode enabled, the API returns
403 Forbidden.
Endpoints
List Subnets
GET /api/v1/subnets
Returns all subnets assigned to the authenticated customer, including IP counts.
Response
{
"data": [
{
"id": 1,
"cidr": "10.1.0.0/24",
"ip_version": 4,
"asn": 64500,
"metro": "London",
"region": "Europe",
"ip_counts": {
"total": 254,
"enabled": 200,
"disabled": 54,
"pending": 3
}
}
]
}
Show Subnet
GET /api/v1/subnets/{subnet_id}
Returns full detail of a subnet including all IP addresses and their statuses. IPs are sorted numerically.
Response
{
"data": {
"id": 1,
"cidr": "10.1.0.0/24",
"ip_version": 4,
"asn": 64500,
"ips": [
{
"address": "10.1.0.1",
"status": "enabled",
"pending_change": false
},
{
"address": "10.1.0.2",
"status": "disabled",
"pending_change": true
}
]
},
"meta": {
"pending_changes": 3
}
}
List Pending Changes
GET /api/v1/subnets/{subnet_id}/pending
Returns only IP addresses that have uncommitted changes (pending_change = true).
Enable Single IP
POST /api/v1/ips/enable
Enable a single IP address. The system automatically resolves which subnet the IP belongs to — no subnet ID needed.
Request Body
{
"ip": "10.1.0.5"
}
With synchronous mode:
{
"ip": "10.1.0.5",
"synchronous": true
}
Validation
| Field | Rules |
|---|---|
ip |
Required, valid IP address |
synchronous |
Optional, boolean. Defaults to false. Requires allow_synchronous on your account |
Response (200 OK)
{
"data": {
"address": "10.1.0.5",
"status": "enabled",
"pending_change": true
},
"meta": {
"pending_changes": 4
}
}
With synchronous: true:
{
"data": {
"address": "10.1.0.5",
"status": "enabled",
"pending_change": false
},
"meta": {
"synchronous": true,
"commit_status": "success",
"pending_changes": 0
}
}
Error: IP not found (404)
{
"message": "IP 192.168.1.1 not found in your account."
}
Error: Synchronous not enabled (403)
{
"message": "Synchronous mode is not enabled for your account."
}
Disable Single IP
POST /api/v1/ips/disable
Disable a single IP address. Same request/response format as enable.
Request Body
{
"ip": "10.1.0.5"
}
Supports the optional "synchronous": true parameter (see Enable Single IP for details).
Bulk Enable/Disable
POST /api/v1/ips/bulk
Enable or disable multiple IPs at once. Three input modes are supported: IP list, IP range, or CIDR block.
Request Body: IP List
{
"action": "disable",
"ips": [
"10.1.0.5",
"10.1.0.6",
"10.1.0.7"
]
}
Request Body: IP Range
{
"action": "enable",
"range": {
"start": "10.1.0.10",
"end": "10.1.0.20"
}
}
Request Body: CIDR Block
{
"action": "disable",
"cidr": "10.1.0.0/28"
}
This will disable all IPs your account owns within the specified CIDR block. IPs outside your subnets are ignored.
Validation
| Field | Rules |
|---|---|
action |
Required, must be enable or disable |
synchronous |
Optional, boolean. Defaults to false. Requires allow_synchronous on your account |
ips |
Required if range and cidr are not provided. Array of valid IP addresses |
ips.* |
Each must be a valid IP address |
range |
Required if ips and cidr are not provided |
range.start |
Required with range. Valid IP address |
range.end |
Required with range. Valid IP address |
cidr |
Required if ips and range are not provided. Valid CIDR notation (e.g. 10.0.0.0/24) |
Provide exactly one of ips, range, or cidr.
Response (200 OK)
{
"data": {
"changed": 14,
"action": "disable"
},
"meta": {
"pending_changes": 18
}
}
The changed count reflects how many IPs actually changed status. IPs already in the target status are skipped (idempotent).
Error: IP not in your account (422)
{
"message": "IP 192.168.1.1 does not belong to your account."
}
Source Whitelist
The Source Whitelist lets authorised customers register source IP addresses of their own infrastructure located in another datacenter, so that traffic arriving from those addresses is accepted by our antispoofing firewall rather than dropped.
This is the inverse of the enable/disable endpoints above. Those operate on IPs inside your assigned subnets; whitelist entries are external addresses you own elsewhere, so you must tell us which region(s) the whitelist applies to (the location(s) where your cross-datacenter link lands).
The whitelist is deployed as a separate per-customer prefix-list named NetCTL-{YOUR-NAME}-SRC_WHITELIST, independent of your blackhole list.
This feature must be enabled on your account by an administrator (allow_source_whitelist). Without it, these endpoints return 403 Forbidden:
{
"message": "Source whitelist is not enabled for your account."
}
Trusted vs Standard accounts
- Trusted — your submission is accepted and deployed immediately; an administrator audits it afterwards. Returned
statusispending_deploywithauto_approved: true. - Standard — your submission is queued for review and only deployed after an administrator approves it. Until then, traffic from the submitted source remains blocked. Returned
statusispending_review.
Statuses
| Status | Meaning |
|---|---|
pending_review |
Awaiting administrator approval — not yet on routers |
pending_deploy |
Approved/auto-accepted — being pushed to routers |
active |
Live on the routers in its region(s) |
rejected |
Declined by an administrator |
failed |
A deployment attempt failed (will be retried) |
Limits & safeguards
A submission is rejected with 422 Unprocessable Entity if it violates any of:
- Entry limit — each account has a maximum number of active/pending entries.
- Maximum prefix size — submissions broader than the configured maximum (default
/24) are not allowed. - Reserved space — private, bogon, and reserved ranges cannot be whitelisted.
- Overlap — a submission overlapping an existing entry (yours, or another customer's, in the same region).
- Region capability — you may only target regions provisioned for source whitelisting.
Evidence (required)
Every submission requires an evidence file proving the addresses belong to you. Because of the file upload, the create endpoint uses multipart/form-data, not JSON. Accepted types: jpg, jpeg, png, pdf. The maximum size is configurable (default 5 MB).
Idempotency
The create and delete endpoints are safe to retry:
- Create — re-submitting an identical request (same source range and the same set of regions, while still active/pending) returns the existing request with
200 OKinstead of creating a duplicate. A genuinely different range that overlaps an existing one is still rejected with422. - Delete — deleting an already-removed request returns
200 OK(idempotent);404is reserved for arequest_idthat doesn't belong to you.
List Whitelist Requests
GET /api/v1/source-whitelist
Returns your whitelist requests, grouped by submission, with current usage against your limit.
Response
{
"data": [
{
"request_id": "018f2b5c-6a7f-7b12-9d6f-2f8a4e0c9c11",
"input": "203.0.113.0/24",
"status": "active",
"auto_approved": true,
"regions": ["Europe", "US - East"],
"created_at": "2026-06-19T12:00:00+00:00"
}
],
"meta": {
"used": 3,
"limit": 50
}
}
Submit a Whitelist Request
POST /api/v1/source-whitelist
Content-Type: multipart/form-data
Submit one source IP, range, or CIDR for one or more regions, with supporting evidence. Provide exactly one of ip, range, or cidr.
Form Fields
| Field | Rules |
|---|---|
ip |
Required if range and cidr are omitted. Valid IPv4 address |
range[start] |
Required with range. Valid IPv4 address |
range[end] |
Required with range. Valid IPv4 address |
cidr |
Required if ip and range are omitted. Valid IPv4 CIDR (e.g. 203.0.113.0/24) |
regions[] |
Required. One or more region IDs to apply the whitelist to |
evidence |
Required. File: jpg, jpeg, png, or pdf |
Response (201 Created)
{
"data": {
"request_id": "018f2b5c-6a7f-7b12-9d6f-2f8a4e0c9c11",
"input": "203.0.113.0/24",
"regions": ["Europe", "US - East"],
"status": "pending_review",
"auto_approved": false
},
"meta": {
"used": 4,
"limit": 50
}
}
For trusted accounts the status is pending_deploy and auto_approved is true. An idempotent replay of an identical earlier submission returns the same body with 200 OK instead of 201 (see Idempotency).
Errors (422)
{ "message": "Source whitelist limit reached (50 entries)." }
{ "message": "Prefix too broad: the maximum allowed size is /24." }
{ "message": "Address space 10.0.0.0/8 is reserved and cannot be whitelisted." }
{ "message": "Range overlaps an existing whitelist entry (203.0.113.0/24) in your account for region Europe." }
Delete a Whitelist Request
DELETE /api/v1/source-whitelist/{request_id}
Removes a whitelist request from all of its regions. If it was live, the addresses are removed from the routers on the next deployment. The record is retained internally for audit.
Response (200 OK)
{
"data": {
"request_id": "018f2b5c-6a7f-7b12-9d6f-2f8a4e0c9c11",
"status": "removed"
}
}
Returns 404 Not Found if the request does not belong to your account.
Pending Changes and Deployment
When you enable or disable an IP, the change is recorded in the database with pending_change = true. The change is not immediately pushed to routers.
The deployment flow:
- Your API call updates the IP status and sets
pending_change = true - A scheduler runs every minute checking for customer+region pairs with pending changes
- If the last change for your account in a region is 2+ minutes old, a commit is triggered
- The system aggregates your disabled IPs into CIDR blocks and pushes your prefix-list (
NetCTL-{YOUR-NAME}-BLACKHOLE) to all routers in the region - On successful deployment to all routers,
pending_changeis cleared for your account
This 2-minute delay allows multiple changes to be batched into a single router push. Your changes are independent of other customers — another customer's activity does not delay your commits.
Checking Deployment Status
Use the List Pending Changes endpoint to check if your changes have been deployed. When pending_changes in the meta object reaches 0, all changes have been successfully pushed.
Webhooks
If your customer account has a webhook URL configured, NetCTL will POST a notification when a commit affecting your prefix-list completes:
POST {your-webhook-url}
Content-Type: application/json
{
"event": "commit_completed",
"commit_set_id": "018f2b5c-6a7f-7b12-9d6f-2f8a4e0c9c11",
"customer": "Acme Corp",
"prefix_list_name": "NetCTL-ACME-CORP-BLACKHOLE",
"region": "Europe",
"status": "success",
"completed_at": "2026-04-13T16:30:00+00:00"
}
Possible status values:
success— all routers updated successfullypartial_failure— some routers updated, others failedfailed— all routers failed
Webhook delivery is retried 3 times with 30-second intervals on failure.
Note: commit_set_id is a UUID string.
HTTP Status Codes
| Code | Meaning |
|---|---|
200 |
Success |
201 |
Created (source whitelist submission) |
401 |
Missing or invalid API token |
403 |
Subnet belongs to another customer, or synchronous / source-whitelist not enabled for your account |
404 |
IP or whitelist request not found in your account |
422 |
Validation error (invalid input, IP outside account, whitelist limit/overlap/prefix-size/reserved) |
429 |
Rate limit exceeded |
500 |
Internal server error |
Examples
cURL: List subnets
curl -H "Authorization: Bearer YOUR_TOKEN" \
https://netctl.test/api/v1/subnets
cURL: Enable an IP
curl -X POST \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"ip": "10.1.0.5"}' \
https://netctl.test/api/v1/ips/enable
cURL: Disable an IP
curl -X POST \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"ip": "10.1.0.5"}' \
https://netctl.test/api/v1/ips/disable
cURL: Bulk disable by CIDR
curl -X POST \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"action": "disable", "cidr": "10.1.0.0/28"}' \
https://netctl.test/api/v1/ips/bulk
cURL: Bulk enable by range
curl -X POST \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"action": "enable", "range": {"start": "10.1.0.10", "end": "10.1.0.20"}}' \
https://netctl.test/api/v1/ips/bulk
cURL: Bulk disable by IP list
curl -X POST \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"action": "disable", "ips": ["10.1.0.5", "10.1.0.6", "10.1.0.7"]}' \
https://netctl.test/api/v1/ips/bulk
cURL: Check pending changes
curl -H "Authorization: Bearer YOUR_TOKEN" \
https://netctl.test/api/v1/subnets/1/pending
cURL: Submit a source whitelist request
Uses multipart/form-data because of the evidence file (note the -F flags):
curl -X POST \
-H "Authorization: Bearer YOUR_TOKEN" \
-F "cidr=203.0.113.0/24" \
-F "regions[]=1" \
-F "regions[]=2" \
-F "evidence=@/path/to/proof.pdf" \
https://netctl.test/api/v1/source-whitelist
cURL: List / delete source whitelist requests
curl -H "Authorization: Bearer YOUR_TOKEN" \
https://netctl.test/api/v1/source-whitelist
curl -X DELETE \
-H "Authorization: Bearer YOUR_TOKEN" \
https://netctl.test/api/v1/source-whitelist/018f2b5c-6a7f-7b12-9d6f-2f8a4e0c9c11
PHP (Guzzle)
$client = new \GuzzleHttp\Client([
'base_uri' => 'https://netctl.test/api/v1/',
'headers' => [
'Authorization' => 'Bearer YOUR_TOKEN',
'Accept' => 'application/json',
],
]);
// List subnets
$response = $client->get('subnets');
$subnets = json_decode($response->getBody(), true);
// Disable an IP
$response = $client->post('ips/disable', [
'json' => ['ip' => '10.1.0.5'],
]);
// Bulk enable by IP list
$response = $client->post('ips/bulk', [
'json' => [
'action' => 'enable',
'ips' => ['10.1.0.10', '10.1.0.11', '10.1.0.12'],
],
]);
// Bulk disable by CIDR
$response = $client->post('ips/bulk', [
'json' => [
'action' => 'disable',
'cidr' => '10.1.0.0/28',
],
]);
// Bulk enable by range
$response = $client->post('ips/bulk', [
'json' => [
'action' => 'enable',
'range' => ['start' => '10.1.0.10', 'end' => '10.1.0.20'],
],
]);
Python (requests)
import requests
BASE_URL = "https://netctl.test/api/v1"
HEADERS = {
"Authorization": "Bearer YOUR_TOKEN",
"Content-Type": "application/json",
}
# List subnets
response = requests.get(f"{BASE_URL}/subnets", headers=HEADERS)
subnets = response.json()
# Enable an IP
response = requests.post(
f"{BASE_URL}/ips/enable",
headers=HEADERS,
json={"ip": "10.1.0.5"},
)
# Bulk disable by CIDR
response = requests.post(
f"{BASE_URL}/ips/bulk",
headers=HEADERS,
json={
"action": "disable",
"cidr": "10.1.0.0/28",
},
)
# Bulk enable by range
response = requests.post(
f"{BASE_URL}/ips/bulk",
headers=HEADERS,
json={
"action": "enable",
"range": {"start": "10.1.0.1", "end": "10.1.0.50"},
},
)