NetCTL

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.


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:

  1. Your API call updates the IP status and sets pending_change = true
  2. A scheduler runs every minute checking for customer+region pairs with pending changes
  3. If the last change for your account in a region is 2+ minutes old, a commit is triggered
  4. The system aggregates your disabled IPs into CIDR blocks and pushes your prefix-list (NetCTL-{YOUR-NAME}-BLACKHOLE) to all routers in the region
  5. On successful deployment to all routers, pending_change is 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:

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"},
    },
)