Skip to content

IPAM REST API Documentation

Overview

The IPAM REST API provides programmatic access to all IP Address Management functionality with full CRUD operations, filtering, pagination, and interactive Swagger UI documentation.

Status: ✅ Fully Implemented and Production-Ready

API Base URL

http://localhost:5000/api/v1

Interactive Documentation

Access the interactive Swagger UI at:

http://localhost:5000/api/v1/docs

The Swagger UI provides: - Complete API reference with request/response schemas - Interactive "Try it out" functionality - Request/response examples - Model definitions

Authentication

API requests require a token when API_TOKENS is configured.

Headers: - Authorization: Bearer <token> (preferred) - X-API-Key: <token> (alternative)

Requests without a valid token return 401 Unauthorized. Authentication is disabled when API_TOKENS is empty.

Rate Limiting

Rate limits apply to API endpoints when RATELIMIT_ENABLED=true. Default limit: 200 per minute (configurable via API_RATE_LIMIT). Use RATELIMIT_STORAGE_URI to configure the limiter backend. For multi-process or multi-pod deployments, use a shared backend like Redis.

Getting Started

Starting the API Server

# Initialize database (first time only)
export FLASK_APP=app.py
flask db upgrade

# Start the development server
python app.py

The API will be available at http://localhost:5000/api/v1

API Endpoints

Networks

List Networks

GET /api/v1/networks

Query Parameters: - page (int, default: 1) - Page number - per_page (int, default: 50) - Items per page - name (string) - Filter by network name - domain (string) - Filter by domain - vlan_id (int) - Filter by VLAN ID - location (string) - Filter by location

Response:

{
  "data": [
    {
      "id": 1,
      "network": "192.168.1.0",
      "cidr": 24,
      "broadcast_address": "192.168.1.255",
      "name": "Office Network",
      "domain": "office.local",
      "vlan_id": 100,
      "description": "Main office network",
      "location": "Building A",
      "total_hosts": 254,
      "used_hosts": 45,
      "available_hosts": 209
    }
  ],
  "pagination": {
    "page": 1,
    "per_page": 50,
    "total_items": 1,
    "total_pages": 1
  }
}

Get Network

GET /api/v1/networks/{id}

Response: Single network object (same structure as list item)

Create Network

POST /api/v1/networks
Content-Type: application/json

{
  "network": "10.0.0.0",
  "cidr": 16,
  "name": "Corporate Network",
  "domain": "corp.local",
  "vlan_id": 200,
  "description": "Corporate network",
  "location": "HQ"
}

network accepts any host IP within the network, not just its base address, and is normalized before saving (for example, 10.0.0.42 with cidr: 16 is stored as 10.0.0.0). Creating a network that overlaps an existing one (a subnet, supernet, or any other partial overlap) is rejected (HTTP 400); networks may not overlap.

Response: Created network object (HTTP 201)

Update Network

PUT /api/v1/networks/{id}
Content-Type: application/json

{
  "network": "10.0.0.0",
  "cidr": 16,
  "name": "Updated Name",
  "domain": "corp.local",
  "vlan_id": 200,
  "description": "Updated description",
  "location": "HQ"
}

network is normalized the same way as on create. cidr may not be changed (HTTP 400); delete and recreate the network if it needs a different CIDR. Moving the network's address onto a range that overlaps another existing network is rejected the same way as on create.

Response: Updated network object

Upsert Network

POST /api/v1/networks/upsert
Content-Type: application/json

{
  "network": "10.0.0.0",
  "cidr": 16,
  "name": "Corporate Network",
  "domain": "corp.local",
  "vlan_id": 200,
  "description": "Corporate network",
  "location": "HQ"
}

Creates a network if none with this network address exists yet, otherwise updates it. network and cidr are required, and network is normalized the same way as on create (matched against the normalized address). Only fields present in the request body are changed; fields left out are kept as-is on an existing network. Sending a field with an explicit null clears it. Changing cidr on an existing network is rejected (HTTP 400), as is creating a network that overlaps an existing one.

