UnifiApi

CIHex.pmHexDocsLicense

Elixir HTTP client for UniFi Dream Machine APIs, covering both the Network API (v10.1.84) and the Protect API (v6.2.88). Built on Req.

Installation

Add unifi_api to your list of dependencies in mix.exs:

def deps do
[
{:unifi_api, "~> 0.4.0"}
]
end

unifi_api is a library and ships no Application callback — it does not start a supervision tree of its own. Consumers that need the auto-rotating cookie/CSRF session (UnifiApi.Auth.Session) add it to their own supervision tree:

children = [
{UnifiApi.Auth.Session,
name: MyApp.UnifiSession,
client: UnifiApi.new(base_url: "https://192.168.1.1", api_key: "..."),
relogin: fn -> UnifiApi.Auth.Cookie.login(...) end,
style: :udm}
]
Supervisor.start_link(children, strategy: :one_for_one)

Stateless one-shot scripts don't need any supervision — just call UnifiApi.Auth.Cookie.login/4 directly (see "Operational Data").

Configuration

Application config

# config/config.exs
config :unifi_api,
base_url: "https://192.168.1.1",
api_key: "your-api-key",
# Secure by default (CWE-295 / OWASP A02). Set to `false` only for
# self-signed controllers on a trusted network, or pin the leaf cert
# with `cert_fingerprints` — see "Self-Signed Certificates".
verify_ssl: true,
# UDM defaults — for Cloud Key, set both to "/integration"
network_path: "/proxy/network/integration",
protect_path: "/proxy/protect/integration"

Environment variables

# config/runtime.exs
config :unifi_api,
base_url: System.get_env("UNIFI_BASE_URL", "https://192.168.1.1"),
api_key: System.get_env("UNIFI_API_KEY", ""),
# `verify_ssl: true` by default (secure-by-default). To disable:
# - set `UNIFI_VERIFY_SSL=false`, or
# - set the last-resort escape hatch `UNIFI_INSECURE_TLS=1`.
# Both are insecure; prefer `cert_fingerprints` for self-signed controllers.
verify_ssl: System.get_env("UNIFI_VERIFY_SSL", "true") == "true" and
System.get_env("UNIFI_INSECURE_TLS", "0") not in ["1", "true"],
network_path: System.get_env("UNIFI_NETWORK_PATH", "/proxy/network/integration"),
protect_path: System.get_env("UNIFI_PROTECT_PATH", "/proxy/protect/integration")

Path prefixes

The four API path prefixes are resolved once when the client is built and carried on the client struct, so clients for different controller flavours coexist in the same VM.

On UDM / UDM Pro / UDM SE (UniFi OS) the APIs run behind a reverse proxy; on Cloud Key and standalone controllers they sit at the root. Pick the flavour with :style:

udm = UnifiApi.new(base_url: udm_url, api_key: k1, style: :udm) # default
cloud_key = UnifiApi.new(base_url: ck_url, api_key: k2, style: :cloud_key)
API:udm (default):cloud_keyOverride optionEnv var
Network/proxy/network/integration/integration:network_pathUNIFI_NETWORK_PATH
Protect/proxy/protect/integration/integration:protect_pathUNIFI_PROTECT_PATH
Network v1 (cookie auth)/proxy/network"":v1_pathUNIFI_V1_PATH
Protect v1 (cookie auth)/proxy/protect/protect:protect_v1_path

Not sure which flavour you have? UnifiApi.detect/1 probes the controller and its :style feeds straight back into new/1:

{:ok, info} = UnifiApi.detect(UnifiApi.new(base_url: url))
client = UnifiApi.new(base_url: url, api_key: key, style: info.style)

Setting the paths in application config still works and is honoured when no :style is given, but it is global — it cannot describe two controllers at once:

config :unifi_api,
network_path: "/integration",
protect_path: "/integration"

Resolution order, highest first: an explicit :network_path (etc.) option, the :style preset, the application env key, then the :udm default.

Runtime override

Pass options directly when creating a client to override application config:

client = UnifiApi.new(
base_url: "https://192.168.0.1",
api_key: "my-api-key",
# verify_ssl: true is the default; only set false with a self-signed
# controller on a trusted network, or use cert_fingerprints.
cert_fingerprints: ["sha256:AB:CD:EF:..."]
)

Timeouts, retries, and pools

All optional, with production-safe defaults:

OptionDefaultNotes
:connect_timeout5_000TCP/TLS connect. Mint's own default is 30s, which makes a black-holed controller IP block ~61s per call. Also config :unifi_api, connect_timeout: ms.
:receive_timeout30_000Time to first response.
:pool_timeout5_000Connection checkout.
:max_retries1Retries transient failures on GET/HEAD only. A server-supplied Retry-After is honoured but clamped to 300s.
:finchName of a Finch pool you started yourself. Mutually exclusive with the TLS and connect-timeout options, which then belong on your pool; combining them raises instead of silently dropping your transport settings.
client = UnifiApi.new(
base_url: "https://192.168.0.1",
api_key: "my-api-key",
connect_timeout: 2_000,
receive_timeout: 10_000
)

Quick Start

# Create a client (uses application config)
client = UnifiApi.new()
# Or with explicit options
client = UnifiApi.new(base_url: "https://192.168.1.1", api_key: "my-key")
# Check the controller version
{:ok, info} = UnifiApi.Network.Info.get_info(client)
# => {:ok, %{"applicationVersion" => "10.1.84"}}
# List all sites
{:ok, sites} = UnifiApi.Network.Sites.list(client)
# List devices on a site
{:ok, devices} = UnifiApi.Network.Devices.list(client, "site-uuid")
# List Protect cameras
{:ok, cameras} = UnifiApi.Protect.Cameras.list(client)

