Support › Documentatie › REST API

REST API

Nederlands  |  English

REST API

PrivateLAN offers a JSON REST API for managing devices, VLANs and device groups programmatically, so you can integrate provisioning into your own tooling instead of using the web interface for every change.

The API lives on the same hostname as your PrivateLAN appliance, under /api/v1/, for example https://app.privatelan.nl/api/v1/device.

Your appliance also hosts a live, browsable reference and a machine-readable spec β€” useful for exploring the exact set of endpoints on your installed version, or importing into a tool like Postman or Insomnia:

  • https://<your-hostname>/api/docs β€” browsable reference page
  • https://<your-hostname>/api/openapi.json β€” OpenAPI 3.1 specification

Both are public (no API key needed) β€” the same as this written documentation, they only describe the API's shape, not any account data, and are the same convention major APIs (Stripe, GitHub, ...) follow for their own spec pages.

Authentication

The API is secured with an API key, created from the Users page in the same way you create a regular user account β€” just choose "API key (bot)" as the type instead of "Human". An API key is a restricted user: it is assigned roles and a license slot exactly like a human user, and every action it performs is written to the audit log.

When an API key is created, the full key is shown exactly once. It cannot be retrieved again afterwards β€” store it securely. If it is lost, generate a new one from the user's edit page ("Rotate API key"); this immediately invalidates the old key.

Send the key as a bearer token on every request:

Authorization: Bearer plk_5f3a1c9e7b2d4f18_9a7c...

An API key that fails to authenticate ten times from the same IP address within five minutes is temporarily locked out, exactly like a human login. Wait a few minutes and try again.

Restricting where a key can be used from

Optionally, an API key can be restricted to one or more source IP addresses or subnets (CIDR notation), configured on the user's edit page. Requests from any other address are rejected as if the key were invalid. Leave this empty to allow the key to be used from any address.

Request / response format

All requests and responses use JSON. POST and PUT requests must send a JSON body with Content-Type: application/json.

A successful response always has this shape:

{
  "success": true,
  "data": { ... }
}

An error response always has this shape:

{
  "success": false,
  "error": {
    "code": 3000,
    "slug": "DEVICE_NOT_FOUND",
    "message": "Device does not exist."
  }
}

The numeric code is stable across every endpoint, so your integration only has to handle each error once, wherever it occurs.

Retrying safely (Idempotency-Key)

Send an Idempotency-Key header (any unique string you generate, e.g. a UUID) on a POST request to make it safe to retry after a network error or timeout, without risking doing the same thing twice:

POST /api/v1/device
Content-Type: application/json
Idempotency-Key: 8f14e45f-ceea-467e-9d1a-1f3e5c9b2a11

{ "device_hostname": "printer-floor2", ... }
  • Retrying with the same key and the same request body returns the exact same response as the original request produced β€” the device is not created twice, no duplicate job is queued.
  • Retrying with the same key but a different body is rejected with error code 4006 (IDEMPOTENCY_KEY_MISMATCH) β€” reuse a key only for retries of the exact same request.
  • Sending no key at all behaves exactly as before β€” nothing changes for integrations that don't use this.
  • A key is remembered for 24 hours after the original request.

Rate limits

Each API key may make a limited number of requests per minute. This protects the appliance and the switches behind it from a runaway integration; it is separate from the lockout that already applies to failed authentication attempts.

There are two budgets, and they are counted separately:

BudgetDefaultApplies to
General100 requests per minuteEvery API endpoint.
Switch-touching10 requests per minuteDevice actions, switch actions, live device status, live switch status, service port activation, issuing or revoking a certificate, and starting a system update.

A switch-touching request counts against both budgets, because each one opens an SSH session to a switch or queues a job that does. A switch has only a handful of terminal lines, which is why that budget is much smaller. Requests that only read already-collected data β€” listing devices, the status overview for many devices at once, a switch's interface list β€” count against the general budget only.

Both limits are configurable by an administrator under System β†’ Security, and either can be set to 0 to switch it off.

Every response tells you where you stand:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 93
X-RateLimit-Bucket: default
X-RateLimit-Reset: 60

X-RateLimit-Bucket names which budget was charged β€” default, or heavy for a switch-touching request. On a switch-touching request the headers describe the smaller budget, since that is the one that will run out first.

Going over a limit returns 429 with error code 5003 and a Retry-After header:

HTTP/1.1 429 Too Many Requests
Retry-After: 6

{
  "success": false,
  "error": {
    "code": 5003,
    "slug": "RATE_LIMIT_EXCEEDED",
    "message": "Rate limit exceeded for this API key. See the Retry-After header.",
    "retry_after": 6,
    "bucket": "heavy"
  }
}

Wait the number of seconds in Retry-After and try again. The budget refills continuously rather than resetting at a fixed moment, so a client that spreads its requests evenly never hits the limit at all: 100 per minute is also roughly 1.7 per second sustained, with room to burst up to the full 100 after a quiet period.

Error codes