This endpoint does not use schema validation (unlike POST and PUT above), since schema validation would reject an explicit null on a typed field.

Reconciliation rules for assignment, lease and observation fields (applied per aspect, so a collector only sends the aspect it owns):

  • Older data never replaces newer data. If the request carries assignment_last_confirmed_at, lease_observed_at or last_seen older than the stored value, the whole aspect is ignored.
  • Persistent assignments survive leases. An existing reservation or static assignment is never replaced by dynamic or unknown; that aspect is ignored. Switching between reservation and static, or clearing with an explicit "assignment_type": null, is allowed and is the documented way to remove a persistent assignment.
  • Ignored aspects are reported in the X-IPAM-Ignored-Aspects response header (comma separated: assignment, lease, observation); the rest of the request is still applied.
  • MAC changes are deliberate. Changing mac_address on a host with a reservation/static assignment returns HTTP 409 with existing_mac_address, requested_mac_address and assignment_type in the body. Send "confirm_mac_change": true to accept it; the previous device's lease and observation data is then cleared unless supplied in the same request. MACs are compared case- and separator-insensitively.
  • Invalid enum values or timestamps return HTTP 400 and change nothing.

Example: a reservation collector and a lease collector updating the same host without overwriting each other:

POST /api/v1/hosts/upsert
{"ip_address": "192.168.1.100", "mac_address": "11:22:33:44:55:66",
 "assignment_type": "reservation", "assignment_source": "udm-reservations",
 "assignment_last_confirmed_at": "2026-10-31T20:00:00Z"}

POST /api/v1/hosts/upsert
{"ip_address": "192.168.1.100", "lease_expires_at": "2026-11-01T08:00:00Z",
 "lease_source": "udm-leases", "lease_observed_at": "2026-10-31T20:00:05Z"}

Response: Network object, HTTP 201 if created or HTTP 200 if updated

Delete Network

DELETE /api/v1/networks/{id}

Response: HTTP 204 (No Content) Error: HTTP 400 if network has assigned hosts

Get Network Hosts

GET /api/v1/networks/{id}/hosts

Response:

{
  "network_id": 1,
  "network": "192.168.1.0/24",
  "hosts": [
    {
      "id": 1,
      "ip_address": "192.168.1.10",
      "hostname": "server01",
      "cname": "web",
      "mac_address": "aa:bb:cc:dd:ee:ff",
      "status": "active",
      "is_assigned": true,
      "last_seen": "2025-12-28T10:01:58+00:00",
      "discovery_source": "nmap",
      "description": "Web server"
    }
  ]
}

Hosts

List Hosts

GET /api/v1/hosts

Query Parameters: - page (int, default: 1) - Page number - per_page (int, default: 50) - Items per page - hostname (string) - Filter by hostname (wildcard supported) - cname (string) - Filter by CNAME - status (string) - Filter by status (active, inactive, reserved) - is_assigned (bool) - Filter by assignment status - mac_address (string) - Filter by MAC address - network_id (int) - Filter by network ID - assignment_type (string) - Filter by assignment type (reservation, static, dynamic, unknown, or unset for hosts without one)

Response:

{
  "data": [
    {
      "id": 1,
      "ip_address": "192.168.1.10",
      "hostname": "server01",
      "cname": "web",
      "mac_address": "aa:bb:cc:dd:ee:ff",
      "status": "active",
      "is_assigned": true,
      "last_seen": "2025-12-28T10:01:58+00:00",
      "discovery_source": "nmap",
      "description": "Web server",
      "network_id": 1,
      "network": "192.168.1.0/24",
      "assignment_type": "reservation",
      "assignment_source": "udm-reservations",
      "assignment_last_confirmed_at": "2025-12-28T09:00:00+00:00",
      "lease_expires_at": "2025-12-29T10:00:00+00:00",
      "lease_source": "udm-leases",
      "lease_observed_at": "2025-12-28T10:00:00+00:00",
      "last_seen_source": "nmap",
      "lease_active": true,
      "allocation_state": "reserved_lease_active",
      "allocation_label": "Reserved; lease active"
    }
  ],
  "pagination": {
    "page": 1,
    "per_page": 50,
    "total_items": 1,
    "total_pages": 1
  }
}