Quality-of-Life Helpers

A few small utilities that show up everywhere:

# Reachability — works against both API-key and cookie-authed clients
:ok = UnifiApi.ping(client)
# Detect controller style and the matching path conventions
{:ok, %{style: :udm, network_prefix: _, v1_prefix: _, auth_path: _}} =
UnifiApi.detect(client)
# Resolve a site by display name without listing manually
{:ok, %{"id" => site_id}} = UnifiApi.Network.Sites.find_by_name(client, "HQ")
{:ok, default} = UnifiApi.Network.Sites.find_by_internal_reference(client, "default")
# Unix-millisecond helpers for time-window queries
import UnifiApi.Time
UnifiApi.Protect.Events.list(authed,
start: hours_ago(1),
end: now_ms(),
types: ["motion"]
)

Network API

All Network API functions require a site_id (except Info, Resources.list_dpi_categories/2, Resources.list_dpi_applications/2, Resources.list_countries/2, and Devices.list_pending/2).

Sites

{:ok, sites} = UnifiApi.Network.Sites.list(client)
# => {:ok, [%{"id" => "abc-123", "name" => "Default", "internalReference" => "default"}]}

Devices

# List all devices on a site
{:ok, devices} = UnifiApi.Network.Devices.list(client, site_id)
# Get a specific device
{:ok, device} = UnifiApi.Network.Devices.get(client, site_id, device_id)
# Get latest device statistics
{:ok, stats} = UnifiApi.Network.Devices.get_statistics(client, site_id, device_id)
# Adopt a new device
{:ok, _} = UnifiApi.Network.Devices.adopt(client, site_id, %{mac: "aa:bb:cc:dd:ee:ff"})
# Execute a device action (restart, locate, etc.)
{:ok, _} = UnifiApi.Network.Devices.execute_action(client, site_id, device_id, %{action: "restart"})
# Execute a port action (PoE cycle, etc.)
{:ok, _} = UnifiApi.Network.Devices.execute_port_action(client, site_id, device_id, 3, %{action: "cycle"})
# Remove a device
{:ok, _} = UnifiApi.Network.Devices.remove(client, site_id, device_id)
# List pending devices (not site-scoped)
{:ok, pending} = UnifiApi.Network.Devices.list_pending(client)

Clients

# List connected clients
{:ok, clients} = UnifiApi.Network.Clients.list(client, site_id)
# Each client has: type (WIRED/WIRELESS/VPN/TELEPORT), id, name, connectedAt, ipAddress, access

Networks

# List all networks
{:ok, networks} = UnifiApi.Network.Networks.list(client, site_id)
# Get a specific network
{:ok, network} = UnifiApi.Network.Networks.get(client, site_id, network_id)
# Create a network
{:ok, network} = UnifiApi.Network.Networks.create(client, site_id, %{
name: "Guest VLAN",
vlanId: 100
})
# Update a network
{:ok, _} = UnifiApi.Network.Networks.update(client, site_id, network_id, %{name: "New Name"})
# Delete a network
{:ok, _} = UnifiApi.Network.Networks.delete(client, site_id, network_id)

WiFi

# List WiFi broadcasts (SSIDs)
{:ok, ssids} = UnifiApi.Network.Wifi.list(client, site_id)

Firewall

# --- Zones ---
{:ok, zones} = UnifiApi.Network.Firewall.list_zones(client, site_id)
{:ok, zone} = UnifiApi.Network.Firewall.get_zone(client, site_id, zone_id)
{:ok, zone} = UnifiApi.Network.Firewall.create_zone(client, site_id, %{name: "DMZ", networkIds: [net_id]})
{:ok, _} = UnifiApi.Network.Firewall.update_zone(client, site_id, zone_id, %{name: "DMZ-Updated"})
{:ok, _} = UnifiApi.Network.Firewall.delete_zone(client, site_id, zone_id)
# --- Policies ---
{:ok, policies} = UnifiApi.Network.Firewall.list_policies(client, site_id)
{:ok, policy} = UnifiApi.Network.Firewall.get_policy(client, site_id, policy_id)
{:ok, policy} = UnifiApi.Network.Firewall.create_policy(client, site_id, %{
name: "Block IoT to LAN",
enabled: true,
action: "BLOCK",
source: %{zoneId: iot_zone_id},
destination: %{zoneId: lan_zone_id}
})
{:ok, _} = UnifiApi.Network.Firewall.update_policy(client, site_id, policy_id, %{enabled: false})
{:ok, _} = UnifiApi.Network.Firewall.delete_policy(client, site_id, policy_id)

Hotspot Vouchers

# List all vouchers
{:ok, vouchers} = UnifiApi.Network.Hotspot.list_vouchers(client, site_id)
# Get a specific voucher
{:ok, voucher} = UnifiApi.Network.Hotspot.get_voucher(client, site_id, voucher_id)
# Create vouchers (1-1000 at a time)
{:ok, vouchers} = UnifiApi.Network.Hotspot.create_vouchers(client, site_id, %{
count: 10,
name: "Event Pass",
timeLimitMinutes: 1440,
authorizedGuestLimit: 1,
dataUsageLimitMBytes: 500,
rxRateLimitKbps: 5000,
txRateLimitKbps: 1000
})
# Delete a specific voucher
{:ok, _} = UnifiApi.Network.Hotspot.delete_voucher(client, site_id, voucher_id)
# Delete all vouchers
{:ok, _} = UnifiApi.Network.Hotspot.delete_vouchers(client, site_id)

ACL Rules

