← All Docs

API Reference

Complete REST API documentation for IPCraft. Manage folders, subnets, IP addresses, VLANs, VRFs, locations, webhooks, and your team programmatically.

Base URL

https://api.ipcraft.io

All endpoint paths below are relative to this base URL. All resource IDs are UUIDs.

Authentication

All requests except the Network Tools endpoints require authentication via one of two methods:

State-changing requests (POST, PATCH, DELETE) using a session cookie must include the X-Requested-With: XMLHttpRequest header for CSRF protection. API key requests do not need this header. Session requests that the browser marks as started by another website (Sec-Fetch-Site: cross-site), such as a link on a third-party page to an export URL, are refused with 403; API keys are unaffected.

API keys carry read and/or write permissions (both by default). Two additional restrictions apply to write requests:

Every response carries an X-API-Version: v1 header.

Example (API key)
curl https://api.ipcraft.io/api/v1/folders \
  -H "Authorization: Bearer ipc_abc123..."
Example (Session cookie with CSRF)
curl -X POST https://api.ipcraft.io/api/v1/folders \
  -H "Content-Type: application/json" \
  -H "X-Requested-With: XMLHttpRequest" \
  -b "__Host-ipcraft_session=..." \
  -d '{"name": "Production"}'

Errors & Plan Limits

All errors return a JSON object with an error key and an appropriate HTTP status code.

{
  "error": "subnet overlaps with existing subnet 10.0.0.0/16"
}
StatusMeaning
400Bad request — invalid input or validation error
401Unauthorized — missing, invalid or expired credentials (an expired API key returns the error API key expired)
402Payment required — plan limit exceeded
403Forbidden — insufficient permissions, CSRF failure, or free-plan API write
404Not found
409Conflict — the change clashes with existing data (a duplicate network or name, an IP recorded twice, a gateway inside a child subnet, an exhausted IP pool, the last admin leaving), or a PATCH lost a race: the subnet or address was changed by another request between this one reading and writing it. Nothing was written; send the same PATCH again
413Request body too large — over 1 MB, or 5 MB for a scan and 10 MB for an import
429Rate limited — too many requests; wait for the Retry-After header
502Bad gateway — a webhook test ping got no 2xx answer, or an invitation email could not be sent
503Temporarily unavailable — a shared capacity limit is reached; retry after the Retry-After header

Plan Limits

LimitFreePro ($29/mo)Teams ($199/mo)Enterprise
Users1210Custom
Folders5Unlimited*Unlimited*Custom
Subnets100Unlimited*Unlimited*Custom
IP Addresses1,5005,00010,000Custom
VLANs10Unlimited*Unlimited*Custom
VRFs5Unlimited*Unlimited*Custom
Locations5Unlimited*Unlimited*Custom
Webhooks—250500
API Keys5100100Custom
API AccessRead-onlyRead/WriteRead/WriteRead/Write
Rate Limit (per user)10 req/s50 req/s50 req/sCustom
Burst (per user, per second)20100100Custom
Activity Log7 days30 days90 daysCustom

* Unlimited features are subject to the fair use limits described in the Terms of Service.

Requests that would exceed your plan limits return 402. Authenticated requests are rate-limited per user: a budget of 60 × your plan’s rate per minute, and a burst cap of twice the rate (at least 5) per second. Every response carries X-RateLimit-Limit (the per-minute budget), X-RateLimit-Remaining, X-RateLimit-Reset (Unix time the minute window ends) and X-RateLimit-Policy (600;w=60, 20;w=1 on Free). A 429 carries Retry-After in seconds: 1 after a burst rejection, 60 when the minute budget is spent. Back off by Retry-After, not X-RateLimit-Reset; a burst rejection also uses one request of the minute budget.

Failed authentication is limited per client IP address (IPv6 clients per /64, with a whole /48 allowed four times that), with API keys and session cookies counted separately: after 30 requests in a minute whose API key was rejected with 401, requests from that address that carry an unfamiliar API key return 429 with Retry-After: 60 without checking it, and the same holds for session cookies. A key or session that authenticated in the last hour, or a session that has just signed in, is still accepted, so one client failing with a revoked key doesn’t lock out others on the same address. Requests that send no credential at all are not counted.

Bulk Limits

Every bulk request handles at most 1,000 items, on every plan. A request over the limit returns 400 (for example "maximum 1,000 addresses per request") and changes nothing. Send larger jobs as several requests of 1,000 or fewer; the web app already does this, sending bigger selections and CSV files in batches of 1,000.

RequestLimit per request
Batch Create addresses1,000 IPs
Batch Update addresses1,000 IDs
Batch Delete addresses1,000 IDs
Import Subnets, Import Addresses1,000 rows (10 MB body)
Scan reconcile apply1,000 hosts written: records marked seen plus records created (5 MB paste)

Each bulk request writes one activity entry, and that entry lists every record the request touched (ips or networks). Batch create also lists the IPs it skipped because they were already recorded, under skipped_ips. These lists are never truncated, in the activity log or in webhook payloads. Plan limits still apply to every item, so a request within 1,000 items can still be refused with 402. Split takes a part count instead of a list (a power of two up to 1,024) and is not a bulk request in this sense.

Auth Endpoints

IPCraft is passwordless. Sign in with a magic link or Google OAuth — there are no register or password endpoints. Accounts and organizations are created automatically on first sign-in. Send Magic Link and Google OAuth are limited to 5 requests per minute per IP, and the verify and accept-invite pages to 20; Send Magic Link also has per-email and daily limits (below).

POST /api/v1/auth/send-link

Send a passwordless sign-in link to the given email address. Each link works once and expires after 15 minutes. Requesting another link doesn't cancel the earlier ones; signing in with any of them cancels the rest. Limited to 3 links per email address per 15 minutes. Past that the endpoint returns 429, and the links already sent still work. Each IP can request 200 links per day. Once 60 links have gone out from an IP in a day, a link to an address that doesn’t have an account yet gets 429 with a Retry-After header, and an address that has one still gets its link with the same answer. Sign-in email to new addresses is also limited service-wide per hour; past that limit it returns 503 with a Retry-After header, and an address that already has an account still gets its link. A higher service-wide hourly ceiling covers all sign-in email; past it the endpoint returns 503 and sends nothing. Each of these answers is the same for every address. Requests must carry an X-Requested-With header (any value) and, if they send an Origin, come from the app; anything else, such as a form posted from another website, gets 403.

Request body
{
  "email": "john@example.com"
}
Response
{
  "message": "sign-in link sent to john@example.com"
}

Verify Magic Link

GET /api/v1/auth/verify?token=<token>