Get Host

GET /api/v1/hosts/{id}

Response: Single host object

Address assignment, leases and observations

A host record carries three independent aspects. Updating or clearing one never changes the other two, and none of them is encoded in status. Background: ADDRESS_ASSIGNMENT_MODEL.md.

Aspect Fields Meaning
Assignment assignment_type, assignment_source, assignment_last_confirmed_at How the address is allocated
Lease lease_expires_at, lease_source, lease_observed_at An explicitly observed DHCP lease
Observation last_seen, last_seen_source The latest actual sighting of the device

assignment_type values:

  • reservation - a DHCP reservation (e.g. on the UDM).
  • static - configured on the host or explicitly declared static.
  • dynamic - allocated from a DHCP pool.
  • unknown - the mechanism has not been established. This does not mean the address is free.
  • null - not set (all records that existed before this feature).

lease_expires_at = null means unknown / not supplied, not infinite validity. A reservation keeps assignment_type=reservation while its client also holds a lease.

Timestamps are ISO 8601. They are stored as UTC and always returned with an explicit offset (+00:00). The new timestamp fields reject values without an offset (Z or +hh:mm); the legacy last_seen still accepts naive values and treats them as UTC. Values with another offset are converted to UTC.

Derived, read-only fields (never stored, computed from the facts):

  • lease_active - an observed lease exists and has not yet expired.
  • allocation_state / allocation_label:
allocation_state Label Facts
reserved Reserved Reservation, no valid lease
reserved_lease_active Reserved; lease active Reservation and valid lease
static Statically allocated Static assignment (regardless of observations)
dynamic Dynamically allocated No persistent assignment, valid lease (or dynamic without known expiry)
lease_expired No confirmed current allocation Expired lease, no persistent assignment
observed Observed; assignment unknown Only an observation
unknown Recorded; assignment unknown None of the above (e.g. legacy records)

Legacy fields: status is kept unchanged for backward compatibility and is not reinterpreted. is_assigned remains a writable curation flag but is not authoritative for allocation; use assignment_type / allocation_state. Neither is used to infer an assignment type.

Create Host

POST /api/v1/hosts
Content-Type: application/json

{
  "ip_address": "192.168.1.100",
  "hostname": "server02",
  "cname": "api",
  "mac_address": "11:22:33:44:55:66",
  "status": "active",
  "is_assigned": true,
  "last_seen": "2025-12-28T10:01:58Z",
  "discovery_source": "nmap",
  "description": "API server",
  "network_id": 1,
  "assignment_type": "static",
  "assignment_source": "manual"
}

Notes: - If network_id is omitted, the system auto-detects the network based on IP address. - If is_assigned is omitted, the default is controlled by HOST_ASSIGN_ON_CREATE. - Assignment, lease and observation fields are optional and stored as given (see above). - If hostname ends with the resolved network's domain (the given or auto-detected one), that suffix is stripped before saving, e.g. server02.corp.local becomes server02 for a network with domain corp.local.

Response: Created host object (HTTP 201)

Update Host

PUT /api/v1/hosts/{id}
Content-Type: application/json

{
  "ip_address": "192.168.1.100",
  "hostname": "server02-updated",
  "cname": "api",
  "mac_address": "11:22:33:44:55:66",
  "status": "active",
  "is_assigned": true,
  "last_seen": "2025-12-28T10:01:58",
  "discovery_source": "nmap",
  "description": "Updated description",
  "network_id": 1
}

hostname has a matching network domain suffix stripped the same way as on create, based on the resolved network (the given network_id, or the current association if omitted).