# List ACL rules
{:ok, rules} = UnifiApi.Network.ACL.list(client, site_id)
# Create an ACL rule
{:ok, rule} = UnifiApi.Network.ACL.create(client, site_id, %{
type: "IPV4",
name: "Block SSH",
enabled: true,
action: "BLOCK",
protocolFilter: %{protocol: "TCP", dstPort: 22}
})
# Update and delete
{:ok, _} = UnifiApi.Network.ACL.update(client, site_id, rule_id, %{enabled: false})
{:ok, _} = UnifiApi.Network.ACL.delete(client, site_id, rule_id)
# Manage rule ordering
{:ok, ordering} = UnifiApi.Network.ACL.get_ordering(client, site_id)
{:ok, _} = UnifiApi.Network.ACL.update_ordering(client, site_id, %{ids: ["rule-1", "rule-2"]})

DNS Policies

# List DNS policies
{:ok, policies} = UnifiApi.Network.DNS.list(client, site_id)
# Create a DNS record
{:ok, policy} = UnifiApi.Network.DNS.create(client, site_id, %{
type: "A_RECORD",
name: "app.local",
value: "192.168.1.50"
})
# Supported types: A_RECORD, AAAA_RECORD, CNAME_RECORD, MX_RECORD,
# TXT_RECORD, SRV_RECORD, FORWARD_DOMAIN

Traffic Matching

{:ok, lists} = UnifiApi.Network.TrafficMatching.list(client, site_id)
# Types: PORTS, IPV4_ADDRESSES, IPV6_ADDRESSES

Supporting Resources

# WAN interfaces
{:ok, wans} = UnifiApi.Network.Resources.list_wans(client, site_id)
# VPN
{:ok, tunnels} = UnifiApi.Network.Resources.list_vpn_tunnels(client, site_id)
{:ok, servers} = UnifiApi.Network.Resources.list_vpn_servers(client, site_id)
# RADIUS
{:ok, profiles} = UnifiApi.Network.Resources.list_radius_profiles(client, site_id)
# Device tags
{:ok, tags} = UnifiApi.Network.Resources.list_device_tags(client, site_id)
# DPI (not site-scoped)
{:ok, categories} = UnifiApi.Network.Resources.list_dpi_categories(client)
{:ok, apps} = UnifiApi.Network.Resources.list_dpi_applications(client)
# Countries (not site-scoped)
{:ok, countries} = UnifiApi.Network.Resources.list_countries(client)

Protect API

Protect endpoints are not site-scoped.

Cameras

# List all cameras
{:ok, cameras} = UnifiApi.Protect.Cameras.list(client)
# Get a specific camera
{:ok, camera} = UnifiApi.Protect.Cameras.get(client, camera_id)
# Update camera settings
{:ok, _} = UnifiApi.Protect.Cameras.update(client, camera_id, %{
name: "Front Door",
micVolume: 80,
videoMode: "highFps",
ledSettings: %{isEnabled: false}
})
# Take a snapshot (returns JPEG binary)
{:ok, jpeg} = UnifiApi.Protect.Cameras.snapshot(client, camera_id)
File.write!("snapshot.jpg", jpeg)
# High quality snapshot
{:ok, jpeg} = UnifiApi.Protect.Cameras.snapshot(client, camera_id, high_quality: true)
# PTZ controls
{:ok, _} = UnifiApi.Protect.Cameras.ptz_goto(client, camera_id, 1) # Go to preset slot 1
{:ok, _} = UnifiApi.Protect.Cameras.ptz_patrol_start(client, camera_id, 0) # Start patrol slot 0
{:ok, _} = UnifiApi.Protect.Cameras.ptz_patrol_stop(client, camera_id) # Stop patrol

NVR

{:ok, nvr} = UnifiApi.Protect.NVR.get(client)
# => {:ok, %{"id" => "...", "name" => "UNVR", "doorbellSettings" => %{...}}}

Viewers

{:ok, viewers} = UnifiApi.Protect.Viewers.list(client)
{:ok, viewer} = UnifiApi.Protect.Viewers.get(client, viewer_id)
{:ok, _} = UnifiApi.Protect.Viewers.update(client, viewer_id, %{liveview: liveview_id})

Liveviews

{:ok, liveviews} = UnifiApi.Protect.Liveviews.list(client)
# Each has: id, name, isDefault, isGlobal, owner, layout (1-26), slots

Sensors

{:ok, sensors} = UnifiApi.Protect.Sensors.list(client)
# Each has: id, name, state, mountType, batteryStatus, stats,
# isOpened, isMotionDetected, temperature/humidity/light/leak settings

Lights

{:ok, lights} = UnifiApi.Protect.Lights.list(client)
# Each has: id, name, state, isDark, isLightOn, lastMotion,
# lightModeSettings, lightDeviceSettings, camera

Chimes

{:ok, chimes} = UnifiApi.Protect.Chimes.list(client)
# Each has: id, name, state, cameraIds, ringSettings

Operational Data (Legacy v1 / v2 API)

The integration API doesn't expose the operational and monitoring data ops users actually want — events, alarms, IDS detections, rich live wireless stats, topology, traffic, WAN health, and so on. These live on the legacy /api/s/{site}/... and /v2/api/site/{site}/... paths and require cookie + CSRF authentication rather than x-api-key.

Authenticate (one-shot scripts)

# Build an unauthenticated client, then log in.
client = UnifiApi.new(base_url: "https://192.168.1.1",
cert_fingerprints: ["sha256:AB:CD:EF:..."])
{:ok, authed} = UnifiApi.Auth.Cookie.login(client, "admin", "password",
style: :udm # or :cloud_key
)

If you're not sure which style your controller uses, probe it first:

