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¶
Interactive Documentation¶
Access the interactive Swagger UI at:
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¶
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¶
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_atorlast_seenolder than the stored value, the whole aspect is ignored. - Persistent assignments survive leases. An existing
reservationorstaticassignment is never replaced bydynamicorunknown; that aspect is ignored. Switching betweenreservationandstatic, 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-Aspectsresponse header (comma separated:assignment,lease,observation); the rest of the request is still applied. - MAC changes are deliberate. Changing
mac_addresson a host with areservation/staticassignment returns HTTP 409 withexisting_mac_address,requested_mac_addressandassignment_typein the body. Send"confirm_mac_change": trueto 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¶
Response: HTTP 204 (No Content) Error: HTTP 400 if network has assigned hosts
Get Network 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¶
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¶
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_atorlast_seenolder than the stored value, the whole aspect is ignored. - Persistent assignments survive leases. An existing
reservationorstaticassignment is never replaced bydynamicorunknown; that aspect is ignored. Switching betweenreservationandstatic, 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-Aspectsresponse header (comma separated:assignment,lease,observation); the rest of the request is still applied. - MAC changes are deliberate. Changing
mac_addresson a host with areservation/staticassignment returns HTTP 409 withexisting_mac_address,requested_mac_addressandassignment_typein the body. Send"confirm_mac_change": trueto 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¶
Response: HTTP 204 (No Content)
DHCP Ranges¶
List 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¶
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_atorlast_seenolder than the stored value, the whole aspect is ignored. - Persistent assignments survive leases. An existing
reservationorstaticassignment is never replaced bydynamicorunknown; that aspect is ignored. Switching betweenreservationandstatic, 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-Aspectsresponse header (comma separated:assignment,lease,observation); the rest of the request is still applied. - MAC changes are deliberate. Changing
mac_addresson a host with areservation/staticassignment returns HTTP 409 withexisting_mac_address,requested_mac_addressandassignment_typein the body. Send"confirm_mac_change": trueto 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¶
Response: HTTP 204 (No Content)
Network 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¶
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¶
Response: Created backup object (HTTP 201)
Verify Backup¶
Response:
Restore Backup¶
Response:
{
"name": "ipam-backup-20251228-120000Z.db",
"restored": true,
"integrity_ok": true,
"integrity_message": "ok"
}
IP Management¶
Get Next Available IP¶
Response:
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¶
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¶
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:
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:
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:
Filter networks by location:
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¶
- Performance tuning - Query optimization for large datasets
- API versioning - Support for future API versions
Related Documentation¶
- 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¶
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 exampleudm-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 asallocation_statemay 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¶
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'sassignment_source(for exampleudm-reservations).action:deleteorconvert.expected: the complete host object previously returned by GET/list (same rules as forretire-lease).lease(convertonly): the current lease of the address, withmac_address,lease_expires_at,lease_source,lease_observed_at(offset-aware, not in the future) and optionalhostname.
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.