Assignment, lease and observation fields are only changed when present in the body, so clients that do not know them leave them untouched. PUT is a manual update: the freshness and persistence rules of the upsert endpoint do not apply. Because PUT uses schema validation, clear these fields through the upsert endpoint with an explicit null.

Response: Updated host object

Upsert Host

POST /api/v1/hosts/upsert
Content-Type: application/json

{
  "ip_address": "192.168.1.100",
  "hostname": "server02",
  "mac_address": "11:22:33:44:55:66",
  "status": "active",
  "last_seen": "2025-12-28T10:01:58",
  "discovery_source": "nmap"
}

Creates a host if none with this ip_address exists yet, otherwise updates it. ip_address is required. Only fields present in the request body are changed; fields left out are kept as-is on an existing host (e.g. a manually curated cname or description survives a discovery-driven upsert that doesn't know about it). Sending a field with an explicit null clears it.

If network_id is omitted, it is auto-detected from the IP address when a new host is created; on an update, an omitted network_id leaves the host's current network association untouched. hostname has a matching domain suffix of the resolved network stripped the same way as on POST/PUT.

This endpoint does not use schema validation (unlike POST and PUT above), since schema validation would reject an explicit null on a typed field.

Reconciliation rules for assignment, lease and observation fields (applied per aspect, so a collector only sends the aspect it owns):

  • Older data never replaces newer data. If the request carries assignment_last_confirmed_at, lease_observed_at or last_seen older than the stored value, the whole aspect is ignored.
  • Persistent assignments survive leases. An existing reservation or static assignment is never replaced by dynamic or unknown; that aspect is ignored. Switching between reservation and static, or clearing with an explicit "assignment_type": null, is allowed and is the documented way to remove a persistent assignment.
  • Ignored aspects are reported in the X-IPAM-Ignored-Aspects response header (comma separated: assignment, lease, observation); the rest of the request is still applied.
  • MAC changes are deliberate. Changing mac_address on a host with a reservation/static assignment returns HTTP 409 with existing_mac_address, requested_mac_address and assignment_type in the body. Send "confirm_mac_change": true to accept it; the previous device's lease and observation data is then cleared unless supplied in the same request. MACs are compared case- and separator-insensitively.
  • Invalid enum values or timestamps return HTTP 400 and change nothing.

Example: a reservation collector and a lease collector updating the same host without overwriting each other:

POST /api/v1/hosts/upsert
{"ip_address": "192.168.1.100", "mac_address": "11:22:33:44:55:66",
 "assignment_type": "reservation", "assignment_source": "udm-reservations",
 "assignment_last_confirmed_at": "2026-10-31T20:00:00Z"}

POST /api/v1/hosts/upsert
{"ip_address": "192.168.1.100", "lease_expires_at": "2026-11-01T08:00:00Z",
 "lease_source": "udm-leases", "lease_observed_at": "2026-10-31T20:00:05Z"}

Response: Host object, HTTP 201 if created or HTTP 200 if updated, HTTP 409 on an unconfirmed MAC conflict

Delete Host

DELETE /api/v1/hosts/{id}

Response: HTTP 204 (No Content)

DHCP Ranges

List DHCP Ranges

GET /api/v1/dhcp-ranges

Query Parameters: - page (int, default: 1) - Page number - per_page (int, default: 50) - Items per page - network_id (int) - Filter by network ID

Response:

{
  "data": [
    {
      "id": 1,
      "network_id": 1,
      "start_ip": "192.168.1.100",
      "end_ip": "192.168.1.150",
      "description": "Office DHCP pool",
      "is_active": true
    }
  ],
  "pagination": {
    "page": 1,
    "per_page": 50,
    "total_items": 1,
    "total_pages": 1
  }
}

Create DHCP Range

POST /api/v1/dhcp-ranges
Content-Type: application/json

{
  "network_id": 1,
  "start_ip": "192.168.1.100",
  "end_ip": "192.168.1.150",
  "description": "Office DHCP pool",
  "is_active": true
}

Response: Created DHCP range object (HTTP 201)

Get DHCP Range

GET /api/v1/dhcp-ranges/{id}

Response: Single DHCP range object

Update DHCP Range

PUT /api/v1/dhcp-ranges/{id}
Content-Type: application/json

{
  "network_id": 1,
  "start_ip": "192.168.1.110",
  "end_ip": "192.168.1.160",
  "description": "Updated DHCP pool",
  "is_active": false
}

Response: Updated DHCP range object

Upsert DHCP Range

POST /api/v1/dhcp-ranges/upsert
Content-Type: application/json

{
  "network_id": 1,
  "start_ip": "192.168.1.100",
  "end_ip": "192.168.1.150",
  "description": "Office DHCP pool",
  "is_active": true
}

Creates a DHCP range if none with this (network_id, start_ip) exists yet, otherwise updates it. start_ip and end_ip are always required since they define the range; network_id may be omitted, in which case it is auto-detected from start_ip (the request fails with HTTP 400 if no network contains it). description is only changed when present in the request body; leaving it out keeps the existing value, and an explicit null clears it.

This endpoint does not use schema validation (unlike POST and PUT above), since schema validation would reject an explicit null on a typed field.

Reconciliation rules for assignment, lease and observation fields (applied per aspect, so a collector only sends the aspect it owns):

  • Older data never replaces newer data. If the request carries assignment_last_confirmed_at, lease_observed_at or last_seen older than the stored value, the whole aspect is ignored.
  • Persistent assignments survive leases. An existing reservation or static assignment is never replaced by dynamic or unknown; that aspect is ignored. Switching between reservation and static, or clearing with an explicit "assignment_type": null, is allowed and is the documented way to remove a persistent assignment.
  • Ignored aspects are reported in the X-IPAM-Ignored-Aspects response header (comma separated: assignment, lease, observation); the rest of the request is still applied.
  • MAC changes are deliberate. Changing mac_address on a host with a reservation/static assignment returns HTTP 409 with existing_mac_address, requested_mac_address and assignment_type in the body. Send "confirm_mac_change": true to accept it; the previous device's lease and observation data is then cleared unless supplied in the same request. MACs are compared case- and separator-insensitively.
  • Invalid enum values or timestamps return HTTP 400 and change nothing.

Example: a reservation collector and a lease collector updating the same host without overwriting each other:

POST /api/v1/hosts/upsert
{"ip_address": "192.168.1.100", "mac_address": "11:22:33:44:55:66",
 "assignment_type": "reservation", "assignment_source": "udm-reservations",
 "assignment_last_confirmed_at": "2026-10-31T20:00:00Z"}

POST /api/v1/hosts/upsert
{"ip_address": "192.168.1.100", "lease_expires_at": "2026-11-01T08:00:00Z",
 "lease_source": "udm-leases", "lease_observed_at": "2026-10-31T20:00:05Z"}

Response: DHCP range object, HTTP 201 if created or HTTP 200 if updated

Delete DHCP Range

DELETE /api/v1/dhcp-ranges/{id}

Response: HTTP 204 (No Content)

Network DHCP Ranges

GET /api/v1/networks/{id}/dhcp-ranges

Response:

{
  "data": [
    {
      "id": 1,
      "network_id": 1,
      "start_ip": "192.168.1.100",
      "end_ip": "192.168.1.150",
      "description": "Office DHCP pool",
      "is_active": true
    }
  ]
}

Backups

List Backups

GET /api/v1/backups

Query Parameters: - page (int, default: 1) - Page number - per_page (int, default: 50) - Items per page

Response:

{
  "data": [
    {
      "name": "ipam-backup-20251228-120000Z.db",
      "size_bytes": 40960,
      "created_at": "2025-12-28T12:00:00+00:00"
    }
  ],
  "pagination": {
    "page": 1,
    "per_page": 50,
    "total_items": 1,
    "total_pages": 1
  }
}

Create Backup

POST /api/v1/backups

Response: Created backup object (HTTP 201)

Verify Backup

GET /api/v1/backups/{name}/verify

Response:

{
  "name": "ipam-backup-20251228-120000Z.db",
  "integrity_ok": true,
  "integrity_message": "ok"
}

Restore Backup

POST /api/v1/backups/{name}/restore

Response:

{
  "name": "ipam-backup-20251228-120000Z.db",
  "restored": true,
  "integrity_ok": true,
  "integrity_message": "ok"
}

IP Management

Get Next Available IP

GET /api/v1/ip/networks/{network_id}/next-ip

Response:

{
  "ip_address": "192.168.1.45",
  "network": "192.168.1.0/24",
  "network_id": 1
}

Error: HTTP 400 if no available IPs

Next IP, available IPs and the network counters (available_hosts) share one rule: an address is offered only if no host record exists for it and it is not inside an active DHCP range. Any record keeps the address out, whatever its assignment, lease or observation state: an expired lease or a missed ping is not proof that an address is free. An offered address has no recorded allocation; it is not confirmed free.

Get Available IPs

GET /api/v1/ip/networks/{network_id}/available-ips?limit=10

Query Parameters: - limit (int, optional) - Limit number of IPs returned

Note: Uses the same rule as next IP (see above); active DHCP ranges and recorded addresses are excluded.

Response:

{
  "network": "192.168.1.0/24",
  "network_id": 1,
  "total_available": 209,
  "available_ips": [
    "192.168.1.11",
    "192.168.1.12",
    "..."
  ]
}

Query IP Address

GET /api/v1/ip/{ip_address}

Example: GET /api/v1/ip/192.168.1.10

Response (Assigned):

{
  "ip_address": "192.168.1.10",
  "status": "assigned",
  "allocation_state": "reserved",
  "allocation_label": "Reserved",
  "host": {
    "id": 1,
    "hostname": "server01",
    "cname": "web",
    "mac_address": "aa:bb:cc:dd:ee:ff",
    "status": "active",
    "is_assigned": true,
    "last_seen": "2025-12-28T10:01:58+00:00",
    "last_seen_source": "nmap",
    "discovery_source": "nmap",
    "description": "Web server",
    "network_id": 1,
    "network": "192.168.1.0/24",
    "assignment_type": "reservation",
    "assignment_source": "udm-reservations",
    "assignment_last_confirmed_at": null,
    "lease_expires_at": null,
    "lease_source": null,
    "lease_observed_at": null,
    "lease_active": false
  }
}

Response (Available):

{
  "ip_address": "192.168.1.45",
  "status": "available",
  "allocation_state": "no_record",
  "allocation_label": "No recorded allocation",
  "confirmed_free": false,
  "network": {
    "id": 1,
    "network": "192.168.1.0/24",
    "name": "Office Network",
    "domain": "office.local",
    "vlan_id": 100,
    "location": "Building A"
  }
}

status: "available" is kept for compatibility. It only means that no host record exists and no active DHCP range covers the address; confirmed_free is always false because IPAM cannot positively confirm that an address is unused.

Response (DHCP Range):

{
  "ip_address": "192.168.1.120",
  "status": "dhcp",
  "allocation_state": "no_record",
  "allocation_label": "No recorded allocation",
  "dhcp_range": {
    "id": 1,
    "start_ip": "192.168.1.100",
    "end_ip": "192.168.1.150",
    "network_id": 1
  },
  "network": {
    "id": 1,
    "network": "192.168.1.0/24",
    "name": "Office Network",
    "domain": "office.local",
    "vlan_id": 100,
    "location": "Building A"
  }
}

Response (Unmanaged):

{
  "ip_address": "8.8.8.8",
  "status": "unmanaged",
  "message": "IP address is not in any managed network"
}

Error Responses

All errors follow this format:

{
  "message": "Error description",
  "errors": {
    "field": "Detailed error information"
  }
}

Common HTTP Status Codes: - 200 OK - Successful GET/PUT request - 201 Created - Successful POST request - 204 No Content - Successful DELETE request - 400 Bad Request - Validation error or business logic error - 404 Not Found - Resource not found - 500 Internal Server Error - Server error

Examples

cURL Examples

List all active hosts in a network:

curl "http://localhost:5000/api/v1/hosts?status=active&network_id=1"

Create a network:

curl -X POST http://localhost:5000/api/v1/networks \
  -H "Content-Type: application/json" \
  -d '{
    "network": "10.1.0.0",
    "cidr": 24,
    "name": "Test Network",
    "vlan_id": 300
  }'