{:ok, info} = UnifiApi.detect(client)
{:ok, authed} = UnifiApi.Auth.Cookie.login(client, user, pass, style: info.style)

For Cloud Key controllers pass style: :cloud_key when building the client, which sets the v1 prefix to "" (the UDM default is /proxy/network). See "Path prefixes".

Authenticate (long-running app)

For pollers and supervised processes that need cookie auth over hours or days, UnifiApi.Auth.Cookie.login/4's static request struct goes stale when the controller rotates the CSRF token. Use UnifiApi.Auth.Session instead — a supervised GenServer that holds the cookie + CSRF state and auto-rotates the token from response headers:

# Fingerprints for the controller's self-signed cert, comma-separated.
fingerprints =
System.get_env("UNIFI_CERT_FINGERPRINTS", "") |> String.split(",", trim: true)
base = UnifiApi.new(base_url: "https://192.168.1.1", cert_fingerprints: fingerprints)
children = [
{UnifiApi.Auth.Session,
name: MyApp.UnifiSession,
client: base,
# Preferred over :username / :password (CWE-522): the session calls this
# only when a fresh login is needed and never holds the plaintext itself.
relogin: fn ->
UnifiApi.Auth.Cookie.login(
base,
System.fetch_env!("UNIFI_USERNAME"),
System.fetch_env!("UNIFI_PASSWORD"),
style: :udm
)
end,
style: :udm}
]
Supervisor.start_link(children, strategy: :one_for_one)
# Anywhere in your app — a lock-free read, no call into the GenServer:
authed = UnifiApi.Auth.Session.client(MyApp.UnifiSession)
{:ok, events} = UnifiApi.Network.Events.list(authed, "default")

Every request through authed pulls the current cookies + CSRF from the GenServer at send time and writes back any rotated token captured from the response. Session.refresh/1 and Session.relogin/1 are escape hatches for the rare case the auto-rotation misses.

Available modules

ModuleEndpointPurpose
Network.Events/stat/eventClient/AP/system events
Network.Alarms/list/alarmActive and archived alarms
Network.Anomalies/stat/anomaliesDiagnostic anomalies
Network.IDS/stat/ips/eventIDS / IPS detections
Network.RogueAP/stat/rogueap, /rest/rogueknownNeighbouring / rogue APs
Network.ClientsLive/stat/sta, /stat/alluserRich wireless stats; offline history
Network.ClientsHistory/v2/.../clients/historySearchable client history
Network.DPI/stat/sitedpi, /stat/stadpiDPI by site / per-client
Network.Traffic/v2/.../traffic, /country-trafficTime-series by client / country
Network.SystemLog/v2/.../system-log/allController system log
Network.ActiveLeases/v2/.../active-leasesLive DHCP table
Network.WAN/v2/.../wan/..., /wan-slasWAN config, ISP status, SLAs
Network.PortAnomalies/v2/.../ports/port-anomaliesSwitch port anomalies
Network.UPS/stat/ups-devicesUPS battery / load
Network.PortForward/rest/portforwardNAT port forward CRUD
Network.Dashboard/v2/.../aggregated-dashboardOne-shot dashboard payload
Network.Topology/v2/.../topologyTopology graph
Protect.Events/proxy/protect/api/eventsMotion, ring, smartDetect events + thumbnails

Quick example

# Recent events (last 24 hours, up to 1000)
{:ok, events} = UnifiApi.Network.Events.list(authed, "default",
within_hours: 24, limit: 1000)
# Worst-RSSI wireless clients right now
{:ok, clients} = UnifiApi.Network.ClientsLive.list(authed, "default")
worst =
clients
|> Enum.reject(& &1["is_wired"])
|> Enum.sort_by(& &1["signal"])
|> Enum.take(10)
# All Protect motion events in the last hour, with thumbnails
hour_ago = System.os_time(:millisecond) - 60 * 60 * 1000
{:ok, motion} =
UnifiApi.Protect.Events.list(authed, start: hour_ago, types: ["motion"])
for ev <- motion do
{:ok, jpeg} = UnifiApi.Protect.Events.thumbnail(authed, ev["id"])
File.write!("event-#{ev["id"]}.jpg", jpeg)
end

DPI with names

The legacy DPI endpoints return numeric cat and app IDs only. Combine them with the integration-API category / application lists via UnifiApi.Network.DPI.with_names/2:

# These don't change often — fetch once, reuse:
{:ok, categories} = UnifiApi.Network.Resources.list_dpi_categories(client)
{:ok, applications} = UnifiApi.Network.Resources.list_dpi_applications(client)
# Then on every poll:
{:ok, dpi} = UnifiApi.Network.DPI.by_site(authed, "default")
named =
UnifiApi.Network.DPI.with_names(dpi,
categories: categories,
applications: applications
)
# Top 10 apps by tx_bytes
named
|> Enum.flat_map(& &1["by_app"])
|> Enum.sort_by(& &1["tx_bytes"], :desc)
|> Enum.take(10)
|> Enum.map(&{&1["application_name"], &1["tx_bytes"]})

Note: v1 / v2 endpoint shapes are documented from community sources (primarily unpoller/unpoller). They have not been exercised end-to-end against live UDM Pro / Cloud Key hardware in v0.4.0. Please file an issue with controller model and firmware version if anything looks off — most fixes will be one-line tweaks.

Streaming & Pagination

Every list endpoint has a stream variant that returns a lazy Stream powered by Stream.resource/3. Pages are fetched on demand — no data is pulled until you consume the stream with Enum or Stream functions.