Verify the magic link token from the email. This endpoint is opened by the browser, not called programmatically. The GET shows a confirmation page, and its button posts the token back to the same URL, so mail scanners that prefetch links don't use it up. The POST consumes the token, sets the __Host-ipcraft_session cookie and redirects (303) to the app. On first sign-in it creates the user and their organization. Invalid or expired tokens redirect to the login page with an error. A POST submitted from another website is refused with a 403 page, and the link in the email keeps working.

Google OAuth

GET /api/v1/auth/oauth/google

Start the Google OAuth sign-in flow. Redirects to Google; the callback (/api/v1/auth/oauth/google/callback) sets the session cookie and redirects to the app. Browser-only — not for programmatic use.

Get Current User

GET /api/v1/auth/me

Returns the authenticated user, their organization, and their role (admin, editor, or viewer). Requires auth.

Response
{
  "user": {
    "id": "7f3e9a12-4b6c-4d2e-9f01-8a5b3c7d1e42",
    "email": "john@example.com",
    "name": "John Smith",
    "email_verified": true,
    "created_at": "2026-03-01T12:00:00Z"
  },
  "organization": {
    "id": "c2a8f5d0-1e3b-4a7c-8d9e-0f1a2b3c4d5e",
    "name": "John Smith's Org",
    "slug": "john-smiths-org",
    "plan": "free",
    "created_at": "2026-03-01T12:00:00Z",
    "updated_at": "2026-03-01T12:00:00Z"
  },
  "role": "admin"
}

Logout

POST /api/v1/auth/logout

Destroys the current session and clears the session cookie. No request body required.

Response
200 OK

Delete Account

DELETE /api/v1/auth/account

Permanently deletes the authenticated user's account and clears the session. If the user is the only member of their organization, the organization and all of its data are deleted with it (subnets, addresses, VLANs, VRFs, folders, locations, webhooks, API keys and the activity log); this cannot be undone. Returns 409 if the user is the only admin of an organization with other members (promote someone first), and 403 with an API key: deleting an account needs a signed-in browser session.

Response
{
  "message": "account deleted"
}

Folders

Folders are top-level organizational containers for grouping subnets (e.g., "Production", "Development").

List Folders

GET /api/v1/folders

Returns all folders for the authenticated organization.

Response
[
  {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "name": "Production",
    "description": "Production network ranges",
    "sort_order": 0,
    "created_at": "2026-03-01T12:00:00Z"
  }
]

Create Folder

POST /api/v1/folders

Create a new folder.

Request body
{
  "name": "Production",
  "description": "Production network ranges",
  "sort_order": 0
}

Only name is required. description and sort_order are optional.

Response

Returns 201 with the created folder object.

Get Folder

GET /api/v1/folders/{id}

Returns a single folder by ID.

Update Folder

PATCH /api/v1/folders/{id}

Update a folder. All fields are optional — only provided fields are changed.

Request body
{
  "name": "Prod (US-East)",
  "description": "US East production ranges",
  "sort_order": 1
}
Response
200 OK

Delete Folder

DELETE /api/v1/folders/{id}

Delete a folder. Subnets in the folder are kept and become unfoldered.

Response
204 No Content

Subnets

Subnets represent CIDR network ranges. They form a hierarchy — creating a subnet within an existing range automatically nests it as a child. Overlap detection prevents conflicting ranges within the same VRF.

List Subnets

GET /api/v1/subnets?folder_id={folder_id}

List all subnets, optionally filtered by folder. Includes utilization statistics and resolved VLAN/VRF/location/folder names. Not paginated.

Query parameters
  • folder_id — filter by folder (optional)
  • vlan_id, vrf_id, location_id — filter by VLAN, VRF or location (optional)
Response
[
  {
    "id": "b4c5d6e7-f8a9-0123-bcde-f01234567890",
    "folder_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "network": "10.0.0.0/16",
    "name": "Core network",
    "description": "Main production range",
    "vlan_id": null,
    "vrf_id": null,
    "location_id": null,
    "is_pool": false,
    "gateway": "10.0.0.1",
    "dns_primary": "10.0.0.53",
    "dns_secondary": null,
    "scan_enabled": false,
    "tracked": true,
    "utilization": 0.12,
    "used_count": 7860,
    "total_count": 65534,
    "folder_name": "Production",
    "created_at": "2026-03-01T12:00:00Z",
    "updated_at": "2026-03-01T12:00:00Z"
  }
]

Nullable/empty fields (parent_id, vlan_number, vrf_name, location_name, custom_fields, counts) are omitted when unset.

Create Subnet

POST /api/v1/subnets

Create a new subnet. The parent is auto-detected based on CIDR containment (and existing subnets inside the new range are re-parented under it). Address records the parent already holds inside the new range move into the new subnet, except any that would be its network or broadcast address. If the parent's gateway moves with them, the gateway field moves too: the parent's is cleared, and the new subnet takes it over unless the request sets a different gateway. The same CIDR can't be created twice in one VRF (or twice outside any VRF); that returns 409.

Request body
{
  "network": "10.0.1.0/24",
  "name": "Web servers",
  "description": "Web tier subnet",
  "folder_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "vlan_id": null,
  "vrf_id": null,
  "location_id": null,
  "gateway": "10.0.1.1",
  "dns_primary": "10.0.0.53",
  "dns_secondary": null,
  "is_pool": false,
  "tracked": true
}

Only network is required. All other fields are optional. network must be the network address itself: 10.0.1.5/24 returns 400 naming 10.0.1.0/24. An IPv4-mapped IPv6 prefix such as ::ffff:10.0.1.0/120 is stored as the IPv4 prefix it denotes, 10.0.1.0/24. tracked defaults to true when omitted; send false to create a structure-only container subnet that's excluded from utilization stats. is_pool marks an IP pool, which lets Create Address pick the next free IP when the request has none.

Response

Returns 201 with the created subnet object. If the new subnet (created without a VRF) overlaps ranges that exist in other VRFs, the response is wrapped as {"subnet": {...}, "warnings": [{"id", "network", "name", "vrf_name"}]} — the create still succeeds.

Get Subnet

GET /api/v1/subnets/{id}

Returns a subnet with utilization statistics and its child subnets.

Response
{
  "id": "b4c5d6e7-f8a9-0123-bcde-f01234567890",
  "network": "10.0.0.0/16",
  "name": "Core network",
  "utilization": 0.12,
  "children": [
    {
      "id": "c5d6e7f8-a9b0-1234-cdef-012345678901",
      "network": "10.0.1.0/24",
      "name": "Web servers"
    }
  ]
}

Update Subnet