Get next available IP:

curl "http://localhost:5000/api/v1/ip/networks/1/next-ip"

Filter networks by location:

curl "http://localhost:5000/api/v1/networks?location=datacenter&page=1&per_page=20"

Python Example

import requests

BASE_URL = "http://localhost:5000/api/v1"

# Get next available IP
response = requests.get(f"{BASE_URL}/ip/networks/1/next-ip")
next_ip = response.json()["ip_address"]

# Create host with that IP
host_data = {
    "ip_address": next_ip,
    "hostname": "new-server",
    "mac_address": "aa:bb:cc:dd:ee:ff",
    "status": "active",
    "network_id": 1
}
response = requests.post(f"{BASE_URL}/hosts", json=host_data)
created_host = response.json()
print(f"Created host: {created_host['hostname']} with IP {created_host['ip_address']}")

Implementation Status

Completed: - ✅ All endpoint implementations, including upsert for networks/hosts - ✅ Request/response models - ✅ Filtering and pagination - ✅ Swagger UI integration - ✅ Input validation - ✅ Error handling - ✅ API authentication - ✅ Rate limiting - ✅ API tests

The circular import issue that previously blocked this (see REFACTORING.md for the historical write-up) has been resolved.

Next Steps

  1. Performance tuning - Query optimization for large datasets
  2. API versioning - Support for future API versions
  • FEATURES.md: Feature roadmap and requirements (IPAM-023)
  • REFACTORING.md: Detailed refactoring plan
  • CLAUDE.md: Project coding standards