CodeSlugMeaning
1000INVALID_API_KEYThe API key is missing, malformed, or does not match a known key.
1001API_KEY_DISABLEDThe key exists but has been disabled.
1002API_KEY_NOT_YET_VALIDThe key has a "valid from" date in the future.
1003API_KEY_EXPIREDThe key has passed its expiration date.
1004TOO_MANY_ATTEMPTSToo many failed authentication attempts from this address. Try again later.
1100FORBIDDENThe key does not have the role required for this action.
2000VALIDATION_ERROROne or more submitted fields failed validation β€” see the fields object in the response for details.
2001INVALID_JSON_BODYThe request body is missing or is not valid JSON.
2002METHOD_NOT_ALLOWEDThis HTTP method is not supported on this endpoint.
2004INVALID_LIMITThe limit query parameter must be a number between 1 and 500.
2005INVALID_CURSORThe cursor query parameter is not valid or no longer points to an existing item.
3000DEVICE_NOT_FOUNDThe requested device does not exist.
3001VLAN_NOT_FOUNDThe requested VLAN does not exist.
3002DEVICE_GROUP_NOT_FOUNDThe requested device group does not exist.
3003SWITCH_NOT_FOUNDThe requested switch does not exist.
3004SWITCH_GROUP_NOT_FOUNDThe requested switch group does not exist.
3005VLAN_DEVICE_GROUP_NOT_FOUNDThe requested VLAN/device-group link does not exist.
3006VLAN_SWITCH_GROUP_NOT_FOUNDThe requested VLAN/switch-group link does not exist.
3007INTERFACE_NOT_AVAILABLEThe interface does not exist, is already assigned, or is not a device port.
3008JOB_NOT_FOUNDThe requested job does not exist.
3009CERTIFICATE_NOT_FOUNDThis certificate file does not exist β€” it may not have been generated yet.
3010WEBHOOK_NOT_FOUNDThe requested webhook endpoint does not exist.
3011WEBHOOK_CONDITION_NOT_FOUNDThe requested webhook condition does not exist.
3012SERVICE_PORT_NOT_FOUNDThe interface does not exist, or is not tagged as a service port.
3013SBOM_NOT_FOUNDNo SBOM has been generated on this appliance yet.
4000DEVICE_LOCKEDThe device is locked and cannot be modified, deleted, or acted on.
4001LINK_IN_USEThis VLAN/device-group link is still used by one or more devices.
4002MISSING_IPNo longer returned. Issuing a certificate used to require an IP address; it no longer does. The code is kept listed so a client that switches on it does not break.
4003JOB_ALREADY_STARTEDThe job has already started and can no longer be cancelled.
4004JOB_NOT_FINISHEDOnly a finished job (succeeded, failed or cancelled) can be retried.
4005UNKNOWN_ACTIONThis action is not supported for this resource.
4006IDEMPOTENCY_KEY_MISMATCHThis Idempotency-Key was already used with a different request body.
4007UPDATE_ALREADY_RUNNINGA system update is already in progress.
4008SERVICE_PORT_CONFIG_EMPTYNo service port configuration is set, so the port cannot be programmed.
4009DEVICE_IS_VIRTUALA virtual device has no ports of its own; assign interfaces to the device it runs on.
4010DEVICE_HAS_CHILDRENVirtual devices still run on this device; delete or move them before deleting it.
4011POE_CLASS_INSUFFICIENTThe interface cannot supply the PoE class the device's group requires.
4012SBOM_GENERATION_RUNNINGAn SBOM generation is already queued or running. Wait until it has finished.
5000INTERNAL_ERRORSomething went wrong while processing the request.
5001LICENSE_LIMIT_REACHEDCreating this object would exceed your license limit.
5002UPDATE_CHECK_FAILEDUnable to check for available updates (e.g. no connection to the update server).
5003RATE_LIMIT_EXCEEDEDThis API key has exceeded its request limit. Wait the number of seconds in the Retry-After header and retry.

Endpoints

Each resource supports the same six actions:

MethodPathAction
GET/api/v1/{resource}List all
GET/api/v1/{resource}/{id}Read one
POST/api/v1/{resource}Create
PUT/api/v1/{resource}/{id}Replace (fields you omit reset to their default)
PATCH/api/v1/{resource}/{id}Partially update (fields you omit are left unchanged)
DELETE/api/v1/{resource}/{id}Delete

Available resources: device, vlan, device-group, switch, switch-group, vlan-device-group, vlan-switch-group.

Partial updates with PATCH

PUT replaces the entire record β€” any field you don't send is reset to its default, exactly as if you'd cleared it in the web interface. PATCH only changes the field(s) you actually send; everything else stays exactly as it was:

PATCH /api/v1/device/42
Content-Type: application/json

{ "device_description": "Moved to 3rd floor" }

Only device_description changes β€” the device's hostname, IP, MAC address, security type and everything else on device 42 are left untouched. Use PATCH whenever you only want to change one or two fields; use PUT when you want to fully replace a record.

Paging through long lists

By default, GET /api/v1/{resource} returns every item in a single response, exactly as before β€” nothing changes for existing integrations. To page through the results instead, add ?limit=:

GET /api/v1/device?limit=50
{
  "success": true,
  "data": [ ... up to 50 devices ... ],
  "meta": { "next_cursor": "NDI=", "limit": 50 }
}

If next_cursor is not null, there are more results β€” request the next page with:

GET /api/v1/device?limit=50&cursor=NDI=

limit must be between 1 and 500. Treat next_cursor as an opaque token β€” always use the exact value returned, don't construct or decode it yourself.

Devices

GET /api/v1/device/42
{
  "success": true,
  "data": {
    "device_id": 42,
    "device_hostname": "printer-floor2",
    "device_ip": "10.20.0.55",
    "device_mac": ["aabbccddeeff"],
    "device_security": "MAC",
    "device_dg": 3
  }
}
POST /api/v1/device
Content-Type: application/json

{
  "device_hostname": "printer-floor2",
  "device_ip": "10.20.0.55",
  "device_mac": ["aa:bb:cc:dd:ee:ff"],
  "device_description": "2nd floor printer",
  "device_security": "MAC",
  "device_dg": 3
}