PATCH /api/v1/subnets/{id}

Update subnet metadata. All fields are optional — only provided fields are changed. The network itself cannot be changed.

Request body
{
  "name": "Web servers (prod)",
  "description": "Production web tier",
  "folder_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "location_id": null,
  "vlan": "120",
  "vrf": "CORP",
  "is_pool": true,
  "tracked": false,
  "gateway": "10.0.1.1",
  "dns_primary": "10.0.0.53",
  "dns_secondary": null,
  "custom_fields": { "rack": "R12" }
}

Note: unlike create, update takes vlan as a VLAN number (1–4094) and vrf as a VRF name; both are auto-created if they don't exist, and an empty string clears the assignment. vlan_id and vrf_id (UUIDs, as on create) are also accepted and win over the legacy fields.

Changing the VRF moves the subnet into that VRF's tree. Its child subnets stay in the old VRF and move up to its former parent. In the new VRF it nests under the most specific subnet that contains it, adopts the subnets and records inside its range as create does, and returns 409 if the same CIDR already exists there or if another subnet in the new VRF already records one of its IPs. Its own records that fall inside a subnet it now contains move to the most specific such subnet, and if its gateway would fall inside one, the move returns 409. The activity entry lists the moved records under moved_down.

A gateway inside one of the subnet's child subnets returns 409: a gateway belongs to the most specific subnet that contains it, so set it on the child. The same applies on create when the new subnet contains existing subnets.

Response
200 OK

Delete Subnet

DELETE /api/v1/subnets/{id}

Delete a subnet and its own address records. Child subnets are kept: each direct child moves up one level, to the deleted subnet's parent (or to the top level), with its addresses intact. Webhook subscribers get subnet.deleted for the subnet and, when children moved, one subnet.updated listing them (see Webhooks).

This action is irreversible. The subnet's own address records are permanently deleted; export them first if you need them.
Response
204 No Content

Split Subnet

POST /api/v1/subnets/{id}/split

Split a subnet into equal parts. The number of parts must be a power of 2 (2, 4, 8, ... up to 1024). The original subnet is deleted and the parts are created in its place, with new IDs. Each part inherits the original's name, description, folder, parent, VLAN, VRF, location, DNS servers, custom fields, and pool, scan, and tracked flags. Webhook subscribers get subnet.deleted for the original and one subnet.created listing every part with its new ID (see Webhooks).