Conditional retirement of an expired dynamic host

POST /api/v1/hosts/{id}/retire-lease

This is the collector-facing alternative to unconditional host deletion. It removes the entire host record only if the previously read snapshot still matches every stored Host column in one atomic SQL DELETE. No database migration is required. Normal API authentication and rate limiting apply.

Request fields:

  • source: nonempty collector identifier, for example udm-leases.
  • expired_before: offset-aware ISO 8601 cutoff. Future cutoffs are rejected.
  • expected: the complete host object previously returned by GET/list. All stored columns, including null values, are mandatory. Computed fields such as allocation_state may be included and are ignored for comparison.

The expected host must have assignment_type=dynamic, and assignment_source, lease_source and discovery_source must all equal source. It must have a MAC, finite expired lease, and non-future assignment/lease confirmation times. is_assigned=true, CNAME, description, device last_seen or last_seen_source protect the record. A collector-owned hostname alone does not protect an old lease. Reservations, static/unknown assignments and foreign sources are refused.

LEASE_RETIRE_MIN_AGE_SECONDS imposes a server-side minimum age after lease expiry (default 3600). The effective cutoff is the earlier of the client's expired_before and server UTC now minus this minimum. A collector may request longer grace, but cannot shorten the server minimum. Configure a nonnegative integer; 0 explicitly disables the server-side grace period.