# Stream ALL devices across pages — fetches 200 per page automatically
UnifiApi.Network.Devices.stream(client, site_id)
|> Enum.to_list()
# Only the first page is fetched
UnifiApi.Network.Devices.stream(client, site_id)
|> Enum.take(5)
# Filter + stream — composable with the full Stream/Enum API
UnifiApi.Network.Clients.stream(client, site_id, filter: "type.eq(WIRELESS)")
|> Stream.map(& &1["name"])
|> Enum.to_list()
# Count all clients without loading them all into memory at once
UnifiApi.Network.Clients.stream(client, site_id)
|> Enum.count()
# Custom page size
UnifiApi.Network.Devices.stream(client, site_id, limit: 50)
|> Enum.to_list()
# Stream firewall policies, vouchers, ACL rules, DNS, etc.
UnifiApi.Network.Firewall.stream_policies(client, site_id)
|> Stream.filter(& &1["enabled"])
|> Enum.to_list()
UnifiApi.Network.Hotspot.stream_vouchers(client, site_id)
|> Stream.reject(& &1["expired"])
|> Enum.map(& &1["code"])
UnifiApi.Network.Resources.stream_dpi_categories(client)
|> Enum.to_list()

Stream functions raise on API errors, making them safe to compose in pipelines.

Available stream functions

Integration API (offset / limit):

ModuleFunction
Sitesstream/2
Devicesstream/3, stream_pending/2
Clientsstream/3
Networksstream/3
Wifistream/3
Firewallstream_zones/3, stream_policies/3
Hotspotstream_vouchers/3
ACLstream/3
DNSstream/3
TrafficMatchingstream/3
Resourcesstream_wans/3, stream_vpn_tunnels/3, stream_vpn_servers/3, stream_radius_profiles/3, stream_device_tags/3, stream_dpi_categories/2, stream_dpi_applications/2, stream_countries/2

Operational v1 API (_start / _limit, requires cookie auth):

ModuleFunction
Eventsstream/3 (with :within_hours)
Alarmsstream/3 (with :archived)
IDSstream/3 (with :within_hours)

Operational v2 API (pageSize / pageNumber, requires cookie auth):

ModuleFunction
ClientsHistorystream/3 (with :within_hours, :type, :search)
SystemLogstream/3
# Stream every event in the last 24 hours, no manual paging
UnifiApi.Network.Events.stream(authed, "default", within_hours: 24)
|> Enum.to_list()
# Top 5 most recent IDS detections
UnifiApi.Network.IDS.stream(authed, "default", within_hours: 1)
|> Enum.take(5)

For other v1 endpoints, drop down to UnifiApi.Client.stream_v1/3 directly — it takes a path and arbitrary :params. For arbitrary page-numbered v2 endpoints, use UnifiApi.Client.stream_paged/2 with a custom fetch_page function.

Manual pagination

If you need per-page control, use list with :offset and :limit:

{:ok, page1} = UnifiApi.Network.Devices.list(client, site_id, limit: 50, offset: 0)
{:ok, page2} = UnifiApi.Network.Devices.list(client, site_id, limit: 50, offset: 50)
# Filter (UniFi filter expression syntax)
{:ok, wireless} = UnifiApi.Network.Clients.list(client, site_id,
filter: "type.eq(WIRELESS)"
)

Filter syntax

Filters use the format property.function(args) and can be combined:

FunctionExample
eqname.eq(Office)
netype.ne(WIRELESS)
gt, ge, lt, leconnectedAt.gt(1700000000)
in, notIntype.in(WIRED,VPN)
likename.like(cam*)
isNull, isNotNullipAddress.isNotNull()
isEmptyname.isEmpty()
contains, containsAny, containsAll, containsExactlytags.contains(vip)

Combine with and(), or(), not().

Data Extraction Recipes

Common patterns for pulling structured data out of your UniFi controller.

Export all clients to a list of maps

client = UnifiApi.new()
all_clients =
UnifiApi.Network.Sites.stream(client)
|> Enum.flat_map(fn site ->
UnifiApi.Network.Clients.stream(client, site["id"])
|> Enum.map(&Map.put(&1, "site", site["name"]))
end)
# Filter only wireless clients
wireless = Enum.filter(all_clients, &(&1["type"] == "WIRELESS"))

Build a device inventory CSV

client = UnifiApi.new()
rows =
UnifiApi.Network.Sites.stream(client)
|> Enum.flat_map(fn site ->
UnifiApi.Network.Devices.stream(client, site["id"])
|> Enum.map(fn device ->
[site["name"], device["name"], device["mac"], device["model"], device["state"]]
|> Enum.join(",")
end)
end)
csv = ["site,name,mac,model,state" | rows] |> Enum.join("\n")
File.write!("devices.csv", csv)

Scrape all camera snapshots

client = UnifiApi.new()
{:ok, cameras} = UnifiApi.Protect.Cameras.list(client)
for camera <- cameras, camera["state"] == "CONNECTED" do
case UnifiApi.Protect.Cameras.snapshot(client, camera["id"], high_quality: true) do
{:ok, jpeg} ->
name = camera["name"] |> String.replace(~r/[^\w]/, "_")
File.write!("snapshots/#{name}.jpg", jpeg)
{:error, reason} ->
IO.puts("Failed #{camera["name"]}: #{inspect(reason)}")
end
end

Collect network topology (sites, networks, devices)

client = UnifiApi.new()
topology =
UnifiApi.Network.Sites.stream(client)
|> Enum.map(fn site ->
sid = site["id"]
%{
site: site["name"],
networks:
UnifiApi.Network.Networks.stream(client, sid)
|> Enum.map(&Map.take(&1, ["id", "name", "vlanId", "subnet"])),
devices:
UnifiApi.Network.Devices.stream(client, sid)
|> Enum.map(&Map.take(&1, ["id", "name", "mac", "model", "state"]))
}
end)

