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

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:

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:


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:

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