Example after a successful, complete source collection and candidate selection:

from datetime import datetime, timedelta, timezone
import requests

# session already has the normal Authorization header.
url = f"{base_url}/hosts/{host_id}"
snapshot_response = session.get(url, timeout=30)
snapshot_response.raise_for_status()
snapshot = snapshot_response.json()
response = session.post(
    url + "/retire-lease",
    json={
        "source": "udm-leases",
        "expired_before": (
            datetime.now(timezone.utc) - timedelta(hours=2)
        ).isoformat(),
        "expected": snapshot,
    },
    timeout=30,
)
response.raise_for_status()

Responses:

Status Meaning
204 Matching expired dynamic host removed
400 Missing/malformed snapshot, invalid types/timestamps or future cutoff
404 Host already absent; a repeated request does not remove another row
409 Protected/foreign record, missing evidence or grace period not elapsed
412 Stored record changed since the supplied snapshot; nothing deleted

On 412, collect/reconcile again; do not replace the snapshot and retry blindly. A changed hostname, MAC, network, status, confirmation timestamp or any other stored value invalidates the request, even if the lease remains expired. Lease renewals and reassignment therefore invalidate an older deletion request.

This endpoint does not prove that the source snapshot is complete or that an IP is free on the network. Those are collector responsibilities. Only call it when a complete successful snapshot has no current lease/reservation/static claim for the candidate. Source strings are ownership assertions, not token scopes; existing API tokens retain their existing privileges. The old DELETE /hosts/{id} remains unconditional for manual administration. Addresses in active DHCP pools remain excluded from static allocation suggestions regardless of host removal. No scheduled cleanup is enabled by this change.