Monitor connected client count over time

client = UnifiApi.new()
[site | _] = UnifiApi.Network.Sites.stream(client) |> Enum.take(1)
# Poll every 60 seconds
Stream.interval(60_000)
|> Stream.map(fn _ ->
counts =
UnifiApi.Network.Clients.stream(client, site["id"])
|> Enum.group_by(& &1["type"])
|> Map.new(fn {type, list} -> {type, length(list)} end)
{DateTime.utc_now(), counts}
end)
|> Stream.each(fn {time, counts} ->
IO.puts("#{time} | WIRED=#{counts["WIRED"] || 0} WIRELESS=#{counts["WIRELESS"] || 0} VPN=#{counts["VPN"] || 0}")
end)
|> Stream.run()

Export firewall rules

client = UnifiApi.new()
UnifiApi.Network.Sites.stream(client)
|> Enum.map(fn site ->
sid = site["id"]
%{
site: site["name"],
zones:
UnifiApi.Network.Firewall.stream_zones(client, sid)
|> Enum.map(&Map.take(&1, ["id", "name", "networkIds"])),
policies:
UnifiApi.Network.Firewall.stream_policies(client, sid)
|> Enum.map(&Map.take(&1, ["id", "name", "enabled", "action", "source", "destination"]))
}
end)

Export hotspot voucher codes

client = UnifiApi.new()
[site | _] = UnifiApi.Network.Sites.stream(client) |> Enum.take(1)
active =
UnifiApi.Network.Hotspot.stream_vouchers(client, site["id"])
|> Stream.reject(& &1["expired"])
|> Enum.map(&Map.take(&1, ["code", "name", "timeLimitMinutes", "expiresAt"]))
# Print as a table
for v <- active do
IO.puts("#{v["code"]} #{v["name"]} #{v["timeLimitMinutes"]}min")
end

Dump all Protect device info

client = UnifiApi.new()
{:ok, cameras} = UnifiApi.Protect.Cameras.list(client)
{:ok, sensors} = UnifiApi.Protect.Sensors.list(client)
{:ok, lights} = UnifiApi.Protect.Lights.list(client)
{:ok, chimes} = UnifiApi.Protect.Chimes.list(client)
{:ok, nvr} = UnifiApi.Protect.NVR.get(client)
protect_inventory = %{
nvr: Map.take(nvr, ["id", "name", "modelKey"]),
cameras: Enum.map(cameras, &Map.take(&1, ["id", "name", "state", "mac", "modelKey"])),
sensors: Enum.map(sensors, &Map.take(&1, ["id", "name", "state", "batteryStatus"])),
lights: Enum.map(lights, &Map.take(&1, ["id", "name", "state", "isLightOn"])),
chimes: Enum.map(chimes, &Map.take(&1, ["id", "name", "state"]))
}

Dashboard data scraper

Pull everything you need for a custom dashboard in one shot — network overview, client breakdown, device health, WiFi status, and Protect camera states.

client = UnifiApi.new()
# Get controller info
{:ok, info} = UnifiApi.Network.Info.get_info(client)
# Collect per-site data
sites_data =
UnifiApi.Network.Sites.stream(client)
|> Enum.map(fn site ->
sid = site["id"]
# Clients grouped by type
clients = UnifiApi.Network.Clients.stream(client, sid) |> Enum.to_list()
client_breakdown =
clients
|> Enum.group_by(& &1["type"])
|> Map.new(fn {type, list} -> {type, length(list)} end)
# Devices with health status
devices =
UnifiApi.Network.Devices.stream(client, sid)
|> Enum.map(fn d ->
%{
name: d["name"],
mac: d["mac"],
model: d["model"],
state: d["state"],
ip: d["ip"]
}
end)
connected_devices = Enum.count(devices, & &1.state == "CONNECTED")
disconnected_devices = Enum.count(devices, & &1.state == "DISCONNECTED")
# Networks
networks =
UnifiApi.Network.Networks.stream(client, sid)
|> Enum.map(&Map.take(&1, ["id", "name", "vlanId"]))
# WiFi SSIDs
ssids =
UnifiApi.Network.Wifi.stream(client, sid)
|> Enum.map(&Map.take(&1, ["id", "name", "enabled"]))
# WANs
wans =
UnifiApi.Network.Resources.stream_wans(client, sid)
|> Enum.map(&Map.take(&1, ["id", "name", "status"]))
%{
site_id: sid,
site_name: site["name"],
clients: %{
total: length(clients),
wired: client_breakdown["WIRED"] || 0,
wireless: client_breakdown["WIRELESS"] || 0,
vpn: client_breakdown["VPN"] || 0,
teleport: client_breakdown["TELEPORT"] || 0
},
devices: %{
total: length(devices),
connected: connected_devices,
disconnected: disconnected_devices,
list: devices
},
networks: networks,
ssids: ssids,
wans: wans
}
end)
# Protect overview
{:ok, cameras} = UnifiApi.Protect.Cameras.list(client)
{:ok, sensors} = UnifiApi.Protect.Sensors.list(client)
{:ok, lights} = UnifiApi.Protect.Lights.list(client)
{:ok, nvr} = UnifiApi.Protect.NVR.get(client)
protect_data = %{
nvr: Map.take(nvr, ["id", "name", "modelKey"]),
cameras: %{
total: length(cameras),
connected: Enum.count(cameras, & &1["state"] == "CONNECTED"),
list:
Enum.map(cameras, fn c ->
%{
id: c["id"],
name: c["name"],
state: c["state"],
model: c["modelKey"]
}
end)
},
sensors: %{
total: length(sensors),
open_doors: Enum.count(sensors, & &1["isOpened"]),
motion_detected: Enum.count(sensors, & &1["isMotionDetected"])
},
lights: %{
total: length(lights),
on: Enum.count(lights, & &1["isLightOn"])
}
}
# Full dashboard payload
dashboard = %{
controller_version: info["applicationVersion"],
scraped_at: DateTime.utc_now(),
sites: sites_data,
protect: protect_data
}
# Write to JSON
File.write!("dashboard.json", JSON.encode!(dashboard))

