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,
"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,
"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."
}
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 |
401 |
Missing or invalid API token |
403 |
Subnet belongs to another customer, or synchronous mode not enabled for your account |
404 |
IP not found in your account |
422 |
Validation error (invalid input, IP outside account) |
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
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"},
},
)