Address records move into the part that contains them, and the gateway goes to whichever part contains it (unless it would be that part's network or broadcast address). Existing child subnets are re-parented under the part that contains them. A part that falls inside a larger subnet already below the original nests under the narrowest such subnet instead of the original's parent, and takes over that subnet's address records and gateway inside its range, as a newly created child would. A part that would duplicate an existing child subnet returns 409, as does a split that would move two records of one IP into the same part, one from the original and one from the subnet the part nests under.

Set the optional discard_addresses (default false) to true to permanently delete the original's address records (and clear its gateway) instead of moving them. Records held by subnets below the original are kept, and still move into a part that nests inside their subnet.
Request body
{
  "parts": 4,
  "discard_addresses": false
}
Response
[
  { "id": "d6e7f8a9-...", "network": "10.0.0.0/26" },
  { "id": "e7f8a9b0-...", "network": "10.0.0.64/26" },
  { "id": "f8a9b0c1-...", "network": "10.0.0.128/26" },
  { "id": "a9b0c1d2-...", "network": "10.0.0.192/26" }
]

Subnet Usage

GET /api/v1/subnets/{id}/usage

Returns utilization statistics for a subnet.

Response
{
  "subnet_id": "c5d6e7f8-a9b0-1234-cdef-012345678901",
  "network": "10.0.1.0/24",
  "used": 42,
  "total": 254,
  "utilization": 0.165
}

First Free IP

GET /api/v1/subnets/{id}/first-free

Returns the first available IP address in the subnet, or null if the subnet is full. The ranges of child subnets are skipped, as are IPs an enclosing subnet already records, so the IP returned can always be created here.

Response
{
  "ip": "10.0.1.5"
}
Response (full subnet)
{
  "ip": null,
  "message": "no available addresses"
}

Free Blocks

GET /api/v1/subnets/{id}/free-blocks?prefix={prefix}&limit={limit}

Finds unallocated child blocks of a requested size inside a subnet — the subnet-level counterpart of First Free IP. Returns the lowest free CIDR blocks of prefix length prefix that don't collide with any existing subnet in the same VRF (nested children are handled; supernets are ignored). prefix must be longer than the subnet's own prefix. limit caps the candidates returned (default 5, max 50); total is the count of all free blocks, and capped is true when the total hit the reporting cap of 1,000,000 (large IPv6 containers).

Response
{
  "subnet_id": "c5d6e7f8-a9b0-1234-cdef-012345678901",
  "network": "10.0.0.0/16",
  "prefix": 24,
  "blocks": ["10.0.4.0/24", "10.0.5.0/24"],
  "total": 253,
  "capped": false
}

List Overlaps

GET /api/v1/subnets/overlaps

Returns all pairs of subnets whose CIDR ranges overlap, classified by overlap type: duplicate (same CIDR in different VRFs), containment (parent/child), or partial.

Response
[
  {
    "subnet_a": { "id": "...", "network": "10.0.0.0/16", "vrf_name": "CORP" },
    "subnet_b": { "id": "...", "network": "10.0.0.0/16" },
    "type": "duplicate"
  }
]

Addresses

Individual IP address records within a subnet. Each address is validated against the parent subnet's CIDR range. Valid status values are assigned, reserved, d-dhcp (dynamic DHCP), m-dhcp (manual DHCP), and gateway.

An address is recorded on the most specific subnet that contains it, within its VRF, so each IP has one record. A subnet refuses an IP that falls inside one of its child subnets (at any depth) with 409, naming the child: "10.77.0.5 is inside child subnet 10.77.0.0/26; record it there". A child's own network and broadcast addresses are refused on the parent the same way. A subnet also refuses, with 409, an IP that an enclosing subnet already records, which happens when the record was made before the child existed; release that record first. Every path that creates records follows this rule: create, batch create, CSV import, scan reconcile, and the gateway field.

List Addresses

GET /api/v1/subnets/{id}/addresses?limit={limit}&offset={offset}

List IP addresses in a subnet. Paginated: limit defaults to 1,000 (also the maximum); the total count is returned in the X-Total-Count response header.

Response
[
  {
    "id": "0a1b2c3d-4e5f-6789-0abc-def123456789",
    "subnet_id": "c5d6e7f8-a9b0-1234-cdef-012345678901",
    "ip": "10.0.1.10",
    "hostname": "web-01.prod",
    "mac_address": "00:1a:2b:3c:4d:5e",
    "description": "Primary web server",
    "status": "assigned",
    "owner": "platform-team",
    "created_at": "2026-03-01T12:00:00Z",
    "updated_at": "2026-03-01T12:00:00Z"
  }
]

Optional fields (ptr_record, custom_fields, last_seen) are omitted when unset.

Create Address

POST /api/v1/subnets/{id}/addresses

Assign an IP address within a subnet. The IP must fall inside the subnet's CIDR range and not already be assigned. An IP inside a child subnet, or one an enclosing subnet already records, returns 409 (see Addresses).

Request body
{
  "ip": "10.0.1.10",
  "hostname": "web-01.prod",
  "mac_address": "00:1A:2B:3C:4D:5E",
  "description": "Primary web server",
  "status": "assigned",
  "owner": "platform-team"
}

ip is the only required field, unless the subnet is an IP pool (below). status defaults to assigned.

Response

Returns 201 with the created address object.

Auto-assign on an IP pool. On a subnet with is_pool set to true, leave ip out (or send it empty) and the API records the next free address, the same one First Free IP would return, and gives it back in the created object. The IP is picked inside the create, so concurrent requests each get a different one; calling first-free and then creating can hand two callers the same IP. A pool with no free address left returns 409 ("no free addresses left in 10.0.1.0/24"). On a subnet without is_pool, or a container subnet, a missing ip is a 400. Batch create doesn't auto-assign.

Request body (IP pool, no ip)
{
  "hostname": "build-agent-07",
  "owner": "ci"
}
Response (201)
{
  "id": "7f8e9d0c-1b2a-3c4d-5e6f-708192a3b4c5",
  "subnet_id": "c5d6e7f8-a9b0-1234-cdef-012345678901",
  "ip": "10.0.1.11",
  "hostname": "build-agent-07",
  "status": "assigned",
  "owner": "ci",
  "created_at": "2026-03-01T12:00:00Z",
  "updated_at": "2026-03-01T12:00:00Z"
}

Batch Create

POST /api/v1/subnets/{id}/addresses/batch-create

Assign up to 1,000 IPs in one request (e.g., reserve a range; see Bulk Limits). status applies to all IPs and defaults to reserved. IPs this subnet already records are skipped. An IP inside a child subnet, or one an enclosing subnet records, fails the whole batch with 409 and creates nothing.

Request body
{
  "ips": ["10.0.1.20", "10.0.1.21", "10.0.1.22"],
  "status": "reserved"
}
Response
{
  "created": 3
}

Get Address

GET /api/v1/addresses/{id}

Returns a single address by ID (same shape as the list items above).

Update Address

PATCH /api/v1/addresses/{id}

Update address metadata. All fields are optional. The IP itself cannot be changed.

Request body
{
  "hostname": "web-01a.prod",
  "mac_address": "00:1A:2B:3C:4D:5F",
  "description": "Migrated web server",
  "status": "assigned",
  "owner": "platform-team",
  "custom_fields": { "asset_tag": "A-1042" }
}
Response
200 OK

Delete Address

DELETE /api/v1/addresses/{id}

Remove an IP address assignment (release the IP).

Response
204 No Content

Batch Update

POST /api/v1/addresses/batch-update

Update the status and/or owner of up to 1,000 addresses at once (see Bulk Limits). IDs that match nothing in your organization are skipped; an ID that is not a UUID fails the request with 400.

Request body
{
  "ids": ["0a1b2c3d-...", "1b2c3d4e-..."],
  "status": "reserved",
  "owner": "network-team"
}
Response
{
  "updated": 2
}

Batch Delete

POST /api/v1/addresses/batch-delete

Release up to 1,000 addresses at once (see Bulk Limits). IDs that match nothing in your organization are skipped; an ID that is not a UUID fails the request with 400.

Request body
{
  "ids": ["0a1b2c3d-...", "1b2c3d4e-..."]
}
Response
{
  "deleted": 2
}

VLANs

Track VLAN assignments alongside your subnets. Standard CRUD:

GET /api/v1/vlans  ·  POST /api/v1/vlans  ·  GET/PATCH/DELETE /api/v1/vlans/{id}

A VLAN has a vlan_number (1–4094, required on create), optional name, description, and location_id. List responses include a computed subnet_count.

Example object
{
  "id": "2c3d4e5f-6789-0abc-def1-234567890abc",
  "vlan_number": 120,
  "name": "Web tier",
  "description": "Production web VLAN",
  "subnet_count": 3,
  "created_at": "2026-03-01T12:00:00Z"
}

VRFs

VRFs (virtual routing and forwarding instances) let the same CIDR exist more than once — overlap detection is scoped per VRF. Standard CRUD:

GET /api/v1/vrfs  ·  POST /api/v1/vrfs  ·  GET/PATCH/DELETE /api/v1/vrfs/{id}

A VRF has a name (required on create), optional rd (route distinguisher, e.g. 65000:1) and description. List responses include a computed subnet_count. Deleting a VRF keeps its subnets and moves them out of any VRF, where they nest into the existing tree; webhook subscribers get one subnet.updated listing every subnet that moved or got a new parent. The delete returns 409 if one of them would duplicate a network that already exists outside any VRF, or if one of them and a subnet outside any VRF that it would nest with both record the same IP, which after the merge would be one address with two records. The message names the first conflict. After the merge, records that sit on a subnet but inside a subnet nested under it move to the most specific nested subnet (listed under moved_down in the activity entry); a gateway that would end up inside a nested subnet returns 409.

Example object
{
  "id": "3d4e5f67-890a-bcde-f123-4567890abcde",
  "name": "CORP",
  "rd": "65000:1",
  "subnet_count": 12,
  "created_at": "2026-03-01T12:00:00Z"
}

Locations

Physical sites (offices, datacenters) that subnets and VLANs can be tagged with. Standard CRUD:

GET /api/v1/locations  ·  POST /api/v1/locations  ·  GET/PATCH/DELETE /api/v1/locations/{id}

A location has a name (required on create) and optional address. List responses include computed subnet_count and vlan_count.

Example object
{
  "id": "4e5f6789-0abc-def1-2345-67890abcdef1",
  "name": "Datacenter US-East",
  "address": "Ashburn, VA",
  "subnet_count": 8,
  "vlan_count": 4,
  "created_at": "2026-03-01T12:00:00Z"
}

Webhooks

Subscribe to real-time event notifications. See the Webhooks guide for setup instructions and signature verification.

List Webhooks

GET /api/v1/webhooks

List all webhook subscriptions. The signing secret is never included in list responses. For viewer members and API keys without write access (including every key on the Free plan), url is cut to its scheme and host, such as https://hooks.slack.com/…, and "url_masked": true is added, because a chat webhook’s URL lets anyone post to the channel. The same applies to Get Webhook, and List Deliveries omits response_body for those callers. The activity log and its CSV export show those callers webhook URLs in the same masked form.

Response
[
  {
    "id": "5f678901-abcd-ef12-3456-7890abcdef12",
    "url": "https://example.com/webhooks/ipcraft",
    "description": "CMDB sync",
    "event_types": ["subnet.created", "subnet.deleted"],
    "is_active": true,
    "failure_count": 0,
    "dropped_count": 0,
    "created_at": "2026-03-21T16:00:00Z"
  }
]

An inactive webhook includes deactivated_reason: plan (paused by a plan downgrade; it turns back on when the plan has room for it), failures (disabled after 10 consecutive delivery failures), or user (disabled through Settings or this API). Active webhooks omit it, as do webhooks disabled before the field was added. dropped_count counts events that were never queued for the webhook because the organization already had 20,000 deliveries waiting for a retry (see Webhooks).

Create Webhook

POST /api/v1/webhooks

Create a new webhook subscription. The URL must use HTTPS and be 2,048 characters or fewer; the description is limited to 500. Returns the signing secret — store it securely, it is only shown once.

Request body
{
  "url": "https://example.com/webhooks/ipcraft",
  "description": "CMDB sync",
  "event_types": ["subnet.created", "subnet.deleted"]
}

Leave event_types empty ([]) to receive all event types.

Response
{
  "id": "5f678901-abcd-ef12-3456-7890abcdef12",
  "url": "https://example.com/webhooks/ipcraft",
  "secret": "whsec_a1b2c3d4e5f6...",
  "description": "CMDB sync",
  "event_types": ["subnet.created", "subnet.deleted"],
  "is_active": true
}
Copy the secret value immediately. It cannot be retrieved again. Use it to verify webhook signatures.

Get Webhook

GET /api/v1/webhooks/{id}

Get a single webhook subscription. The signing secret is not included.

Update Webhook

PATCH /api/v1/webhooks/{id}

Update a webhook's URL, description, event types, or active status.

Request body
{
  "url": "https://new-endpoint.example.com/hook",
  "event_types": ["address.created"],
  "is_active": true
}
Response
200 OK

Delete Webhook

DELETE /api/v1/webhooks/{id}

Permanently delete a webhook subscription and all its delivery history.

Response
204 No Content

List Deliveries

GET /api/v1/webhooks/{id}/deliveries

List recent deliveries for a webhook. Each record is one delivery: attempt counts how many times it has been sent, and id is the delivery_id in the body and the X-Webhook-Delivery-ID header every attempt carried. Supports ?limit= (default 50, max 100) and ?offset= query parameters. Delivery records are retained for 7 days; one still waiting for a retry at 7 days is marked expired and removed a day later.

Response
[
  {
    "id": "6789012a-bcde-f123-4567-890abcdef123",
    "event_type": "subnet.created",
    "response_status": 200,
    "duration_ms": 142,
    "attempt": 1,
    "delivered_at": "2026-03-21T16:30:00Z"
  }
]

Test Ping

POST /api/v1/webhooks/{id}/test

Send a test ping event to the webhook endpoint. Returns the delivery result so you can verify connectivity.

Response
{
  "id": "789012ab-cdef-1234-5678-90abcdef1234",
  "event_type": "ping",
  "response_status": 200,
  "duration_ms": 95,
  "attempt": 1
}

Returns 502 if the endpoint is unreachable or returns anything but a 2xx, the same rule real deliveries use. Redirects are not followed, so a 3xx answer is a 502 whose error says so. Returns 402 on the free plan, 429 when the organization’s delivery budget of 100 per minute is spent, and 409 when the webhook’s signing secret can’t be read because the server’s encryption key changed; rotate the secret to fix it.

Rotate Secret

POST /api/v1/webhooks/{id}/rotate-secret

Generate a new signing secret for the webhook. The old secret stops working immediately.

Response
{
  "secret": "whsec_new_secret_value..."
}
Update your endpoint to use the new secret before rotating, or you'll miss events during the transition.

API Keys

Manage API keys for programmatic access. The full key is only shown once at creation time. Creating and deleting keys requires a signed-in browser session: requests with an API key return 403. Listing keys works with either.

List Keys

GET /api/v1/api-keys

List all API keys. Only the key prefix is shown — the full key is never returned after creation. expires_at is present on keys created with a lifetime; a key past it is listed until revoked but no longer authenticates.

Response
[
  {
    "id": "789012ab-cdef-1234-5678-90abcdef1234",
    "name": "Ansible automation",
    "key_prefix": "ipc_a1b2c3d4",
    "permissions": ["read", "write"],
    "created_at": "2026-03-01T12:00:00Z",
    "last_used_at": "2026-03-14T09:15:00Z",
    "expires_at": "2026-05-30T12:00:00Z"
  }
]

Create Key

POST /api/v1/api-keys

Generate a new API key. The full key is only returned in this response — store it securely.

Request body
{
  "name": "Ansible automation",
  "permissions": ["read", "write"],
  "expires_in_days": 90
}

permissions is optional and defaults to ["read", "write"]. Use ["read"] for a read-only key. expires_in_days is optional: 1–365 gives the key a lifetime, after which requests with it return 401 with the error API key expired, and the key’s creator is emailed two weeks before (for lifetimes over two weeks); omitted (or 0) the key never expires. Any other value returns 400. A key cannot be extended; generate a new one and revoke the old one.

Response
{
  "id": "789012ab-cdef-1234-5678-90abcdef1234",
  "name": "Ansible automation",
  "key": "ipc_a1b2c3d4e5f6...(68 chars total)",
  "key_prefix": "ipc_a1b2c3d4",
  "permissions": ["read", "write"],
  "created_at": "2026-03-01T12:00:00Z",
  "expires_at": "2026-05-30T12:00:00Z"
}

expires_at is null for a key without a lifetime.

Copy the key value immediately. It cannot be retrieved again.

Delete Key

DELETE /api/v1/api-keys/{id}

Revoke an API key. Any requests using this key will immediately start returning 401.

Response
204 No Content

Team

Manage organization members and invitations (any plan with more than one seat: Pro, Teams and Enterprise). Roles are admin, editor, and viewer. Inviting, changing, and removing members requires a signed-in browser session: requests with an API key return 403. Listing members and invitations works with either.

List Members

GET /api/v1/team/members
Response
[
  {
    "user_id": "7f3e9a12-4b6c-4d2e-9f01-8a5b3c7d1e42",
    "email": "john@example.com",
    "name": "John Smith",
    "role": "admin",
    "accepted_at": "2026-03-01T12:00:00Z"
  }
]

Update Member Role

PATCH /api/v1/team/members/{user_id}
Request body
{
  "role": "editor"
}
Response
{
  "message": "role updated"
}

Remove Member

DELETE /api/v1/team/members/{user_id}

Remove a member from the organization. Their API keys are revoked and the pending invitations they sent are cancelled in the same step; each cancelled invitation gets its own entry in the activity log.

Response
{
  "message": "member removed"
}

Invite User

POST /api/v1/team/invitations

Email an invitation to join your organization. Returns 402 if you're at your plan's seat limit. Invitation creation is limited to 10 per organization per hour. The invitee accepts via the emailed link (GET /api/v1/auth/accept-invite?token=..., browser-only).

Request body
{
  "email": "teammate@example.com",
  "role": "editor"
}
Response

Returns 201 with the invitation object (id, email, role, expires_at, created_at). If the invitation email can’t be sent, returns 502 and no invitation is created, so the request can simply be retried.

List Invitations

GET /api/v1/team/invitations

List pending invitations.

Cancel Invitation

DELETE /api/v1/team/invitations/{id}
Response
{
  "message": "invitation cancelled"
}

Billing

Manage your subscription plan via Stripe integration. Checkout, portal, and org-name endpoints require the admin role and a signed-in browser session: requests with an API key return 403. Get Plan works with either.

Get Plan

GET /api/v1/billing/plan

Returns current plan details, usage, and limits. usage.users counts members plus pending invitations and usage.webhooks active webhooks; limits adds users, webhooks, rate_per_sec and activity_retention_days to the resource caps, a limit of 100,000 or more means unlimited, and plans gives the same limits (plus api_write) for every plan. checkout_available_at is the moment a newly created organization may start its first paid checkout (see Checkout); it is null once that wait has passed. has_subscription is true while the organization still has a Stripe subscription, which on the Free plan means an unpaid one: pay it in the Customer Portal to restore the plan.

Response
{
  "plan": "free",
  "org_name": "John Smith's Org",
  "has_billing": false,
  "has_subscription": false,
  "checkout_available_at": "2026-09-06T22:32:05Z",
  "checkout_cooldown_seconds": 600,
  "usage": {
    "folders": 1,
    "subnets": 3,
    "addresses": 42
  },
  "limits": {
    "folders": 5,
    "subnets": 100,
    "addresses": 1500
  }
}

Checkout

POST /api/v1/billing/checkout

Creates a Stripe Checkout session for upgrading to Pro or Teams. Redirect the user to the returned URL. Returns 400 if the organization is already on a paid plan, or still has an unpaid subscription — use the Customer Portal to switch plans or pay instead. For Enterprise, contact us.

Organizations younger than the checkout cooldown (10 minutes by default) receive 403 with "code": "checkout_cooldown", retry_after_seconds, an RFC 3339 available_at, and a Retry-After header. The short wait exists to deter card testing; nothing else is restricted during it.

Request body
{
  "plan": "pro"
}

plan is "pro" or "teams". Omitting the body defaults to "pro".

Response
{
  "url": "https://checkout.stripe.com/c/pay/cs_live_..."
}

Customer Portal

POST /api/v1/billing/portal

Creates a Stripe Customer Portal session for managing billing, invoices, and cancellation.

Response
{
  "url": "https://billing.stripe.com/p/session/..."
}

Update Organization Name

PATCH /api/v1/billing/org-name

Rename your organization (max 100 characters). A name containing control characters (such as a line break) or invisible formatting characters (such as a right-to-left override or a zero-width space) returns 400. The zero-width joiner and non-joiner, which emoji and scripts such as Persian need, are allowed.

Request body
{
  "name": "Acme Networks"
}
Response
200 OK

Import & Export

Bulk import and CSV export. See the CSV Import & Export guide for the UI workflow, column aliases, and normalization rules. Import request bodies are limited to 10 MB. Text fields are stored as sent, apart from control characters and escape sequences, which become spaces (descriptions keep their line breaks), and the one leading apostrophe the CSV export adds to values a spreadsheet would treat as a formula, which is removed so exported values can be posted back unchanged. The export adds a second apostrophe to a value that already starts with one in front of such a value, so the round trip always gives back the stored text. Folder, VRF and location names a subnet row creates are limited to 255 characters, as on their create endpoints.

Import Subnets

POST /api/v1/import/subnets

Create up to 1,000 subnets per request (see Bulk Limits). Folders, VLANs, VRFs, and locations referenced by name/number are auto-created. Duplicates (same CIDR + VRF) and partial overlaps are skipped, not errored. Rows that would exceed plan limits are reported as errors; successfully imported rows are kept (no rollback).

Request body
{
  "subnets": [
    {
      "network": "10.0.1.0/24",
      "name": "Web servers",
      "folder": "Production",
      "vlan": "120",
      "vrf": "CORP",
      "location": "Datacenter US-East",
      "gateway": "10.0.1.1",
      "is_pool": false
    }
  ]
}

Only network is required per row. Also accepted: description, dns_primary, dns_secondary, scan_enabled, tracked (defaults to true when absent), custom_fields.

Response
{
  "results": [
    { "row": 1, "status": "created", "id": "...", "network": "10.0.1.0/24" }
  ],
  "summary": { "created": 1, "skipped": 0, "errors": 0 }
}

Import Addresses

POST /api/v1/import/addresses

Create up to 1,000 addresses per request (see Bulk Limits). Each row needs ip and subnet (the parent CIDR, which must already exist). Also accepted: vrf, hostname, mac_address, description, status, owner, ptr_record, custom_fields. Already-assigned IPs are skipped. A row whose IP lies inside a child subnet of its subnet, or is already recorded by an enclosing subnet, is reported as an error with the reason and the rest of the file still imports. Same response shape as subnet import.

vrf names the VRF that holds subnet, matched case-insensitively; a VRF without that subnet is a row error. When it is blank or absent, the row targets the subnet with that CIDR outside any VRF, or the only VRF copy if there is no global one. A CIDR held by several VRFs and no global subnet needs vrf, or the row is an error.

Request body
{
  "addresses": [
    {
      "ip": "10.0.1.10",
      "subnet": "10.0.1.0/24",
      "vrf": "CORP",
      "hostname": "web-01.prod",
      "status": "assigned"
    }
  ]
}

Export Subnets

GET /api/v1/export/subnets?folder_id={folder_id}

Download all subnets as CSV (columns: network, name, description, folder, vlan, vrf, location, gateway, dns_primary, dns_secondary, is_pool, scan_enabled, tracked, custom_fields; custom_fields is a JSON object, blank when unset). Folder/VLAN/VRF/location are exported as human-readable names. folder_id optionally limits the export to one folder.

Export Addresses

GET /api/v1/export/addresses?subnet_id={subnet_id}

Download addresses as CSV (columns: ip, subnet, vrf, hostname, mac_address, description, status, owner, ptr_record, custom_fields). vrf is the parent subnet's VRF name, blank outside a VRF. subnet_id optionally limits the export to one subnet; omit it to export all addresses. Rows are in IP order. If the export fails partway through, the connection is closed without the end of the response, so clients report a failed transfer rather than saving a short file.

Full-text search across subnets and addresses.

GET /api/v1/search?q={query}

Subnets match on network CIDR, name, or description. Addresses match on IP address, hostname, description, MAC address, owner, PTR record, or custom field value. Words are matched by prefix, and a MAC matches in colon, hyphen, Cisco dotted, or bare-hex notation, whole or by its leading octets. Returns matching subnets and addresses, up to 50 of each.

Query parameters
  • q — search query (required, max 500 characters)
Response
{
  "subnets": [
    {
      "id": "c5d6e7f8-a9b0-1234-cdef-012345678901",
      "network": "10.0.1.0/24",
      "name": "Web servers"
    }
  ],
  "addresses": [
    {
      "id": "0a1b2c3d-4e5f-6789-0abc-def123456789",
      "ip": "10.0.1.10",
      "hostname": "web-01.prod"
    }
  ]
}

Activity

Audit log of actions performed within your organization. History is retained per your plan (7/30/90 days; see Plan Limits).

GET /api/v1/activity?limit={limit}&offset={offset}

Returns a paginated list of activity entries.

Query parameters
  • limit — entries to return (default 50, max 500)
  • offset — pagination offset
  • action, resource_type, user_id, since, until — optional filters
Response
{
  "entries": [
    {
      "id": 1042,
      "user_id": "7f3e9a12-4b6c-4d2e-9f01-8a5b3c7d1e42",
      "action": "create",
      "resource_type": "subnet",
      "resource_id": "c5d6e7f8-a9b0-1234-cdef-012345678901",
      "changes": { "network": "10.0.1.0/24" },
      "created_at": "2026-03-14T10:30:00Z"
    }
  ],
  "total": 137,
  "users": { "7f3e9a12-...": "john@example.com" }
}

Activity Filters

GET /api/v1/activity/filters

Returns the distinct actions and resource_types present in your activity log, for building filter UIs.

Activity Export

GET /api/v1/activity/export

Download the activity log as CSV, every entry that matches. Accepts the same filter parameters as the list endpoint. As with the address export, a failure partway through closes the connection instead of ending the file early.

Verify Activity Integrity

GET /api/v1/activity/verify

Recompute the activity log’s hash chain and report whether every entry still matches (see Concepts). A large log is checked a few seconds at a time: while the walk is still running the response has "complete": false and the counts so far, and the next request continues where it stopped. Repeat the request until complete is true. Only one request per organization runs at a time (another gets 409), an organization can start 12 new checks an hour (429 past that), and when the service is busy with other checks the request returns 503 with Retry-After.

Response
{
  "valid": true,
  "entries_checked": 4821,
  "unhashed_entries": 0,
  "anchor_id": 10233,
  "complete": true
}

anchor_id is the oldest entry, which starts the chain and is not itself checked. When an entry fails, valid is false and first_invalid_id names the first one.

Network Tools

Public endpoints for common network lookups. No authentication required. Rate-limited per IP address (IPv6 clients per /64, with a whole /48 allowed four times that): 10 requests per minute for lookups, and 3 requests per minute for active probes (ping, port scan, traceroute). Exceeding a limit returns 429 with a Retry-After header. When too many probes or connection checks (SSL, HTTP/2, headers) are already running service-wide, a request waits up to 3 seconds for a slot and then returns 503 with Retry-After. All tools together also count against a quota of 500 requests per client IP per 24 hours (an IPv6 /64, with the /48 allowed 2,000); past it the API returns 429 with Retry-After set to the seconds until the window resets.

Lookups that fail upstream (an unreachable host, a nameserver that doesn’t answer) return 200 with an error field; 4xx is reserved for invalid input and 429 for rate limits. Browser requests must come from ipcraft.io or app.ipcraft.io; cross-site embeds are refused with 403. Command-line and server-side clients are unaffected.

DNS Lookup

GET /api/v1/tools/dns?domain={domain}&type={type}

Resolve a domain against 3 independent resolvers: Cloudflare (1.1.1.1), Google (8.8.8.8), and Quad9 (9.9.9.9).

Query parameters
  • domain — domain name to resolve (required); underscore labels such as _dmarc.example.com or selector._domainkey.example.com are accepted
  • type — record type: A, AAAA, CNAME, MX, NS, PTR, SOA, SRV, TXT, CAA, DNSKEY, DS, NAPTR, TLSA, HTTPS, or SVCB (default: A). Other types, including AXFR, IXFR, and ANY, return 400
  • all=true — query all common record types at once
  • authoritative=true — query the domain's own nameservers instead
Response
{
  "domain": "example.com",
  "type": "A",
  "results": [
    {
      "resolver": "1.1.1.1:53",
      "records": ["93.184.216.34"],
      "ttl": 300,
      "response_time_ms": 2.3
    },
    { "resolver": "8.8.8.8:53", "records": ["93.184.216.34"], "ttl": 300, "response_time_ms": 5.4 },
    { "resolver": "9.9.9.9:53", "records": ["93.184.216.34"], "ttl": 37, "response_time_ms": 0.7 }
  ]
}

DNS Propagation

GET /api/v1/tools/dns-propagation?domain={domain}&type={type}

Check how a DNS record has propagated across 8 resolvers around the world (Cloudflare, Google, Quad9, OpenDNS, Yandex, KT, Neustar, 114DNS). Each result includes the resolver's label and location in addition to the fields returned by DNS Lookup. Accepts the same record types as DNS Lookup.

Reverse DNS

GET /api/v1/tools/rdns?ip={ip}

Perform a reverse DNS (PTR) lookup for an IP address, with forward-confirmation (FCrDNS) of each returned name. At most the first 5 names are returned and checked; "truncated": true is added when the zone had more.

Response
{
  "ip": "8.8.8.8",
  "arpa_query": "8.8.8.8.in-addr.arpa.",
  "names": [
    { "name": "dns.google", "fcrdns_valid": true }
  ],
  "authoritative_ns": "ns1.google.com"
}

WHOIS Lookup

GET /api/v1/tools/whois?query={query}

Perform a WHOIS lookup for a domain or IP address. Returns the raw WHOIS text plus a parsed summary: for domains, registrar, created, expires, expires_in_days, updated, nameservers, status; for IPs, cidr, net_name, organization, abuse_contact.

Response
{
  "query": "example.com",
  "raw": "Domain Name: EXAMPLE.COM\n...",
  "parsed": {
    "registrar": "IANA",
    "created": "1995-08-14T04:00:00Z",
    "expires": "2026-08-13T04:00:00Z",
    "expires_in_days": 40,
    "nameservers": ["a.iana-servers.net", "b.iana-servers.net"]
  }
}

SSL Certificate Check

GET /api/v1/tools/ssl?host={host}

Inspect a host's TLS certificate chain: each certificate's subject, issuer, sans, validity window, and days_remaining, plus chain_valid, tls_version, and cipher_suite.

Response (abridged)
{
  "host": "example.com",
  "tls_version": "TLS 1.3",
  "cipher_suite": "TLS_AES_128_GCM_SHA256",
  "chain_valid": true,
  "certificates": [
    {
      "subject": "example.com",
      "issuer": "DigiCert TLS RSA SHA256 2020 CA1",
      "sans": ["example.com", "*.example.com"],
      "not_after": "2026-08-29T21:41:26Z",
      "days_remaining": 57,
      "is_expired": false
    }
  ]
}

SPF Check

GET /api/v1/tools/spf?domain={domain}

Fetch and validate a domain's SPF record: parsed mechanisms, dns_lookup_count against the 10-lookup limit, plus valid, warnings, and errors.

Response
{
  "domain": "example.com",
  "found": true,
  "valid": true,
  "raw": "v=spf1 include:_spf.example.com ~all",
  "mechanisms": [
    { "qualifier": "+", "mechanism": "include", "value": "_spf.example.com" }
  ],
  "dns_lookup_count": 1,
  "dns_lookup_limit": 10,
  "warnings": ["Using ~all (softfail) instead of -all (hardfail)"]
}

DMARC Check

GET /api/v1/tools/dmarc?domain={domain}

Fetch and validate a domain's DMARC record: policy, subdomain_policy, parsed tags, plus valid, warnings, and errors.

Response
{
  "domain": "example.com",
  "found": true,
  "valid": true,
  "policy": "reject",
  "raw": "v=DMARC1; p=reject; rua=mailto:...",
  "tags": { "v": "DMARC1", "p": "reject" }
}

Blacklist Check

GET /api/v1/tools/blacklist?ip={ip}

Check an IP against 7 DNS blocklists (Spamhaus ZEN, Barracuda, SpamCop, Mailspike, UCEPROTECT L1, PSBL, SpamRATS).

Response
{
  "ip": "203.0.113.42",
  "listed_count": 0,
  "total_checked": 7,
  "results": [
    { "list": "SpamCop", "zone": "bl.spamcop.net", "listed": false }
  ]
}

ASN Lookup

GET /api/v1/tools/asn?query={query}

Look up the ASN, announcing prefix, and registry data for an IP address or AS number.

Response
{
  "query": "8.8.8.8",
  "type": "ip",
  "asn": "15169",
  "as_name": "GOOGLE - Google LLC, US",
  "prefix": "8.8.8.0/24",
  "country": "US",
  "rir": "arin",
  "allocated": "2023-12-28"
}

HTTP/2 Check

GET /api/v1/tools/http2?host={host}

Check which HTTP protocol versions a host supports.

Response
{
  "host": "example.com",
  "http11": true,
  "http2": true,
  "http3_advertised": false,
  "alpn_negotiated": "h2",
  "tls_version": "TLS 1.3"
}

Ping (TCP Check)

GET /api/v1/tools/ping?host={host}

TCP connectivity check to a host. Probes ports 80 and 443 by default; pass ?port= to check a specific port. Rate-limited to 3 requests per minute.

Response
{
  "host": "example.com",
  "ip": "93.184.216.34",
  "results": [
    { "port": 80, "open": true, "latency_ms": 1.3 },
    { "port": 443, "open": true, "latency_ms": 1.0 }
  ],
  "checked_at": "2026-03-21T16:30:00Z"
}

Port Scan

GET /api/v1/tools/ports?host={host}

Scan 19 common ports on a host (FTP, SSH, HTTP, HTTPS, DNS, SMTP, databases, RDP, etc.), or pass ?ports= with a comma-separated list (max 25; repeated ports are scanned once). The host is resolved once (IPv4 preferred) and every port is probed on that address, returned as ip. Rate-limited to 3 requests per minute.

Response
{
  "host": "example.com",
  "ip": "93.184.216.34",
  "open_count": 2,
  "total": 19,
  "results": [
    { "port": 80, "service": "HTTP", "category": "Web", "open": true, "latency_ms": 2.0 },
    { "port": 443, "service": "HTTPS", "category": "Web", "open": true, "latency_ms": 1.7 },
    { "port": 22, "service": "SSH", "category": "Remote Access", "open": false }
  ]
}

Traceroute

GET /api/v1/tools/traceroute?host={host}

Trace the network path to a host with 3 round-trip measurements per hop. Unresponsive hops have null values. Rate-limited to 3 requests per minute.

Response
{
  "host": "example.com",
  "ip": "93.184.216.34",
  "hops": [
    { "hop": 1, "ip": "172.31.1.1", "rtt1_ms": 2.3, "rtt2_ms": 2.7, "rtt3_ms": 2.8 },
    { "hop": 2, "ip": null, "rtt1_ms": null, "rtt2_ms": null, "rtt3_ms": null }
  ]
}

My IP

GET /api/v1/tools/myip

Returns the client's public IP address, plus country, datacenter (edge location), and hostname (reverse DNS) when available.

Response
{
  "ip": "203.0.113.42",
  "country": "US",
  "datacenter": "DEN"
}

Request Headers

GET /api/v1/tools/headers

Returns the HTTP headers sent by the client. Pass ?url=https://... to instead fetch a remote site and get back its response headers with a security_checklist (HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy). The url must be https:// on port 443; its query string and fragment are dropped, and the URL actually fetched is returned as url. Requests identify themselves with User-Agent: IPCraft-Tools/1.0.

Response
{
  "headers": {
    "User-Agent": "curl/8.1.0",
    "Accept": "*/*"
  },
  "method": "GET",
  "proto": "HTTP/1.1"
}