You can run this on an interval to feed a time-series database, or serve it from a Phoenix endpoint for a live dashboard:

# Poll every 30 seconds and write fresh data
Stream.interval(30_000)
|> Stream.each(fn _ ->
# ... same scraper logic above ...
File.write!("dashboard.json", JSON.encode!(dashboard))
IO.puts("[#{DateTime.utc_now()}] Dashboard updated")
end)
|> Stream.run()

Formatted Output

UnifiApi.Formatter prints API responses as colored ANSI tables in iex.

Quick shortcuts

{:ok, sites} = UnifiApi.Network.Sites.list(client)
UnifiApi.Formatter.sites(sites)
{:ok, devices} = UnifiApi.Network.Devices.list(client, site_id)
UnifiApi.Formatter.devices(devices)
# State column is color-coded: green=CONNECTED, yellow=CONNECTING, red=DISCONNECTED
{:ok, clients} = UnifiApi.Network.Clients.list(client, site_id)
UnifiApi.Formatter.clients(clients)
# Type column is color-coded: blue=WIRED, magenta=WIRELESS, cyan=VPN
{:ok, cameras} = UnifiApi.Protect.Cameras.list(protect)
UnifiApi.Formatter.cameras(cameras)
{:ok, networks} = UnifiApi.Network.Networks.list(client, site_id)
UnifiApi.Formatter.networks(networks)

Operational (v1) shortcuts (use the cookie-auth authed from UnifiApi.Auth.Cookie.login/4 or UnifiApi.Auth.Session.client/1):

{:ok, events} = UnifiApi.Network.Events.list(authed, "default", within_hours: 1)
UnifiApi.Formatter.events(events)
# subsystem column is color-coded: magenta=wlan, blue=lan, cyan=wan, red=ips, ...
{:ok, alarms} = UnifiApi.Network.Alarms.list(authed, "default")
UnifiApi.Formatter.alarms(alarms)
# severity column is color-coded: red=critical, yellow=warn, blue=info
{:ok, clients} = UnifiApi.Network.ClientsLive.list(authed, "default")
UnifiApi.Formatter.clients_live(clients)
# signal column buckets RSSI by strength (green ≥ -60, yellow -60..-70, red < -70)
# satisfaction column buckets the 0..100 score (green ≥ 80, yellow ≥ 50, red < 50)
{:ok, anomalies} = UnifiApi.Network.Anomalies.list(authed, "default")
UnifiApi.Formatter.anomalies(anomalies)

Custom tables

# Pick any columns
{:ok, devices} = UnifiApi.Network.Devices.list(client, site_id)
UnifiApi.Formatter.table(devices, ["name", "mac", "model", "state", "ip"],
title: "My Devices",
colors: %{"state" => :state}
)
# Detail view for a single record
{:ok, nvr} = UnifiApi.Protect.NVR.get(protect)
UnifiApi.Formatter.detail(nvr, title: "NVR Info")

Built-in colour rules

Pass any of these as values in the :colors map on table/3 to colour a column based on its cell value:

RuleBehaviour
:stateCONNECTED/ONLINE → green, CONNECTING/UPDATING → yellow, DISCONNECTED/OFFLINE → red
:typeWIRED → blue, WIRELESS → magenta, VPN → cyan, TELEPORT → yellow
:subsystemwlan → magenta, lan → blue, wan/vpn → cyan, ips/alarm → red, system → yellow
:severitycritical/error → red, warn/warning → yellow, info → blue
:rssinumeric dBm: ≥ -60 green, ≥ -70 yellow, else red
:satisfactionnumeric 0..100: ≥ 80 green, ≥ 50 yellow, else red

Error Handling

All functions return {:ok, body} on success or {:error, reason} on failure.

Every error is one of five exception structs, together forming the UnifiApi.Error.t() umbrella, so an exhaustive case is possible:

