All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
0.4.0 - 2026-08-13
Upgrading from 0.3.0? This release is deliberately breaking. See UPGRADING.md for a step-by-step migration guide with before/after examples for every incompatibility below.
Added
cert_fingerprintsoption onUnifiApi.new/1for SHA-256 certificate pinning. Set this to a list of fingerprints ("sha256:AB:CD:...","AB:CD:...", or plain 64-char hex) to verify the controller's self-signed certificate without disabling TLS validation entirely. Overrides:verify_sslwhen present. Also configurable viaconfig :unifi_api, :cert_fingerprints, [...].UnifiApi.Auth.Cookie.login/4for cookie + CSRF authentication. Use this to access endpoints that Ubiquiti has not yet exposed underx-api-key(events, alarms, IDS, anomalies, historical clients, DPI, topology, …) and on Cloud Key controllers without an API key. Supports both:udm(/api/auth/login) and:cloud_key(/api/login) styles. Also exposesrefresh_csrf/2,csrf_token/1, andlogout/2. CSRF rotation is not auto-handled in this release — see the module docs for trade-offs.UnifiApi.detect/1probesGET /and reports whether the controller is UniFi OS (:udm) or a standalone / Cloud Key (:cloud_key), returning a bundle withnetwork_prefix,protect_prefix,v1_prefix, andauth_path. Heuristic mirrorsunpoller/unpoller.UnifiApi.Client.v1_prefix/0and matching:v1_pathconfig key (default/proxy/network, override with""for Cloud Key orUNIFI_V1_PATH) to support the legacy/api/s/{site}/...endpoints.UnifiApi.Client.get_v1/3— generic GET that unwraps the%{"meta" => %{"rc" => "ok"}, "data" => [...]}envelope used by every legacy v1 endpoint, surfacingmeta.rc == "error"as{:error, {:unifi_error, msg}}.:paramspassthrough on allClient.{get,post,put,patch,delete}/3for arbitrary query params (used by v1 modules to send_start,_limit,within, etc. without polluting the integration-API param builder).- New v1 / v2 endpoint modules (require cookie + CSRF auth). Full Phase 3
surface, mirroring
unpoller/unpoller:UnifiApi.Network.Events—/api/s/{site}/stat/event.UnifiApi.Network.Alarms—/api/s/{site}/list/alarmplusarchive/3.UnifiApi.Network.Anomalies—/api/s/{site}/stat/anomalies.UnifiApi.Network.IDS—/api/s/{site}/stat/ips/event.UnifiApi.Network.RogueAP—/api/s/{site}/stat/rogueapand/rest/rogueknown.UnifiApi.Network.ClientsLive—/api/s/{site}/stat/sta(rich wireless stats) pluslist_all/3for/stat/alluser.UnifiApi.Network.ClientsHistory—/v2/api/site/{site}/clients/history.UnifiApi.Network.DPI—/api/s/{site}/stat/sitedpiand/stat/stadpi.UnifiApi.Network.Traffic—/v2/api/site/{site}/trafficand/country-traffic.UnifiApi.Network.SystemLog—/v2/api/site/{site}/system-log/all.UnifiApi.Network.ActiveLeases—/v2/api/site/{site}/active-leases.UnifiApi.Network.WAN—/wan/enriched-configuration,/wan/{id}/isp-status,/wan/load-balancing,/wan-slas.UnifiApi.Network.PortAnomalies—/v2/api/site/{site}/ports/port-anomalies.UnifiApi.Network.UPS—/api/s/{site}/stat/ups-devices.UnifiApi.Network.PortForward— full CRUD for/api/s/{site}/rest/portforward.UnifiApi.Network.Dashboard—/v2/api/site/{site}/aggregated-dashboard?historySeconds=N.UnifiApi.Network.Topology—/v2/api/site/{site}/topology.UnifiApi.Protect.Events—/proxy/protect/api/events,/api/events/{id}/thumbnail(binary JPEG),/api/events/system-logs.
UnifiApi.Formattershortcuts for the new modules:events/1,alarms/1(severity-coloured),clients_live/1,anomalies/1. Plus new:subsystemand:severitycolour rules ontable/3.- New examples scripts under
examples/:operational.exs— cookie auth + recent events + active alarms- worst-RSSI clients, with
UnifiApi.detect/1controller probe.
- worst-RSSI clients, with
protect_events.exs— pulls Protect motion / smartDetect events from the last hour and saves each thumbnail as a JPEG.
- README "Multiple Controllers" section with parallel
Task.async_streampattern and a per-controller path-config recipe for mixed UDM / Cloud Key fleets. UnifiApi.Auth.Session— supervised GenServer that holds cookie + CSRF auth state and auto-rotates the CSRF token from response headers. Add it to your supervision tree once and callSession.client/1to get aReq.Requestwhose request steps pull the latest auth state at send time. Closes out the deferred auto-refresh story inUnifiApi.Auth.Cookie.UnifiApi.Client.stream_v1/3— paginates legacy v1 endpoints via_start/_limit, mirroringClient.stream/3. Addedstream/3variants onUnifiApi.Network.Events,Alarms, andIDS.UnifiApi.Network.DPI.with_names/2— joins numericcat/appIDs againstResources.list_dpi_categories/1andlist_dpi_applications/1, populatingcategory_nameandapplication_nameon everyby_cat/by_appentry.UnifiApi.Formatternumeric colour rules:rssi(signal-strength buckets) and:satisfaction(UniFi 0..100 score). Wired into theclients_live/1shortcut so thesignalandsatisfactioncolumns render colour-coded by value.- CI: the
testjob now runs across three Elixir / OTP combinations (1.18.0on OTP 26.2.5,1.18.3on OTP 27.2,1.18.4on OTP 27.2) to catch compat regressions across the supported floor. UnifiApi.Client.stream_paged/2— generic page-number paginator (pageSize/pageNumberstyle) for endpoints that don't fit the integrationoffset/limitor v1_start/_limitpatterns. Used by the new v2 streams below.UnifiApi.Network.ClientsHistory.stream/3andUnifiApi.Network.SystemLog.stream/3— auto-paginated lazy streams, closing the last gaps in the pagination audit.UnifiApi.ping/1— auth-agnosticGET /reachability check.UnifiApi.Time—now_ms/0,minutes_ago/1,hours_ago/1,days_ago/1for the unix-millisecond timestamp params used byProtect.Events,Network.Traffic, etc.UnifiApi.Network.Sites.find_by_name/2andfind_by_internal_reference/2— resolve a site map by human-readable name or controller slug without writingSites.list(client) |> Enum.find(...)boilerplate.- README: expanded "Self-Signed Certificates" section covering all three
TLS modes (
verify_ssl: false, fingerprint pinning, real CA), with anopensslrecipe for extracting the fingerprint. UnifiApi.ApiErrorandUnifiApi.TransportError— the two error structs that were missing, completing theUnifiApi.Error.t()umbrella.UnifiApi.Error— the umbrella type plusfrom_transport/1.:styleoption onUnifiApi.new/1(:udm|:cloud_key) selecting all four path prefixes at once, plus per-prefix:network_path,:protect_path,:v1_path,:protect_v1_pathoverrides.UnifiApi.Client.prefix/2,network_prefix/1,protect_prefix/1,v1_prefix/1,protect_v1_prefix/1, andstyle/1— the client-aware replacements for the removed 0-arity prefix functions.:connect_timeoutoption onUnifiApi.new/1(default 5_000ms), also settable withconfig :unifi_api, connect_timeout: ms.:finchoption onUnifiApi.new/1accepting the name of a pool you started yourself. Mutually exclusive with the TLS and connect-timeout options, which then belong on your own pool; combining them raises rather than silently dropping your transport settings.opts \\ []on ten paginatedlist/*— ProtectChimes,Viewers,Lights,Sensors,Liveviewsand NetworkActiveLeases,PortForward,PortAnomalies,RogueAP,UPS. Validated withKeyword.validate!/2, so an unknown key raisesArgumentError.protect_v1_prefixinUnifiApi.detect/1'scontroller_info, which previously reported only 2 of the 4 prefixes a caller needs.info.stylenow feeds straight back intoUnifiApi.new/1.@type tonUnifiApi.StreamError, clearing the project's only dialyzer error (defexceptionnever emits one).- Every resource
stream/*@docnow documents the error contract: the enumerable is heterogeneous by default and its last element may be{:error, %UnifiApi.StreamError{}, cursor}. - A real-TLS-handshake test harness (
test/support/tls_server.explus committed certificate fixtures). The suite previously reached:sslnowhere, which is how the pinning defects below shipped.
Changed
- Breaking: the
reqdependency requirement moved from~> 0.6to~> 0.7. Consumers must allow req 0.7 — a locked~> 0.6in your ownmix.exswill block resolution. Transitively this bumps the lock from req 0.6.3 → 0.7.2, finch 0.21.0 → 0.23.0, mint 1.7.1 → 1.9.3, and hpax 1.0.3 → 1.0.4. Runmix deps.update reqafter upgrading. - Breaking: all errors are now one of five structs,
UnifiApi.Error.t(), replacing ten ad-hoc shapes.{:error, {status, body}}becomes%UnifiApi.ApiError{};{:error, {:unifi_error, msg}}becomes%UnifiApi.ApiError{code: msg}; a bare%Req.TransportError{}becomes%UnifiApi.TransportError{}. Raw response bodies are no longer retained — only a scrubbed, truncatedbody_preview. - Breaking:
UnifiApi.detect/1andUnifiApi.ping/1returned different shapes for the identical situation and are now reconciled on%UnifiApi.ApiError{}. - Breaking:
UnifiApi.Client.network_prefix/0,protect_prefix/0,v1_prefix/0andprotect_v1_prefix/0are removed in favour of arity-1 versions taking the client. Prefixes are resolved once innew/1and carried on the struct, so a UDM client and a Cloud Key client can finally coexist in one VM. TheApplicationenv keys still work. - Breaking:
Network.Events,Alarms,IDS,ClientsHistoryandSystemLogstream/3now honour:max_pages,:max_itemsand:raise_errors, which they previously discarded in silence. Code that passedmax_items:and was ignored will now be capped — correct, but a behaviour change. Unknown option keys raiseArgumentError. - Breaking: the ten paginated
list/*are documented as returning the first page only; use the matchingstream/*for full enumeration. - Breaking:
Protect.Cameras.ptz_patrol_start/3,ptz_goto/3andNetwork.Devices.execute_port_action/5now guard their integer path segments; a non-integer raisesFunctionClauseError. UnifiApi.Auth.Session.client/1andcsrf_token/1are lock-free:persistent_termreads with noGenServer.call. Callers previously queued behind a re-login's blocking HTTP round trip.Session.refresh/2andrelogin/2take an optional timeout, default 60_000ms.refresh/1's old 5s default reliably raisedexit(:timeout)while the session process carried on working.- Concurrent
Session.relogin/1calls are coalesced: a request carrying a timestamp older than the last successful login is answered:okwithout logging in again. On expiry every consumer sees a 401 at once, which used to become N sequential full logins. - Server-dictated retry sleeps are clamped to 300s.
Retry-After: 3600previously parked the calling process for an hour inside what reads as a boundedReq.get/2—:receive_timeoutdoes not cover the retry sleep. - New internal
UnifiApi.Resourcemacro replaces 37 copies ofdefp prefix, 20 ofdefp maybe_param, and shortens 115 inlineClient.validate_id!/1calls. Internal, but it is why every resource module's diff is large. mix.exspackage/0gained afiles:allow-list, so the tarball no longer shipspriv/(a 4.4 MB dialyzer PLT). It is now ~70 KB.- The
hex_vetCI gate fails the build instead of only printing,publishdepends on it, andmix hex.auditruns in CI.
Fixed
- An unparseable
Retry-Afterno longer raises; it falls back to the bounded default. scrub_body_preview/1truncates before scrubbing. Producing a 128-char preview from an 897 KB body cost ~32ms and ~2.6 MB of garbage on every 401/403/429; it is now ~24µs.:cert_fingerprintsno longer puts the 162-certificate OS trust store into Req's Finch pool key, which Req hashes on every request: measured 476 KB serialized and ~2.4ms of CPU per request, now 364 bytes and ~2µs.- A CSRF rotation that does not change the token no longer rewrites
:persistent_term. Each write triggers a global scan of every process (~169µs with 2000 live processes) and controllers echo the header on essentially every response. - A rotated CSRF token is now published synchronously by the response step rather than via a cast, closing a window in which the next request from the same process injected the token the controller had just replaced.
nilfromconfig :unifi_api, verify_ssl: nilno longer reads as "disabled" and silently downgrades TLS toverify: :verify_none.
Security
- Certificate pinning did not work and did not protect anyone. Three
defects, all found by driving a real TLS handshake (CWE-295):
- The code set
server_name, which is not an:ssloption.:sslforwarded it togen_tcp:connect/4, which raised:badarg— so every pinned connection failed before sending a byte. - The
verify_funreturned{:valid, state}for{:bad_cert, :selfsigned_peer}unconditionally and only checked the fingerprint in the:valid_peerclause, which OTP never reaches for a self-signed peer. Any self-signed certificate was accepted whatever its fingerprint — a total bypass of the advertised guarantee. - A custom
verify_funreplaces OTP's hostname check, so no hostname verification was happening either. The pin is now the trust anchor, every:bad_certdecision consults it and fails closed, and hostname verification is enforced for trust inherited from a pinned CA. See UPGRADING.md — pinning a leaf behind an unknown private CA is now correctly rejected.
- The code set
- The API key no longer lives in a header on the client struct; a request
step closing over it injects it at send time. Req's
Inspectredacts onlyauthorization, and the client is argument one of every public function, sox-api-keypreviously reached every stack frame,dbg/1call, crash log, and error-tracker breadcrumb (CWE-522 / CWE-209). UnifiApi.Auth.Sessionimplementsformat_status/1, so:gen_server's abnormal-termination log no longer dumps the session cookie, the CSRF token, or the plaintext credentials captured by the deprecated:username/:passwordclosure (CWE-209 / CWE-532).- Three integer path segments were interpolated into request paths without
validation while sibling id arguments went through
validate_id!/1. A consumer forwarding an HTTP parameter handed an attacker authenticated SSRF against the controller's own API (CWE-22 / OWASP A03).
Removed
UnifiApi.Client.network_prefix/0,protect_prefix/0,v1_prefix/0,protect_v1_prefix/0— replaced by the arity-1 forms.lib/unifi_api/application.ex. A library must not ship anApplicationcallback; consumers superviseUnifiApi.Auth.Sessionthemselves.
Notes
UnifiApi.Auth.CookieandUnifiApi.detect/1are unit-tested against mockedReq.Testplugs but not yet exercised end-to-end against live UDM Pro and Cloud Key hardware. Please file an issue with controller model and firmware version if you encounter shape mismatches.
0.3.0 - 2026-05-02
Upgrading from 0.2.x? See UPGRADING.md for a step-by-step migration guide with before/after examples and a search-and-replace cheat sheet for the breaking change below.
Added
- Typed errors:
UnifiApi.RateLimitError(with parsedRetry-After) andUnifiApi.AuthErrorare now returned for 429, 401, and 403 responses, so callers can pattern-match without inspecting the status tuple. - Runnable example scripts under
examples/(quickstart.exs,dashboard.exs,snapshots.exs). CHANGELOG.mdis now bundled in the generated docs.- README: status badges, "Self-signed certificates" section, expanded error handling docs with the new typed errors and a 0.2.x → 0.3.0 migration note.
UnifiApi.Network.Devices@moduledocnow documents response fields and the shape returned byget_statistics/3.
Changed
- Breaking: 401, 403, and 429 responses now return exception structs
(
%UnifiApi.AuthError{}/%UnifiApi.RateLimitError{}) instead of{:error, {status, body}}tuples. The motivation is twofold: pattern matching on specific HTTP status numbers leaks transport-level concerns into caller code, and the parsedRetry-After(clamped 1..300s) lets pollers back off correctly without re-parsing the response. Callers matching{:error, {401, _}},{:error, {403, _}}, or{:error, {429, _}}must update to match the new structs — see UPGRADING.md. Other non-2xx responses still return{:error, {status, body}}. Catch-all{:error, _}matches are unaffected. mix.exspackage metadata: addedmaintainers,ChangelogandUpgradinglinks, and bundledCHANGELOG.md+UPGRADING.mdindocs.extras.
0.2.0 - 2026-04-30
Added
- Stream-based auto-pagination via
Stream.resource/3for every list endpoint (UnifiApi.Network.Devices.stream/3,Clients.stream/3, etc.). - ANSI formatter (
UnifiApi.Formatter) for printing API responses as colored tables in IEx, with shortcuts for devices/clients/cameras/networks/sites. - UDM proxy path support:
Client.network_prefix/0andClient.protect_prefix/0default to/proxy/network/integrationand/proxy/protect/integration; override withnetwork_path/protect_pathconfig (orUNIFI_NETWORK_PATH/UNIFI_PROTECT_PATHenv). - Comprehensive dashboard data scraper recipe in the README.
- CI/CD pipeline: format check, Credo strict, Dialyzer, ExUnit on Elixir 1.18.3 / OTP 27.2, automated Hex publish on tags.
- Full
@speccoverage and@moduledoc/@docfor every public function.
Changed
- Bumped Elixir requirement to
~> 1.18. - Replaced
Jasonwith the Elixir 1.18 stdlibJSONmodule. - Formatter now correctly handles wrapped (
%{"data" => [...]}) responses.
0.1.0
Added
- Initial implementation of the UniFi Network and Protect API client over Req, with API-key authentication and the core Network (Sites, Devices, Clients, Networks, Wifi, Firewall, Hotspot, ACL, DNS, TrafficMatching, Resources) and Protect (Cameras, NVR, Sensors, Lights, Chimes, Viewers, Liveviews) modules.