Conditional release of a removed reservation

POST /api/v1/hosts/{id}/release-reservation

Collector-facing operation for a reservation that no longer exists at its source. Like retire-lease, it applies only if the previously read snapshot still matches every stored Host column, in one atomic SQL statement. It replaces deleting the reservation with the unconditional DELETE and converting it with two separate upserts.

Request fields:

  • source: the reservation source; must equal the snapshot's assignment_source (for example udm-reservations).
  • action: delete or convert.
  • expected: the complete host object previously returned by GET/list (same rules as for retire-lease).
  • lease (convert only): the current lease of the address, with mac_address, lease_expires_at, lease_source, lease_observed_at (offset-aware, not in the future) and optional hostname.

Only hosts with assignment_type=reservation owned by source are eligible; static, dynamic and foreign records are refused with 409.

delete removes the host. It is refused (409) while the stored lease is still valid: removing a reservation must not free an address that a current lease still claims. Use convert instead.

convert turns the reservation into a dynamic allocation backed by the supplied lease, which must still be valid and must not be older than the stored lease observation (409 otherwise). The host gets assignment_type=dynamic, assignment_source/discovery_source/lease_source = lease.lease_source, assignment_last_confirmed_at = lease.lease_observed_at, is_assigned=false and the lease fields. CNAME, description, status and network stay unchanged. The hostname is taken from the lease when supplied (network domain suffix stripped). If the lease MAC differs, another device holds the address: the hostname is replaced (cleared when the lease has none) and last_seen / last_seen_source are cleared. The response is the updated host.

response = session.post(
    f"{base_url}/hosts/{host_id}/release-reservation",
    json={
        "source": "udm-reservations",
        "action": "convert",
        "expected": snapshot,
        "lease": {
            "mac_address": "aa:bb:cc:dd:ee:ff",
            "lease_expires_at": "2026-11-02T20:00:00+00:00",
            "lease_source": "udm-leases",
            "lease_observed_at": "2026-11-02T16:00:00+00:00",
            "hostname": "printer",
        },
    },
    timeout=30,
)

Responses:

Status Meaning
200 convert: reservation converted; body is the updated host
204 delete: reservation host removed
400 Invalid action, snapshot, lease, MAC or timestamps
404 Host already absent
409 Not a reservation of source; delete with valid stored lease; convert with expired or older lease
412 Stored record changed since the supplied snapshot; nothing changed

As with retire-lease, the endpoint does not prove that the source snapshot is complete; only call it after a complete successful collection shows the reservation is gone. The converted record fits the retire-lease contract once its lease expires, provided it carries no CNAME or description.