case UnifiApi.Network.Devices.get(client, site_id, "bad-id") do
{:ok, device} ->
IO.inspect(device)
{:error, %UnifiApi.AuthError{reason: :unauthorized}} ->
IO.puts("Invalid API key")
{:error, %UnifiApi.AuthError{reason: :forbidden}} ->
IO.puts("API key lacks permission")
{:error, %UnifiApi.RateLimitError{retry_after: seconds}} ->
Process.sleep(seconds * 1000)
retry()
{:error, %UnifiApi.ApiError{status: 404}} ->
IO.puts("Not found")
{:error, %UnifiApi.ApiError{code: code}} when is_binary(code) ->
# Legacy v1 endpoints answer 200 with the failure in the body.
IO.puts("Controller error: #{code}")
{:error, %UnifiApi.ApiError{status: status, body_preview: preview}} ->
IO.puts("HTTP #{status}: #{preview}")
{:error, %UnifiApi.TransportError{reason: reason}} ->
IO.puts("Transport error: #{inspect(reason)}")
end
StructWhen
UnifiApi.AuthError401 / 403
UnifiApi.RateLimitError429, with a parsed and clamped Retry-After
UnifiApi.ApiErrorany other non-2xx, or a legacy v1 envelope error (HTTP 200 with code set)
UnifiApi.TransportErrorno HTTP response at all — refused, DNS, TLS, timeout. The underlying Req exception is kept in :original
UnifiApi.StreamErrormid-stream failure, from the stream/* functions only

Raw response bodies are never retained. Each struct carries a :body_preview — scrubbed of URLs and hostnames, truncated to 128 characters — so an error reaching a log or an exception tracker cannot leak the response (CWE-209 / OWASP A09).

A couple of functions return a documented plain atom for a non-failure outcome: Sites.find_by_name/2 gives {:error, :not_found} and Cookie.logout/2 gives {:error, :not_logged_in}.

Upgrading from a previous version

See UPGRADING.md for breaking-change details and concrete before/after examples. v0.4.0 is a breaking release: the error umbrella above replaces ten ad-hoc shapes, path prefixes moved onto the client, and certificate pinning — which never actually worked — is now enforced.

Multiple Controllers

The library is stateless — every API call takes a Req.Request.t() as its first argument and the request struct holds all the configuration. Managing multiple controllers is just managing multiple request structs:

controllers = %{
hq: UnifiApi.new(base_url: "https://10.0.0.1", api_key: hq_key),
branch: UnifiApi.new(base_url: "https://10.1.0.1", api_key: branch_key),
home: UnifiApi.new(base_url: "https://192.168.1.1", api_key: home_key)
}
# Pull devices from every controller in parallel
controllers
|> Task.async_stream(fn {name, client} ->
case UnifiApi.Network.Sites.list(client) do
{:ok, sites} -> {name, length(sites)}
{:error, _} -> {name, :unreachable}
end
end,
max_concurrency: 5,
timeout: 10_000
)
|> Enum.to_list()

If the controllers use different path conventions (UDM vs Cloud Key), say so per client. Each client carries its own prefixes:

defmodule MyApp.Controllers do
@controllers %{
hq:
UnifiApi.new(
base_url: "https://10.0.0.1",
api_key: System.fetch_env!("HQ_KEY"),
style: :udm
),
branch:
UnifiApi.new(
base_url: "https://10.1.0.1",
api_key: System.fetch_env!("BRANCH_KEY"),
style: :cloud_key
)
}
def call(name, fun), do: fun.(@controllers[name])
end
MyApp.Controllers.call(:branch, fn client ->
UnifiApi.Network.Sites.list(client)
end)

Before v0.4.0 this needed Application.put_env/3 around every call, which could not describe two flavours at once and raced any concurrent request.

For long-running pollers that need cookie-authenticated v1 access on multiple controllers, log in once per controller at startup and reuse the authenticated request struct — UnifiApi.Auth.Cookie.refresh_csrf/2 can refresh the CSRF token without a full re-login.

Self-Signed Certificates

UDM and Cloud Key controllers ship self-signed TLS certificates by default. As of v0.4.0, unifi_api is secure-by-default: TLS verification is on (verify_ssl: true) unless you explicitly opt out. This closes CWE-295 / OWASP A02 — previously the x-api-key, session cookie, and (cookie-auth flow) controller username/password all round-tripped over an unverified connection, exposing every credential to MITM.

For self-signed controllers you have three options — pick fingerprint pinning or a trusted CA, not "no verification":

client = UnifiApi.new(
base_url: "https://192.168.1.1",
api_key: "abc",
cert_fingerprints: ["sha256:AB:CD:EF:..."]
)

The TLS handshake is rejected unless the certificate the controller presents matches one of the configured SHA-256 fingerprints. This pins the connection to the specific physical device — much stronger than verify_ssl: false and without requiring a CA.

Fixed in v0.4.0. In v0.3.0 this feature did not work and did not protect you: the option set was rejected by :ssl on every pinned connection, and the verification callback accepted any self-signed certificate regardless of its fingerprint. See UPGRADING.md.

Pin the certificate the controller cannot prove:

A pinned certificate is not additionally hostname-checked: a SHA-256 pin names one exact certificate and is strictly stronger than a name match, and UniFi controllers are routinely reached by IP with a certificate whose CN/SAN does not cover it. Trust inherited from a pinned CA is hostname-checked.

Get the fingerprint with openssl:

echo | openssl s_client -connect 192.168.1.1:443 2>/dev/null \
| openssl x509 -fingerprint -sha256 -noout
# => sha256 Fingerprint=AB:CD:EF:...

Accepted formats:

cert_fingerprints: ["sha256:AB:CD:EF:01:..."] # ssh-keygen / openssl style
cert_fingerprints: ["AB:CD:EF:01:..."] # without prefix
cert_fingerprints: ["abcdef01..."] # plain 64-char hex
cert_fingerprints: ["fp1...", "fp2..."] # multiple (e.g. cert rotation)

2. Real CA verification

client = UnifiApi.new(verify_ssl: true) # default as of v0.4.0

Use this if you've installed your own CA on the controller and trusted it at the OS level. The strongest option, and the default behaviour.

3. No verification (last-resort escape hatch — insecure)

# Per-client opt-out:
client = UnifiApi.new(base_url: "https://192.168.1.1", api_key: "abc", verify_ssl: false)
# Or globally via env var (config/runtime.exs reads this):
# UNIFI_INSECURE_TLS=1

The connection is encrypted but unauthenticated. Anyone on the network path between you and the controller can intercept traffic — including the x-api-key and cookie-auth credentials — without detection. Fine for a throwaway script on a trusted LAN; don't ship this to production.

Generating Docs

mix deps.get
mix docs
open doc/index.html

License

Apache License 2.0 — see LICENSE for details.