unifi-api
UniFi API Skill
Interact with a UniFi Dream Router or other UniFi OS console via its REST API. Tested against UDR-7 running UniFi OS 4.x / Network Application 10.x.
Setup
Ask the user for:
- Router IP (commonly
192.168.1.1,10.0.0.1, or10.0.1.1) - API key — read from
UNIFI_API_KEYenv var (generate in Network App → Integrations → Create New API Key)
All requests go to port 443 over HTTPS. The router uses a self-signed cert by default.
import os, urllib.request, json, ssl
BASE = f"https://{ROUTER_IP}"
KEY = os.environ["UNIFI_API_KEY"]
HEADERS = {"X-API-KEY": KEY, "Accept": "application/json"}
CTX = ssl._create_unverified_context() # self-signed cert
def get(path):
req = urllib.request.Request(f"{BASE}{path}", headers=HEADERS)
with urllib.request.urlopen(req, context=CTX) as r:
return json.loads(r.read())
def post(path, body=None):
data = json.dumps(body or {}).encode()
req = urllib.request.Request(f"{BASE}{path}", data=data, method="POST",
headers={**HEADERS, "Content-Type": "application/json"})
with urllib.request.urlopen(req, context=CTX) as r:
return json.loads(r.read())
def put(path, body):
data = json.dumps(body).encode()
req = urllib.request.Request(f"{BASE}{path}", data=data, method="PUT",
headers={**HEADERS, "Content-Type": "application/json"})
with urllib.request.urlopen(req, context=CTX) as r:
return json.loads(r.read())
Curl equivalent:
curl -sk -H "X-API-KEY: $UNIFI_API_KEY" -H "Accept: application/json" \
https://<ROUTER_IP>/proxy/network/integration/v1/sites
Note: The API key works on both the Integration API and the legacy private API — no session cookie needed on UDR-7.
Security note:
ssl._create_unverified_context()disables TLS certificate verification to accommodate the router's self-signed cert. This is acceptable on a trusted home LAN but means a MITM on the same network could intercept the API key. Do not use this pattern over untrusted networks.
Site Identifiers
Two formats are used depending on the API layer. Always start by fetching sites:
sites = get("/proxy/network/integration/v1/sites")["data"]
SITE = sites[0]["id"] # UUID — for Integration API
SITE_S = sites[0]["internalReference"] # short name (usually "default") — for Legacy API
IV1 = f"/proxy/network/integration/v1/sites/{SITE}"
LEGACY = f"/proxy/network/api/s/{SITE_S}"
Response Formats
Integration API — paginated:
{"offset": 0, "limit": 25, "count": 25, "totalCount": 100, "data": [...]}
Page through with ?offset=N&limit=25. To get all results loop until offset + count >= totalCount.
Legacy API — flat list:
{"meta": {"rc": "ok"}, "data": [...]}
Endpoints Reference
Clients / Stations
# All currently connected clients (rich: ip, mac, hostname, tx/rx bytes,
# uptime, switch port, network, fingerprint, fixed_ip flag, etc.)
get(f"{LEGACY}/stat/sta")
# All known clients including historical
get(f"{LEGACY}/stat/alluser")
# Single client by MAC
get(f"{LEGACY}/stat/user/aa:bb:cc:dd:ee:ff")
# Via Integration API (lighter fields, paginated)
get(f"{IV1}/clients")
get(f"{IV1}/clients/{{clientId}}")
Key client fields: mac, ip, hostname, name, network, network_id,
is_wired, uptime, first_seen, last_seen, wired-tx_bytes,
wired-rx_bytes, wired-tx_bytes-r (current rate), wired-rx_bytes-r,
sw_port, use_fixedip, fixed_ip, local_dns_record, is_guest,
dev_family, oui, confidence.
Client commands (destructive — confirm MAC before executing):
post(f"{LEGACY}/cmd/stamgr", {"cmd": "block-sta", "mac": "aa:bb:cc:dd:ee:ff"})
post(f"{LEGACY}/cmd/stamgr", {"cmd": "unblock-sta", "mac": "aa:bb:cc:dd:ee:ff"})
post(f"{LEGACY}/cmd/stamgr", {"cmd": "kick-sta", "mac": "aa:bb:cc:dd:ee:ff"})
post(f"{LEGACY}/cmd/stamgr", {"cmd": "forget-sta", "mac": "aa:bb:cc:dd:ee:ff"})
Devices
# Full device stats (rich: system-stats, uplink, wan1, speedtest-status, port stats)
get(f"{LEGACY}/stat/device")
# Lightweight list
get(f"{LEGACY}/stat/device-basic")
# Via Integration API
get(f"{IV1}/devices")
get(f"{IV1}/devices/{{deviceId}}")
Key device fields: mac, name, model, version, uptime,
system-stats (cpu%, mem%, uptime string), uplink (ip, rx_bytes, tx_bytes,
drops, latency, xput_down, xput_up, speedtest_lastrun), wan1 (full port stats),
speedtest-status (latency, xput_download, xput_upload, server details).
Device commands (destructive — restart, upgrade, and power-cycle will disrupt connectivity; confirm with user before executing):
post(f"{LEGACY}/cmd/devmgr", {"cmd": "restart", "mac": "aa:bb:cc:dd:ee:ff"})
post(f"{LEGACY}/cmd/devmgr", {"cmd": "upgrade", "mac": "aa:bb:cc:dd:ee:ff"})
post(f"{LEGACY}/cmd/devmgr", {"cmd": "set-locate", "mac": "aa:bb:cc:dd:ee:ff"}) # blink LED
post(f"{LEGACY}/cmd/devmgr", {"cmd": "unset-locate", "mac": "aa:bb:cc:dd:ee:ff"})
post(f"{LEGACY}/cmd/devmgr", {"cmd": "speedtest"})
post(f"{LEGACY}/cmd/devmgr", {"cmd": "speedtest-status"})
post(f"{LEGACY}/cmd/devmgr", {"cmd": "power-cycle", "mac": "...", "port_idx": 1})
Health & Status
# Subsystem health: wlan, wan, www, lan, vpn
# wan: WAN IP, ISP name, latency monitors, uptime stats, client counts, CPU/mem
# www: xput_up/down, latency, speedtest_status, speedtest_lastrun
get(f"{LEGACY}/stat/health")
# Controller version, timezone, hostname, all IP addresses, uptime
get(f"{LEGACY}/stat/sysinfo")
Health www subsystem fields: xput_up, xput_down, latency, uptime,
drops, speedtest_status, speedtest_lastrun, speedtest_ping.
Networks / VLANs
# Full network config (WAN + all LANs/VLANs)
# Fields: name, purpose (wan/corporate), ip_subnet, vlan, wan_dns1/2,
# wan_provider_capabilities, firewall_zone_id
get(f"{LEGACY}/rest/networkconf")
put(f"{LEGACY}/rest/networkconf/{{id}}", updated_fields)
# Integration API (cleaner, full CRUD)
get(f"{IV1}/networks")
post(f"{IV1}/networks", new_network)
put(f"{IV1}/networks/{{networkId}}", updated_network)
# DELETE also available
WiFi / SSIDs
# All WLANs — fields: name, enabled, wlan_band (both/2g/5g), wpa_mode,
# x_passphrase (returned in plaintext — avoid logging), networkconf_id,
# usergroup_id, hide_ssid, is_guest, pmf_mode
get(f"{LEGACY}/rest/wlanconf")
# Toggle on/off
put(f"{LEGACY}/rest/wlanconf/{{id}}", {"enabled": True})
# Change password
put(f"{LEGACY}/rest/wlanconf/{{id}}", {"x_passphrase": "newpassword"})
# Create new SSID
post(f"{LEGACY}/add/wlanconf", {...})
Firewall
Firewall policies (Integration API — the primary firewall system on UDR-7):
# Paginate through all policies
get(f"{IV1}/firewall/policies?offset=0&limit=25")
# Fields: id, enabled, name, description, index, action.type (BLOCK/ALLOW),
# source.zoneId, source.trafficFilter, destination.zoneId,
# destination.trafficFilter (ipAddressFilter, portFilter, macAddressFilter),
# ipProtocolScope.ipVersion, loggingEnabled, metadata.origin
# Enable/disable a policy (PATCH — use requests library for PATCH support)
# PATCH /proxy/network/integration/v1/sites/{SITE}/firewall/policies/{id}
# body: {"enabled": false}
Firewall zones:
get(f"{IV1}/firewall/zones")
# Zone fields: id, name, networkIds, metadata.origin (SYSTEM_DEFINED/USER_DEFINED)
# System zones: Gateway, Vpn, Dmz, Hotspot, External, Internal
Legacy firewall (old-style rules — may be empty if using new policy system):
get(f"{LEGACY}/rest/firewallrule") # individual rules
get(f"{LEGACY}/rest/firewallgroup") # IP/MAC address groups
Traffic rules (v2):
get(f"/proxy/network/v2/api/site/{SITE_S}/trafficrules")
post(f"/proxy/network/v2/api/site/{SITE_S}/trafficrules", new_rule)
Port Forwarding
# Read current rules
# Fields: name, fwd (dest IP), fwd_port, dst_port, proto, enabled,
# pfwd_interface, rx_bytes, rx_packets
get(f"{LEGACY}/stat/portforward")
# Create
post(f"{LEGACY}/rest/portforward", {
"name": "My Service", "fwd": "10.0.0.x", "fwd_port": "8080",
"dst_port": "8080", "proto": "tcp_udp", "pfwd_interface": "wan"
})
# Enable/disable
put(f"{LEGACY}/rest/portforward/{{id}}", {"enabled": False})
Time-Series Stats
# Always specify attrs to get useful data back
ATTRS = ["bytes", "rx_bytes", "tx_bytes", "num_sta"]
post(f"{LEGACY}/stat/report/5minutes.site", {"attrs": ATTRS})
post(f"{LEGACY}/stat/report/hourly.site", {"attrs": ATTRS})
post(f"{LEGACY}/stat/report/daily.site", {"attrs": ATTRS})
# Per-client (filter by MAC)
post(f"{LEGACY}/stat/report/hourly.user",
{"attrs": ["bytes", "rx_bytes", "tx_bytes"], "mac": "aa:bb:cc:dd:ee:ff"})
# Per-AP
post(f"{LEGACY}/stat/report/hourly.ap", {"attrs": ATTRS})
Data retention defaults: 24h at 5-min resolution, 7d hourly, 90d daily.
Events & Alarms
get(f"{LEGACY}/stat/event") # recent events
get(f"{LEGACY}/stat/alarm") # active alarms
Site Settings
# Full settings blob (keys: super_identity, super_mgmt, connectivity, locale,
# snmp, mgmt, usg, ips, rsyslogd, broadcastping, and more)
get(f"{LEGACY}/rest/setting")
What Does NOT Work on UDR-7 (Integration API 404s)
These endpoints exist in the docs but return 404 on UDR-7 firmware 4.x:
/wifi-broadcasts→ use legacy/rest/wlanconfinstead/dns-policies/supporting-resources/wan-interfaces/supporting-resources/dpi-categories/supporting-resources/device-tags/supporting-resources/site-to-site-vpn-tunnels/supporting-resources/vpn-servers/supporting-resources/radius-profiles/v2/api/site/{site}/trafficstats
Common Patterns
Get WAN status:
health = get(f"{LEGACY}/stat/health")["data"]
wan = next(s for s in health if s["subsystem"] == "wan")
www = next(s for s in health if s["subsystem"] == "www")
print(f"WAN IP: {wan['wan_ip']}, ISP: {wan.get('isp_name','?')}")
print(f"Speed: ↓{www['xput_down']} ↑{www['xput_up']} Mbps, latency: {www['latency']}ms")
List clients sorted by current throughput:
clients = get(f"{LEGACY}/stat/sta")["data"]
for c in sorted(clients, key=lambda x: x.get("wired-tx_bytes-r", 0) + x.get("wired-rx_bytes-r", 0), reverse=True):
name = c.get("name") or c.get("hostname") or c["mac"]
tx_r = c.get("wired-tx_bytes-r", 0)
rx_r = c.get("wired-rx_bytes-r", 0)
print(f"{name:35} {c.get('ip','?'):16} ↓{rx_r/1024:.1f} ↑{tx_r/1024:.1f} KB/s")
Find a client by name, hostname, or IP:
clients = get(f"{LEGACY}/stat/sta")["data"]
query = "my-device"
target = next((c for c in clients if
query.lower() in (c.get("name","") + c.get("hostname","")).lower() or
c.get("ip") == query), None)
Paginate through all firewall policies:
policies, offset = [], 0
while True:
page = get(f"{IV1}/firewall/policies?offset={offset}&limit=25")
policies.extend(page["data"])
if offset + page["count"] >= page["totalCount"]:
break
offset += 25
Toggle an SSID:
wlans = get(f"{LEGACY}/rest/wlanconf")["data"]
wlan = next(w for w in wlans if w["name"] == "My SSID")
put(f"{LEGACY}/rest/wlanconf/{wlan['_id']}", {"enabled": not wlan["enabled"]})
PATCH support (needed for firewall policy enable/disable):
# urllib doesn't support PATCH — install requests or use this workaround:
import urllib.request
class PatchRequest(urllib.request.Request):
def get_method(self): return "PATCH"
req = PatchRequest(f"{BASE}{path}", data=json.dumps(body).encode(),
headers={**HEADERS, "Content-Type": "application/json"})
with urllib.request.urlopen(req, context=CTX) as r:
return json.loads(r.read())