device_mac is always an array of strings, even for a single address β€” this keeps the format consistent whether a device has one MAC address or several (see below). Each entry is normalized the same way the web interface does: separators (:/-/.) and case are stripped/lowercased automatically, so "aa:bb:cc:dd:ee:ff" and "aabbccddeeff" are equivalent. Omit the field, or send an empty array, for a device with no MAC address.

device_dg is the ID of the device group + VLAN combination the device belongs to (as shown on the Device Management page). See Device Management for what each field means and which security types require a username/password.

device_username and device_password are required when device_security is PEAP (any username/password, 4–100 characters) or MAB (both must be set to the device's own MAC address β€” PrivateLAN normalizes case/separators for you). Omit both for Unsecure, MAC and EAP-TLS.

{
  "device_hostname": "phone-floor2",
  "device_ip": "10.20.0.60",
  "device_security": "PEAP",
  "device_username": "phone-floor2",
  "device_password": "a-strong-password",
  "device_dg": 3
}

device_connection (wired or wireless, default wired) says how the device reaches the network. A wireless device is attached to an access point and has no switch port, so port assignment, port configuration, the port actions and the per-interface reports do not apply to it β€” but it still authenticates, is still reachability-checked and can still be given a certificate.

device_ip_mode (static or dynamic, default static) says where the address comes from. With static, device_ip is required and is what you send. With dynamic, the address is discovered from the switches' DHCP snooping table: device_ip must not be sent β€” a request that supplies both is refused rather than having one of them quietly dropped β€” and two read-only fields report the result, device_ip_source (manual or dhcp-snooping) and device_ip_seen (when it was last confirmed). Switching an existing device to dynamic clears its address until one is observed.

device_kind (hardware or virtual, default hardware) says whether the device has switch ports of its own or runs on another registered device β€” a virtual machine on a hypervisor, for example. A virtual device names that device in device_parent (an integer; null for a hardware device, and sending one for a hardware device is refused) and uses its ports: the status and action endpoints below answer with the parent's interfaces, and POST /api/v1/device-interface refuses it with error code 4009 (DEVICE_IS_VIRTUAL). The parent must be a wired hardware device without a parent of its own, and must not be locked; when the parent's device group is in access port mode (see Device groups) the virtual device must be in the parent's VLAN. Every device also carries two read-only fields, parent_hostname and child_count. Deleting a device that still carries virtual devices returns 4010 (DEVICE_HAS_CHILDREN). See Device Management for how a shared port is programmed.

A PUT replaces every field, so send device_connection, device_ip_mode and device_kind on a full update or they fall back to their defaults; a PATCH leaves out fields you do not mention.

device_is_camera (boolean, default false) flags the device for the optional Camera Live View feature. Configuring the camera's own ONVIF connection details is a separate step done from the web interface after creating the device β€” it is not part of this API.

device_mac accepts more than one MAC address β€” just add more entries to the array. This is typically used with security type MAC (sticky), where any of the listed addresses is accepted on the port.

{
  "device_hostname": "voip-phone-12",
  "device_mac": ["aabbccddee01", "aabbccddee02"],
  "device_security": "MAC",
  "device_dg": 3
}

Switches

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

{
  "switch_hostname": "sw-floor2-01",
  "switch_group": 1,
  "switch_location": "Building A",
  "switch_rack": "Rack 4",
  "switch_type": "Cisco-IOS",
  "switch_ip": "10.10.0.11",
  "switch_username": "svc-privatelan",
  "switch_port": 22,
  "switch_auth_mode": "password",
  "switch_password": "...",
  "switch_enable": "..."
}

switch_port is the TCP port PrivateLAN opens its SSH session to the switch on. It is optional β€” omit it, or send an empty value, and the standard port 22 is used. Set it only when the switch offers management SSH somewhere else; anything outside 1–65535 is rejected with a 422.

switch_type must be one of Cisco-IOS, ArubaOS, ArubaOS-CX. switch_password, switch_enable, switch_ssh_key and switch_ssh_key_passphrase are write-only β€” they are never returned by GET, and on PUT an omitted or empty value leaves the currently stored credential unchanged (send a new value only when you want to rotate it).

SSH key authentication

switch_auth_mode selects how PrivateLAN logs in to the switch: password (the default) or key. To use an SSH key, send the private key along with the mode:

PUT /api/v1/switch/12
Content-Type: application/json

{
  "switch_hostname": "sw-floor2-01",
  "switch_group": 1,
  "switch_type": "Cisco-IOS",
  "switch_ip": "10.10.0.11",
  "switch_username": "svc-privatelan",
  "switch_auth_mode": "key",
  "switch_ssh_key": "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEA...\n-----END OPENSSH PRIVATE KEY-----",
  "switch_ssh_key_passphrase": ""
}

PEM and OpenSSH key formats are both accepted (RSA, ECDSA and Ed25519). The key is stored encrypted and is never returned again. A key that cannot be read β€” including one whose switch_ssh_key_passphrase is wrong β€” is rejected with a 422.

switch_ssh_key_passphrase belongs to the key it is sent with: it is only read when switch_ssh_key is present in the same request, so send both together or neither. Setting switch_auth_mode to key without a key ever having been stored is rejected with a 422.

The response contains switch_ssh_pubkey, the public half derived from the key you sent. This is the value to install on the switch itself β€” it is read-only and is also shown on the switch's page in the web interface.

DELETE /api/v1/switch/{id} accepts an optional ?switch_action= query parameter before removing the switch's own data:

ValueEffect
lock_devicesReset only the ports currently assigned to devices to their default state first.
lock_allReset every port on the switch (including uplinks) to its default state first.
resetFactory-reset the switch itself first.
(omitted)No pre-delete action β€” just remove the switch's data from PrivateLAN.

Switch groups

POST /api/v1/switch-group
Content-Type: application/json

{
  "sg_name": "floor2-switches",
  "sg_description": "2nd floor access switches"
}

Binds a VLAN to a device group β€” the resulting link's id is what a device's device_dg field (see Devices above) actually refers to. A device group can be linked to more than one VLAN, and vice versa.

POST /api/v1/vlan-device-group
Content-Type: application/json

{
  "vlan_id": 20,
  "dg_id": 3
}

DELETE /api/v1/vlan-device-group/{id} is refused with error code 4001 (LINK_IN_USE) while any device still has this link as its device_dg β€” reassign or remove those devices first.

Binds a VLAN to a switch group, so it gets programmed onto every switch in that group.

POST /api/v1/vlan-switch-group
Content-Type: application/json

{
  "vlan_id": 20,
  "sg_id": 1
}

Deleting a link does not automatically reprogram the affected switches β€” trigger VLAN programming from the VLAN page afterwards if you want the change pushed immediately.

Assigning a switch interface to a device

Not a CRUD resource β€” a single endpoint handles all actions, since it involves two IDs (device and interface) rather than one.

To find an interface_id for a known port on a known switch (e.g. to assign GigabitEthernet1/0/5 on a switch you already have the switch_id for β€” see Switches), list every interface on that switch:

GET /api/v1/device-interface?switch_id=3
{
  "success": true,
  "data": [
    {
      "interface_id": 87,
      "name": "GigabitEthernet1/0/5",
      "non_device_port": false,
      "trunk": false,
      "sfp": false,
      "service": false,
      "assigned_device_id": null,
      "free": true
    }
  ]
}

free is true only when the interface is currently unassigned (assigned_device_id is null) and is not a non-device, trunk or uplink port. Only free interfaces can be assigned.

Once you have an interface_id, list the interfaces currently assigned to a specific device instead:

GET /api/v1/device-interface?device_id=12
{
  "success": true,
  "data": [
    {
      "switch_interface_id": 87,
      "switch_interface_name": "GigabitEthernet1/0/12",
      "switch_hostname": "sw-floor2-01",
      "sg_name": "floor2-switches",
      "switch_interface_patchpoint_value": "Patch panel 3 - port 12"
    }
  ]
}
POST /api/v1/device-interface
Content-Type: application/json

{ "device_id": 12, "interface_id": 87 }

Only a currently-unassigned device port (not a network/uplink port) can be assigned. Optionally include "patchpoint" in the same request to set it in one call (see below); if that part fails, the assignment itself still succeeds β€” retry the patch point separately with PUT.

DELETE /api/v1/device-interface
Content-Type: application/json

{ "device_id": 12, "interface_id": 87 }

Both actions above are refused with error code 4000 (DEVICE_LOCKED) while the device is locked, and with 4009 (DEVICE_IS_VIRTUAL) for a virtual device, which has no ports of its own β€” assign the port to the device it runs on instead. Listing the interfaces of a virtual device (?device_id=) returns its parent's.

Assigning is also refused with 4011 (POE_CLASS_INSUFFICIENT) when the interface cannot supply the PoE class the device's group requires (dg_poe_class). Each entry of GET /api/v1/device-interface?switch_id= carries poe_max_class, the highest PoE class that port declares through its [P0]–[P8] interface tag, so a suitable port can be picked before attempting the assignment. -1 means the port declares no PoE at all, which is also what an untagged port means.

DELETE normally also queues the default port configuration over the freed port. The response field default_skipped reports the one case where it does not: a port carrying the appliance's own LAN address keeps the configuration it has, because resetting it would disconnect the appliance from its own network. It is false for every other unassign.

Patch point

A free-text note on where an assigned interface's cable is physically terminated (for example, a patch panel and port number). Stays with the interface even if it is later unassigned and connected to a different device.

PUT /api/v1/device-interface
Content-Type: application/json

{ "device_id": 12, "interface_id": 87, "patchpoint": "Patch panel 3 - port 12" }

The interface must currently be assigned to the given device β€” setting a patch point on an interface that belongs to a different device (or none) returns error code 3007 (INTERFACE_NOT_AVAILABLE). Send an empty string to clear it. Max 100 characters; letters, numbers, dashes, dots, single/double quotes and spaces only.

Issuing and revoking a device certificate

For devices using EAP-TLS. Not a CRUD resource β€” both actions only queue the work and return immediately (202 Accepted) with a job_id (see Jobs below); check device_cert_generated on GET /api/v1/device/{id}, or poll the job, to see when it completes.

POST /api/v1/device-certificate
Content-Type: application/json

{ "device_id": 12 }
{
  "success": true,
  "data": { "queued": true, "device_id": 12, "job_id": "b3f2c1a0-...", "status": "queued" }
}
DELETE /api/v1/device-certificate
Content-Type: application/json

{ "device_id": 12 }

Generating no longer requires the device to have an IP address. Where there is none β€” a device whose address comes from DHCP, for instance β€” the certificate is issued against the device's hostname instead of its address. That is what decides whether authentication succeeds in any case, so nothing about how the certificate works changes.

Downloading certificates

Once a device certificate has been generated, download it directly with your API key β€” no browser session needed:

GET /api/v1/device/{id}/certificate/download?format=pem

format is one of pem, crt, pfx, pfx-legacy, p12, csr, key. Returns 3009 (CERTIFICATE_NOT_FOUND) if the certificate hasn't been generated yet (or in that format).

The CA certificate (needed to configure supplicants to trust PrivateLAN's RADIUS server) is available the same way, without a device ID:

GET /api/v1/ca-certificate/download?format=pem

format is pem or der.

VLANs

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

{
  "vlan_id": 20,
  "vlan_name": "guests",
  "vlan_ip": "10.20.0.0",
  "vlan_subnet": "255.255.255.0",
  "vlan_gateway": "10.20.0.1"
}

See VLANs for routing-related fields.

Device groups

POST /api/v1/device-group
Content-Type: application/json

{
  "dg_name": "printers",
  "dg_description": "Networked printers",
  "dg_port_mode": "access",
  "dg_poe_class": -1,
  "dg_deploy_config": "...",
  "dg_unsecure_config": "...",
  "dg_secure_config_mac": "...",
  "dg_secure_config_mab": "...",
  "dg_secure_config_peap": "...",
  "dg_secure_config_eap_tls": "..."
}

Each config field must contain the [Cisco-IOS], [ArubaOS] and [ArubaOS-CX] markers on their own lines, exactly like the web interface requires β€” see Device Management.

dg_port_mode (access or trunk, default access) says what kind of port the group's templates produce. In access mode a virtual device must be in the VLAN of the device it runs on; in trunk mode it may be in another VLAN and the #vlan-list# placeholder renders all of them. Switching a group back to access is refused while a virtual device behind one of its devices is in another VLAN.

dg_poe_class (-1 to 8, default -1) is the IEEE PoE class the group's devices need, with -1 meaning the group has no PoE requirement. An interface can only be assigned to a device in this group when the class it declares through its [P0]–[P8] interface tag is at least this high; a weaker port is refused with 4011 (POE_CLASS_INSUFFICIENT). Raising the class is refused while a device in the group already sits on a port that cannot supply it. The value configures nothing on a switch: the PoE configuration a port gets still comes from the group's config templates, where an explicit power inline never stays authoritative.

Operational actions, status & jobs

Beyond managing configuration, the API can also trigger live actions on devices and switches, check status, and track background work. These endpoints require the same kind of role an API key needs for anything else β€” assign it on the Users page like any other role.

Device actions

POST /api/v1/device/{id}/actions/{action}
Content-Type: application/json

{ "params": { "interface": "GigabitEthernet1/0/12" } }

action is one of:

ActionEffect
restart-interfaceBounce the port(s) this device is connected to.
enable-interfaceEnable the port(s).
disable-interfaceDisable the port(s).
reauthenticateForce the device to re-authenticate (802.1x/MAB).
check-authenticationRead back the current authentication state.
pingCheck whether the device answers a ping.

params.interface is optional β€” omit it to apply the action to every interface currently assigned to the device. This is a live action: it opens a session to the switch(es) and returns the result directly (not queued as a job).

restart-interface, enable-interface, disable-interface and reauthenticate change the state of a live switch port, so they are refused with error code 4000 (DEVICE_LOCKED) while the device is locked. ping and check-authentication only read, and stay available β€” see "Locked devices".

disable-interface, restart-interface and reauthenticate all put a port down β€” a restart and a reauthenticate only briefly, but the session to the switch runs over that same port, so the second half never arrives if the appliance is behind it. On the port that carries the appliance's own LAN address these three are therefore not sent to the switch at all: that interface comes back in results with "success": false and a detail explaining why, while every other interface in the same call is still acted on. Without this, disabling the appliance's own port would cut the appliance off from its own network and from the switch needed to bring the port back β€” a serial console or a second machine on that switch is the only way out. The port is recognised by the LAN address configured under System > Networking; the WAN address is not considered.

ping is the one action that is not about interfaces, so it takes params.from instead of params.interface:

POST /api/v1/device/{id}/actions/ping
Content-Type: application/json

{ "params": { "from": "appliance" } }
params.fromMeaning
appliance (default)Ping from the appliance itself. Answers whether the appliance can reach the device at all, and returns the average round-trip time in avg_ms.
switchPing from the switch the device is patched into. Answers whether the device responds inside its own VLAN, which is what you want when the appliance is not on that VLAN. Returns one result per switch.

Pinging from a switch opens a session to that switch, so it is slower than pinging from the appliance and needs the switch login to be working.

Device status (live)

GET /api/v1/device/{id}/status

Returns the device's current link status, authentication state, PoE status and discovered neighbor, read live from the switch it's connected to.

Switch actions

POST /api/v1/switch/{id}/actions/{action}

action is one of reload, factory-reset, clear-counters (all live, immediate) or sync (queues an immediate refresh of this switch's cached status β€” see below β€” instead of waiting for the next automatic refresh; returns a job_id).

Switch status & interfaces

GET /api/v1/switch/{id}/status

Live overview of the switch itself (uptime, stack members, etc.).

GET /api/v1/switch/{id}/interfaces

Every interface on the switch, including which device (if any) is assigned to each one, its patch point, and its last known status. device_id and device_hostname name the device that owns the port; devices lists every device on it β€” the owner first, then the virtual devices running on it β€” each with device_id, device_hostname and kind.

Service ports

Service ports give temporary access to the network during maintenance. A port becomes a service port by tagging its interface [SERVICE] in the web interface β€” that tag is the only way one is created, so there is no endpoint to create or delete one here.

GET /api/v1/service-port
GET /api/v1/service-port?switch_id=3

Every service port with its current session state:

{
  "success": true,
  "data": [
    {
      "interface_id": 87,
      "interface_name": "GigabitEthernet1/0/7",
      "switch_id": 3,
      "switch_hostname": "CISCO-SW1",
      "switch_group": "Default",
      "link_status": "connected",
      "active": true,
      "vlan_id": 1030,
      "expires_at": "2026-08-30 15:30:41",
      "activated_at": "2026-08-30 11:30:41",
      "activated_by": 4,
      "config": null
    }
  ]
}

config is null when the port uses the general configuration; it only contains a template when that one port has been given its own.

Open a session β€” the port is programmed in the chosen VLAN and shuts down by itself when the session expires:

POST /api/v1/service-port/87/actions/activate
{ "params": { "vlan_id": 1030, "duration_hours": 4 } }

duration_hours must be 4, 8, 12 or 24, and the VLAN must be one that is available on the switch the port belongs to β€” both are checked, so the API cannot open a longer session or a VLAN that the web interface would refuse.

Close it early:

POST /api/v1/service-port/87/actions/disable

Both actions return as soon as the session has been recorded; the switch is reconfigured in the background. A successful response therefore means "the session is open" (or closed), not "the switch has finished" β€” read the port back with GET /api/v1/service-port/87 to see the result.

The configuration that gets applied on activation:

GET /api/v1/service-port-config
PUT /api/v1/service-port-config
{ "config": "[Cisco-IOS]\nconfigure terminal\n..." }

It applies to every service port, and must contain a [Cisco-IOS], [ArubaOS] and [ArubaOS-CX] section, each on its own line. Use #interface# and #vlan-id# where the port name and the chosen VLAN belong. A single port can be given its own template instead:

PUT /api/v1/service-port/87
{ "config": "[Cisco-IOS]\n..." }

Send { "config": null } to put that port back on the general configuration.

Status for many devices at once

GET /api/v1/devices/status?device_ids=12,13,14

or, for an entire device group:

GET /api/v1/devices/status?device_group_id=3

Unlike the single-device status endpoint above, this reads from a periodically refreshed cache (roughly every 5 minutes) rather than opening a live session per device β€” this is what makes it practical to check hundreds of devices in one call. The response includes a cached_as_of timestamp so you know how fresh the data is.

Jobs

Actions that don't complete immediately (certificate generation/revocation above, a switch sync) return a job_id. Use it to track progress:

GET /api/v1/jobs/{id}
{
  "success": true,
  "data": {
    "job_id": "b3f2c1a0-...",
    "status": "succeeded",
    "created_at": "2026-08-22T10:00:00Z",
    "finished_at": "2026-08-22T10:00:04Z",
    "result": { ... },
    "error": null
  }
}

status is one of queued, running, succeeded, failed, cancelled.

POST /api/v1/jobs/{id}/cancel

Only effective while the job is still queued β€” a job that has already started cannot be interrupted (error code 4003, JOB_ALREADY_STARTED).

POST /api/v1/jobs/{id}/retry

Queues a new job that repeats a finished one β€” only works on a job that is succeeded, failed or cancelled (error code 4004, JOB_NOT_FINISHED, otherwise). Returns a new job_id; the original job record is left as-is.

System updates

Check for and trigger PrivateLAN system updates without using the web interface β€” useful for automating an update right after a configuration change.

GET /api/v1/system-update
{
  "success": true,
  "data": {
    "current_version": "26.8.5",
    "update_available": true,
    "latest_available_version": "26.9.0",
    "update_running": false,
    "reboot_pending": false,
    "firmware_update_pending": false,
    "automatic_updates_enabled": true
  }
}
POST /api/v1/system-update

Starts an update to the latest available version and returns 202 immediately β€” this does not wait for the update to finish and there is no job to poll. The update runs in the background and typically takes a few minutes; call GET /api/v1/system-update again to see when current_version/update_running reflect that it's done. If you're already on the latest version, this responds with {"already_up_to_date": true} instead of starting anything. Returns error code 4007 (UPDATE_ALREADY_RUNNING) if an update is already in progress.

SBOM

Generate and download the appliance's Software Bill of Materials (SBOM) β€” the list of all installed software components in CycloneDX JSON format. Useful for feeding a vulnerability scanner or for your own CRA or NIS2 documentation without logging in to the web interface. The API key needs the system-sbom role ("Can view SBOM").

GET /api/v1/system-sbom
{
  "success": true,
  "data": {
    "generation_running": false,
    "sbom": {
      "bom_ref": "PrivateLAN 26.9.4",
      "release": "26.9.4",
      "operating_system": "Ubuntu 26.04",
      "spec_version": "1.6",
      "generated_at": "2026-09-14T10:12:31+02:00",
      "component_count": 951,
      "size_bytes": 528099,
      "sha256": "8960b6c2def76f38e05a9f1a782c72ffe728c5576224420f303bac53d977b8be"
    }
  }
}

sbom is null when no SBOM has been generated yet. release is null when the appliance could not determine its own version at the moment the SBOM was generated.

POST /api/v1/system-sbom

Queues a new SBOM generation and returns 202 with a job_id immediately. Generation takes a few minutes. Call GET /api/v1/system-sbom again until generation_running is false and generated_at is later than your request β€” that is the reliable completion signal. Returns error code 4012 (SBOM_GENERATION_RUNNING) if a generation is already queued or running.

GET /api/v1/system-sbom/download

Downloads the current SBOM as a JSON file. The X-Checksum-SHA256 response header carries the SHA-256 of the file, which is the same value as sbom.sha256 above. Returns 3013 (SBOM_NOT_FOUND) when no SBOM exists and 4012 while a generation is still running, so you never receive the previous SBOM by accident.

curl -s -X POST -H "Authorization: Bearer $KEY" https://privatelan.example/api/v1/system-sbom
curl -s -H "Authorization: Bearer $KEY" https://privatelan.example/api/v1/system-sbom
curl -s -H "Authorization: Bearer $KEY" -o sbom.json https://privatelan.example/api/v1/system-sbom/download

Webhooks

Where the rest of this API is you asking PrivateLAN a question, a webhook is PrivateLAN telling you something happened β€” a switch going offline, a device failing 802.1x, a maintenance port being opened. You register a URL, tick the events you care about, and PrivateLAN calls that URL each time one occurs.

A webhook target does not have to be your own server. The method, authentication and body are all configurable, so the same mechanism can call a relay module or another appliance directly β€” which is why, for example, a door relay can be switched by a failed authentication without anything in between.

Webhooks are managed under Logging > Webhooks in the web interface, or through these endpoints. Both need the "Can manage webhooks" right; you only ever see your own webhooks, unless you are an administrator.

GET    /api/v1/webhook
POST   /api/v1/webhook
GET    /api/v1/webhook/{id}
PATCH  /api/v1/webhook/{id}
DELETE /api/v1/webhook/{id}
GET    /api/v1/webhook/{id}/deliveries

List, create, read, partially update and delete a webhook, and read one webhook's delivery log. Deleting a webhook also removes its conditions and its delivery log.

{
  "url": "https://example.com/hooks/privatelan",
  "events": ["switch.offline", "device.authentication.failed"],
  "condition_logic": "AND",
  "conditions": [
    { "field": "switch_hostname", "operator": "contains", "value": "core" }
  ]
}

Creating a webhook returns a signing secret (whsec_…) once, in the create response only. It is never shown again β€” store it when you create the webhook.

Conditions

A webhook with no conditions receives every occurrence of the events it is subscribed to. Conditions narrow that down: each one names a field from the event payload, an operator (eq, neq, contains, gt, lt, in) and a value, and condition_logic decides whether all of them have to match (AND) or any one of them (OR).

Because payload field names are shared between related events β€” every event about a switch port calls it switch_hostname, both authentication events carry the same set of keys β€” one condition usually covers a whole group of events rather than just one.

Events

EventFires when
device.createdA device is added
device.updatedA device's settings change. Only a real change fires; re-saving an unchanged form does not
device.deletedA device is removed
device.authentication.succeededA device authenticates successfully on the network
device.authentication.failedAn authentication attempt is refused
device.onlineA device answers again after having been unreachable
device.offlineA device stops answering
device.ip_changedA device whose address comes from DHCP is discovered on a different address
device.ip_conflictA discovered address already belongs to another registered device. The address is reported, never taken over
device.insecure_port_openThe SSL check finds a port open that should be closed on this device (by default port 80)
device.ssl_problemThe SSL check finds the secure port of a device closed, without encryption, or offering the wrong certificate (by default port 443)
device.ssl_recoveredA device that had an SSL check problem has none left
switch.onlineA switch answers again after having been unreachable
switch.offlineA switch stops answering
service.port.openedA maintenance session is started on a service port
service.port.closedA maintenance session ends β€” manually, by running out, or because the port is no longer marked as a service port
inspection.unexpected_port_usageA connected port has no device assigned to it
inspection.unexpected_port_usage.resolvedThat port is no longer in that state
inspection.disconnected_portA port marked as an uplink, SFP or non-device port is not connected
inspection.disconnected_port.resolvedThat port is no longer in that state
certificate.issuedA device certificate has been generated
certificate.revokedA device certificate has been withdrawn
certificate.expiringA certificate is approaching its expiry date
auditlog.criticalA critical entry is written to the audit log
system.reboot_requiredThe appliance needs a restart to finish applying updates
system.firmware_update_availableFirmware updates are waiting to be installed
system.update_availableA new PrivateLAN version is available
system.reboot_required.resolvedThe restart has happened
system.firmware_update_available.resolvedThe firmware updates have been installed
system.update_available.resolvedThe new version has been installed
honeypot.triggeredA device connects to a decoy network port that nothing on the appliance actually serves

A few events are listed in the web interface but marked as not yet available; those can be selected through the API but will not deliver anything until a later release.

How often an event fires

Events that describe something an administrator did β€” a device added, a maintenance session started β€” fire once per action. Events that describe a state fire on the change, not continuously: a switch that has been unreachable for a week sends one switch.offline, not one per check, and it sends switch.online when it comes back.

Device reachability follows the same rule as switch reachability, so bear in mind what that means for equipment that is not on all the time: a workstation switched off at night sends an offline in the evening and an online in the morning, every day. If that is not what you want, filter on something in the payload, or subscribe only the receiver that should hear about it.

A single unanswered ping is not treated as an outage. A switch or device that was reachable is pinged a second time before it is reported offline, so one dropped packet does not produce an offline followed five minutes later by an online.

device.ip_conflict is sent once per device when the conflict is first found, not again on every switch poll while it lasts. Once the device is seen without the conflict it is forgotten, so a conflict that comes back later is reported again.

The two inspection events describe a finding, not a moment. The appliance re-checks both continuously and reports a port once when it starts matching and once more when it stops β€” the .resolved half. A port nobody has got round to fixing therefore does not re-announce itself, and every alert you receive eventually closes. opened_at on the resolved message says when the finding first appeared. These findings are the same ones the Inspection page shows, and there is a ceiling on how many are reported per check, so a single large event β€” a switch rebooting, an uplink dying β€” is summarised rather than sent port by port.

Authentication is the one place this matters in practice. A device re-authenticates by itself far more often than anyone wants to be told about, so an event is only sent when a device's result changes β€” the first refusal after a run of successes, or the success after a run of refusals. A device that keeps failing does not repeat the alert every few seconds, and there is a minimum interval per device on top of that, so a device flapping between the two cannot flood your receiver either.

certificate.expiring works in steps: at 30, 14, 7 and 1 day remaining a certificate sends one message each, never more. Those steps are configurable.

Attempts a switch refuses without ever asking PrivateLAN β€” a port shut for another reason, a cable pulled β€” are not authentication events and produce nothing here.

honeypot.triggered is throttled per source address and port: repeated attempts from the same device within a short window send one alert, not one per attempt.

The three SSL check events come from the check the appliance runs every five minutes (Inspection > SSL) and also describe a finding. device.insecure_port_open and device.ssl_problem are sent once per port when it turns into a problem, and once more only when it turns into a different problem β€” for example from an expired certificate to one issued for another name. device.ssl_recovered is sent once per device when it has no problems left. The payload names the device, the port, its status, a list of short problems codes and the same message the Inspection page shows; for device.ssl_problem it also carries the TLS version and the certificate the device presented. A muted device sends none of these, and the first check after the feature is switched on only records what it finds, so existing problems are shown on the page without arriving as a burst of events.

The inspection and SSL check events require the Inspection option in your license.

Payloads and delivery

By default the body is JSON:

{
  "event": "service.port.closed",
  "timestamp": 1756900000,
  "data": {
    "interface_id": 412,
    "interface_name": "GigabitEthernet1/0/24",
    "switch_id": 7,
    "switch_hostname": "sw-core-01",
    "switch_group": "Head office",
    "vlan_id": 30,
    "expires_at": "2026-09-03 18:00:00",
    "reason": "expired",
    "occurred_at": "2026-09-03T14:02:11+02:00"
  }
}

Each call carries an X-PrivateLAN-Signature header, an HMAC-SHA256 over the body and the timestamp, plus the X-PrivateLAN-Timestamp it was computed with. Verify it before acting on a delivery. You can also replace the body entirely with your own template if the receiver expects a specific format.

Payloads never contain credentials. A device's authentication password, switch passwords, certificate private keys and the passwords logged with a refused login attempt are all deliberately left out β€” device.updated names the fields that changed but not their values, for the same reason. The delivery log keeps every payload for as long as its retention period, and anyone who can manage webhooks can read it.

A delivery that fails is retried with increasing delays for up to eight attempts, after which it is marked undeliverable and the webhook is flagged as failing. Return 2xx promptly and do the real work afterwards: a receiver that is slow to answer is treated as a failure. Retries mean a receiver should be able to handle the same event arriving twice.

Delivery log

GET /api/v1/webhook/{id}/deliveries
{
  "success": true,
  "data": [
    {
      "id": 918,
      "event": "device.authentication.failed",
      "attempt": 1,
      "status": "delivered",
      "last_http_status": 200,
      "last_error": null,
      "next_retry_at": null,
      "created_at": "2026-09-03 14:02:12"
    }
  ]
}

status is pending, delivered, failed (waiting for a retry) or dead_letter (out of attempts). Entries are removed once they pass the configured retention period.

Validation errors

A 422 response with error code 2000 includes a fields object mapping each invalid field to a human-readable reason:

{
  "success": false,
  "error": {
    "code": 2000,
    "slug": "VALIDATION_ERROR",
    "message": "One or more fields failed validation.",
    "fields": {
      "device_hostname": "Hostname: This hostname already exists.",
      "device_ip": "IP: This IP is not part of the correct subnet."
    }
  }
}

Deleting a device

Deleting a device that is locked returns error code 4000 (DEVICE_LOCKED). Unlock it from the Device Management page first.

Locked devices

A device can be locked from the Device Management page to freeze it. While it is locked, the API refuses every request that would change it or its ports with error code 4000 (DEVICE_LOCKED) and HTTP status 409:

RequestRefused while locked
PUT / PATCH /api/v1/device/{id}Yes
DELETE /api/v1/device/{id}Yes
POST /api/v1/device-interface and DELETE /api/v1/device-interfaceYes
POST /api/v1/device-certificate (issue and revoke)Yes
POST /api/v1/device/{id}/actions/{restart-interface,enable-interface,disable-interface,reauthenticate}Yes
POST /api/v1/device/{id}/actions/{ping,check-authentication}No β€” read-only
GET requestsNo β€” read-only

Reading a locked device, its status and its authentication state all keep working, so a locked device can still be diagnosed. Unlock it from the Device Management page to make changes.

There are three lock strengths, and they differ only in who may release the lock again β€” the API refuses the same requests for all three:

LockReleased by
Basic lockAny user who can log in
Admin lockAdministrators and the PrivateLAN Support Team
Support lockThe PrivateLAN Support Team only

A support lock is meant for a device PrivateLAN support is working on, and an administrator of your own installation cannot release it. You are only offered the lock strengths you could also undo yourself.

← Terug naar documentatie
Unauthorized

Unauthorized

You don't have authorization to access this function.