# go-unifi (/docs) **go-unifi** is a Go client library for the [UniFi Network](https://ui.com) controller API. It powers the [Terraform provider for UniFi](https://github.com/filipowm/terraform-provider-unifi) but works standalone in any Go project that needs to talk to a UniFi controller. It gives you strongly-typed access to networks, WLANs, clients, devices, firewall rules, settings and dozens of other resources — most of them generated directly from the controller's own API definitions for broad, accurate coverage. ## Two API surfaces, one client [#two-api-surfaces-one-client] A single `unifi.Client` exposes both of the controller's APIs: The legacy UniFi Network API the library has always wrapped. Every resource method (`ListNetwork`, `CreateUser`, …) lives here, reachable directly on the client or via `c.Internal()`. The official UniFi OpenAPI (`integration/v1`), reached via `c.Official()`. A fluent, per-resource surface that requires a newer controller and an API key. ## Start here [#start-here] Add the module and import the packages. Connect to a controller and make your first call in one runnable program. Create an API key and understand the version requirements. Task-oriented recipes: networks, devices, clients, firewall, and more. The exported Go API: client, configuration, errors, and resources. The Official OpenAPI rendered endpoint-by-endpoint, with Go equivalents. ## Requirements [#requirements] * **Go 1.26** or later. * A **UniFi OS** console (new-style) running **UniFi Network 9.0.114** or newer — API-key authentication is the only supported auth. The Official API surface additionally requires **UniFi Network 10.1.78** or newer. (Both are *Network Application* versions; UniFi OS — the console's operating system — versions separately.) Upgrading from go-unifi 1.x? See the [migration guide](/docs/migrating/from-1.x). Coming from `paultyng/go-unifi`? See [Migrating from upstream](/docs/migrating/from-paultyng). ## AI & LLMs [#ai--llms] Pointing an LLM or AI coding agent at these docs? They're published in machine-readable form following the [llms.txt](https://llmstxt.org) convention: * [`/llms.txt`](/llms.txt) — a compact index of every page, with descriptions. * [`/llms-full.txt`](/llms-full.txt) — the entire documentation as a single plain-text file. Every page header also has a button to copy that page as Markdown. # Compatibility (/docs/advanced/compatibility) This page maps `go-unifi` releases to the UniFi Network controller versions they are known to work with, and records the breaking compatibility changes between releases. Every version on this page is a **UniFi Network Application** version (the application that manages your network and serves the API). UniFi Network versions track **independently** of **UniFi OS**, the console operating system that hosts the application — a console on UniFi OS `5.x` may run UniFi Network `10.x`. The floors below (`9.0.114`, `10.1.78`) are Network Application versions, not UniFi OS versions. ## Release ↔ controller matrix [#release--controller-matrix] | `go-unifi` version | Min UniFi Network | Internal API version | Official API version | | --------------------- | ----------------- | -------------------- | -------------------- | | `v2.3.0` | `9.0.114` | `9.5.21` (frozen) | `10.4.57` | | `v2.2.0` | `9.0.114` | `9.5.21` (frozen) | `10.3.58` | | `v2.1.0` | `9.0.114` | `9.5.21` (frozen) | `10.2.97` | | `v2.0.0` | `9.0.114` | `9.5.21` (frozen) | `10.1.85` | | `v1.11.0` – `v1.11.1` | `5.12.35` | `9.5.21` | — | | `v1.10.0` | `5.12.35` | `9.4.19` | — | | `v1.9.0` – `v1.9.1` | `5.12.35` | `9.3.45` | — | | `v0.0.1` – `v1.8.1` | `5.12.35` | `9.0.114` | — | Only the **min** and **latest** controller versions in each row are explicitly verified. Versions in between (and newer ones released after the latest tested) are very likely supported too, but this is **not** checked. If you hit an issue on a specific controller version, please [open an issue](https://github.com/filipowm/go-unifi/issues). ### 2.0.0 specifics [#200-specifics] * The **minimum** controller version (`9.0.114`) is set by API-key authentication — the only supported auth in 2.0.0. Old-style (classic) controllers are unsupported: `NewClient` returns `ErrOldStyleUnsupported`. * The **Internal API** is frozen at `9.5.21` for the 2.0.0 lifecycle. The daily codegen CI run is a deterministic no-op for the internal half, so the legacy resource set does not move forward. * The **Official OpenAPI** surface (`c.Official()`) requires controller `10.1.78` or newer. Operations return `ErrOfficialAPIUnavailable` on older controllers. Its spec version tracks the latest committed snapshot and may update when the Official spec changes. Two plain-text version markers at the repo root record the pinned versions, written by `go generate`: * `.unifi-version` — the Internal (legacy) API controller version. * `.unifi-version-official` — the Official OpenAPI (`integration/v1`) spec version (requires controller ≥ `10.1.78`). ## Compatibility changelog [#compatibility-changelog] ### 2.0.0 — breaking changes [#200--breaking-changes] 2.0.0 is a major release with several breaking changes. The headline items: * **API-key-only auth** — username/password login was removed (`Login`/`Logout` are gone). * **TLS verify by default** — the old `VerifySSL` field became `SkipVerifySSL` (secure-by-default zero value). * **Go 1.26+** required. * **`NewBareClient` removed** — use `ClientConfig.SkipSystemInfo: true` for offline/deferred construction. * **`Client` no longer embeds `Logger`** — call `client.Logger().Errorf(...)` instead of `client.Errorf(...)`. See the full [breaking changes guide](/docs/migrating/breaking-changes) and the [migration guide from 1.x](/docs/migrating/from-1.x). ### 1.11.0 / UniFi Network 9.5.21 [#1110--unifi-network-9521] **Breaking changes** `ChannelPlan` — trimmed down to `Date` and `RadioTable` (no replacement identified): * removed fields `ApBlacklistedChannels`, `ConfSource`, `Coupling`, `Fitness`, `Note`, `Radio`, `Satisfaction`, `SatisfactionTable`, `SiteBlacklistedChannels` * removed types `ChannelPlanApBlacklistedChannels`, `ChannelPlanCoupling`, `ChannelPlanSatisfactionTable`, `ChannelPlanSiteBlacklistedChannels` * removed field `ChannelPlanRadioTable.BackupChannel` `DeviceRadioTable` — removed fields `BackupChannel` and `ChannelOptimizationEnabled`. **Additions** * `Network`: `WANDHCPv6PDSizeAuto` (auto IPv6 prefix-delegation size); `WANType` accepts `dslite-over-pppoe`. * `SettingRadioAi`: `HighPriorityDevices` (high-priority device MACs); `Radios` accepts `6e` (Wi-Fi 6E band). ### 1.10.0 / UniFi Network 9.4.19 [#1100--unifi-network-9419] **Breaking changes** `SettingIps` — DNS filtering & ad-blocking moved out into the new `ContentFiltering` resource: * removed fields `DNSFiltering`, `DNSFilters`, `AdBlockingEnabled`, `AdBlockingConfigurations` (and types `SettingIpsDNSFilters`, `SettingIpsAdBlockingConfigurations`) * added `ContentFilteringBlockingPageEnabled` `Device`: * `Mbb` field renamed to `MbbOverrides` (type `DeviceMbb` → `DeviceMbbOverrides`) * `DeviceSim.Iccid` removed `ContentFiltering` — new resource, replaces the removed IPS fields. **Additions** * New `ContentFiltering` resource. It exposes `ListContentFiltering`, `CreateContentFiltering`, `UpdateContentFiltering`, and `DeleteContentFiltering` — there is **no** public `GetContentFiltering` (list and filter instead). * `Device`: `NutServer`, `DeviceCurrentApn.PDpType`, and `DeviceSim` SIM-data fields (`DataSoftLimitDisplayUnit`, `DataWarningThreshold`, `ResetDate`, `ResetPolicy`, `UseCustomApn`). * `Network`: IPv6 DHCP support (`WANDHCPv6Cos`, `WANDHCPv6Options`) and MAP-E `WANType` values. * `WLANMdnsProxyCustom.ServicesMode` accepts `none`; new dashboard widgets. ## Next steps [#next-steps] Step-by-step upgrade to 2.0.0. The complete 2.0.0 breaking-change list. API keys and the 9.0.114 version floor. # Concurrency (/docs/advanced/concurrency) A `unifi.Client` (and everything obtained from it — `Internal()`, `Official()`, resource methods) is **safe for concurrent use by multiple goroutines**. Build one client at startup and share it; do not create a client per request or per goroutine. ## Share one client [#share-one-client] Requests run concurrently through a goroutine-safe `net/http.Client` and are not serialized, so fanning work out is straightforward: ```go title="shared-client.go" func listAcrossSites(ctx context.Context, c unifi.Client, sites []string) { var wg sync.WaitGroup for _, site := range sites { wg.Add(1) go func(site string) { defer wg.Done() // Safe: every method on the client is goroutine-safe. _, _ = c.ListNetwork(ctx, site) }(site) } wg.Wait() } ``` Why share? `NewClient` does real work — it parses the URL, builds the HTTP transport, registers validators, and (unless `SkipSystemInfo` is set) makes a round-trip to verify your API key. A single client also lets the underlying transport pool and reuse TCP connections across goroutines. Use `context.Context` for per-call cancellation and deadlines, not separate clients. Every method takes a `ctx` first; cancel it to abort an in-flight request without affecting others sharing the client. ## UseLocking is a deprecated no-op [#uselocking-is-a-deprecated-no-op] `ClientConfig.UseLocking` exists only for source compatibility with older releases. Since 1.11.0 it has **no effect**: requests are no longer serialized behind a mutex. Leave it unset. ```go // Deprecated: UseLocking is a no-op since 1.11.0 and is ignored. UseLocking bool ``` If you previously relied on `UseLocking: true` to serialize calls, you don't need it — the client was made goroutine-safe instead. If your *application* needs to serialize a specific sequence of operations (say, a read-modify-write against one resource), coordinate that in your own code with a mutex; the client will not do it for you. ## Next steps [#next-steps] All ClientConfig fields, including the deprecated UseLocking. Build your first client. # Configuration (/docs/advanced/configuration) Every client is built from a [`*unifi.ClientConfig`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#ClientConfig). Only `URL` and `APIKey` are required (see the [Quickstart](/docs/getting-started/quickstart) and [Connecting](/docs/getting-started/connecting)). This page covers the fields you reach for when the defaults aren't enough. ', }, HttpTransportCustomizer: { description: 'Mutate the default *http.Transport (idle conns, TLS, proxy).', type: 'func(*http.Transport) (*http.Transport, error)', default: 'nil', }, HttpRoundTripperProvider: { description: 'Replace the entire transport. Takes precedence; you own TLS.', type: 'func() http.RoundTripper', default: 'nil', }, ErrorHandler: { description: 'Custom mapping from an HTTP response to an error.', type: 'ResponseErrorHandler', default: 'default handler', }, CustomValidators: { description: 'Extra request-body validation rules.', type: '[]CustomValidator', default: 'nil', }, }" /> For the full, exhaustive field list — including `SkipVerifySSL`, `ValidationMode`, `APIStyle`, `Logger`, `SkipSystemInfo`, and `DisableOfficialAPI` — see [Reference > Configuration Types](/docs/reference/configuration-types). ## Timeout [#timeout] `Timeout` bounds how long the client waits for a controller response, end to end. The zero value means **no timeout** — a slow or unreachable controller can hang a goroutine indefinitely — so the client logs a `WARN` at build time when it is unset. Set one for any production deployment: ```go c, err := unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.example.com", APIKey: "your-api-key", Timeout: 30 * time.Second, }) ``` Each call still takes a `context.Context` first, so you can additionally bound an individual request with `context.WithTimeout`. The tighter of the two deadlines wins. ## User-Agent [#user-agent] `UserAgent` overrides the header sent on every request. When unset, the client derives a default from its own module version (`go-unifi/`): ```go c, err := unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.example.com", APIKey: "your-api-key", UserAgent: "my-app/1.0", }) ``` ## Customizing the HTTP transport [#customizing-the-http-transport] There are two ways to shape the underlying `*http.Client`. You normally use one or the other; if both are set, `HttpRoundTripperProvider` takes precedence (see the note below): * **`HttpTransportCustomizer`** — receives the client's pre-built `*http.Transport` so you can tweak it (idle connection limits, proxy, TLS) and return it (or return a fresh one). * **`HttpRoundTripperProvider`** — returns a complete `http.RoundTripper`, replacing the transport entirely. Use this when an `*http.Transport` isn't enough (custom round-trippers, tracing wrappers, body rewriting). ```go title="HttpTransportCustomizer" c, err := unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.example.com", APIKey: "your-api-key", HttpTransportCustomizer: func(t *http.Transport) (*http.Transport, error) { t.MaxIdleConns = 50 t.IdleConnTimeout = 120 * time.Second t.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12} return t, nil }, }) ``` ```go title="HttpRoundTripperProvider" c, err := unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.example.com", APIKey: "your-api-key", HttpRoundTripperProvider: func() http.RoundTripper { // You own the entire transport here, including TLS config. return &http.Transport{ TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, } }, }) ``` **Precedence and TLS.** If both are set, `HttpRoundTripperProvider` wins — unless it returns `nil`, in which case the client falls back to a default transport and applies `HttpTransportCustomizer`. When the provider supplies the transport, **TLS is entirely yours**: the client does no certificate validation and the `SkipVerifySSL` flag is ignored. The client logs a `WARN` at build time as a reminder. For self-signed certs without a custom transport, set [`SkipVerifySSL: true`](/docs/getting-started/connecting) instead. ## Custom error handling [#custom-error-handling] By default the client maps non-2xx responses to a [`*unifi.ServerError`](/docs/guides/error-handling). To change that mapping — for example to fail on any 4xx/5xx before the SDK inspects the body — implement `ResponseErrorHandler` and set `ErrorHandler`: ```go type statusErrorHandler struct{} func (h *statusErrorHandler) HandleError(resp *http.Response) error { if resp.StatusCode >= 400 { return fmt.Errorf("controller returned HTTP %d", resp.StatusCode) } return nil } c, err := unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.example.com", APIKey: "your-api-key", ErrorHandler: &statusErrorHandler{}, }) ``` `HandleError` runs **after** response interceptors and **before** the response body is decoded. Returning a non-nil error short-circuits decoding and surfaces straight to the caller. See [Interceptors](/docs/advanced/interceptors) for the full request/response order, and [Error handling](/docs/guides/error-handling) for the default `ServerError` shape. ## Custom validators [#custom-validators] The client validates request bodies before sending them (see [Validation](/docs/advanced/validation) for the `ValidationMode` settings). `CustomValidators` registers additional rules on top of the built-in set. Each validator's tag becomes a [`go-playground/validator`](https://pkg.go.dev/github.com/go-playground/validator/v10) tag name; `NewCustomRegexValidator` builds a regex-backed one: ```go c, err := unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.example.com", APIKey: "your-api-key", ValidationMode: unifi.HardValidation, CustomValidators: []unifi.CustomValidator{ unifi.NewCustomRegexValidator("vlan_name", `^[a-z][a-z0-9-]{0,31}$`), }, }) ``` The tag is then usable in a `validate:"..."` struct tag on any field you marshal through the raw [`Do`/`Post`/`Put`](/docs/advanced/raw-http) helpers. ## See also [#see-also] Every `ClientConfig` field, documented field-by-field. Hook into requests and responses. The `Logger` field and log levels. TLS verification, `SkipVerifySSL`, and the basics. # Advanced (/docs/advanced) The [Getting Started](/docs/getting-started/quickstart) and [Guides](/docs/guides) sections cover everyday use. These pages go deeper: how to reshape the HTTP transport, call endpoints that have no generated method, hook into the request/response cycle, and reason about validation, logging, and concurrency. ## Topics [#topics] `ClientConfig` beyond `URL` and `APIKey` — timeouts, User-Agent, custom transports, error handling, and custom validators. Reach endpoints without a generated method using `Do`/`Get`/`Post`/`Put`/`Patch`/`Delete`, and understand how paths resolve. Hook into every request and response for logging, metrics, or header injection — and the body-read hazard to avoid. Configure the SDK logger, pick a level, or plug in your own implementation. Soft, hard, and disabled validation modes, and how to add your own rules. Why one client is safe to share across goroutines and the `UseLocking` no-op. Controller version requirements and API-style detection. Diagnose common connection, authentication, and API errors. # Interceptors (/docs/advanced/interceptors) An interceptor is a hook that runs on every HTTP request before it's sent and on every response before it's decoded. Use them for cross-cutting concerns: logging, metrics, tracing spans, or stamping custom headers. ## The interface [#the-interface] Implement [`ClientInterceptor`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#ClientInterceptor) — two methods: ```go type ClientInterceptor interface { InterceptRequest(req *http.Request) error InterceptResponse(resp *http.Response) error } ``` Returning an error from either method aborts the call and surfaces that error to the caller. ```go // loggingInterceptor logs request method/URL and response status. type loggingInterceptor struct{} func (l *loggingInterceptor) InterceptRequest(req *http.Request) error { log.Printf("[request] %s %s", req.Method, req.URL) return nil } func (l *loggingInterceptor) InterceptResponse(resp *http.Response) error { // Do NOT read resp.Body here — the SDK decodes it after interceptors run. log.Printf("[response] %d %s", resp.StatusCode, resp.Request.URL) return nil } // headerInterceptor stamps a custom header on every outgoing request. type headerInterceptor struct { value string } func (h *headerInterceptor) InterceptRequest(req *http.Request) error { req.Header.Set("X-Custom-Header", h.value) return nil } func (h *headerInterceptor) InterceptResponse(_ *http.Response) error { return nil } ``` ## Registering interceptors [#registering-interceptors] Register interceptors when you build the client, via `ClientConfig.Interceptors`. They apply to every request the client makes: ```go c, err := unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.example.com", APIKey: "your-api-key", Interceptors: []unifi.ClientInterceptor{ &loggingInterceptor{}, &headerInterceptor{value: "example"}, }, }) ``` ## Execution order [#execution-order] On every request the client runs interceptors in this order: **Built-in API-key auth** — sets the `X-Api-Key` header. **Built-in default headers** — `User-Agent` , `Accept` , `Content-Type` . **Your interceptors** , in the order you registered them. Response interceptors (`InterceptResponse`) run in that same order, **before** error handling and response decoding. Because your interceptors run last on the request, you can override the built-in headers (including auth) if you really need to. **One interceptor per concrete type.** The client deduplicates by concrete Go type: if you register two interceptors of the same type, the second is silently dropped and a `WARN` is logged. To run, say, two logging behaviors, use two distinct types (or a single configurable type) rather than two values of the same struct. ## The response body-read hazard [#the-response-body-read-hazard] This is the most common interceptor mistake: **Do not read or consume `resp.Body` inside `InterceptResponse`.** The body is decoded *after* all interceptors run. If an interceptor drains it, the caller receives a zero-valued response struct with **no error** — a silent, hard-to-debug failure. If you need to observe or rewrite the body — for logging, tracing, or transformation — wrap the transport with [`HttpRoundTripperProvider`](/docs/advanced/configuration#customizing-the-http-transport) instead. A `http.RoundTripper` runs *before* the SDK touches the body, so it can buffer and restore it safely: ```go // bodyLoggingTransport buffers and restores the response body safely, before // the SDK touches it — the correct place to observe a body. type bodyLoggingTransport struct { wrapped http.RoundTripper } func (t bodyLoggingTransport) RoundTrip(req *http.Request) (*http.Response, error) { resp, err := t.wrapped.RoundTrip(req) if err != nil || resp == nil { return resp, err } body, _ := io.ReadAll(resp.Body) resp.Body.Close() log.Printf("response body: %s", body) resp.Body = io.NopCloser(bytes.NewReader(body)) // restore for the SDK to decode return resp, nil } c, err := unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.example.com", APIKey: "your-api-key", HttpRoundTripperProvider: func() http.RoundTripper { return bodyLoggingTransport{wrapped: http.DefaultTransport} }, }) ``` The same approach works for request/response **timing**: record `time.Now()` before `RoundTrip` and log the delta after — a round-tripper sees the full network round-trip, where an interceptor only sees the request and the not-yet-read response. ## See also [#see-also] `HttpRoundTripperProvider` and the rest of `ClientConfig`. The SDK's built-in logger, an alternative to a logging interceptor. The requests your interceptors observe. # Logging (/docs/advanced/logging) The client logs its own diagnostics (request tracing, validation warnings, build-time warnings) through a small `Logger` interface. You choose the implementation via [`ClientConfig.Logger`](/docs/reference/configuration-types), and you can reach the active logger from the client with `c.Logger()`. **2.0.0 change.** `Client` no longer *embeds* `Logger`, so methods like `Errorf` are no longer promoted onto the client itself. Replace any `client.Errorf(...)` calls with `client.Logger().Errorf(...)`. See the [breaking changes](/docs/migrating/breaking-changes). ## The default logger [#the-default-logger] If you set nothing, the client uses a logger backed by Go's standard [`log/slog`](https://pkg.go.dev/log/slog), emitting plain text (no ANSI colour) to `os.Stderr` at `Info` level. To change the level, build one explicitly with `NewDefaultLogger`: ```go title="default-logger.go" c, err := unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.example.com", APIKey: "your-api-key", Logger: unifi.NewDefaultLogger(unifi.DebugLevel), }) ``` ### Logging levels [#logging-levels] `NewDefaultLogger` takes a `unifi.LoggingLevel`. From least to most verbose: ## Reaching the active logger [#reaching-the-active-logger] The configured logger is available on the client via `Logger()`. The SDK uses it internally; you can use it too, for example to keep your own logs on the same handler: ```go title="logger-accessor.go" log := c.Logger() log.Debug("a debug message") log.Debugf("listing networks for site %q", "default") log.Errorf("something went wrong: %v", err) ``` The `Logger` interface has paired plain and formatted methods at every level: ```go type Logger interface { Trace(format string) Debug(format string) Info(format string) Warn(format string) Error(format string) Tracef(format string, args ...any) Debugf(format string, args ...any) Infof(format string, args ...any) Warnf(format string, args ...any) Errorf(format string, args ...any) } ``` ## Wrapping your own \*slog.Logger [#wrapping-your-own-sloglogger] Already have a configured `*slog.Logger` (with your own handler, attributes, output)? Wrap it directly with `NewSlogLogger` so the SDK shares it: ```go title="slog-logger.go" handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}) logger := slog.New(handler).With("component", "unifi") c, err := unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.example.com", APIKey: "your-api-key", Logger: unifi.NewSlogLogger(logger), }) ``` The same `LoggingLevel` → `slog.Level` mapping applies, so `TraceLevel` lands on `slog.LevelDebug - 4`. ## A fully custom logger [#a-fully-custom-logger] To route logs somewhere `slog` doesn't reach — a metrics pipeline, an existing logging facade, a test sink — implement the `Logger` interface yourself and pass it as `Logger`: ```go title="custom-logger.go" // metricsLogger is a minimal custom Logger implementation. type metricsLogger struct{} func (l *metricsLogger) Trace(msg string) {} func (l *metricsLogger) Debug(msg string) {} func (l *metricsLogger) Info(msg string) {} func (l *metricsLogger) Warn(msg string) {} func (l *metricsLogger) Error(msg string) {} func (l *metricsLogger) Tracef(format string, args ...any) {} func (l *metricsLogger) Debugf(format string, args ...any) {} func (l *metricsLogger) Infof(format string, args ...any) {} func (l *metricsLogger) Warnf(format string, args ...any) {} func (l *metricsLogger) Errorf(format string, args ...any) {} func newClient() (unifi.Client, error) { return unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.example.com", APIKey: "your-api-key", Logger: &metricsLogger{}, }) } ``` You must implement all ten methods; `var _ unifi.Logger = (*metricsLogger)(nil)` is a handy compile-time check. To silence the SDK entirely, pass `unifi.NewDefaultLogger(unifi.DisabledLevel)` — it returns a no-op logger, so `c.Logger()` calls are cheap and produce no output. ## Next steps [#next-steps] Every ClientConfig field, including Logger. Hook the request/response cycle for richer logging or tracing. Turn the level up to Trace to diagnose connection and decode problems. # Raw HTTP calls (/docs/advanced/raw-http) Most resources have a typed method (`ListNetwork`, `CreateUser`, …). When an endpoint isn't covered — a brand-new controller feature, a niche setting, or an Official endpoint without a wrapper yet — the client exposes generic HTTP helpers. They handle URL building, JSON marshaling, authentication, interceptors, error handling, and response decoding for you, so you only supply a path and your own request/response types. ## The helpers [#the-helpers] | Method | Does | | ------------------------------------------ | ------------------------------------------------- | | `Do(ctx, method, path, reqBody, respBody)` | The core call: any HTTP method. The rest wrap it. | | `Get(ctx, path, reqBody, respBody)` | HTTP GET. | | `Post(ctx, path, reqBody, respBody)` | HTTP POST. | | `Put(ctx, path, reqBody, respBody)` | HTTP PUT. | | `Patch(ctx, path, reqBody, respBody)` | HTTP PATCH (partial update). | | `Delete(ctx, path, reqBody, respBody)` | HTTP DELETE. | `reqBody` is marshaled to JSON (pass `nil` for none); `respBody` is decoded from the JSON response (pass `nil` to ignore it). Both are `any`, so use anonymous structs or any existing `unifi` type. See the full signatures on [pkg.go.dev](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#Client). ## Path resolution [#path-resolution] The `path` argument follows one rule (`buildRequestURL`): * **No leading slash (site-relative):** the path is joined under the controller's primary API prefix. On new-style (UniFi OS) controllers — the **only** style supported in 2.0.0 — that prefix is `/proxy/network/api`. So `"s/default/rest/networkconf"` becomes `/proxy/network/api/s/default/rest/networkconf`. * **Leading slash or absolute URL:** used **as-is**, bypassing the prefix entirely. Use this only for paths outside the standard API tree. The classic `/api/...` form that worked on old-style controllers **does not work** on new-style controllers — use the site-relative form instead. Old-style (classic) controllers aren't supported at all in 2.0.0; the client [fails fast](/docs/advanced/compatibility) on them. ### Reading a resource [#reading-a-resource] ```go var resp struct { Meta unifi.Meta `json:"meta"` Data []unifi.Network `json:"data"` } // No leading slash => joined under /proxy/network/api on a new-style controller. if err := c.Get(ctx, "s/default/rest/networkconf", nil, &resp); err != nil { log.Fatalf("raw get: %v", err) } for _, n := range resp.Data { log.Printf("%s", n.Name) } ``` Legacy Internal endpoints wrap their payload in a `{ "meta": {...}, "data": [...] }` envelope, so decode into a struct with `Meta` and `Data` fields. [`unifi.Meta`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#Meta) carries the `rc`/`msg` status — and the client already turns an `rc: "error"` envelope into a returned error for you, so a successful call means `Data` is populated. ### Creating a resource [#creating-a-resource] ```go payload := struct { Name string `json:"name"` Purpose string `json:"purpose"` }{Name: "iot", Purpose: "corporate"} var resp struct { Meta unifi.Meta `json:"meta"` Data []unifi.Network `json:"data"` } if err := c.Post(ctx, "s/default/rest/networkconf", payload, &resp); err != nil { log.Fatalf("raw post: %v", err) } ``` ## The v2 API tree [#the-v2-api-tree] Some newer resources (firewall zones, zone policies, AP groups, …) live under the **v2** API tree at `/proxy/network/v2/api`. Rather than hard-coding that, use the exported `unifi.NewStyleAPI.ApiV2Path` constant. Because the result starts with a slash, it is sent as-is: ```go var zones []struct { ID string `json:"_id"` Name string `json:"name"` } // Leading slash => used as-is, bypassing the primary API prefix. path := unifi.NewStyleAPI.ApiV2Path + "/site/default/firewall/zone" if err := c.Get(ctx, path, nil, &zones); err != nil { log.Fatalf("list zones: %v", err) } ``` v2 responses are typically bare (no `meta`/`data` envelope), so decode directly into your target type. ## Reaching the Official API by hand [#reaching-the-official-api-by-hand] The Official UniFi OpenAPI lives under `/proxy/network/integration/v1`. Most of it is already wrapped by the fluent [`c.Official()`](/docs/guides/official-api) surface — prefer that. But you can hit any path directly with an absolute (leading-slash) path. A few Official endpoints (e.g. firewall policies) accept `PATCH` for partial updates; most — including networks — use `PUT`: ```go patch := struct { Name string `json:"name"` }{Name: "updated-name"} var resp struct { ID string `json:"id"` Name string `json:"name"` } // Firewall policies are the one Official resource that accepts PATCH. path := "/proxy/network/integration/v1/sites//firewall/policies/" if err := c.Patch(ctx, path, patch, &resp); err != nil { log.Fatalf("patch: %v", err) } ``` `Patch` sends a literal HTTP `PATCH`. Only a few Official `integration/v1` endpoints (e.g. firewall policies) accept it — most, including Official networks, require a full `PUT`, as do legacy Internal REST resources (e.g. `networkconf`). Use `Put` for those, sending the complete object. ## See also [#see-also] The fluent, typed surface for `integration/v1`. The Official OpenAPI rendered endpoint-by-endpoint. `ServerError`, `ErrNotFound`, and how failures surface. Observe or modify the raw requests these helpers send. # Troubleshooting (/docs/advanced/troubleshooting) This page collects the failures people hit most often, the symptom you actually see, and the fix. When in doubt, turn the logger up to `Trace` (see [Logging](/docs/advanced/logging)) to watch URL building, interceptors, and decoding. ## Connection and TLS [#connection-and-tls] Your controller presents a self-signed certificate and `NewClient` fails its eager system-info round-trip with a TLS error. TLS verification is **on by default** in 2.0.0. For a self-signed controller cert, opt out with `SkipVerifySSL`: ```go title="self-signed.go" c, err := unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.example.com", APIKey: "your-api-key", SkipVerifySSL: true, }) ``` Disabling verification logs a WARN on every client build. The more secure fix is to trust the controller's CA in your system or process trust store and leave `SkipVerifySSL` false. See [Connecting to your controller](/docs/getting-started/connecting). `Timeout` defaults to zero, meaning **no timeout** — a slow or unreachable controller can hang a request indefinitely. The client logs a WARN at build time when `Timeout` is unset. Set one (30s is a reasonable default), and use a per-call `context.Context` deadline for finer control: ```go unifi.NewClient(&unifi.ClientConfig{URL: "...", APIKey: "...", Timeout: 30 * time.Second}) ``` ## Version mismatches [#version-mismatches] You pointed the client at an **old-style (classic)** controller. API-key authentication — the only auth in 2.0.0 — requires a new-style (UniFi OS) controller, so the client fails fast on a classic (old-style) one. There is no workaround other than upgrading the controller. A *new-style* controller below `9.0.114` is still detected as new-style and instead surfaces an authentication error from the eager system-info call, not this sentinel. See [Authentication](/docs/getting-started/authentication) and the [compatibility matrix](/docs/advanced/compatibility). The Official OpenAPI surface (`c.Official()`) needs controller `10.1.78` or newer. On older controllers the capability probe fails and Official operations return `ErrOfficialAPIUnavailable`. The Internal surface (resource methods directly on the client) still works down to `9.0.114`. If you deliberately don't use the Official API, set `DisableOfficialAPI: true` — Official calls then fail fast with `ErrOfficialAPIDisabled` and the probe is skipped entirely. The Internal resource types are **generated** from a frozen controller snapshot (`9.5.21` for 2.0.0). A field your controller version returns may not exist on the struct yet, or may have moved between releases — see the [compatibility changelog](/docs/advanced/compatibility) for known moves (e.g. IPS DNS-filtering fields relocating into `ContentFiltering`). If a field you need is genuinely absent, reach it with a [raw request](/docs/advanced/raw-http) and consider opening an issue. ## JSON decode errors [#json-decode-errors] The controller is loose about scalar types: the same field can arrive as a number, a quoted string, an empty string, `null`, or a word like `"auto"` / `"enabled"` depending on firmware. The library smooths this over with custom unmarshalers in `unifi/json.go`: * **`numberOrString`** — accepts a JSON number *or* a string (e.g. a radio `channel` of `36` or `"auto"`), and maps `""`/`null` to the empty string. * **`emptyStringInt`** — accepts an int that the controller sometimes sends as `""`; decodes empty/`null` to `0` and marshals `0` back as `""` to round-trip cleanly. * **`booleanishString`** — tolerates `true`/`false`, `"enabled"`/`"disabled"`, `"1"`/`"0"`, and empty/`null`, collapsing them to a bool (used by fields like `Device.LtePoe`). These are applied automatically on the affected fields, so you normally never see them. If you *do* hit an error like `json: cannot unmarshal string into Go struct field … of type int`, it usually means the controller sent a shape a field doesn't yet tolerate — a sign the generated types have drifted from your firmware. Capture the raw response (a body-logging `HttpRoundTripperProvider`, see [Interceptors](/docs/advanced/interceptors)) and [open an issue](https://github.com/filipowm/go-unifi/issues) with the payload. Don't read `resp.Body` inside an `InterceptResponse` — the body is decoded *after* interceptors run, and draining it yields a silently zero-valued result. Use an `HttpRoundTripperProvider` to buffer and restore the body safely. ## Validation rejections [#validation-rejections] In `HardValidation` mode a write can fail locally before it ever reaches the controller, surfacing a `*unifi.ValidationError`. The generated `validate` tags come from the controller's own API definitions and are occasionally **stricter** than a given firmware actually enforces. If you're confident a value is valid for your controller, you have two mitigations: * Switch to the default `SoftValidation` — the field is logged as a WARN but the request is still sent. * Use `DisableValidation` to skip validation entirely (last resort): ```go title="disable-validation.go" c, err := unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.example.com", APIKey: "your-api-key", ValidationMode: unifi.DisableValidation, }) ``` See [Validation](/docs/advanced/validation) for inspecting `ValidationError.Messages` and registering custom validators. ## Still stuck? [#still-stuck] Turn the level up to Trace to see exactly what the client does. Sentinels and ServerError — what each failure means. Reach endpoints the typed methods don't cover. # Validation (/docs/advanced/validation) Before sending a write request, the client validates the request body against the [`go-playground/validator`](https://pkg.go.dev/github.com/go-playground/validator/v10) struct tags carried by the generated resource types (e.g. `Network.Purpose` only accepts `corporate`, `guest`, `wan`, …). This catches malformed payloads on your side instead of as an opaque controller error. How a failure is handled depends on the **validation mode**. Validation only runs on **request bodies** (the `reqBody` of POST/PUT/PATCH calls). Read calls (`Get`, `List…`) are never validated, and a `nil` body is skipped. ## Validation modes [#validation-modes] Set the mode with [`ClientConfig.ValidationMode`](/docs/reference/configuration-types): ```go title="hard-validation.go" c, err := unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.example.com", APIKey: "your-api-key", ValidationMode: unifi.HardValidation, }) ``` The default `SoftValidation` is deliberate: the generated tags come from the controller's own API definitions and can be stricter than a given controller version actually enforces. Soft mode keeps you moving (and logs a warning you can act on), while hard mode is best in tests and CI where you want a fast, local failure. ## Handling a ValidationError [#handling-a-validationerror] In `HardValidation` mode a failed validation surfaces as a `*unifi.ValidationError`, wrapped with context. Use `errors.As` to reach it and read the per-field messages from `Messages`: ```go title="handle-validation-error.go" n := &unifi.Network{ Name: "my-network", Purpose: "invalid-purpose", // not one of the allowed values IPSubnet: "10.0.0.10/24", } _, err := c.CreateNetwork(ctx, "default", n) if err != nil { var verr *unifi.ValidationError if errors.As(err, &verr) { for field, msg := range verr.Messages { log.Printf("validation error on %s: %s", field, msg) } return verr } return err } ``` `ValidationError` has two fields: * `Messages map[string]string` — a human-readable, translated message per failing field (keys are sorted when rendered via `Error()`, so the string form is deterministic). * `Root error` — the underlying cause. For a normal struct failure this is the raw [`validator.ValidationErrors`](https://pkg.go.dev/github.com/go-playground/validator/v10#ValidationErrors), so `errors.As(err, &validationErrs)` works if you need each field's tag, kind, and value. (On a non-struct or nil argument it instead carries the validator's `InvalidValidationError`.) `Unwrap()` exposes `Root`, so `errors.Is`/`errors.As` walk straight through. ## Custom validators [#custom-validators] The client registers a couple of built-in regex tags (`w_regex`, `numeric_nonzero`) that the generated structs reference. To add your own tag, build one with `NewCustomRegexValidator` and register it through [`ClientConfig.CustomValidators`](/docs/reference/configuration-types): ```go title="custom-validator.go" // Register a new "hostname_rfc1123" tag usable on request-body struct fields. hostname := unifi.NewCustomRegexValidator("hostname_rfc1123", `^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`) c, err := unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.example.com", APIKey: "your-api-key", CustomValidators: []unifi.CustomValidator{hostname}, }) ``` `NewCustomRegexValidator(tag, regex)` returns a `CustomValidator` whose `tag` becomes a go-playground/validator tag name. Any struct field carrying `validate:"...,hostname_rfc1123"` is then checked against the pattern, and a failure produces the message ` must comply with the regular expression pattern ''`. Each custom validator is registered on the client's own validator instance — it does not mutate global state, so two clients can carry different sets. Custom validators only fire during the client's own request-body validation, on fields whose struct tags reference your tag. The generated resource types are not editable (they are regenerated) and there is no public accessor to the client's validator, so this is most useful for tags the generated definitions already emit but the built-in set does not yet cover. ## Next steps [#next-steps] Sentinels, ServerError, and reacting to controller failures. All ClientConfig options, including ValidationMode and CustomValidators. When to relax validation for a stubborn controller version. # Code Generation (/docs/developers/code-generation) A single `go generate` produces all of go-unifi's `*.generated.go` files. Under the hood it runs **two passes** over **two independent inputs**, pinned by **two version markers**. This page explains the moving parts; for the exact commands to run, see [Regenerating](/docs/developers/regenerating). ## One command, two passes [#one-command-two-passes] The root generator (`codegen/main.go`, package `main`) orchestrates everything. Its `generate()` function resolves versions, makes the inputs available, runs both passes, and writes the version artifacts. **Internal pass** — `codegen/internal` (`internal.Generate`). Reads the controller's field-definition JSONs and emits `unifi/.generated.go` (resource structs + private CRUD) and `unifi/client.generated.go` (the `Client`/`InternalClient` interfaces). Hand-maintained `v2/` definitions render through a dedicated template. **Official pass** — `codegen/official` (`runOfficialPass`). Reads the committed OpenAPI snapshot and emits the whole `unifi/official/` surface: the oapi-codegen models, the per-tag fluent wrappers, the `Client` interface, and the per-group mocks. **Version artifacts** — root writes `unifi/version.generated.go` plus both markers (`.unifi-version` and `.unifi-version-official`) so the generated code and the markers always move together. ### Why the Official pass is a separate module [#why-the-official-pass-is-a-separate-module] `codegen/official` carries its **own** `go.mod`. The root generator shells out to it as a subprocess (`go run .`) rather than importing it. That keeps the heavy `oapi-codegen` / `kin-openapi` toolchain out of the **published** `go.mod` graph — consumers of the library never pull those in. The subprocess runs with `GOFLAGS=-buildvcs=false` and reads the committed spec offline. ```text codegen/main.go (package main, root module) ├── codegen/internal Internal-API pass (in-process) │ └── unifi/*.generated.go, unifi/client.generated.go └── codegen/official Official-API pass (separate go.mod, shelled out via `go run .`) └── unifi/official/*.generated.go ``` ## Two inputs, both committed snapshots [#two-inputs-both-committed-snapshots] Both passes read from **committed snapshots**, so generation is deterministic and fully offline by default. No controller download is needed for a normal regenerate. | Pass | Input | Committed location | | -------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------- | | Internal | Controller field-definition JSONs (extracted from the UniFi Network Application package) | `codegen/v/` (e.g. `codegen/v9.5.21/`) | | Official | UniFi OpenAPI (`integration/v1`) spec | `codegen/openapi/integration-.json` | The Internal field snapshot is **frozen** at `codegen/v9.5.21/` (committed, with a matching `.gitignore` un-ignore rule). Because the inputs are committed, the daily regeneration CI is a deterministic no-op unless a snapshot or template actually changes. **Multiple Official snapshots may coexist** under `codegen/openapi/`: bumping the spec keeps the older `integration-.json` alongside the new one. The Official pass generates the Go surface from the **pinned** version (the positional argument / `.unifi-version-official`); the [docs website](/docs) always renders the **newest** committed snapshot by version order. If the pinned snapshot already exists, the generator uses it offline and skips the download. If a committed snapshot is missing, the generator falls back to downloading it. `download.go` is the **only** remote-ingest point — HTTPS with Ubiquiti-host pinning, atomic extraction, and size caps. A package that predates the Official API (no `integration.json`) is skipped with a warning so the Internal pass never regresses. ## Two version axes [#two-version-axes] The Internal and Official surfaces version **independently**. Each has its own marker file at the repo root and its own resolver in `codegen/version.go`. ### The Internal axis is capped at 9.5.21 [#the-internal-axis-is-capped-at-9521] `9.5.21` is the **last** UniFi Network Application release that ships the internal `api/fields/*.json` definitions. Newer Network Application packages (`10.x` and later) dropped those files, so there is nothing to generate Internal resources from beyond this point — the legacy field-definition format is end-of-life there. (`9.5.21` and `10.x` are Network Application versions, unrelated to the console's UniFi OS version.) `resolveInternalVersion` enforces the cap: * `latest` resolves the newest release, then **clamps** to `min(resolved, 9.5.21)`. * An explicit version `≤ 9.5.21` is used as-is. * An explicit version `> 9.5.21` **fails loudly**, pointing you at the Official OpenAPI surface instead. ### The Official axis floors at 10.1.78 [#the-official-axis-floors-at-10178] `10.1.78` is the first controller whose package ships `integration.json` (the Official OpenAPI spec). `resolveOfficialSpecVersion` decouples this from the Internal version: when set explicitly (the **positional argument**) it uses exactly that version; otherwise it reuses the Internal version if it is `≥ 10.1.78`, and falls back to the latest release when the Internal version predates the Official API. The committed `go generate` directive pins **both** axes explicitly, so the canonical regenerate is fully reproducible. The positional argument is the **Official spec version**; `-legacy-version` pins the Internal controller version: ```go title="unifi/codegen.go" //go:generate go run ../codegen/ -version-base-dir=../codegen/ -legacy-version=9.5.21 10.1.85 ``` ## Generator flags [#generator-flags] The root generator (`go run ./codegen [OPTIONS] [official-spec-version]`) accepts: | Flag | Description | Default | | ------------------- | ----------------------------------------------------------------------- | -------- | | `-version-base-dir` | Base directory for version JSON files (and the `openapi/` snapshot dir) | `.` | | `-output-dir` | Output directory for the generated Go code | `.` | | `-legacy-version` | Pin the Internal/legacy controller version (capped at `9.5.21`) | `latest` | | `-download-only` | Only fetch/build the input JSONs; do not generate | `false` | | `-debug` | Enable debug logging | `false` | | `-trace` | Enable trace logging (takes precedence over `-debug`) | `false` | The positional `[official-spec-version]` argument pins the Official OpenAPI spec version (e.g. `10.1.85`); leave it empty to auto-select (the Internal version when `≥ 10.1.78`, otherwise the latest release). ## What guards it [#what-guards-it] CI re-runs generation and fails if the working tree is not byte-identical: * `test-codegen` runs `go generate unifi/codegen.go`, then `git diff --exit-code`. * `test-codegen-official` runs the `codegen/official` module's own test suite. * The Official pass is additionally guarded by determinism / committed-surface tests in the `codegen/official` module (`TestSurfaceMatchesCommitted`, `TestSurfaceDeterministic`). * The generated models carry a round-trip test, `models_roundtrip_test.go`, which lives in the generated `unifi/official` package (not the `codegen/official` module) and runs under the normal `go test ./...` suite. ## See also [#see-also] How customizations.yml steers the Internal pass. The exact commands and the snapshot-refresh procedure. # Contributing (/docs/developers/contributing) This page collects the conventions a change to go-unifi is expected to follow. Most are enforced by tooling; run the [pre-push gate](#the-pre-push-gate) before opening a PR and CI should be green. ## The cardinal rule: never edit generated files [#the-cardinal-rule-never-edit-generated-files] Files named `*.generated.go` start with `// Code generated … DO NOT EDIT.` **Never hand-edit them.** A daily CI job regenerates and overwrites them. To change generated output, edit [`customizations.yml`](/docs/developers/customizations) and [regenerate](/docs/developers/regenerating), or add a hand-written sibling `.go` file. This is the single most important convention — see the [architecture overview](/docs/developers) for the generated-vs-hand-written split. ## Wrapping generated code [#wrapping-generated-code] Generated CRUD methods are **private** (`getUser`, `listUser`, `createUser`). Expose them through **public wrappers** in the hand-written sibling (`GetUser`, `ListUser`, …), and put any custom logic — search-by-MAC, field initialization, custom marshaling — in the wrapper, not the generated file. That way a regeneration never clobbers your behavior. A few more client conventions: * **Transport:** use `c.Get`/`Post`/`Put`/`Patch`/`Delete` (or `c.Do`) from `requests.go`; don't build raw `http.Request`s. * **Errors:** surface API failures as `ServerError` (status, method, URL, code, validation details). Return the `ErrNotFound` sentinel when a single-resource GET finds nothing. * **JSON edge cases:** for empty-string-or-int, string-or-number, or enabled/disabled fields, use the helpers in `json.go` (`emptyStringInt`, `numberOrString`, `booleanishString`) rather than ad-hoc unmarshaling. * **Validation:** use [go-playground/validator](/docs/advanced/validation) struct tags; register custom regex validators via `NewCustomRegexValidator` in `validation.go`. * **One-way Official dependency:** `unifi/official` must import nothing from `unifi`. The transport is injected as the structural `Doer` and the capability check as a `Gate`. ## Go style [#go-style] | Rule | Enforced by | | -------------------------- | ------------------- | | **Tabs** for indentation | `gofmt` family | | Max line length **200** | `golangci-lint` | | Import grouping & ordering | `goimports` + `gci` | | Stricter formatting | `gofumpt` | Run the formatters with `golangci-lint fmt` (aliased to `make fmt`). A few language conventions on top: * **Context first.** Every client method takes `context.Context` as its first argument. * **Wrap errors with `%w` and context.** Never return a bare error that adds no information — name what failed: `fmt.Errorf("injecting validation tags: %w", err)`. The exception: if the callee already wraps with sufficient context, bubbling it up verbatim is fine. * **Comments explain *why*, not *what*.** Keep them short (≈2 lines); only exceed that for genuinely complex logic that naming and structure can't make self-evident. Don't narrate obvious code. * **Logging.** The client holds a named `log Logger` field (not embedded) and exposes a `Logger()` accessor. The default logger is `log/slog`-backed — don't import logrus in the `unifi` package. go-unifi targets **Go 1.26**. ## Commit messages [#commit-messages] Use [conventional commits](https://www.conventionalcommits.org/). The type you choose drives the [changelog](/docs/developers/release-process#changelog-generation): `feat`, `fix`, `perf`, and `security` appear in release notes; a `!` marker (`feat!:`) flags a breaking change; `chore`/`ci`/`docs`/`refactor`/ `style`/`test`/`build` are treated as internal noise and excluded. ## The pre-push gate [#the-pre-push-gate] ```bash make check # build + lint + test — the pre-push gate ``` Or run the steps individually: ```bash go build ./... golangci-lint run go test ./... ``` If you touched anything that feeds code generation (`customizations.yml`, a template, a snapshot, the `DeviceState` enum, or the `Client` interface), also confirm the generated output is current — CI fails on a diff: ```bash go generate unifi/codegen.go && git diff --exit-code # mirrors CI's test-codegen go generate unifi/device.go && git diff --exit-code # the DeviceState stringer ``` `golangci-lint` reads the Go version from `go.mod`. If a tool manager shadows your Go install with an older version, formatting and linting can fail spuriously — make sure the `go` on your `PATH` matches `go.mod`. ## See also [#see-also] The generated-vs-hand-written split these rules protect. Test conventions and the fixtures to reuse. Commands to run after a codegen change. # Customizations (/docs/developers/customizations) The Internal pass is driven by `codegen/internal/customizations.yml`. It is the supported way to shape generated output **without** hand-editing `*.generated.go`. When the controller's raw field definitions produce a wrong Go type, a bad JSON tag, or an endpoint you don't want, you fix it here and [regenerate](/docs/developers/regenerating). The file has two top-level sections under `customizations:` — `resources:` (per-resource and per-field overrides) and `client:` (interface-level customizations). ## Field overrides [#field-overrides] Under `resources..fields.`, each of these keys is optional: For example, the controller sends `Device.X`/`Device.Y` as ambiguous numbers and `StpPriority` as a string-or-number; the overrides pin sane Go types: ```yaml title="codegen/internal/customizations.yml" resources: Device: fields: _all: omitEmpty: true X: fieldType: "float64" Y: fieldType: "float64" StpPriority: fieldType: "string" customUnmarshalType: "numberOrString" QOSProfile: fieldType: "*DeviceQOSProfile" ``` The special field name `_all` applies to **every** field of the resource (it runs first, then the named-field override). Above, `_all.omitEmpty: true` makes every `Device` field omit-empty unless a specific field opts back out. ### `ifFieldType` — conditional overrides [#iffieldtype--conditional-overrides] `ifFieldType` only applies the override when the source field's type matches. This is how the same logical field is handled across resources where the controller is inconsistent — e.g. a `Channel` that is sometimes a number and sometimes the string `"auto"`: ```yaml title="codegen/internal/customizations.yml" ChannelPlan: fields: Channel: ifFieldType: "string" customUnmarshalType: "numberOrString" ``` ### `omitEmpty: false` — forcing a field onto the wire [#omitempty-false--forcing-a-field-onto-the-wire] Slices default to `,omitempty`, which drops an empty slice entirely. Some controller endpoints choke on a missing array. Forcing the field to serialize as `[]` keeps the UI happy: ```yaml title="codegen/internal/customizations.yml" FirewallGroup: fields: GroupMembers: omitEmpty: false ``` ## Resource-level overrides [#resource-level-overrides] Under `resources.` (alongside `fields:`): * **`resourcePath`** — override the REST path segment for the resource, e.g. `firewall/zone` or `content-filtering`. * **`queryParams`** — a map of query-string parameters appended to every emitted URL for the resource. This is the first-class alternative to smuggling a `?foo=bar` suffix into `resourcePath` (which would produce a malformed id-suffixed URL). Keys are rendered in sorted, URL-encoded order. * **`excludeFunctions`** — omit specific CRUD actions (`Get`, `Create`, …) from the generated client. Unknown action names are warned and ignored. ```yaml title="codegen/internal/customizations.yml" ContentFiltering: resourcePath: "content-filtering" excludeFunctions: ["Get"] # no public Get for this resource DescribedFeature: resourcePath: "described-features" queryParams: includeSystemFeatures: "true" ``` ## Client-level customizations [#client-level-customizations] Under `customizations.client`: * **`imports`** — extra imports to add to the generated client file. * **`functions`** — declare additional methods on the generated `Client` interface (signature + doc comment). These are the **declarations**; the implementations live in hand-written siblings. This is how methods like `AdoptDevice`, `GetDeviceByMAC`, `ListSites`, or the transport methods (`Get`/`Post`/`Put`/`Patch`/`Delete`) appear on the interface. * **`excludeResources`** — omit a resource from the **`Client` interface only**. Its `*.generated.go` (types + private CRUD) is still emitted, so a hand-written wrapper can wire it up (e.g. `FirewallZoneMatrix`, `DescribedFeature`). * **`excludeGeneration`** — skip a resource **entirely** — no `*.generated.go` at all. Use this for unsupported resources with no wrapper so no dead code ships (e.g. `Dpi*`). Patterns support leading/trailing `*` wildcards. ```yaml title="codegen/internal/customizations.yml" client: imports: ["io"] excludeResources: ["DescribedFeature", "FirewallZoneMatrix"] excludeGeneration: ["Dpi*"] functions: - name: "GetDeviceByMAC" resourceName: "Device" params: - { name: "ctx", type: "context.Context" } - { name: "site", type: "string" } - { name: "mac", type: "string" } returns: ["*Device", "error"] ``` ## Custom unmarshalers [#custom-unmarshalers] `customUnmarshalType` / `customUnmarshalFunc` reference helpers defined in **`unifi/json.go`** — a hand-written file. When you need a new wire-shape handler, add it there and reference it from the YAML. The existing helpers cover the common controller quirks: | Helper | Handles | | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | | `numberOrString` | A field that may be a JSON number **or** a string like `"auto"`. Treats `null`/`""` as empty. | | `emptyStringInt` | An int that the controller sometimes sends as `""`. Decodes empty/null to `0`; marshals `0` back to `""`. | | `booleanishString` | A boolean sent as `true`/`false`, `"enabled"`/`"disabled"`, `"1"`/`"0"`, or empty. Permissive — unrecognized input decodes to `false`. | | `emptyBoolToTrue` | A `customUnmarshalFunc` that defaults a missing `*bool` to `true`. | `customizations.yml` is embedded into the generator binary via `//go:embed`. Editing the file is not enough on its own — you must **regenerate** for the change to reach `*.generated.go`, and commit the regenerated files alongside the YAML edit. CI's `test-codegen` job fails if they drift. ## See also [#see-also] Where customizations fit in the two-pass pipeline. Run the generator after editing the YAML. The runtime side: go-playground validator tags. # Architecture (/docs/developers) This section is for people who work **on** go-unifi rather than with it. It explains how the library is put together so you can change it safely. If you just want to call the API, start with the [guides](/docs/guides) instead. The library has two halves: a large body of **generated** code describing every resource the controller exposes, and a thin **hand-written** layer that turns that code into a usable client. ## Generated vs. hand-written [#generated-vs-hand-written] Most resource types and their CRUD methods are generated directly from the UniFi controller's own API definitions. This is what gives the library its broad, accurate coverage — there are far more resources than anyone would hand-maintain. Files named `*.generated.go` begin with `// Code generated … DO NOT EDIT.` **Never hand-edit them.** CI regenerates and overwrites them, so your change would be silently lost. To change generated output, edit the [codegen customizations](/docs/developers/customizations) or add a hand-written sibling file. The split inside `unifi/` looks like this: | File pattern | Origin | Contains | | ----------------------------------------------------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `*.generated.go` | [code generation](/docs/developers/code-generation) | Resource structs, **private** CRUD methods (`getUser`, `listUser`), the `Client`/`InternalClient` interfaces, the moq mock | | `.go` | hand-written | **Public** wrappers (`GetUser`, `ListUser`), business logic (search-by-MAC, field init, custom marshaling) | | `client.go`, `requests.go`, `interceptors.go`, `json.go`, … | hand-written | Transport, config, auth, error handling, JSON edge cases | Generated CRUD methods are deliberately private. The public surface lives in the hand-written sibling, so a regeneration never clobbers your custom logic. See [Contributing](/docs/developers/contributing) for the wrapper convention. ## The Client interface [#the-client-interface] The generated [`Client`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#Client) interface **embeds** [`InternalClient`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#InternalClient) — the interface carrying every resource CRUD method — and then adds transport/lifecycle methods (`Get`/`Post`/`Put`/`Patch`/ `Delete`/`Do`, `BaseURL`, `Version`) plus the two API-surface accessors: ```go title="surfaces.go" package developers import ( "context" "github.com/filipowm/go-unifi/v2/unifi" "github.com/filipowm/go-unifi/v2/unifi/official" ) // exampleSurfaces shows the two API surfaces hanging off one client. func exampleSurfaces(ctx context.Context, c unifi.Client) { // Internal() returns the legacy resource CRUD surface (the default). var internal unifi.InternalClient = c.Internal() // Official() returns the Official OpenAPI surface, bound to the same transport. var off official.Client = c.Official() _, _ = internal.ListNetwork(ctx, "default") _ = off } ``` Because the resource methods are promoted through the embedded `InternalClient`, `c.ListNetwork(...)` and `c.Internal().ListNetwork(...)` are the same call. `c.Internal()` and `c.Official()` simply name the surface you mean. The full reference is on the [client reference page](/docs/reference/client) and on [pkg.go.dev](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#Client). ## The one-way `unifi → official` dependency [#the-one-way-unifi--official-dependency] The Official OpenAPI surface lives in its own package, `unifi/official`. The dependency is strictly one-way: ```text unifi ──imports──▶ unifi/official ▲ │ └──── imports nothing ─┘ ``` `unifi/official` imports **nothing** from `unifi`. That keeps it independently testable and prevents an import cycle. The two packages meet at two small structural seams, both injected by `unifi` when it constructs the Official client: A five-method interface (`Get`/`Post`/`Put`/`Patch`/`Delete`). `*unifi.client` satisfies it through its public request methods, so the Official wrappers send requests without knowing about the concrete client. A `func(ctx) error` run before every Official call. `unifi` injects a gate that checks opt-out, requires a new-style controller with API-key auth, and probes the version floor (10.1.78+) once. The wiring lives in `unifi/official_surface.go`: `Official()` calls `official.New(c, integrationV1Path, c.officialAvailable)`, passing the client as the `Doer` and its gate method as the `Gate`. Official wrappers pass full leading-`/` paths under the `integration/v1` prefix, which `buildRequestURL` resolves directly against the base URL — bypassing the legacy API-path logic. This is why `unifi/official` never needs to know about old-vs-new API path styles. ## Where to go next [#where-to-go-next] The two-pass pipeline and the two version axes. Steer generated output via customizations.yml. The exact commands to regenerate everything. Fixtures, golden tests, and mocks. goreleaser, changelog automation, and the daily regen. Style, conventions, and the pre-push gate. # Regenerating (/docs/developers/regenerating) This is the command reference for regenerating go-unifi's generated code. For *what* the pipeline does, read [Code generation](/docs/developers/code-generation) first. A local-only `Makefile` wraps every command below. ## Regenerate everything (canonical) [#regenerate-everything-canonical] The one command CI runs — it regenerates **both** surfaces (Internal resources + the Official models, wrappers, interface, and mock) from the committed snapshots, offline: ```bash go generate unifi/codegen.go ``` This invokes the directive in `unifi/codegen.go`, which pins both version axes explicitly (`-legacy-version=9.5.21 10.1.85` — the positional argument is the Official spec version), so the result is byte-for-byte reproducible. After running it, `git diff` should be empty unless a snapshot, template, or `customizations.yml` actually changed. ## Regenerate the DeviceState stringer [#regenerate-the-devicestate-stringer] `DeviceState` has a generated `String()` method. Regenerate it after changing the enum: ```bash go generate unifi/device.go ``` CI's `stringer` job runs this and fails on any diff. ## The Makefile wrappers [#the-makefile-wrappers] ```bash make generate # stringer + resources make generate-stringer # just the DeviceState stringer make generate-resources # just the resource types (VERSION=latest by default) ``` `make generate-resources` runs the root generator directly, passing `VERSION` as the positional Official-spec-version argument: ```bash go run ./codegen/ -version-base-dir=./codegen/ -output-dir=./unifi ``` `VERSION` pins the **Official spec version** (default `latest`); override the **Internal** controller version with `-legacy-version` (default `latest`, which clamps to the `9.5.21` cap): ```bash make generate-resources VERSION=10.1.85 make generate VERSION=10.1.85 # stringer + resources at that spec version ``` When `VERSION` is empty the Official spec auto-resolves (latest when the Internal version predates the Official API). For a fully reproducible regenerate that pins both axes, prefer `go generate unifi/codegen.go`. ## Regenerate the moq mock [#regenerate-the-moq-mock] `ClientMock` (in `unifi/client_mock.generated.go`) is a [moq](https://github.com/matryer/moq)-generated double for the public `Client` interface. Regenerate it after changing the interface: ```bash go generate ./unifi/... # equivalently: go run github.com/matryer/moq@latest -out client_mock.generated.go . Client ``` The `@latest` form needs network access to fetch moq. Offline, run a locally-installed `moq` binary against the `unifi` package instead. The `.generated.go` suffix keeps the mock out of the coverage report. ## Pinning the Official spec version [#pinning-the-official-spec-version] The Official OpenAPI spec version is the **positional argument**; `-legacy-version` pins the Internal version independently: ```bash go run ./codegen/ -version-base-dir=./codegen/ -output-dir=./unifi \ -legacy-version=9.5.21 10.1.85 ``` Multiple snapshots may be committed side by side under `codegen/openapi/` — the positional version selects which one the Go surface is generated from. When the snapshot `codegen/openapi/integration-.json` is already committed, the generator uses it offline; otherwise it downloads and commits it (keeping any existing snapshots). The [docs website](/docs) always renders the newest committed snapshot. ## Refreshing a frozen snapshot [#refreshing-a-frozen-snapshot] The Internal field inputs are frozen at `codegen/v9.5.21/` (a committed snapshot plus a `.gitignore` un-ignore rule), which is why daily regeneration is a deterministic no-op. To move to a new Internal snapshot: Remove the old snapshot directory and its `.gitignore` exception. Run `make generate-resources VERSION=` to download and extract the new field JSONs. Re-add the `!/codegen/v/` `.gitignore` exception so the new snapshot is committed. Bump the version in `unifi/codegen.go` and `.unifi-version` . Regenerate ( `go generate unifi/codegen.go` ) and review the golden diff. Refreshing the **Official** spec is simpler: bump the positional version in the `unifi/codegen.go` directive (the older `integration-.json` can stay committed alongside the new one), regenerate to download and commit the new snapshot, and verify the diff. ## Verify before committing [#verify-before-committing] ```bash go build ./... go generate unifi/codegen.go && git diff --exit-code # mirrors CI's test-codegen make check # build + lint + test ``` ## See also [#see-also] The pipeline behind these commands. Edit customizations.yml, then regenerate. The golden/determinism tests that guard generation. # Release Process (/docs/developers/release-process) go-unifi is a Go module, so a "release" is a Git tag: there are no binaries to build. Tagging triggers goreleaser to draft the GitHub Release, after which automation backfills the changelog. This page documents the pipeline end-to-end. ## Cutting a release [#cutting-a-release] Releases are tag-driven. Pushing a `v*` tag runs `.github/workflows/release.yaml`: **goreleaser** runs `release --clean` with an imported GPG key. Because this is a library, `builds` is `skip: true` — goreleaser produces the GitHub Release and notes, not artifacts. **Changelog backfill** (a dependent job) regenerates the release notes into `CHANGELOG.md` on `main` and pushes the commit. Before each run, goreleaser's `before.hooks` run `go mod tidy` and `go generate unifi/device.go` so the tagged tree is tidy and the stringer is current. ## Changelog generation [#changelog-generation] The release notes come from the **git history**, filtered and grouped by [conventional commit](https://www.conventionalcommits.org/) type in `.goreleaser.yaml`: | Group | Matches | | ------------------- | ------------------------------------------------ | | 🚨 Breaking Changes | any commit with a `!` marker (`feat!:`, `fix!:`) | | 🔒 Security | `security:` | | ✨ New Features | `feat:` | | 🔧 Bug Fixes | `fix:` | | ⚡ Performance | `perf:` | Maintenance noise is **excluded** from consumer-facing notes: `chore`, `ci`, `docs`, `refactor`, `style`, `test`, `build`, `revert`, and the `fix(docs|review|skill|tests)` scopes. Keep this in mind when writing commit messages — the type you choose decides whether (and where) a change is published. The changelog job pushes to protected `main` using a dedicated **deploy key** (`CHANGELOG_DEPLOY_KEY`). The GitHub Actions app cannot bypass branch rulesets on a user-owned repo, so the deploy key is registered as a bypass actor. It prepends a dated `## [vX.Y.Z]` section, skips prereleases, and is idempotent (a tag already in `CHANGELOG.md` is a no-op). PR labels are applied automatically too: `conventional-release.yml` runs on PR open/edit and labels by the PR's conventional-commit type, feeding release tooling. ## Version markers [#version-markers] Two marker files at the repo root pin the generated surfaces. They are **written by code generation**, not edited by hand, and they track the generated code so a release records exactly which controller/spec versions it was built against: | Marker | Surface | Current | | ------------------------- | ------------------------------- | --------- | | `.unifi-version` | Internal API controller version | `9.5.21` | | `.unifi-version-official` | Official OpenAPI spec version | `10.1.85` | See [Code generation](/docs/developers/code-generation) for the two version axes behind them. ## Daily regeneration [#daily-regeneration] `.github/workflows/generate.yaml` runs on a daily schedule (and on demand). It runs `go generate unifi/codegen.go` — one pass that refreshes the committed Official OpenAPI snapshot **and** the Internal resources, then folds in the Official models, wrappers, interface, and mock. If anything changed it opens a PR titled after the new controller version. Because the Internal axis is capped at `9.5.21` and both axes are pinned to committed snapshots, this job is normally a deterministic **no-op** — it only produces a PR when an input snapshot actually moves. ## Docs deploy [#docs-deploy] `.github/workflows/docs.yaml` builds this Fumadocs site (`website/`) with pnpm and deploys it to GitHub Pages. It runs when `website/**`, the committed OpenAPI spec (`codegen/openapi/**`), or `.unifi-version-official` changes — so the API reference republishes automatically whenever a new controller spec lands. ## See also [#see-also] Commit conventions that drive the changelog. The version markers and the daily regen. # Testing (/docs/developers/testing) go-unifi has two kinds of tests: **behavioral** tests for the hand-written client (assertions over real HTTP round-trips against a fake controller) and **golden / determinism** tests that lock down the generated output. This page covers the conventions for both. Run the suite with: ```bash go test ./... # everything go test -run TestName ./unifi # a single test make test # with coverage (generated files excluded) make test-fast # no coverage, quick loop ``` ## Conventions for hand-written tests [#conventions-for-hand-written-tests] **Assertions: testify.** Use `testify/assert` + `testify/require` (or `tj/assert` for simple cases). Compare marshaled JSON with `assert.JSONEq` rather than string equality. **Table-driven.** Cases go in a `map[string]struct{...}`, one `t.Run(name, ...)` per case. Call `t.Parallel()` in both the outer test and each subtest. **External package.** Test files are `*_test.go` and use the external `unifi_test` package when exercising the public API, so tests see only what consumers see. ## Mocking a real controller [#mocking-a-real-controller] When a test needs a real HTTP round-trip, mock the controller with `net/http/httptest.NewServer` and assert on the request path/method inside the handler. Prefer the **shared fixtures** over hand-rolling setup: * Route through the `controllerServer` fixture and helpers like `sysinfoRoute` / `clientWith` instead of spinning up a bespoke server + client per test. They keep request routing, the version pre-warm, and the request counters consistent across the suite. * When you find yourself duplicating server setup, route handlers, or assertion counters, **extract a shared fixture or helper** rather than copy-pasting — and reach for an existing fixture before writing new boilerplate. ## Testing with the generated mocks [#testing-with-the-generated-mocks] For unit tests that don't need HTTP at all, use the generated mocks. `ClientMock` is a [moq](https://github.com/matryer/moq)-generated double for the public `Client` interface: set only the `…Func` fields your code under test actually calls. ```go title="mock_test.go" package developers import ( "context" "testing" "github.com/filipowm/go-unifi/v2/unifi" ) // TestListActiveNetworks drives code under test against a ClientMock instead of a // live controller: only the methods the code touches need a Func field. func TestListActiveNetworks(t *testing.T) { mock := &unifi.ClientMock{ ListNetworkFunc: func(ctx context.Context, site string) ([]unifi.Network, error) { return []unifi.Network{{Name: "LAN"}}, nil }, } // The mock satisfies the full unifi.Client interface. var c unifi.Client = mock nets, err := c.ListNetwork(context.Background(), "default") if err != nil { t.Fatalf("list networks: %v", err) } if len(nets) != 1 || nets[0].Name != "LAN" { t.Fatalf("unexpected networks: %+v", nets) } } ``` The Official surface has its own hand-rolled mocks — one per resource group, e.g. `official.ClientsClientMock` or `official.ACLsClientMock`, each a func-field double where a nil field panics if called. These are emitted by the Official codegen pass, not by moq. See [Regenerating](/docs/developers/regenerating) for how the mock is regenerated when the interface changes. ## Golden & determinism tests [#golden--determinism-tests] The generated code is locked down two ways. **At the CI level**, `test-codegen` runs `go generate unifi/codegen.go` then `git diff --exit-code` — the working tree must be byte-identical to the committed output. The `stringer` job does the same for the `DeviceState` stringer. **Inside the `codegen/official` module**, two generator tests assert the surface is stable and correct: | Test | Asserts | | ----------------------------- | ----------------------------------------------------------------------- | | `TestSurfaceMatchesCommitted` | The regenerated Official surface equals the committed `*.generated.go`. | | `TestSurfaceDeterministic` | Generation is byte-stable run-to-run (no map-iteration nondeterminism). | They run offline against the committed OpenAPI snapshot. Because the generator pins a fixed header version and sorts its output, regeneration is reproducible regardless of how it's launched. A third guard, `models_roundtrip_test.go`, lives in the generated **`unifi/official`** package itself — so it runs with the normal `go test ./...` suite rather than the codegen module — and asserts the generated models round-trip through JSON without loss. ## Coverage [#coverage] `make test` writes a coverage profile with `*.generated.go` lines filtered out (generated code isn't hand-tested), matching what CI reports. The Makefile prints the total; `make cover` opens the HTML report. ## See also [#see-also] Regenerate the mock and the golden output. Style and the pre-push gate. The error shapes your tests assert on. # Authentication (/docs/getting-started/authentication) go-unifi authenticates with an **API key** and nothing else. Username/password login was removed in 2.0.0. An API key is a long secret token you generate once in the controller's UI; the client sends it on every request in the `X-Api-Key` header. **Why API key only?** API keys are scoped, revocable, and don't require juggling login sessions or CSRF tokens. They are available in **UniFi Network 9.0.114** and newer (running on a UniFi OS console) — the same floor this library requires. `9.0.114` is a Network Application version, not a UniFi OS version. ## Obtaining an API key [#obtaining-an-api-key] Open your site in the [UniFi Site Manager](https://unifi.ui.com) . Go to **Control Plane → Admins & Users** . Select your admin user. Click **Create API Key** . Give the key a name. **Copy the key and store it securely — it is shown only once.** Click **Done** so the key is hashed and saved. ## Using the key [#using-the-key] Pass the key as `APIKey` when you build the client. That is the only credential field on [`ClientConfig`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#ClientConfig): ```go c, err := unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.example.com", // your controller, no trailing /api APIKey: "your-api-key", }) ``` The client attaches the key to every request automatically as the `X-Api-Key` header — you never set it by hand. (For an end-to-end runnable program, see the [Quickstart](/docs/getting-started/quickstart).) Treat the key like a password. Don't hard-code it in source you commit — read it from an environment variable or a secret store, e.g. `APIKey: os.Getenv("UNIFI_API_KEY")`. ## Version requirements [#version-requirements] API-key authentication needs a **UniFi OS** console (new-style) running **UniFi Network 9.0.114 or newer**. This is the hard floor for the whole library, regardless of which [API surface](/docs/guides/choosing-a-surface) you use. The versions below are **UniFi Network Application** versions — they track separately from the console's UniFi OS version. | Capability | Minimum UniFi Network | | ----------------------------- | --------------------- | | API-key auth (whole library) | **9.0.114** | | Official API (`c.Official()`) | **10.1.78** | ## Old-style controllers are unsupported [#old-style-controllers-are-unsupported] "Classic" (non-UniFi-OS) controllers can't issue API keys, so they aren't supported. When the client detects one at construction time, `NewClient` returns [`unifi.ErrOldStyleUnsupported`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#ErrOldStyleUnsupported). Match it with `errors.Is`: ```go _, err := unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.example.com", APIKey: "your-api-key", }) if errors.Is(err, unifi.ErrOldStyleUnsupported) { log.Fatal("this controller is too old; API-key auth needs UniFi Network 9.0.114+") } ``` The remedy is to move to a **UniFi OS** console (a Dream Machine, a Cloud Key, or the self-hosted UniFi OS Server) running **UniFi Network 9.0.114** or newer. Classic standalone controllers — the Network Application without UniFi OS — can't issue API keys at all, so there is no workaround on that style of install. ## Next steps [#next-steps] URL, TLS, timeouts, and the rest of the client options. Put it together: connect and list your networks. # Connecting (/docs/getting-started/connecting) You connect to a controller by building a `unifi.Client` from a [`*unifi.ClientConfig`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#ClientConfig). Only `URL` and `APIKey` are required; the rest tune TLS, timeouts, and startup behavior. This page walks through the options you reach for most often. ## The minimum [#the-minimum] ```go c, err := unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.example.com", APIKey: "your-api-key", }) ``` `NewClient` returns `(unifi.Client, error)`. On any error it returns a `nil` client, so checking `err != nil` is enough — you won't risk a nil-panic on your first call. ### The URL [#the-url] * It **must be `https://`** — the client refuses plaintext so your API key is never sent in the clear. * Give the **controller's base URL only**, with **no trailing `/api`**. The client appends the right paths itself. `https://unifi.example.com` is correct; `https://unifi.example.com/api` is rejected. * A trailing slash is fine — it's trimmed for you. ## The common options [#the-common-options] For the exhaustive list (interceptors, custom transports, validation modes, loggers), see [`ClientConfig` on pkg.go.dev](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#ClientConfig). ## Self-signed certificates [#self-signed-certificates] TLS verification is **on by default**. A controller with a self-signed certificate will therefore make `NewClient` fail with a TLS error. The pragmatic fix is to disable verification with `SkipVerifySSL: true`: ```go c, err := unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.example.com", APIKey: "your-api-key", SkipVerifySSL: true, }) ``` Disabling verification exposes the connection to man-in-the-middle attacks, and the client logs a warning when you do. For anything beyond a lab, prefer trusting the controller's CA certificate instead. ## Timeouts [#timeouts] By default there is **no timeout**: a slow or unreachable controller can make a call hang indefinitely (and the client logs a warning at build time when `Timeout` is unset). Set a deadline that covers all requests: ```go c, err := unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.example.com", APIKey: "your-api-key", Timeout: 30 * time.Second, }) ``` This timeout applies to the whole HTTP round-trip. Per-call cancellation is separate — every client method also takes a `context.Context` you can give its own deadline. ## Deferring the startup check [#deferring-the-startup-check] By default `NewClient` makes **one round-trip** to the controller to confirm the URL is reachable and the key is valid, so a bad key or wrong URL fails *there* rather than on your first real call. Set `SkipSystemInfo: true` to skip that eager check: ```go c, err := unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.example.com", APIKey: "your-api-key", SkipSystemInfo: true, }) ``` With `SkipSystemInfo: true`, construction does not contact the controller for system info, so a bad key or unreachable host surfaces on the **first API call** instead. This is handy in tests or when the controller isn't guaranteed reachable at startup. ## Opting out of the Official API [#opting-out-of-the-official-api] The client probes for [Official API](/docs/guides/official-api) support lazily. If you only use the Internal API and want to skip that capability entirely, set `DisableOfficialAPI: true`: ```go c, err := unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.example.com", APIKey: "your-api-key", DisableOfficialAPI: true, }) if err != nil { return err } // Any Official() call now fails fast instead of probing the controller. _, err = c.Official().Info().Get(ctx) if errors.Is(err, unifi.ErrOfficialAPIDisabled) { // expected: the Official surface is turned off } ``` Internal resource methods are unaffected — only `c.Official()` operations short-circuit with `unifi.ErrOfficialAPIDisabled`. ## One client, many goroutines [#one-client-many-goroutines] A `unifi.Client` is **safe for concurrent use**. Build it once and share the single value across goroutines; don't construct one per request. (The old `UseLocking` option is a deprecated no-op.) ## Next steps [#next-steps] Make your first call with the client you just built. Internal vs Official — which API to call and when. Sentinels, server errors, and how to react to them. # Introduction (/docs/getting-started) **go-unifi** is a Go client library for the [UniFi Network](https://ui.com) controller — the software that manages Ubiquiti's networking gear (gateways, switches, access points). Instead of hand-rolling HTTP requests and parsing JSON, you call typed Go methods like `ListNetwork` and get back typed Go structs. The library powers the [Terraform provider for UniFi](https://github.com/filipowm/terraform-provider-unifi), but it stands alone in any Go program that needs to read or change controller state. ## A few terms [#a-few-terms] * **UniFi OS** — the operating system that runs on a UniFi console (a Dream Machine, a Cloud Key, or the self-hosted UniFi OS Server). It hosts Ubiquiti's applications — Network, Protect, Access, and others — and carries its **own** version number (the latest is in the `5.x` line). * **UniFi Network Application** — the application, packaged *inside* UniFi OS, that actually manages your network and exposes the API this library targets. It versions **independently** of UniFi OS (the latest is in the `10.x` line). Historically called the "UniFi Network Controller" / "UniFi Controller". * **Controller** — throughout these docs, shorthand for the UniFi Network Application you talk to, reachable over HTTPS. **Every version number in this documentation** — `9.0.114`, `10.1.78`, and so on — **is a UniFi Network Application version, never a UniFi OS version** (a console on UniFi OS `5.x` may run UniFi Network `10.x`). Everything in this library is a request to your controller. * **Site** — a controller hosts one or more *sites* (independent network configurations). A fresh controller has a single site named `default`. * **API surface** — a set of endpoints the controller exposes. go-unifi wraps **two** of them (below). ## Two API surfaces, one client [#two-api-surfaces-one-client] A single `unifi.Client` speaks to both of the controller's APIs. You build the client once and choose a surface per call. The long-standing UniFi Network API this library has always wrapped. It has the broadest coverage: every resource method (`ListNetwork`, `CreateUser`, …) lives here, reachable directly on the client or via `c.Internal()`. Sites are addressed by their **name** (`"default"`). The official UniFi OpenAPI (`integration/v1`), reached via `c.Official()`. A fluent, per-resource surface that requires a newer controller. Sites are addressed by a **UUID**. Calling a resource method straight on the client is identical to calling it through `c.Internal()` — the Internal surface is the canonical default in 2.0.0: ```go nets, _ := c.Internal().ListNetwork(ctx, "default") // Internal API: site NAME id, _ := c.Official().Sites().ResolveID(ctx, "default") // Official API: site UUID ``` Not sure which to use? See [Choosing a surface](/docs/guides/choosing-a-surface). The short version: start with the Internal API; reach for the Official API when you specifically want endpoints it covers. ## The module: `/v2` [#the-module-v2] go-unifi 2.0.0 is a new major version, so its import path carries the `/v2` suffix required by [Go module versioning](https://go.dev/doc/modules/version-numbers): ```text github.com/filipowm/go-unifi/v2 ``` The `/v2` is part of the path you `go get` and the path you `import`. Code written for go-unifi 1.x keeps working on the old path; upgrading means switching to `/v2`. See [Installation](/docs/getting-started/installation) to add it, and the [migration guide](/docs/migrating/from-1.x) if you are coming from 1.x. ## Requirements at a glance [#requirements-at-a-glance] * **Go 1.26** or later. * A **UniFi OS** console (new-style) running **UniFi Network 9.0.114** or newer. Authentication is **API key only** (username/password was removed in 2.0.0), and API keys require that minimum Network Application version. Older "classic" standalone controllers — the Network Application running without UniFi OS — are unsupported and fail fast at construction. * The **Official API surface** additionally needs **UniFi Network 10.1.78** or newer. `9.0.114` and `10.1.78` are **UniFi Network Application** versions, not UniFi OS versions — the two track separately. They are floors, not targets: running the latest Network Application release is recommended, and the library is regenerated daily to track new versions. ## Where to next [#where-to-next] Add the module, learn the import paths, and verify it resolved. Create an API key and understand the version requirements. Connect and list your networks in one runnable program. TLS, timeouts, and the rest of the client options. # Installation (/docs/getting-started/installation) go-unifi is a regular Go module. This page adds it to your project and confirms it resolved. To then make your first call, continue to the [Quickstart](/docs/getting-started/quickstart). ## Requirements [#requirements] * **Go 1.26 or later.** Check yours with `go version`. * A module to add it to. If you are starting fresh, create one first: ```bash mkdir myapp && cd myapp go mod init example.com/myapp ``` ## Add the module [#add-the-module] ```bash go get github.com/filipowm/go-unifi/v2 ``` The `/v2` suffix is part of the path — it is how Go addresses major version 2 of the module. Always include it; omitting it resolves the legacy 1.x line. Coming from the original `paultyng/go-unifi`? The import path changed to `github.com/filipowm/go-unifi/v2`. See [Migrating from upstream](/docs/migrating/from-paultyng). ## The import paths [#the-import-paths] The module is split into three packages. Most programs only ever import the first. `github.com/filipowm/go-unifi/v2/unifi` — the client, the configuration, the error sentinels, and every resource type. This is the package you almost always want. `github.com/filipowm/go-unifi/v2/unifi/official` — helpers for the [Official API](/docs/guides/official-api) surface, such as `ListOptions`, `Page[T]`, and `Collect`. `github.com/filipowm/go-unifi/v2/unifi/features` — named constants for controller feature flags (e.g. `features.ZoneBasedFirewall`), used with `c.IsFeatureEnabled`. A typical import block looks like this: ```go import ( "github.com/filipowm/go-unifi/v2/unifi" ) ``` ## Verify the install [#verify-the-install] Drop this into `main.go`. It does not touch the network — if it builds and runs, the module and its dependencies resolved correctly. ```go title="main.go" package main import ( "fmt" "github.com/filipowm/go-unifi/v2/unifi" ) func main() { // If this compiles and runs, the module and its dependencies resolved correctly. cfg := &unifi.ClientConfig{ URL: "https://unifi.example.com", APIKey: "your-api-key", } fmt.Printf("go-unifi is installed. Auth header: %s, target: %s\n", unifi.ApiKeyHeader, cfg.URL) } ``` Run it: ```bash go run . ``` You should see: ```text go-unifi is installed. Auth header: X-Api-Key, target: https://unifi.example.com ``` If the build fails on the import path, double-check the `/v2` suffix and that your Go is 1.26+. ## Next steps [#next-steps] Create an API key — the only supported credential. Connect to your controller and list its networks. # Quickstart (/docs/getting-started/quickstart) This page gets you from nothing to a working program that talks to your UniFi controller. It assumes you have [installed the module](/docs/getting-started/installation) and [created an API key](/docs/getting-started/authentication). ## The whole program [#the-whole-program] In the module you created during [Installation](/docs/getting-started/installation) (or a new one), replace `main.go` with the program below. It connects to a controller and prints every network on the default site. ```go title="main.go" package main import ( "context" "errors" "fmt" "log" "github.com/filipowm/go-unifi/v2/unifi" ) func main() { ctx := context.Background() // 1. Build a client. NewClient verifies the connection and your API key up front. c, err := unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.example.com", // your controller, no trailing /api APIKey: "your-api-key", }) if err != nil { log.Fatalf("create client: %v", err) } // 2. Call the API. "default" is the site name on a single-site controller. networks, err := c.ListNetwork(ctx, "default") if err != nil { if errors.Is(err, unifi.ErrNotFound) { log.Fatal("site not found") } log.Fatalf("list networks: %v", err) } // 3. Use the strongly-typed result. for _, n := range networks { fmt.Printf("%s (%s)\n", n.Name, n.Purpose) } } ``` Replace `https://unifi.example.com` with your controller's base URL and `your-api-key` with the key you created in [Authentication](/docs/getting-started/authentication). Run it: ```bash go run . ``` With your real URL and key, you should see your networks printed, one per line. ## What just happened [#what-just-happened] **You built a client.** `unifi.NewClient` takes a [`*unifi.ClientConfig`](/docs/reference/configuration-types). Only `URL` and `APIKey` are required. By default it makes one round-trip to the controller to confirm the URL is reachable and the key is valid, so a bad key fails *here* rather than on your first real call. **You called a resource method.** `ListNetwork(ctx, site)` is one of dozens of resource methods on the client. Every method takes a `context.Context` first and a **site name** (`"default"`) for the Internal API. **You got typed structs back.** `networks` is a `[]unifi.Network`, so fields like `Name` and `Purpose` are real Go fields — no `map[string]any` juggling. Using a self-signed controller certificate? `NewClient` will fail with a TLS error. Add `SkipVerifySSL: true` to the config to skip verification (see [Connecting to your controller](/docs/getting-started/connecting)), or better, trust the controller's CA. ## Next steps [#next-steps] Where API keys come from and which controller versions support them. TLS, timeouts, and the rest of the connection options. Manage networks, devices, clients, firewall rules and more. Sentinels, server errors, and how to react to them. # Choosing a surface (/docs/guides/choosing-a-surface) A single `unifi.Client` exposes **two** of the controller's APIs. They cover overlapping resources with different shapes, identifiers and requirements. This guide explains both and helps you pick — or [combine them](#which-should-i-use), since one client can drive both surfaces at the same time. ## The two surfaces [#the-two-surfaces] * **Internal** — the legacy UniFi Network API this library has always wrapped. Every resource method (`ListNetwork`, `CreateUser`, …) lives here, reachable **directly on the client** or via `c.Internal()`. It is the **default**: calling a resource method on the client is identical to calling it on `c.Internal()`. * **Official** — the official UniFi OpenAPI (`integration/v1`), reached via `c.Official()`. A **fluent**, per-resource-group surface generated from the controller's own OpenAPI snapshot. It needs a newer controller. ```go title="surfaces.go" // These two calls are identical — Internal is the default surface. a, err := c.ListNetwork(ctx, "default") b, err := c.Internal().ListNetwork(ctx, "default") _, _ = a, b // The Official surface is explicit and fluent. info, err := c.Official().Info().Get(ctx) if err != nil { log.Fatal(err) } log.Println("controller version:", info.ApplicationVersion) ``` Writing `c.Internal()` is never required in 2.0.0 — it only documents intent. It pays off ahead of the [3.0.0 default flip](#the-20--30-default-flip): code that already says `c.Internal()` keeps working unchanged. ## The Official capability gate [#the-official-capability-gate] The Official surface only works on a capable controller, so every Official operation runs a **capability gate** first and returns a sentinel error when it can't proceed. Match either with `errors.Is`: * `unifi.ErrOfficialAPIUnavailable` — the controller can't serve it: a classic (old-style) controller, a failed `GET /v1/info` probe (a rejected API key surfaces here), or a controller version below **10.1.78**. * `unifi.ErrOfficialAPIDisabled` — you opted out via `ClientConfig.DisableOfficialAPI`. ```go title="gate.go" info, err := c.Official().Info().Get(ctx) switch { case errors.Is(err, unifi.ErrOfficialAPIDisabled): log.Println("Official API turned off via DisableOfficialAPI") return case errors.Is(err, unifi.ErrOfficialAPIUnavailable): log.Println("controller too old, classic, or info probe failed — Official API unavailable") return case err != nil: log.Fatal(err) } log.Println("controller version:", info.ApplicationVersion) ``` The first successful gate check probes `GET /v1/info` once and caches the result, so later calls skip it. ### Opting out [#opting-out] Set `DisableOfficialAPI` to skip the Official surface entirely — the capability probe never runs and every `c.Official()` operation fails fast with `ErrOfficialAPIDisabled`: ```go title="disable.go" c, err := unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.example.com", APIKey: "your-api-key", DisableOfficialAPI: true, }) ``` ## Site identifiers differ [#site-identifiers-differ] The biggest day-to-day difference is how each surface names a site: | Surface | Site identifier | Example | | -------- | -------------------- | ------------------------------------------------ | | Internal | site **name** string | `"default"` | | Official | a `uuid.UUID` | `c.Official().Sites().ResolveID(ctx, "default")` | `Official().Sites().ResolveID(ctx, name)` maps the familiar name to its UUID (and caches it). If you already hold a UUID string, parse it with `uuid.Parse` from `github.com/google/uuid`. See the [Sites guide](/docs/guides/sites) for the full bridge. ## The 2.0 → 3.0 default flip [#the-20--30-default-flip] In **2.0.0 the Internal surface is the canonical default**, so existing code is untouched. **3.0.0 is expected to flip the default to the Official client.** If you want to stay on the legacy API across that change, call resource methods through `c.Internal()` now — it is a no-op today and future-proof later. ## Which should I use? [#which-should-i-use] This is **not** a mutually exclusive, either/or choice. Both surfaces live on the **same client** and can be used side by side — Internal for one resource, Official for another — in the same program. There is no mode to toggle and nothing to re-initialise; just call whichever surface you want: ```go title="combined.go" // One client, two surfaces, used side by side. // Internal stays keyed by the site name: nets, err := c.ListNetwork(ctx, "default") if err != nil { log.Fatal(err) } // Official is keyed by a uuid.UUID, resolved from that same name: siteID, err := c.Official().Sites().ResolveID(ctx, "default") if err != nil { log.Fatal(err) } info, err := c.Official().Info().Get(ctx) if err != nil { log.Fatal(err) } log.Printf("%d networks; official site %s on controller %s", len(nets), siteID, info.ApplicationVersion) ``` Because mixing is supported, **decide deliberately which surface owns which resource** rather than reaching for whichever is closest to hand. Consistency is what keeps a codebase legible: * Prefer **Official** for resources it covers — it is the direction Ubiquiti supports going forward, with stable typed models and explicit pagination. Requires controller **10.1.78+**. * Use **Internal** for everything the Official surface doesn't cover yet, or when you target controllers between **9.0.114** and 10.1.78. It has the broadest resource coverage. * Keep each resource on **one** surface wherever you can. The two expose different models and identifiers for the same thing — site **name** vs `uuid.UUID`, differing field shapes — so reading on one surface and writing on the other invites drift and surprising mismatches. The two surfaces are independent **views**, not a shared store: the same object fetched via Official and via Internal comes back as different Go types with their own fields and IDs. Don't assume a value from one will round-trip through the other — translate explicitly (for example, resolve a site name to its UUID) on the rare occasions you genuinely need to cross over. The [UniFi API reference](/docs/reference/official-api/api) and the [Official reference](/docs/reference/official-api) document the Official surface endpoint-by-endpoint; the [Resources catalogue](/docs/reference/internal-api/resources) lists what the Internal surface exposes. ## Next steps [#next-steps] The fluent per-group surface and its uniform method shapes. Resolve a site name to its Official UUID. Sentinels, server errors, and how to react to them. # Clients & users (/docs/guides/clients-and-users) In the UniFi data model a **client** (a connected or known station — a laptop, phone, printer) is called a **user**, represented by [`unifi.User`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#User). The client exposes REST-style CRUD keyed by the user's `_id`, plus a set of MAC-keyed command actions (block / unblock / kick / forget) for acting on live clients. The snippets assume a connected client `c` and a `ctx` (see the [Quickstart](/docs/getting-started/quickstart)). Every call takes the **site name** (`"default"`) first. ## List and look up clients [#list-and-look-up-clients] ```go users, err := c.ListUser(ctx, "default") if err != nil { panic(err) } for _, u := range users { fmt.Printf("%-18s %-16s blocked=%t\n", u.Name, u.MAC, u.Blocked) } ``` There are two single-client lookups, hitting different endpoints: ```go // By _id, via the REST endpoint. u, err := c.GetUser(ctx, "default", id) // By MAC. This endpoint also populates the live IP address, which GetUser does not return. byMAC, err := c.GetUserByMAC(ctx, "default", "00:11:22:33:44:55") fmt.Printf("%s is at %s\n", byMAC.Name, byMAC.IP) ``` `GetUser` and `GetUserByMAC` return slightly different data because they use separate controller endpoints — notably `IP` is only populated by `GetUserByMAC`. Both return `unifi.ErrNotFound` when nothing matches. ## Create a client [#create-a-client] Creating a user registers a known client — for example to pin a fixed IP or attach a note. A `MAC` is required, and `NetworkID` ties the client to one of your [networks](/docs/guides/networks): ```go created, err := c.CreateUser(ctx, "default", &unifi.User{ MAC: "00:11:22:33:44:55", Name: "Office Printer", NetworkID: networkID, UseFixedIP: true, FixedIP: "192.168.30.10", Note: "managed by go-unifi", }) if err != nil { panic(err) } fmt.Printf("created client %s with id %s\n", created.Name, created.ID) ``` Update an existing client with `UpdateUser` (read-modify-write, like other resources) and remove it by `_id` with `DeleteUser`. ## Block, unblock, kick, forget — by MAC [#block-unblock-kick-forget--by-mac] These act on a live client by MAC address and return only an error. Each returns `unifi.ErrNotFound` if the controller doesn't recognise the MAC. ```go // Deny the client network access (persists until unblocked). if err := c.BlockUserByMAC(ctx, "default", mac); err != nil { panic(err) } // Restore access. if err := c.UnblockUserByMAC(ctx, "default", mac); err != nil { panic(err) } // Force a reconnect — e.g. to re-apply a new VLAN or group assignment. if err := c.KickUserByMAC(ctx, "default", mac); err != nil { panic(err) } // Forget the client entirely (drops its history and known-client entry). if err := c.DeleteUserByMAC(ctx, "default", mac); err != nil { panic(err) } ``` `DeleteUser(ctx, site, id)` and `DeleteUserByMAC(ctx, site, mac)` are not interchangeable: the first removes a known-client record by its `_id` via REST, the second issues the controller's `forget-sta` command by MAC. Reach for `DeleteUserByMAC` when you only have the MAC of a connected station. ## Override a client's fingerprint [#override-a-clients-fingerprint] The controller fingerprints clients to guess their device type. `OverrideUserFingerprint` forces a specific fingerprint id; passing `0` clears the override. ```go // Force the device type to fingerprint id 12. if err := c.OverrideUserFingerprint(ctx, "default", mac, 12); err != nil { panic(err) } // Clear the override. if err := c.OverrideUserFingerprint(ctx, "default", mac, 0); err != nil { panic(err) } ``` ## User groups [#user-groups] A [`unifi.UserGroup`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#UserGroup) caps a client's bandwidth. It has the standard CRUD: `ListUserGroup`, `GetUserGroup`, `CreateUserGroup`, `UpdateUserGroup`, `DeleteUserGroup`. Rates are in **Kbps**, and `-1` means unlimited. ```go group, err := c.CreateUserGroup(ctx, "default", &unifi.UserGroup{ Name: "Throttled", QOSRateMaxDown: 50000, // Kbps; -1 means unlimited QOSRateMaxUp: 10000, }) if err != nil { panic(err) } ``` Assign a client to the group by setting `User.UserGroupID` to `group.ID` and calling `UpdateUser`. ## RADIUS accounts [#radius-accounts] A [`unifi.Account`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#Account) is a RADIUS user account, used for VPN, 802.1X and hotspot authentication. It also follows the standard CRUD: `ListAccount`, `GetAccount`, `CreateAccount`, `UpdateAccount`, `DeleteAccount`. ```go acct, err := c.CreateAccount(ctx, "default", &unifi.Account{ Name: "vpn-user", XPassword: "s3cret", }) if err != nil { panic(err) } fmt.Printf("created RADIUS account %s (%s)\n", acct.Name, acct.ID) ``` ## Next steps [#next-steps] The VLANs and LANs you assign clients to. WLANs clients connect through, including RADIUS-backed WPA-Enterprise. Rules and groups that gate client traffic. Tell ErrNotFound apart from server and validation errors. # Devices (/docs/guides/devices) A **device** is a piece of UniFi hardware managed by the controller — an access point, switch, gateway or camera. Each one is a [`unifi.Device`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#Device), and the client exposes the usual CRUD plus two lifecycle actions: `AdoptDevice` and `ForgetDevice`. The snippets assume a connected client `c` and a `ctx` (see the [Quickstart](/docs/getting-started/quickstart)). Every call takes the **site name** (`"default"`) first. ## List devices [#list-devices] ```go devices, err := c.ListDevice(ctx, "default") if err != nil { panic(err) } for _, d := range devices { fmt.Printf("%-18s %-16s model=%-8s state=%s\n", d.Name, d.MAC, d.Model, d.State) } ``` `ListDevice` returns `[]unifi.Device`. Handy fields: `Name`, `MAC`, `Model`, `Type`, `IP`, `Adopted` and `State`. The struct carries a great deal more (radios, ports, stats); browse the [full type on pkg.go.dev](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#Device). ## Fetch one device — by ID or by MAC [#fetch-one-device--by-id-or-by-mac] There are two lookups, and they hit **different** endpoints: ```go // By the device's _id. This filters the full device list client-side. d, err := c.GetDevice(ctx, "default", id) if err != nil { if errors.Is(err, unifi.ErrNotFound) { fmt.Println("no such device") return } panic(err) } // By MAC. This hits the device endpoint directly. byMAC, err := c.GetDeviceByMAC(ctx, "default", "00:11:22:33:44:55") ``` `GetDevice(ctx, site, id)` calls `ListDevice` and scans for a matching `ID`, so it pays for a full list every call. `GetDeviceByMAC(ctx, site, mac)` queries the controller directly and is cheaper when you already know the MAC. Both return `unifi.ErrNotFound` when nothing matches. ## Update a device [#update-a-device] Like networks, updates are read-modify-write — fetch, mutate, send the whole object back: ```go d, err := c.GetDevice(ctx, "default", id) if err != nil { panic(err) } d.Name = "Living Room AP" updated, err := c.UpdateDevice(ctx, "default", d) if err != nil { panic(err) } fmt.Printf("renamed to %s\n", updated.Name) ``` ## Adopt and forget [#adopt-and-forget] `AdoptDevice` and `ForgetDevice` are command actions keyed by **MAC**, not ID. Adopting brings a factory-default or pending device under this controller's management; forgetting releases it back to an unmanaged state. ```go // Pull a newly-seen device into this site. if err := c.AdoptDevice(ctx, "default", "00:11:22:33:44:55"); err != nil { panic(err) } // Later: remove it from the controller entirely. if err := c.ForgetDevice(ctx, "default", "00:11:22:33:44:55"); err != nil { panic(err) } ``` Both calls are asynchronous on the controller side: a successful return means the command was accepted, not that adoption has finished. Poll `GetDeviceByMAC` and watch `State` to track progress (`Adopting` → `Provisioning` → `Connected`). ## Device state [#device-state] `Device.State` is a typed [`unifi.DeviceState`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#DeviceState) enum with a `String()` method, so it prints a readable label and compares cleanly against the constants: ```go for _, d := range devices { switch d.State { case unifi.DeviceStateConnected: fmt.Printf("%s is online\n", d.Name) case unifi.DeviceStatePending, unifi.DeviceStateAdopting: fmt.Printf("%s is being adopted\n", d.Name) case unifi.DeviceStateUpgrading, unifi.DeviceStateProvisioning: fmt.Printf("%s is busy (%s)\n", d.Name, d.State) default: fmt.Printf("%s: %s\n", d.Name, d.State) // String() gives "HeartbeatMissed", etc. } } ``` The full set of states: | Constant | Value | `String()` | | ----------------------------- | ----- | ------------------ | | `DeviceStateUnknown` | 0 | `Unknown` | | `DeviceStateConnected` | 1 | `Connected` | | `DeviceStatePending` | 2 | `Pending` | | `DeviceStateFirmwareMismatch` | 3 | `FirmwareMismatch` | | `DeviceStateUpgrading` | 4 | `Upgrading` | | `DeviceStateProvisioning` | 5 | `Provisioning` | | `DeviceStateHeartbeatMissed` | 6 | `HeartbeatMissed` | | `DeviceStateAdopting` | 7 | `Adopting` | | `DeviceStateDeleting` | 8 | `Deleting` | | `DeviceStateInformError` | 9 | `InformError` | | `DeviceStateAdoptFailed` | 10 | `AdoptFailed` | | `DeviceStateIsolated` | 11 | `Isolated` | ## Next steps [#next-steps] The VLANs and LANs your devices serve. WLANs and radio settings on your access points. The client stations connecting through these devices. React to ErrNotFound and server errors. # Error Handling (/docs/guides/error-handling) go-unifi returns ordinary Go errors. What makes them useful is that the interesting failures are **matchable**: sentinel values you compare with `errors.Is`, and structured types you unwrap with `errors.As`. This page covers every error worth branching on. ## The sentinels [#the-sentinels] These package-level values let you react to a specific condition with `errors.Is`: | Sentinel | Meaning | | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `unifi.ErrNotFound` | The resource doesn't exist — a real HTTP 404, or a single-resource `Get` that returned empty data. List methods return an empty slice and nil error, not `ErrNotFound`. | | `unifi.ErrOldStyleUnsupported` | Construction-time: the target is an old-style (classic) controller. API-key auth needs UniFi Network **9.0.114+**. | | `unifi.ErrOfficialAPIUnavailable` | The `c.Official()` surface can't be used here — a classic controller, a failed `GET /v1/info` probe (a rejected API key surfaces here), or a controller below **10.1.78**. | | `unifi.ErrOfficialAPIDisabled` | The Official API was explicitly turned off via `ClientConfig.DisableOfficialAPI`. | ## Not found [#not-found] The most common branch. `ErrNotFound` covers both a genuine `404` and the "empty result" case uniformly, so one check handles both: ```go func getOne(ctx context.Context, c unifi.Client) { net, err := c.GetNetwork(ctx, "default", "does-not-exist") if errors.Is(err, unifi.ErrNotFound) { log.Println("no such network") return } if err != nil { log.Fatal(err) } _ = net } ``` ## ServerError: the full HTTP story [#servererror-the-full-http-story] Every non-2xx response from the controller is decoded into a [`*unifi.ServerError`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#ServerError). Reach it with `errors.As`: ```go func inspect(ctx context.Context, c unifi.Client) { _, err := c.CreateNetwork(ctx, "default", &unifi.Network{}) if err == nil { return } var se *unifi.ServerError if errors.As(err, &se) { fmt.Printf("status %d on %s %s\n", se.StatusCode, se.RequestMethod, se.RequestURL) fmt.Printf("controller code=%q message=%q\n", se.ErrorCode, se.Message) for _, d := range se.Details { if d.ValidationError.Field != "" { fmt.Printf(" field %q must match %q\n", d.ValidationError.Field, d.ValidationError.Pattern) } } if se.StatusCode == http.StatusUnauthorized { log.Fatal("API key rejected") } return } // Not a ServerError: a transport, timeout, or context error. log.Printf("non-server error: %v", err) } ``` `ServerError` carries the request context (`StatusCode`, `RequestMethod`, `RequestURL`), the controller's own `ErrorCode` and `Message`, and a `Details` slice of per-field [`ServerErrorDetails`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#ServerErrorDetails) (each with a message and a `ValidationError` field/pattern). Its `Error()` method formats all of that into a single readable line, so logging the error directly is already informative. A `*ServerError` with status `404` also satisfies `errors.Is(err, unifi.ErrNotFound)` — it implements an `Is` method that maps a 404 to the sentinel. So you can keep using the simple `errors.Is(err, unifi.ErrNotFound)` check even when the failure arrives as a structured `ServerError`. Empty-body responses (common for `401`/`403`) and non-JSON gateway pages still produce a fully-populated `ServerError`, never a bare decode error. ## ValidationError: failing before the request leaves [#validationerror-failing-before-the-request-leaves] By default the client validates request structs locally (in **soft** mode it logs; in **hard** mode it rejects). A rejected struct surfaces as a [`*unifi.ValidationError`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#ValidationError), whose `Messages` map names each offending field: ```go func validate(ctx context.Context, c unifi.Client) { _, err := c.CreateNetwork(ctx, "default", &unifi.Network{}) var ve *unifi.ValidationError if errors.As(err, &ve) { for field, msg := range ve.Messages { fmt.Printf("invalid %s: %s\n", field, msg) } } } ``` `ValidationError` also `Unwrap`s to the underlying `go-playground/validator` error, so `errors.As` against the validator's own types works too. See [Request validation](/docs/advanced/validation) for the modes. ## Official API errors [#official-api-errors] The [Official surface](/docs/guides/official-api) fails in two stages. **Before** a request is sent, the capability gate returns one of the `ErrOfficialAPI*` sentinels — match them with `errors.Is`: ```go func official(ctx context.Context, c unifi.Client) { info, err := c.Official().Info().Get(ctx) switch { case errors.Is(err, unifi.ErrOfficialAPIDisabled): log.Println("Official API turned off via ClientConfig.DisableOfficialAPI") return case errors.Is(err, unifi.ErrOfficialAPIUnavailable): log.Println("controller too old (<10.1.78), classic, or info probe failed") return case err != nil: // Past the gate, a request failure is still a *unifi.ServerError. var se *unifi.ServerError if errors.As(err, &se) { log.Printf("official request failed: %d %s", se.StatusCode, se.Message) } return } _ = info } ``` Once past the gate, an Official request that fails on the wire returns the **same** `*unifi.ServerError` as the Internal API — the Official package borrows the client's transport and error decoding, so you handle both surfaces with one `errors.As`. ## Construction-time errors [#construction-time-errors] `NewClient` does an eager system-info round-trip (unless `SkipSystemInfo: true`), so a classic controller or a bad key fails *at construction*. The classic-controller case is its own sentinel: ```go func build() { _, err := unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.example.com", APIKey: "your-api-key", }) if errors.Is(err, unifi.ErrOldStyleUnsupported) { log.Fatal("classic controller — upgrade to UniFi Network 9.0.114+ for API-key auth") } _ = err } ``` ## Next steps [#next-steps] Every exported error type and sentinel. Soft, hard, and disabled validation modes. Returning these errors from mocks in your tests. # Feature Flags (/docs/guides/feature-flags) UniFi controllers expose a set of named **feature flags** that describe which capabilities are available and turned on for a site — things like zone-based firewall, IPS, or WireGuard VPN. Before you build on a feature that only exists on newer firmware, ask the controller whether it is there. The Internal API surfaces this through three client methods and a package of stable name constants. ## Is a feature enabled? [#is-a-feature-enabled] `IsFeatureEnabled` is the shortcut: it returns a single `bool`. Feature names are **case-insensitive**, and the [`features`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi/features) package gives you constants so you never hard-code a string. ```go import ( "context" "fmt" "github.com/filipowm/go-unifi/v2/unifi" "github.com/filipowm/go-unifi/v2/unifi/features" ) func zoneFirewall(ctx context.Context, c unifi.Client) error { enabled, err := c.IsFeatureEnabled(ctx, "default", features.ZoneBasedFirewall) if err != nil { return fmt.Errorf("checking zone-based firewall: %w", err) } if enabled { fmt.Println("zone-based firewall is enabled") } else { fmt.Println("zone-based firewall is supported but off") } return nil } ``` The first argument after the context is the **site name** (`"default"` on a single-site controller), exactly like every other Internal API method. ## Supported vs. enabled [#supported-vs-enabled] There is a subtle distinction worth understanding: * A feature the controller has **never heard of** (too old, wrong product) yields `unifi.ErrNotFound`. * A feature the controller **knows about** comes back as a [`DescribedFeature`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#DescribedFeature) whose `FeatureExists` field reports whether it is actually present/enabled. `GetFeature` gives you that struct so you can tell the two apart: ```go func zoneFirewallMigration(ctx context.Context, c unifi.Client) (bool, error) { f, err := c.GetFeature(ctx, "default", features.ZoneBasedFirewallMigration) if err != nil { if errors.Is(err, unifi.ErrNotFound) { log.Printf("feature %s is unavailable on this controller", features.ZoneBasedFirewallMigration) return false, nil } return false, fmt.Errorf("getting feature: %w", err) } return f.FeatureExists, nil } ``` `IsFeatureEnabled` is exactly this with the `FeatureExists` value returned for you — so an unknown feature surfaces as an error there too, not as `false`. Always handle the `ErrNotFound` case. `GetFeature` and `IsFeatureEnabled` are implemented on top of `ListFeatures`: each call fetches the full feature list and scans it. If you need to test several features at once, call `ListFeatures` once and inspect the slice yourself rather than making one round-trip per flag. ## List every feature [#list-every-feature] `ListFeatures` returns the whole set the controller reports for a site — handy for diagnostics or for building a capability map up front. ```go func listFeatures(ctx context.Context, c unifi.Client) error { all, err := c.ListFeatures(ctx, "default") if err != nil { return fmt.Errorf("listing features: %w", err) } for _, f := range all { fmt.Printf("%-32s exists=%t\n", f.Name, f.FeatureExists) } return nil } ``` ## Custom feature names [#custom-feature-names] The constants in `features` are convenience values, not an allow-list. Any string is accepted, so you can probe a flag the library doesn't ship a constant for: ```go enabled, err := c.IsFeatureEnabled(ctx, "default", "MAGIC_AP_BOOST") ``` If the controller doesn't recognize the name you get `unifi.ErrNotFound`, exactly as with the constants. Feature flags belong to the **Internal API**. They describe controller-wide capabilities and are unrelated to the [Official API](/docs/guides/official-api) capability gate (the `ErrOfficialAPI*` sentinels), which decides whether the `c.Official()` surface can be used at all. See [Error handling](/docs/guides/error-handling) for those. ## Next steps [#next-steps] The full list of `features.*` names. `ErrNotFound`, `ServerError`, and the other sentinels. # File Uploads (/docs/guides/file-uploads) The Guest Hotspot portal can serve custom assets — a background image, a logo, an HTML template. go-unifi uploads those **portal files** to the controller with two methods, both of which use `multipart/form-data` as the controller requires: * `UploadPortalFile` — upload from a file path on disk. * `UploadPortalFileFromReader` — upload from any `io.Reader` (in-memory bytes, a network stream, an embedded asset). Both return a [`*unifi.PortalFile`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#PortalFile) describing the stored file — its `ID`, `URL`, `Filename`, `FileSize`, and detected `ContentType`. ## Upload from disk [#upload-from-disk] ```go title="main.go" package main import ( "context" "log" "github.com/filipowm/go-unifi/v2/unifi" ) func main() { ctx := context.Background() c, err := unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.example.com", APIKey: "your-api-key", }) if err != nil { log.Fatalf("create client: %v", err) } portalFile, err := c.UploadPortalFile(ctx, "default", "/path/to/your/file.png") if err != nil { log.Fatalf("upload portal file: %v", err) } log.Printf("upload successful: id=%s url=%s", portalFile.ID, portalFile.URL) } ``` The content type is detected from the file itself — you don't set it. As with every Internal API call, the argument after the context is the **site name** (`"default"` here). ## Upload from memory or a stream [#upload-from-memory-or-a-stream] When the bytes don't live on disk — they're generated, downloaded, or embedded — use `UploadPortalFileFromReader` and pass the filename the controller should record: ```go func uploadFromReader(ctx context.Context, c unifi.Client) error { content := []byte("...image or HTML content...") var reader io.Reader = bytes.NewReader(content) portalFile, err := c.UploadPortalFileFromReader(ctx, "default", reader, "myfile.png") if err != nil { return fmt.Errorf("uploading portal file: %w", err) } fmt.Printf("uploaded: id=%s url=%s\n", portalFile.ID, portalFile.URL) return nil } ``` Any `io.Reader` works — an `*os.File`, an `http.Response.Body`, a `bytes.Reader`, or an `embed.FS` entry. ## Listing and deleting [#listing-and-deleting] Uploaded files round out a small CRUD surface: `ListPortalFiles`, `GetPortalFile`, and `DeletePortalFile`. ```go func managePortalFiles(ctx context.Context, c unifi.Client) error { files, err := c.ListPortalFiles(ctx, "default") if err != nil { return fmt.Errorf("listing portal files: %w", err) } for _, f := range files { fmt.Printf("%s (%s)\n", f.Filename, f.ID) } if len(files) > 0 { if err := c.DeletePortalFile(ctx, "default", files[0].ID); err != nil { return fmt.Errorf("deleting portal file: %w", err) } } return nil } ``` All the usual client behaviour applies to uploads: request/response interceptors, the `X-Api-Key` header, `ServerError` decoding, and validation all run unchanged. An empty upload response surfaces as `unifi.ErrNotFound`. For uploads to other endpoints there are lower-level `UploadFile` / `UploadFileFromReader` primitives, but those are not part of the public `Client` interface — use the raw-call surface if you need them. ## Next steps [#next-steps] Reacting to upload failures and `ServerError` details. The full `Client` interface, including the portal-file methods. # Firewall (/docs/guides/firewall) The Internal API exposes two generations of firewall configuration: the classic **rule-based** firewall (groups + ordered rules per ruleset) and the newer **zone-based** firewall (zones, a zone matrix, and zone policies). Content filtering rounds out the set. All of these live on `c.Internal()` and take the site **name** (`"default"`). Every example below assumes you have a constructed client `c` and a `ctx` — see the [Quickstart](/docs/getting-started/quickstart) if you don't. ## Firewall groups [#firewall-groups] A **firewall group** is a reusable set of addresses or ports that rules reference by ID. Create, list, update and delete them like any other resource. ```go func exampleFirewallGroup(ctx context.Context, c unifi.Client) error { // Groups are reusable sets of addresses or ports referenced by rules. g, err := c.CreateFirewallGroup(ctx, "default", &unifi.FirewallGroup{ Name: "blocked-hosts", GroupType: "address-group", // address-group | port-group | ipv6-address-group GroupMembers: []string{"10.0.0.5", "10.0.0.6"}, }) if err != nil { return fmt.Errorf("create firewall group: %w", err) } groups, err := c.ListFirewallGroup(ctx, "default") if err != nil { return fmt.Errorf("list firewall groups: %w", err) } _ = groups g.GroupMembers = append(g.GroupMembers, "10.0.0.7") if _, err := c.UpdateFirewallGroup(ctx, "default", g); err != nil { return fmt.Errorf("update firewall group: %w", err) } return c.DeleteFirewallGroup(ctx, "default", g.ID) } ``` `GetFirewallGroup` returns [`unifi.ErrNotFound`](/docs/guides/error-handling) when the ID is unknown, so use `errors.Is` rather than string-matching: ```go g, err := c.GetFirewallGroup(ctx, "default", id) if err != nil { if errors.Is(err, unifi.ErrNotFound) { // no such group } return fmt.Errorf("get firewall group: %w", err) } ``` ## Firewall rules [#firewall-rules] A **firewall rule** acts on a single direction of traffic identified by its `Ruleset` (`LAN_IN`, `WAN_OUT`, `GUEST_LOCAL`, …). Rules can reference firewall groups by ID through `SrcFirewallGroupIDs` / `DstFirewallGroupIDs`. ```go func exampleFirewallRule(ctx context.Context, c unifi.Client, groupID string) error { rule, err := c.CreateFirewallRule(ctx, "default", &unifi.FirewallRule{ Name: "block-bad-hosts", Enabled: true, Action: "drop", // drop | reject | accept Ruleset: "LAN_IN", // which traffic direction the rule applies to RuleIndex: 2000, Protocol: "all", DstFirewallGroupIDs: []string{groupID}, }) if err != nil { return fmt.Errorf("create firewall rule: %w", err) } _ = rule // Reorder rules within a ruleset by assigning new indices. rules, err := c.ListFirewallRule(ctx, "default") if err != nil { return fmt.Errorf("list firewall rules: %w", err) } order := make([]unifi.FirewallRuleIndexUpdate, 0, len(rules)) for i, r := range rules { if r.Ruleset != "LAN_IN" { continue } order = append(order, unifi.FirewallRuleIndexUpdate{ID: r.ID, RuleIndex: 2000 + i}) } return c.ReorderFirewallRules(ctx, "default", "LAN_IN", order) } ``` `ReorderFirewallRules(ctx, site, ruleset, []FirewallRuleIndexUpdate)` posts a new ordering for a single ruleset in one call — the rule with the lowest `RuleIndex` is evaluated first. Pass the **ruleset name** plus the desired `ID`→`RuleIndex` mapping for every rule you want to position. `RuleIndex` for user rules conventionally lives in the `2000`–`2999` and `4000`–`4999` ranges; the controller reserves the rest for predefined rules. See the full field set (protocols, ICMP types, state matching) on [pkg.go.dev](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#FirewallRule). ## Zone-based firewall [#zone-based-firewall] Newer controllers (UniFi Network 9+) replace per-ruleset rules with a **zone-based** model: networks are grouped into [`FirewallZone`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#FirewallZone)s, and a [`FirewallZonePolicy`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#FirewallZonePolicy) governs traffic from a source zone to a destination zone. ```go func exampleZoneFirewall(ctx context.Context, c unifi.Client) error { zone, err := c.CreateFirewallZone(ctx, "default", &unifi.FirewallZone{ Name: "iot", NetworkIDs: []string{}, // attach networks by their IDs }) if err != nil { return fmt.Errorf("create firewall zone: %w", err) } policy, err := c.CreateFirewallZonePolicy(ctx, "default", &unifi.FirewallZonePolicy{ Name: "iot-to-iot-block", Action: "BLOCK", // ALLOW | BLOCK | REJECT Enabled: true, IPVersion: "BOTH", Source: unifi.FirewallZonePolicySource{ ZoneID: zone.ID, }, Destination: unifi.FirewallZonePolicyDestination{ ZoneID: zone.ID, }, Schedule: unifi.FirewallZonePolicySchedule{ Mode: "ALWAYS", }, }) if err != nil { return fmt.Errorf("create firewall zone policy: %w", err) } // Reorder user policies relative to the predefined ones for a zone pair. reordered, err := c.ReorderFirewallPolicies(ctx, "default", &unifi.FirewallPolicyOrderUpdate{ SourceZoneId: zone.ID, DestinationZoneId: zone.ID, AfterPredefinedIds: []string{policy.ID}, BeforePredefinedIds: []string{}, }) if err != nil { return fmt.Errorf("reorder firewall policies: %w", err) } _ = reordered // The matrix summarises the configured action and policy count per zone pair. matrix, err := c.ListFirewallZoneMatrix(ctx, "default") if err != nil { return fmt.Errorf("list firewall zone matrix: %w", err) } for _, m := range matrix { for _, d := range m.Data { fmt.Printf("%s: %s, %d policies\n", m.Name, d.Action, d.PolicyCount) } } return nil } ``` Three things worth knowing: * **`GetFirewallZone` is a client-side filter.** The controller has no single-zone endpoint, so `GetFirewallZone` lists all zones and matches on ID — it still returns `unifi.ErrNotFound` when the ID is absent, but it costs a full list under the hood. * **`ReorderFirewallPolicies` returns the new ordering.** Unlike `ReorderFirewallRules` (which returns only an error), it takes a single [`*FirewallPolicyOrderUpdate`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#FirewallPolicyOrderUpdate) for one zone pair and returns the resulting `[]FirewallZonePolicy`. * **`ListFirewallZoneMatrix` is read-only.** It is the only method on the zone matrix — there is no create/update/delete. Each [`FirewallZoneMatrixData`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#FirewallZoneMatrixData) entry reports the action and policy count for a zone pair. ## Content filtering [#content-filtering] `ContentFiltering` blocks categories (e.g. `ADVERTISEMENT`, `FAMILY`), applies allow/block lists, and can be scheduled. It supports Create, Update, Delete and List. ```go func exampleContentFiltering(ctx context.Context, c unifi.Client) error { cf, err := c.CreateContentFiltering(ctx, "default", &unifi.ContentFiltering{ Name: "block-ads", Enabled: true, Categories: []string{"ADVERTISEMENT"}, // FAMILY | ADVERTISEMENT NetworkIDs: []string{}, AllowList: []string{}, BlockList: []string{}, }) if err != nil { return fmt.Errorf("create content filtering: %w", err) } // There is no GetContentFiltering — fetch via List and match on ID. all, err := c.ListContentFiltering(ctx, "default") if err != nil { return fmt.Errorf("list content filtering: %w", err) } for i := range all { if all[i].ID == cf.ID { cf = &all[i] break } } cf.Enabled = false if _, err := c.UpdateContentFiltering(ctx, "default", cf); err != nil { return fmt.Errorf("update content filtering: %w", err) } return c.DeleteContentFiltering(ctx, "default", cf.ID) } ``` `ContentFiltering` has **no `GetContentFiltering`** method — only `Create`, `Update`, `Delete` and `List`. To read a single record, list them and match on `ID` as shown above. ## Next steps [#next-steps] Create the networks that zones, rules and content-filtering profiles reference. Handle `ErrNotFound` and inspect `ServerError` validation details. Every firewall struct and its fields, generated from the controller API. # Guides (/docs/guides) Each guide takes one task — pick a surface, page through results, manage a resource — and walks it end-to-end: a runnable snippet, what it does and why, then the gotchas worth knowing. New here? Start with [Choosing a surface](/docs/guides/choosing-a-surface), then jump to whatever you need. ## Core concepts [#core-concepts] Internal vs Official: what each is, how to reach them, and the capability gate. The fluent per-group surface, uniform method shapes, and site UUIDs. Bounded pages, lazy streaming, and the Official filter expressions. List sites on both surfaces and bridge the site-name vs UUID gap. ## Resources [#resources] Create, read, update and delete networks and VLANs. Adopt, configure and act on UniFi devices. Work with connected clients and known users. Zone-based firewall policies, zones and rule ordering. WLANs and wireless settings. Read and write site-level settings. ## Going further [#going-further] Check controller capabilities before you call them. Upload files to endpoints that accept them. Query traffic rules and flow data. Sentinels, server errors, and how to react to them. Mock the client and the Official per-group interfaces in tests. # Networks (/docs/guides/networks) A **network** in UniFi is a layer-3 segment — a corporate LAN, a guest VLAN, a WAN uplink or a VPN. On the Internal API every network is a [`unifi.Network`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#Network), managed through five methods on the client: `ListNetwork`, `GetNetwork`, `CreateNetwork`, `UpdateNetwork` and `DeleteNetwork`. The snippets below assume you already have a connected client `c` and a `ctx` — see the [Quickstart](/docs/getting-started/quickstart) for the full setup. All Internal-API calls take the **site name** (`"default"` on a single-site controller) as their first string argument. ## List and inspect networks [#list-and-inspect-networks] ```go networks, err := c.ListNetwork(ctx, "default") if err != nil { panic(err) } for _, n := range networks { fmt.Printf("%-20s purpose=%-10s subnet=%s vlan=%d\n", n.Name, n.Purpose, n.IPSubnet, n.VLAN) } ``` `ListNetwork` returns `[]unifi.Network`. The most useful fields are `Name`, `Purpose` (one of `corporate`, `guest`, `wan`, `vlan-only`, `vpn-client`, `remote-user-vpn`, `site-vpn`), `IPSubnet`, `VLAN` and the `DHCPD*` options. The struct is large because it mirrors the controller's own model — see the [full field list on pkg.go.dev](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#Network). To fetch a single network by its `_id`, use `GetNetwork`: ```go n, err := c.GetNetwork(ctx, "default", id) if err != nil { if errors.Is(err, unifi.ErrNotFound) { fmt.Println("no such network") return } panic(err) } fmt.Printf("%s (%s)\n", n.Name, n.IPSubnet) ``` ## Create a network [#create-a-network] This creates an IoT VLAN on subnet `192.168.30.0/24` with its own DHCP scope: ```go created, err := c.CreateNetwork(ctx, "default", &unifi.Network{ Name: "IoT", Purpose: "corporate", NetworkGroup: "LAN", IPSubnet: "192.168.30.1/24", VLANEnabled: true, VLAN: 30, Enabled: true, // Hand out addresses on this subnet. DHCPDEnabled: true, DHCPDStart: "192.168.30.6", DHCPDStop: "192.168.30.254", }) if err != nil { panic(err) } fmt.Printf("created %s with id %s\n", created.Name, created.ID) ``` `CreateNetwork` returns the server's view of the object, including the generated `ID` and any defaults the controller filled in. `IPSubnet` is the gateway address plus its prefix length (`.1/24`), not the bare network address. `Purpose` is one of a fixed set of values (a regular VLAN uses `corporate`; a guest network uses `guest`), enforced by a `validate` struct tag. By default the client validates in **soft** mode — a bad value is logged as a warning but the request is still sent, so it surfaces as a `ServerError` (HTTP 400) from the controller. Set `ValidationMode: unifi.HardValidation` on the `ClientConfig` to reject invalid structs client-side before the request leaves. Format-only fields like `IPSubnet` and `VLAN` carry no `validate` tag and are checked only by the controller. ## Update a network [#update-a-network] There is no PATCH — updates are read-modify-write. Fetch the full object, change the fields you care about, and send the whole struct back so you don't blank out everything else: ```go n, err := c.GetNetwork(ctx, "default", id) if err != nil { panic(err) } n.DHCPDStop = "192.168.30.200" updated, err := c.UpdateNetwork(ctx, "default", n) if err != nil { panic(err) } fmt.Printf("DHCP range now ends at %s\n", updated.DHCPDStop) ``` Always update the object you got back from `GetNetwork`/`ListNetwork`. Constructing a fresh `unifi.Network` with only a couple of fields set and passing it to `UpdateNetwork` will overwrite the rest with zero values. ## Delete a network [#delete-a-network] ```go if err := c.DeleteNetwork(ctx, "default", id); err != nil { panic(err) } ``` `DeleteNetwork` takes the network `ID` (not its name). System networks may be protected — a network with `NoDelete: true` cannot be removed. ## Related resources [#related-resources] Several other resources hang off the routing/L3 stack. Each follows the same `List`/`Get`/`Create`/`Update`/`Delete` shape and takes the site name first: | Resource | Methods | What it configures | | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------- | | [`Routing`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#Routing) | `ListRouting`, `GetRouting`, `CreateRouting`, `UpdateRouting`, `DeleteRouting` | Static routes (next-hop, interface, blackhole) | | [`DHCPOption`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#DHCPOption) | `ListDHCPOption`, `GetDHCPOption`, `CreateDHCPOption`, `UpdateDHCPOption`, `DeleteDHCPOption` | Custom DHCP option definitions | | [`DNSRecord`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#DNSRecord) | `ListDNSRecord`, `GetDNSRecord`, `CreateDNSRecord`, `UpdateDNSRecord`, `DeleteDNSRecord` | Local DNS records (A, AAAA, CNAME, MX, …) | | [`DynamicDNS`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#DynamicDNS) | `ListDynamicDNS`, `GetDynamicDNS`, `CreateDynamicDNS`, `UpdateDynamicDNS`, `DeleteDynamicDNS` | Dynamic-DNS update clients | | [`PortForward`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#PortForward) | `ListPortForward`, `GetPortForward`, `CreatePortForward`, `UpdatePortForward`, `DeletePortForward` | WAN → LAN port-forward rules | A static route, a local DNS record and a port-forward, created together: ```go route, err := c.CreateRouting(ctx, "default", &unifi.Routing{ Name: "to-lab", Type: "static-route", StaticRouteType: "nexthop-route", StaticRouteNetwork: "10.0.50.0/24", StaticRouteNexthop: "192.168.1.254", Enabled: true, }) // ... rec, err := c.CreateDNSRecord(ctx, "default", &unifi.DNSRecord{ Key: "nas.lan", RecordType: "A", Value: "192.168.30.10", Enabled: true, }) // ... fwd, err := c.CreatePortForward(ctx, "default", &unifi.PortForward{ Name: "https", Enabled: true, Proto: "tcp", DstPort: "443", Fwd: "192.168.30.10", FwdPort: "443", }) ``` `GetDNSRecord` filters the full list client-side (the controller exposes no single-record DNS endpoint), so it costs a `ListDNSRecord` under the hood. That's transparent, but worth knowing if you fetch records in a tight loop — list once and index yourself instead. ## Next steps [#next-steps] Assign clients to the networks you just created, with fixed IPs and user groups. Firewall rules, groups and zones that gate traffic between networks. Tell ErrNotFound apart from validation and server errors. Work with more than one site, and where the site name comes from. # Using the Official API (/docs/guides/official-api) `c.Official()` returns the Official UniFi OpenAPI (`integration/v1`) surface: a **fluent** client with one accessor per resource group. This guide covers the accessors, the uniform method shapes, and how site IDs work. If you haven't yet, read [Choosing a surface](/docs/guides/choosing-a-surface) first — the Official surface is gated and requires controller **10.1.78+**. ## Fluent per-group accessors [#fluent-per-group-accessors] Each accessor on `c.Official()` returns an independently usable (and mockable) per-group interface, derived from the OpenAPI tags: | Accessor | What it exposes | | ------------------------ | --------------------------------------------------------------- | | `Networks()` | networks / VLANs | | `Firewall()` | zone-based policies, zones, rule ordering (incl. `PatchPolicy`) | | `Devices()` | adopted devices, pending adoptions, device/port actions | | `Clients()` | connected clients | | `ACLs()` | ACL rules and ordering | | `DNSPolicies()` | DNS policies | | `TrafficMatchingLists()` | traffic-matching lists | | `WifiBroadcasts()` | Wi-Fi broadcasts | | `Hotspot()` | hotspot **vouchers** (`ListVouchersPage`, `CreateVouchers`, …) | | `Supporting()` | read-only catalogs: countries, RADIUS profiles, WANs, DPI, … | | `Sites()` | site listing and name → UUID resolution | | `Info()` | controller application info | There is **no** top-level `Vouchers()` accessor — vouchers live under `c.Official().Hotspot()`. Likewise, firewall resources (including `PatchPolicy`) live under `c.Official().Firewall()`. ## Uniform method shapes [#uniform-method-shapes] Methods are generated in a uniform shape per resource, so once you know one group you know them all: * **Read one** — `Get…(ctx, siteId, id)` returns the single-item `…Details`. * **List** — two methods, so draining is explicit and never accidental: * `List…Page(ctx, siteId, *official.ListOptions)` returns **one bounded** `official.Page[T]`. * `List…All(ctx, siteId, filter)` returns a lazy `iter.Seq2[T, error]` you can range over. * **Write** — `Create…` / `Update…` take the `…CreateOrUpdate` body; some groups add `Patch…`. * **Delete** — `Delete…(ctx, siteId, id)`. Pagination and filtering get their own guide: see [Pagination & filtering](/docs/guides/pagination-and-filtering). ## Site IDs [#site-ids] Every **site-scoped** resource method takes a `siteId uuid.UUID` (not the legacy site-name string). The two global accessors are the exception: `Sites()` and `Info()` take no `siteId` — and `Sites()` is how you obtain one in the first place. Get the UUID from a familiar name with `Sites().ResolveID`, which caches the lookup: ```go title="official.go" siteID, err := c.Official().Sites().ResolveID(ctx, "default") if err != nil { log.Fatal(err) } networks, err := c.Official().Networks().ListPage(ctx, siteID, nil) if err != nil { log.Fatal(err) } for _, n := range networks.Items { fmt.Printf("%s vlan=%d\n", n.Name, n.VlanId) } vouchers, err := c.Official().Hotspot().ListVouchersPage(ctx, siteID, nil) if err != nil { log.Fatal(err) } for _, v := range vouchers.Items { fmt.Println("voucher:", v.Code) } ``` Already have a UUID string? Parse it with `uuid.Parse` (from `github.com/google/uuid`) instead of resolving a name: ```go title="parse.go" siteID, err := uuid.Parse("0fc8a3b2-3c1e-4f6a-9b2d-1a2b3c4d5e6f") if err != nil { log.Fatal(err) } page, err := c.Official().Networks().ListPage(ctx, siteID, nil) ``` `ResolveID` returns `official.ErrSiteNotFound` when no site matches the name. The first resolve drains the full site list and caches every name → UUID mapping, so repeated lookups are free. ## Next steps [#next-steps] Bounded pages, lazy streaming, and the filter DSL. The site-name vs UUID duality in depth. The Official OpenAPI rendered endpoint-by-endpoint, with Go equivalents. For exhaustive types and method signatures, see the [`official` package on pkg.go.dev](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi/official#Client). # Pagination & filtering (/docs/guides/pagination-and-filtering) Official list endpoints are paged, and the SDK makes draining **explicit** so you never accidentally pull thousands of rows. Every listable group exposes **two** methods: a bounded `List…Page` and a lazy `List…All`. This guide uses `Networks`, but the shapes are identical across every group. ## One bounded page: `List…Page` [#one-bounded-page-listpage] `List…Page(ctx, siteId, *official.ListOptions)` fetches **exactly one** page and returns an `official.Page[T]`. Pass `nil` options for the first page at the default size: ```go title="firstpage.go" page, err := c.Official().Networks().ListPage(ctx, siteID, nil) if err != nil { log.Fatal(err) } fmt.Printf("got %d of %d (offset=%d limit=%d)\n", page.Count, page.TotalCount, page.Offset, page.Limit) for _, n := range page.Items { fmt.Println(n.Name) } ``` The returned `official.Page[T]` carries both the items and the envelope counters: ### Bounding and filtering a page [#bounding-and-filtering-a-page] Pass an `official.ListOptions` to control offset, size and a server-side filter: ```go title="bounded.go" page, err := c.Official().Networks().ListPage(ctx, siteID, &official.ListOptions{ Offset: 0, Limit: 50, // clamped to 200 if larger Filter: "name.eq('lan')", }) ``` The Official spec caps page size at **200**. A `Limit` that is non-positive or above 200 is clamped to 200 for you, so a page never silently returns more than you asked for. ## Streaming every item: `List…All` [#streaming-every-item-listall] `List…All(ctx, siteId, filter)` returns an `iter.Seq2[T, error]` that pages **on demand**. Range over it and `break` to stop — no further requests are made once you stop: ```go title="listall.go" for net, err := range c.Official().Networks().ListAll(ctx, siteID, "") { if err != nil { log.Fatal(err) } fmt.Println(net.Name) if net.Default { break // no further pages are fetched } } ``` The `filter` argument is the same expression as `ListOptions.Filter`; pass `""` to stream unfiltered. Each loop iteration yields either an item **or** an error (the gate failing, or a page fetch failing), so always check `err` inside the loop. ### Materializing with `Collect` [#materializing-with-collect] When you genuinely want every match in a slice, wrap the iterator with `official.Collect`. It drains the stream and short-circuits on the first error: ```go title="collect.go" all, err := official.Collect(c.Official().Networks().ListAll(ctx, siteID, "name.eq('lan')")) if err != nil { log.Fatal(err) } fmt.Println("matched", len(all)) ``` `Collect` (and an un-`break`-ed `ListAll`) loads **all** matching rows into memory — that's the explicit opt-in. Prefer ranging with a `break`, or a single `ListPage`, when you don't need the whole set. ## The filter DSL [#the-filter-dsl] `Filter` is a server-side expression evaluated by the controller. It uses a `field.op('value')` form, for example `name.eq('lan')`. Filtering happens on the controller, so it narrows results **before** they are paged and transferred. Refer to the [UniFi API Reference](/docs/reference/official-api/api) for the operators and fields each endpoint accepts. ## Next steps [#next-steps] Resolve a site name to the UUID these methods need. The fluent surface and its uniform method shapes. Per-endpoint filter fields and operators. # Settings (/docs/guides/settings) UniFi groups site-wide configuration into **settings**, each identified by a string key (`guest_access`, `mgmt`, `usg`, …). go-unifi exposes them two ways: a **typed pair** per setting for compile-time safety, and a **generic** key-based pair when you need to be dynamic. Both operate on the site **name** (`"default"`). These examples assume a constructed client `c` and a `ctx` — see the [Quickstart](/docs/getting-started/quickstart). ## Typed settings (recommended) [#typed-settings-recommended] Each setting has a `GetSetting` / `UpdateSetting` pair that returns and accepts a concrete struct — no key strings, no type assertions. Guest access is a typical example: ```go func exampleGuestAccessSetting(ctx context.Context, c unifi.Client) error { // Typed pair: no key string, no type assertion — you get *SettingGuestAccess. ga, err := c.GetSettingGuestAccess(ctx, "default") if err != nil { return fmt.Errorf("get guest access setting: %w", err) } ga.PortalEnabled = true ga.PortalCustomized = true ga.PortalCustomizedSuccessText = "Welcome to the network!" updated, err := c.UpdateSettingGuestAccess(ctx, "default", ga) if err != nil { return fmt.Errorf("update guest access setting: %w", err) } fmt.Printf("portal enabled: %v\n", updated.PortalEnabled) return nil } ``` The signatures follow a fixed shape for every setting `X`: ```go GetSettingX(ctx context.Context, site string) (*SettingX, error) UpdateSettingX(ctx context.Context, site string, s *SettingX) (*SettingX, error) ``` There is no create or delete — a setting always exists for a site, so you **read-modify-write**: fetch the current value, change the fields you care about, and pass it back to `UpdateSettingX`. The wrapper sets the `Key` field for you, so you never touch it. There are dozens of typed settings (`SettingGuestAccess`, `SettingMgmt`, `SettingUsg`, `SettingCountry`, …). Browse the full catalogue, each struct's fields, and its key constant in the [settings reference](/docs/reference/internal-api/settings). ## Generic settings [#generic-settings] When the key is only known at runtime — or you want to round-trip a setting you don't have a typed accessor for — use the generic pair. `GetSetting` returns the lightweight `*Setting` envelope **and** an `any` holding the concrete fields struct; assert it to the type that matches the key. `SetSetting` takes the modified value back. ```go func exampleGenericSetting(ctx context.Context, c unifi.Client) error { // Generic pair: GetSetting returns the *Setting envelope plus an `any` // holding the concrete fields struct for the key. Assert it to use it. setting, fields, err := c.GetSetting(ctx, "default", unifi.SettingGuestAccessKey) if err != nil { return fmt.Errorf("get setting: %w", err) } ga, ok := fields.(*unifi.SettingGuestAccess) if !ok { return fmt.Errorf("unexpected setting type %T", fields) } fmt.Printf("setting %s portal_enabled=%v\n", setting.Key, ga.PortalEnabled) ga.PortalEnabled = false result, err := c.SetSetting(ctx, "default", unifi.SettingGuestAccessKey, ga) if err != nil { return fmt.Errorf("set setting: %w", err) } _ = result // also the concrete fields struct as `any` return nil } ``` The generic signatures are: ```go GetSetting(ctx context.Context, site, key string) (*Setting, any, error) SetSetting(ctx context.Context, site, key string, reqBody any) (any, error) ``` The `any` returned by both calls is the **same concrete pointer** the typed accessor would give you (here `*unifi.SettingGuestAccess`) — go-unifi looks up the key in its generated registry and unmarshals into the right struct. An unknown key yields an error; a key with no matching record yields [`unifi.ErrNotFound`](/docs/guides/error-handling). Prefer the typed pair whenever one exists — the generic API hands you an `any`, so a wrong type assertion is a runtime bug rather than a compile error. Reach for `GetSetting`/`SetSetting` only when the key is dynamic. Use the exported key constants (e.g. `unifi.SettingGuestAccessKey`) rather than hand-typed strings. ## Next steps [#next-steps] The full catalogue of typed settings, their keys, and every field. Settings are per-site — list sites and pick the right site name. React to `ErrNotFound` and inspect `ServerError` validation details. # Sites (/docs/guides/sites) A UniFi controller can host multiple **sites**. Both API surfaces can list them — but they identify a site differently, and that difference shows up in every site-scoped call. This guide lists sites on each surface and shows how to bridge the two. ## Internal: sites by name [#internal-sites-by-name] `c.ListSites(ctx)` (the Internal surface) returns `[]unifi.Site`. The Internal API identifies a site by its **name** string — the same `"default"` you pass to every Internal resource method: ```go title="internal.go" sites, err := c.ListSites(ctx) if err != nil { log.Fatal(err) } for _, s := range sites { fmt.Printf("%s — %s\n", s.Name, s.Description) } ``` ## Official: sites by UUID [#official-sites-by-uuid] On the Official surface, `c.Official().Sites()` lists sites and each entry carries a `uuid.UUID` id. Like every Official list, it offers a bounded page and a lazy stream — see [Pagination & filtering](/docs/guides/pagination-and-filtering): ```go title="official.go" page, err := c.Official().Sites().ListPage(ctx, nil) if err != nil { log.Fatal(err) } for _, s := range page.Items { // InternalReference is the legacy site name; ID is the Official UUID. fmt.Printf("%s name=%q ref=%q\n", s.ID, s.Name, s.InternalReference) } ``` Each `official.SiteOverview` ties the two worlds together: `ID` is the Official UUID, `Name` is the display name, and **`InternalReference` is the legacy site name** the Internal API uses. ## The site-name vs UUID duality [#the-site-name-vs-uuid-duality] Internal calls take the site **name**; Official calls take a **`uuid.UUID`**. To call an Official resource with the site you already know by name, resolve it once with `Sites().ResolveID`: ```go title="duality.go" // Internal: the site NAME string. nets, err := c.ListNetwork(ctx, "default") if err != nil { log.Fatal(err) } _ = nets // Official: the same site as a uuid.UUID, resolved from the name. siteID, err := c.Official().Sites().ResolveID(ctx, "default") if err != nil { log.Fatal(err) } page, err := c.Official().Networks().ListPage(ctx, siteID, nil) ``` `ResolveID` drains the site list once, caches every `InternalReference` → UUID mapping, and returns `official.ErrSiteNotFound` when no site matches. If you already hold a UUID string, skip the lookup and use `uuid.Parse` (from `github.com/google/uuid`). On a single-site controller the name is almost always `"default"`. Resolve it **once** at startup and reuse the returned `uuid.UUID` for every Official call — `ResolveID` caches, but holding the value is simplest. ## Next steps [#next-steps] Why the identifiers differ and which surface to pick. The fluent per-group surface that consumes these UUIDs. Manage networks on the site you just resolved. # Testing (/docs/guides/testing) Code that depends on go-unifi should depend on the **`unifi.Client` interface**, not the concrete client. That one decision makes your code trivially testable: the library ships generated mocks that satisfy the interface, so your tests run with no controller, no network, and no waiting. There are two kinds of mock: * [`unifi.ClientMock`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#ClientMock) — a [moq](https://github.com/matryer/moq)-generated double for the whole Internal API `Client`. Every method has a `…Func` field you stub and a `…Calls()` helper that records invocations. * The **Official** per-group doubles in `unifi/official` (`official.ClientMock`, `official.SitesClientMock`, `official.NetworksClientMock`, …) — code-generated func-field test doubles (emitted by the `codegen/official` generator, not moq), one per resource group. In both, an **un-stubbed method panics** when called, so a test that exercises an unexpected dependency fails loudly instead of returning a misleading zero value. ## Depend on the interface [#depend-on-the-interface] Write the code under test against `unifi.Client`: ```go func reportActiveNetworks(ctx context.Context, c unifi.Client) ([]string, error) { nets, err := c.ListNetwork(ctx, "default") if err != nil { return nil, err } var names []string for _, n := range nets { if n.Enabled { names = append(names, n.Name) } } return names, nil } ``` ## A table-driven test with ClientMock [#a-table-driven-test-with-clientmock] Stub only the method the code calls. Each `…Func` field is the behaviour for one method; the matching `…Calls()` slice lets you assert how it was called. ```go title="networks_test.go" func TestReportActiveNetworks(t *testing.T) { tests := map[string]struct { networks []unifi.Network listErr error want []string wantErr bool }{ "filters disabled": { networks: []unifi.Network{ {Name: "LAN", Enabled: true}, {Name: "Guest", Enabled: false}, {Name: "IoT", Enabled: true}, }, want: []string{"LAN", "IoT"}, }, "propagates error": { listErr: unifi.ErrNotFound, wantErr: true, }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { c := &unifi.ClientMock{ ListNetworkFunc: func(_ context.Context, site string) ([]unifi.Network, error) { if site != "default" { t.Fatalf("unexpected site %q", site) } return tc.networks, tc.listErr }, } got, err := reportActiveNetworks(context.Background(), c) if tc.wantErr { if err == nil { t.Fatal("expected error, got nil") } return } if err != nil { t.Fatalf("unexpected error: %v", err) } if len(got) != len(tc.want) { t.Fatalf("got %v, want %v", got, tc.want) } // Calls() helpers record every invocation and its arguments. if n := len(c.ListNetworkCalls()); n != 1 { t.Fatalf("ListNetwork called %d times, want 1", n) } if site := c.ListNetworkCalls()[0].Site; site != "default" { t.Fatalf("called with site %q", site) } }) } } ``` Each `…Calls()` method returns a slice of anonymous structs whose fields are the named arguments (`ListNetworkCalls()[0].Site`, `IsFeatureEnabledCalls()[0].Name`, and so on) — handy for asserting that your code passed the right site, MAC, or payload. You don't have to stub every method — only the ones your code path touches. Anything left `nil` panics if called, which is usually what you want: it pins the test to exactly the controller calls you expect. ## Returning sentinels from a mock [#returning-sentinels-from-a-mock] Because the mock returns whatever your `…Func` returns, you can drive the error branches from [Error handling](/docs/guides/error-handling) directly: ```go func TestNotFound(t *testing.T) { c := &unifi.ClientMock{ GetNetworkFunc: func(_ context.Context, _, _ string) (*unifi.Network, error) { return nil, unifi.ErrNotFound }, } _, err := c.GetNetwork(context.Background(), "default", "missing") if !errors.Is(err, unifi.ErrNotFound) { t.Fatalf("want ErrNotFound, got %v", err) } } ``` ## Mocking the Official surface [#mocking-the-official-surface] The Official API is a tree: `c.Official()` returns an `official.Client`, whose accessors (`Sites()`, `Networks()`, …) return per-group clients. To mock it, build an `official.ClientMock` whose accessor returns the per-group mock, then hand it to the top-level `unifi.ClientMock` via `OfficialFunc`: ```go title="sites_test.go" func countSites(ctx context.Context, c unifi.Client) (int, error) { page, err := c.Official().Sites().ListPage(ctx, nil) if err != nil { return 0, err } return page.TotalCount, nil } func TestCountSites(t *testing.T) { sites := &official.SitesClientMock{ ListPageFunc: func(_ context.Context, _ *official.ListOptions) (official.Page[official.SiteOverview], error) { return official.Page[official.SiteOverview]{ Items: []official.SiteOverview{{ID: uuid.New()}}, TotalCount: 3, }, nil }, } off := &official.ClientMock{ SitesFunc: func() official.SitesClient { return sites }, } c := &unifi.ClientMock{ OfficialFunc: func() official.Client { return off }, } got, err := countSites(context.Background(), c) if err != nil { t.Fatalf("unexpected error: %v", err) } if got != 3 { t.Fatalf("got %d sites, want 3", got) } } ``` The Official per-group mocks (`official.SitesClientMock`, `official.NetworksClientMock`, …) are func-field doubles **without** `…Calls()` helpers — that recording machinery exists only on the moq-generated `unifi.ClientMock`. To assert arguments on an Official mock, capture them in a closure variable inside the `…Func` you stub. ## Keeping the mock current [#keeping-the-mock-current] `unifi.ClientMock` lives in `client_mock.generated.go` and is regenerated whenever the `Client` interface changes: ```bash go generate ./unifi/... ``` A compile-time `var _ Client = &ClientMock{}` assertion in that file guarantees the mock always satisfies the interface, so a stale mock is caught at build time. ## Next steps [#next-steps] The sentinels and types your mocks can return. The full `Client` interface the mock implements. # Traffic Flows (/docs/guides/traffic-flows) A **traffic flow** is one connection the controller observed — a source, a destination, the protocol and service, which firewall policy acted on it, how many bytes moved, and when. go-unifi exposes the controller's flow log through a single Internal API method, `GetTrafficFlows`, which takes a filter/pagination request and returns one page of results. This is the data behind the controller's "Traffic" / flow-insights views, useful for auditing what a firewall rule actually matched. ## Fetch a page of flows [#fetch-a-page-of-flows] You build a [`*unifi.TrafficFlowsRequest`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#TrafficFlowsRequest), set whatever filters you want, and read the page out of the [`*unifi.TrafficFlowsResponse`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#TrafficFlowsResponse). ```go import ( "context" "fmt" "time" "github.com/filipowm/go-unifi/v2/unifi" ) func recentBlocks(ctx context.Context, c unifi.Client) error { req := &unifi.TrafficFlowsRequest{ Action: []string{"BLOCK"}, TimestampFrom: time.Now().Add(-time.Hour).UnixMilli(), TimestampTo: time.Now().UnixMilli(), PageNumber: 0, PageSize: 50, } resp, err := c.GetTrafficFlows(ctx, "default", req) if err != nil { return fmt.Errorf("fetching traffic flows: %w", err) } for _, flow := range resp.Data { fmt.Printf("%s %s -> %s %s/%s rx=%d bytes\n", flow.Action, flow.Source.IP, flow.Destination.IP, flow.Protocol, flow.Service, flow.TrafficData.BytesRx, ) } fmt.Printf("page %d of %d (total %d flows)\n", resp.PageNumber, resp.TotalPageCount, resp.TotalElementCount) return nil } ``` Each [`TrafficFlow`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#TrafficFlow) carries a `Source` and `Destination` ([`TrafficFlowTarget`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#TrafficFlowTarget) — IP, MAC, hostname, client name, network and zone), the matched `Policies` ([`TrafficFlowPolicy`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#TrafficFlowPolicy)), and byte/packet counters in `TrafficData`. ## Filtering [#filtering] `TrafficFlowsRequest` has a wide set of optional filter fields — most are `[]string` slices that the controller treats as "match any of these". Leaving a field at its zero value means "don't filter on it". A few of the common ones: | Field | Meaning | | ------------------------------------------ | ---------------------------------- | | `Action` | e.g. `"ALLOW"`, `"BLOCK"` | | `Risk` | risk level reported for the flow | | `Protocol` | `"tcp"`, `"udp"`, … | | `SourceIP` / `DestinationIP` | match specific endpoints | | `SourceMAC` / `DestinationMAC` | match specific clients | | `SourceNetworkID` / `DestinationNetworkID` | match a network | | `SearchText` | free-text search | | `TimestampFrom` / `TimestampTo` | time window, **Unix milliseconds** | See the [`TrafficFlowsRequest` reference](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#TrafficFlowsRequest) for the complete list. `TimestampFrom` and `TimestampTo` are **Unix milliseconds** (`int64`), not seconds — use `time.Time.UnixMilli()`, not `Unix()`. Passing seconds asks for flows from 1970. ## Paging through results [#paging-through-results] `GetTrafficFlows` returns one page. The response tells you whether more pages exist via `HasNext`; advance by incrementing `PageNumber`. ```go func allFlows(ctx context.Context, c unifi.Client) error { req := &unifi.TrafficFlowsRequest{PageNumber: 0, PageSize: 100} for { resp, err := c.GetTrafficFlows(ctx, "default", req) if err != nil { return fmt.Errorf("fetching traffic flows page %d: %w", req.PageNumber, err) } for _, flow := range resp.Data { fmt.Printf("%s: %s\n", flow.ID, flow.Risk) } if !resp.HasNext { break } req.PageNumber++ } return nil } ``` Traffic flows are an **Internal API** feature and are unrelated to the [Official API](/docs/guides/official-api) list endpoints and their `ListOptions`/`Page[T]` paging model. The flow log uses its own page-number request shape shown above. ## Next steps [#next-steps] Create the rules whose effects you see in the flow log. Handling failures from `GetTrafficFlows`. # Wireless (/docs/guides/wireless) Wireless configuration spans several Internal-API resources — SSIDs (`WLAN`), the groups that scope them (`WLANGroup`, `APGroup`), authentication (`RADIUSProfile`), RF planning (`ChannelPlan`), and the guest hotspot (`HotspotOp`, `HotspotPackage`, `Hotspot2Conf`, `BroadcastGroup`). Guest **vouchers** and Wi-Fi **broadcasts** also have a modern surface on the [Official API](/docs/guides/official-api). All Internal examples assume a constructed client `c` and a `ctx` — see the [Quickstart](/docs/getting-started/quickstart). ## Creating a WLAN (SSID) [#creating-a-wlan-ssid] A `WLAN` is one SSID. At minimum set a name, a security mode, and the network and user group it belongs to. For WPA2-Personal, set `Security: "wpapsk"`, a `WPAMode`, and an `XPassphrase`. ```go func exampleWLAN(ctx context.Context, c unifi.Client, networkID, userGroupID string) error { wlan, err := c.CreateWLAN(ctx, "default", &unifi.WLAN{ Name: "Office", Enabled: true, Security: "wpapsk", // open | wpapsk | wep | wpaeap | osen WPAMode: "wpa2", XPassphrase: "super-secret-pass", NetworkID: networkID, UserGroupID: userGroupID, WLANBands: []string{"2g", "5g"}, }) if err != nil { return fmt.Errorf("create wlan: %w", err) } wlans, err := c.ListWLAN(ctx, "default") if err != nil { return fmt.Errorf("list wlans: %w", err) } for _, w := range wlans { fmt.Printf("%s (enabled=%v)\n", w.Name, w.Enabled) } return c.DeleteWLAN(ctx, "default", wlan.ID) } ``` `CreateWLAN` initialises an empty `Schedule` for you when you leave it unset. For WPA-Enterprise, use `Security: "wpaeap"` and point `RADIUSProfileID` at a profile (below). The full field set — band steering, fast roaming, PPSK, MAC filtering, Hotspot 2.0 — is on [pkg.go.dev](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#WLAN). ## WLAN groups and AP groups [#wlan-groups-and-ap-groups] A `WLANGroup` bundles SSIDs together; an `APGroup` restricts broadcasting to specific access points by MAC. A WLAN references them via `WLANGroupID` and `ApGroupIDs`. ```go func exampleWLANGroupAPGroup(ctx context.Context, c unifi.Client) error { wg, err := c.CreateWLANGroup(ctx, "default", &unifi.WLANGroup{Name: "guest-aps"}) if err != nil { return fmt.Errorf("create wlan group: %w", err) } _ = wg ag, err := c.CreateAPGroup(ctx, "default", &unifi.APGroup{ Name: "lobby", DeviceMACs: []string{"00:11:22:33:44:55"}, }) if err != nil { return fmt.Errorf("create ap group: %w", err) } _ = ag return nil } ``` ## RADIUS profiles [#radius-profiles] A `RADIUSProfile` holds the authentication and accounting servers used by WPA-Enterprise SSIDs and wired 802.1X. Each server is a `RADIUSProfileAuthServers` / `RADIUSProfileAcctServers` entry with an IP, port, and shared secret. ```go func exampleRADIUSProfile(ctx context.Context, c unifi.Client) error { p, err := c.CreateRADIUSProfile(ctx, "default", &unifi.RADIUSProfile{ Name: "corp-radius", AccountingEnabled: true, AuthServers: []unifi.RADIUSProfileAuthServers{ {IP: "10.0.0.10", Port: 1812, XSecret: "shared-secret"}, }, AcctServers: []unifi.RADIUSProfileAcctServers{ {IP: "10.0.0.10", Port: 1813, XSecret: "shared-secret"}, }, }) if err != nil { return fmt.Errorf("create radius profile: %w", err) } // Reference p.ID from a WLAN via WLAN.RADIUSProfileID for WPA-Enterprise. _ = p return nil } ``` ## Channel plans [#channel-plans] The controller's auto-RF feature produces `ChannelPlan` records — a snapshot of the channel and transmit-power assignment computed for each radio. They are read-mostly; list them to inspect a plan. ```go func exampleChannelPlan(ctx context.Context, c unifi.Client) error { plans, err := c.ListChannelPlan(ctx, "default") if err != nil { return fmt.Errorf("list channel plans: %w", err) } for _, plan := range plans { fmt.Printf("plan %s computed at %s with %d radios\n", plan.ID, plan.Date, len(plan.RadioTable)) } return nil } ``` ## Guest hotspot [#guest-hotspot] The hotspot/guest portal is built from several resources: a `HotspotOp` operator account staff use to manage guests, `HotspotPackage` paid-access tiers, `Hotspot2Conf` (Passpoint / Hotspot 2.0) profiles, and `BroadcastGroup`s. Each is a standard CRUD resource. ```go func exampleHotspot(ctx context.Context, c unifi.Client) error { // An operator account staff use to manage the guest portal. op, err := c.CreateHotspotOp(ctx, "default", &unifi.HotspotOp{ Name: "front-desk", XPassword: "operator-pass", }) if err != nil { return fmt.Errorf("create hotspot operator: %w", err) } _ = op // A paid-access package offered on the portal. pkg, err := c.CreateHotspotPackage(ctx, "default", &unifi.HotspotPackage{ Name: "1-day", Amount: 5.0, Currency: "USD", Hours: 24, }) if err != nil { return fmt.Errorf("create hotspot package: %w", err) } _ = pkg // Hotspot 2.0 (Passpoint) profiles and broadcast groups. confs, err := c.ListHotspot2Conf(ctx, "default") if err != nil { return fmt.Errorf("list hotspot 2.0 configs: %w", err) } _ = confs bg, err := c.CreateBroadcastGroup(ctx, "default", &unifi.BroadcastGroup{ Name: "all-aps", MemberTable: []string{}, }) if err != nil { return fmt.Errorf("create broadcast group: %w", err) } _ = bg return nil } ``` ## Vouchers and broadcasts on the Official API [#vouchers-and-broadcasts-on-the-official-api] Guest **vouchers** and Wi-Fi **broadcasts** also have a first-class surface on the [Official API](/docs/guides/official-api), reached via `c.Official()`. Unlike the Internal API, the Official surface keys on a site **UUID** — resolve it from the site name with `Sites().ResolveID`. ```go func exampleVouchers(ctx context.Context, c unifi.Client) error { siteID, err := c.Official().Sites().ResolveID(ctx, "default") if err != nil { return fmt.Errorf("resolve site: %w", err) } count := int32(10) result, err := c.Official().Hotspot().CreateVouchers(ctx, siteID, official.HotspotVoucherCreationRequest{ Name: "lobby-guests", Count: &count, TimeLimitMinutes: 1440, // 24 hours of access per voucher }) if err != nil { return fmt.Errorf("create vouchers: %w", err) } if result.Vouchers != nil { for _, v := range *result.Vouchers { fmt.Printf("voucher %s code %s\n", v.Id, v.Code) } } // Drain every existing voucher, paging on demand. for v, err := range c.Official().Hotspot().ListVouchersAll(ctx, siteID, "") { if err != nil { return fmt.Errorf("list vouchers: %w", err) } fmt.Println(v.Code) } return nil } ``` `ListVouchersAll` returns a Go 1.23 range-over-func iterator (`iter.Seq2`) that pages on demand — `break` out of the loop to stop early without fetching more pages. The `filter` argument accepts an Official-API filter expression, or `""` for everything. See [Pagination and filtering](/docs/guides/pagination-and-filtering) for the paging helpers (`ListVouchersPage`, `official.Collect`). Wi-Fi broadcasts work the same way — `ListAll` for an iterator, `Get` for a single record by its UUID: ```go func exampleWifiBroadcasts(ctx context.Context, c unifi.Client, siteID uuid.UUID) error { for b, err := range c.Official().WifiBroadcasts().ListAll(ctx, siteID, "") { if err != nil { return fmt.Errorf("list wifi broadcasts: %w", err) } details, err := c.Official().WifiBroadcasts().Get(ctx, siteID, b.Id) if err != nil { return fmt.Errorf("get wifi broadcast: %w", err) } fmt.Printf("%s enabled=%v\n", details.Name, details.Enabled) } return nil } ``` The Official surface requires controller **10.1.78** or newer and an API key. The request and response models (`HotspotVoucherCreationRequest`, `WifiBroadcastCreateOrUpdate`, …) live in the [`official` package](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi/official); the raw endpoints are in the [UniFi API reference](/docs/reference/official-api/api). ## Next steps [#next-steps] Resolve site UUIDs, page through results, and choose between the two surfaces. Drain iterators, fetch single pages, and apply Official-API filters. Every wireless struct and its fields, generated from the controller API. # Breaking changes (/docs/migrating/breaking-changes) This is the authoritative changelog of every public-API behavior or signature change introduced in the 2.0.0 migration (epic [#117](https://github.com/filipowm/go-unifi/issues/117)). It is keyed to 10 breaking changes plus an additive Official surface. Each entry carries a verified status, a migration note, and a provenance link. For a task-oriented, junior-to-senior walkthrough with rationale and grep hints for each change, see the [1.x upgrade guide](/docs/migrating/from-1.x). **Status key.** **DONE** = the change is in the released 2.0.0 tree and verified against the actual code. **PENDING** = planned; not yet landed (see the **Planned for 3.0.0** section below). ## Landed — 10 breaking changes + additive Official surface [#landed--10-breaking-changes--additive-official-surface] | # | Change | Impact | Status | | -- | --------------------------------------------------- | -------- | ------ | | 0 | Module path bumped to `/v2` | High | DONE | | 1 | API-key authentication only | High | DONE | | 2 | TLS verify-by-default | High | DONE | | 3 | Go version bump to 1.26 | Medium | DONE | | 4 | `Client` gains `SetSetting` | Medium | DONE | | 5 | `Client` gains `*Context` variants | Medium | DONE | | 6 | v1 `Create`/`Update` no longer return `ErrNotFound` | Medium | DONE | | 7 | `meta.rc=="error"` on HTTP 200 → `*ServerError` | Medium | DONE | | 8 | Map 404 → `ErrNotFound` | Low | DONE | | 9 | `UseLocking` is a no-op | Low | DONE | | 10 | Remove CSRF handling | Low | DONE | | — | Official API surface (additive) | Additive | DONE | ### 0. Module path bumped to `/v2` [#0-module-path-bumped-to-v2] **Status: DONE** — `go.mod` declares `module github.com/filipowm/go-unifi/v2`. **Import-path change (compile break).** All import paths change from `github.com/filipowm/go-unifi/unifi` to `github.com/filipowm/go-unifi/v2/unifi`. Update `go.mod` with `go get github.com/filipowm/go-unifi/v2` and run a global find-and-replace on your import statements. ```go // before import "github.com/filipowm/go-unifi/unifi" // after import "github.com/filipowm/go-unifi/v2/unifi" ``` Migration: `go get github.com/filipowm/go-unifi/v2` then `find . -name '*.go' | xargs sed -i 's|filipowm/go-unifi/unifi|filipowm/go-unifi/v2/unifi|g'`. **Provenance:** `go.mod`, module major-version bump convention. ### 1. API-key authentication only [#1-api-key-authentication-only] **Status: DONE** — landed in issue [#125](https://github.com/filipowm/go-unifi/issues/125) (API-key-only auth). **Behavioral change + signature change (compile break).** Username/password authentication (`ClientConfig.User`/`Password`/`RememberMe`) is retired. Every consumer must authenticate with an API key via `ClientConfig.APIKey`. The `Login`/`LoginContext`/`Logout`/`LogoutContext` methods are removed from the `Client` interface. Old-style (classic) controllers, which only supported user/pass auth, are no longer reachable and construction fails immediately with `ErrOldStyleUnsupported`. Compile breaks: `ClientConfig.User`, `.Password`, `.RememberMe` removed; `UserPassCredentials` type removed; `Login`/`LoginContext`/`Logout`/`LogoutContext` methods removed from the `Client` interface; `CsrfHeader` constant removed; `APIPaths.LoginPath`/`.LogoutPath` fields removed. **Provenance:** epic #117, issue #125 (API-key auth retirement). ### 2. TLS verify-by-default [#2-tls-verify-by-default] **Status: DONE** — landed in Wave 1 (ARCH-06). **Signature change + behavioral flip (compile break + silent runtime change).** `ClientConfig.VerifySSL bool` was renamed and inverted to `SkipVerifySSL bool`. The zero value now enables verification (secure by default). ```go // before config := &unifi.ClientConfig{VerifySSL: false} // disabled verification // after config := &unifi.ClientConfig{SkipVerifySSL: true} // disable for self-signed cert ``` A caller that left `VerifySSL` unset got verification OFF; the same zero value now gives verification ON. Connections to self-signed controllers break silently at runtime — except the rename forces a compile error at every call site that touched the field, surfacing the flip at build time. Disabling verification is logged at WARN level on every client build. **Provenance:** ARCH-06, Wave 1. ### 3. Go version bump to 1.26 [#3-go-version-bump-to-126] **Status: DONE** — `go 1.26.0` in `go.mod`. **Build constraint change.** The module requires Go 1.26 or later. Consumers still on Go 1.24/1.25 must upgrade their toolchain. **Provenance:** epic #117, `go.mod`. ### 4. `Client` gains `SetSetting` [#4-client-gains-setsetting] **Status: DONE** — landed in Wave 1 (ARCH-08). **Interface addition (compile break for custom `Client` implementations).** The `Client` interface declares: ```go SetSetting(ctx context.Context, site string, key string, reqBody any) (any, error) ``` The concrete client already implemented it; it was simply missing from the interface. Any third-party type that implements `unifi.Client` must add this method; the moq `ClientMock` is regenerated automatically. **Provenance:** ARCH-08, Wave 1. ### 5. `Client` gains `*Context` variants [#5-client-gains-context-variants] **Status: DONE** — landed in Wave 2 (TEST-15); `Login`/`Logout` ctx variants superseded by #125. **Interface addition (compile break for custom `Client` implementations).** Two context-first lifecycle methods are part of the `Client` interface: ```go VersionContext(ctx context.Context) (string, error) GetSystemInformationContext(ctx context.Context) (*SysInfo, error) ``` `LoginContext`/`LogoutContext` were added in Wave 2 but removed in issue #125 (API-key-only auth, row 1). The no-ctx `Version`/`GetSystemInformation` remain source-compatible. Any third-party type implementing `unifi.Client` must add these two methods; the moq `ClientMock` is regenerated automatically. **Provenance:** TEST-15, Wave 2; #125 for Login/Logout removal. ### 6. v1 `Create`/`Update` no longer return `ErrNotFound` [#6-v1-createupdate-no-longer-return-errnotfound] **Status: DONE** — landed in Wave 2 (ARCH-13). **Behavioral change (no compile break).** The v1-REST template used to return `ErrNotFound` from a *successful* create/update when the response data array length was not exactly 1 — semantically wrong and inconsistent with the v2 template. The \~29 generated v1 `create`/`update` methods now return a descriptive error instead: ```go fmt.Errorf("unexpected response: expected 1 , got %d", n) ``` Any caller doing `errors.Is(err, ErrNotFound)` on a create or update path will no longer match — that branch was always incorrect. Treat any non-nil error from `Create`/`Update` as a failure. `ErrNotFound` remains the contract for `Get` / single-resource lookups only. **Provenance:** ARCH-13, Wave 2. ### 7. `meta.rc=="error"` on HTTP 200 → `*ServerError` [#7-metarcerror-on-http-200--servererror] **Status: DONE** — landed in Wave 2 (ARCH-10). **Behavioral change (no compile break).** The UniFi v1 API can return HTTP 200 with `meta.rc=="error"` (a soft / application-level failure). Previously this was caught in exactly one place (`CreateUser`) and silently swallowed everywhere else. The check is now centralized in `handleResponse`, so **every** decoded `{meta,data}` 200 with `rc=="error"` surfaces a `*ServerError` carrying the controller's `rc`/`msg` (enriched with status/method/URL). ```go var serverErr *unifi.ServerError if errors.As(err, &serverErr) { /* ... */ } ``` It is **not** `ErrNotFound` (`errors.Is(err, ErrNotFound) == false`), so genuine empty-data 200s and real 404s are unaffected. `CreateUser` retains its nested per-object meta check. Requests that pass `nil` as the response body (all generated deletes and fire-and-forget `cmd/` operations) do not perform this check — the response body is not inspected when no response struct is expected. **Provenance:** ARCH-10, Wave 2. ### 8. Map 404 → `ErrNotFound` [#8-map-404--errnotfound] **Status: DONE** — landed in Wave 1 (ARCH-05). **Behavioral widening (no compile break).** `(*ServerError).Is` maps a `*ServerError` with `StatusCode == 404` to the `ErrNotFound` sentinel. Previously only the hand-written list/get wrappers returned `ErrNotFound`; now a genuine 404 from **any** endpoint also satisfies `errors.Is(err, ErrNotFound)`. A consumer that distinguished "404 server error" from the `ErrNotFound` sentinel now sees them as equal. **Provenance:** ARCH-05, Wave 1. ### 9. `UseLocking` is a no-op [#9-uselocking-is-a-no-op] **Status: DONE** — landed in Wave 1 (ARCH-04). **Behavioral change (no compile break).** `net/http.Client` is goroutine-safe and the client no longer serializes requests. `ClientConfig.UseLocking` is retained for source compatibility but is marked `// Deprecated:` and has **no effect** — setting it `true` or `false` changes nothing. This no-op is **not new in 2.0.0**: `UseLocking` has been deprecated and inert since **1.11.0**, so 1.11.x callers already saw no serialisation. Remove it from your config. **Provenance:** ARCH-04, Wave 1. ### 10. Remove CSRF handling [#10-remove-csrf-handling] **Status: DONE** — landed in issue #125 (API-key-only auth). **Type removal (compile break for direct users).** With username/password auth retired (row 1), the `CSRFInterceptor` and its token-management logic are removed. The `CsrfHeader` constant is also removed. Migration: remove any direct use of `CSRFInterceptor` or `CsrfHeader`; neither is relevant with API-key authentication. **Provenance:** epic #117, issue #125 (API-key-only auth). ### Official API surface (additive) [#official-api-surface-additive] **Status: DONE** — landed in PR [#119](https://github.com/filipowm/go-unifi/pull/119). The `Client` interface gains `Internal()` and `Official()` accessors. `Internal()` returns the full `InternalClient` (all resource CRUD, identical to calling methods directly on the client). `Official()` returns the fluent Official OpenAPI client (`integration/v1`), gated behind `ErrOfficialAPIUnavailable` on unsupported controllers. This is additive in 2.0.0 — existing code using resource methods directly on the client is unaffected. The default is expected to flip to `Official()` in 3.0.0. See entries **D** and **E** in the provenance index below for details. ## Additional changes — provenance index [#additional-changes--provenance-index] Changes that complement the 10-row table above. Nothing here is new; entries are relocated from the prior wave-by-wave structure for traceability. ### A. `CSRFInterceptor.CSRFToken`: exported field → accessor method — superseded by #125 [#a-csrfinterceptorcsrftoken-exported-field--accessor-method--superseded-by-125] **Superseded by row 10.** `CSRFInterceptor` is now entirely removed. This Wave 1 intermediate step (field → accessor) is moot; the type is gone. Any code referencing `CSRFInterceptor` in any form fails to compile. ### B. `DpiApp`/`DpiGroup` types removed (ARCH-08, Wave 1) [#b-dpiappdpigroup-types-removed-arch-08-wave-1] **Type removal.** The unused `DpiApp` and `DpiGroup` types and their CRUD were dead code — excluded from the `Client` interface yet still shipped — and are excluded from generation entirely. No `Client` method ever exposed them, so typical consumers are unaffected; any code directly referencing `unifi.DpiApp` / `unifi.DpiGroup` struct types must remove it. DPI *settings* remain available via `SettingDpi`. ### C. `AddInterceptor` signature: `*ClientInterceptor` → `ClientInterceptor` (ARCH-18, Wave 2) [#c-addinterceptor-signature-clientinterceptor--clientinterceptor-arch-18-wave-2] **Signature change (compile break for direct callers).** `AddInterceptor` now takes the interceptor by value, matching how the interceptor slice and `ClientConfig.Interceptors` are typed: ```go // before c.AddInterceptor(&myInterceptor) // after c.AddInterceptor(myInterceptor) ``` Dedup semantics also changed: previously by interface `==` (could panic on non-comparable types); now by concrete type via `reflect.TypeOf` — only one interceptor of a given concrete type is retained, and non-comparable interceptors no longer panic. `AddInterceptor` is **not** part of the public `Client` interface, so consumers using the `Client` interface are unaffected. ### D. `Client` interface split into `InternalClient` + `Internal()`/`Official()` accessors (#119) [#d-client-interface-split-into-internalclient--internalofficial-accessors-119] The `InternalClient` embedded interface and the `Internal()`/`Official()` accessors implement the 2.0.0-canonical-Internal step: in 2.0.0 the embedded Internal surface stays the default, so existing code is untouched; 3.0.0 is expected to flip the default to the Official client (the **3.0.0-flip**). ### E. Official API unavailable on classic/old-style controllers (#119) [#e-official-api-unavailable-on-classicold-style-controllers-119] **New runtime contract (no compile break).** `client.Official()` always returns a non-nil client, but every operation is gated. They return: * `ErrOfficialAPIDisabled` when `ClientConfig.DisableOfficialAPI` is set (fails fast, no probe). * `ErrOfficialAPIUnavailable` on an old-style (classic) controller, a failed `GET /v1/info` probe (a rejected API key surfaces here), or a controller version below 10.1.78. Match with `errors.Is(err, unifi.ErrOfficialAPIUnavailable)` / `errors.Is(err, unifi.ErrOfficialAPIDisabled)`. The Internal API is unaffected. ### F. Internal codegen-tool API changes (not public `unifi` surface) [#f-internal-codegen-tool-api-changes-not-public-unifi-surface] These affect only forks of the generator: * `DownloadAndExtract` gained a leading `*http.Client` parameter (TEST-07, Wave 1). * `DownloadAndExtract`/`downloadJar` gained a leading `context.Context` parameter (ARCH-15, Wave 2). ### G. `NewBareClient` replaced by `NewClient` + `SkipSystemInfo` [#g-newbareclient-replaced-by-newclient--skipsysteminfo] **Status: DONE** — landed; `NewBareClient` removed. **Signature change (compile break).** The `NewBareClient` constructor is removed. Use `NewClient` with `SkipSystemInfo: true` to achieve the same behaviour (skip the eager `GetSystemInformation()` call). ```go // before c, err := unifi.NewBareClient(&unifi.ClientConfig{URL: "...", APIKey: "..."}) // after c, err := unifi.NewClient(&unifi.ClientConfig{URL: "...", APIKey: "...", SkipSystemInfo: true}) ``` **Provenance:** unifi/client.go; delegated from epic #117, issue #148. ### H. New `Patch` method on `Client` [#h-new-patch-method-on-client] **Status: DONE** — landed in unifi/requests.go. **Additive change (no compile break).** The `Client` interface and concrete client expose a `Patch` method for HTTP PATCH requests, alongside the existing `Do`/`Get`/`Post`/`Put`/`Delete`. ```go // new in 2.0.0 — target endpoint must accept PATCH (e.g. the Official integration/v1 surface) err := c.Patch(ctx, "/proxy/network/integration/v1/sites//...", patchBody, &resp) ``` `Patch` sends a literal HTTP PATCH; most legacy Internal REST resources (e.g. `networkconf`) expect `PUT`. **Provenance:** unifi/requests.go; delegated from epic #117, issue #148. ### I. `Logger` decoupled from the `Client` interface [#i-logger-decoupled-from-the-client-interface] **Status: DONE** — landed in issue [#167](https://github.com/filipowm/go-unifi/issues/167). **Interface change (compile break for custom `Client` implementations and mocks).** The `Client` interface no longer **embeds** `Logger`; it now exposes a single accessor `Logger() Logger`. The 10 logging methods (`Trace`/`Debug`/`Info`/`Warn`/`Error` + their `*f` variants) are therefore no longer promoted onto a `Client` value — call them through the accessor. ```go // before c.Errorf("boom: %v", err) // after c.Logger().Errorf("boom: %v", err) ``` Any third-party type implementing `unifi.Client` (or a hand-rolled mock) previously had to satisfy all 10 logging methods; now it must implement only `Logger() Logger`. The moq `ClientMock` is regenerated automatically and exposes a single `LoggerFunc`/`Logger()` pair instead of the 10 logging hooks. The `Logger` interface itself and the `LoggingLevel` enum are unchanged. The default logger is now backed by `log/slog` (no third-party logging dependency in the `unifi` package, no forced ANSI colours); `NewSlogLogger(*slog.Logger) Logger` is added to wrap a caller-supplied `*slog.Logger`. These are additive — only the `Client`-interface decoupling is a compile break. **Provenance:** unifi/client.go, unifi/logging.go, codegen/internal/client.go.tmpl; issue #167. ## Planned for 3.0.0 [#planned-for-300] The changes below are **PENDING** — they are **not** in 2.0.0 and are deferred to the next major version. They keep their original epic-row numbers under a `P` (planned) prefix so they don't collide with the landed rows 4–6 above. ### P4. OpenAPI-shaped structs (deferred to 3.0.0) [#p4-openapi-shaped-structs-deferred-to-300] **Status: PENDING** — no resources migrated yet; landing per-resource as part of the OpenAPI generator wave. **Type change (compile break for each migrated resource).** Resources generated from the official OpenAPI spec (`integration.json`) may adopt different field names, types, or nesting than the legacy reverse-engineered shapes. Each resource is migrated individually; the breaking surface is bounded to that resource's struct. Migration is compile-error-driven: build against the new module version and fix any field references reported by the compiler. **Provenance:** epic #117, OpenAPI-generator wave (issues TBD). ### P5. Occasional field renames (deferred to 3.0.0) [#p5-occasional-field-renames-deferred-to-300] **Status: PENDING** — no field renames yet; land alongside each OpenAPI-driven resource migration. **Signature change (compile break per renamed field).** Where the official spec uses a different field name from the legacy one, the generated struct adopts the spec name. Each rename is documented here when it lands; rename at each call site. **Provenance:** epic #117, per-resource OpenAPI migration. ### P6. New `integration/v1` `APIStyle` (deferred to 3.0.0) [#p6-new-integrationv1-apistyle-deferred-to-300] **Status: PENDING** — routing generated resources through the official `integration/v1` API is part of the OpenAPI generator wave (same milestone as P4/P5); not yet landed. **Interface change (compile break for custom `Client` implementations).** A new `APIStyle` constant will route code-generated resources through the UniFi official `integration/v1` API path instead of the legacy `/api/s/{site}/...` endpoints. The generated CRUD methods and their structs will adopt OpenAPI-derived shapes (see P4/P5). The `integration/v1` path is a capability layered on the new-style API — it is not a fourth independent style alongside `V1`, `V2`, and `new-style`; see `unifi/api_paths.go` for the documented constraint. The `Official()` accessor and the `integration/v1` info/sites vertical that **did** land in PR #119 are documented in entries **D** and **E** of the provenance index above. **Provenance:** epic #117, OpenAPI generator wave (issues TBD, same milestone as P4/P5). # Upgrading from 1.x (/docs/migrating/from-1.x) This guide walks you through every breaking change introduced in go-unifi 2.0.0. It is written for Go developers of all experience levels: a senior can skim the [Fast path](#fast-path) checklist and be done in minutes; a junior can read each thematic section for the full story of what changed, why, and what to look for in their own code. You don't need the [breaking-changes log](/docs/migrating/breaking-changes) open while reading this — all the rationale and affected symbols are inlined here. That page is a cross-reference for completeness; this one is your hands-on guide. ## Fast path [#fast-path] A mechanical checklist — tick each off in order: * [ ] Update the import path to `/v2`: `github.com/filipowm/go-unifi/v2/unifi` * [ ] Update `go get`: `go get github.com/filipowm/go-unifi/v2` * [ ] Replace `ClientConfig.User`/`Password`/`RememberMe` with `APIKey:` * [ ] Remove any calls to `.Login()`, `.LoginContext()`, `.Logout()`, `.LogoutContext()` * [ ] Rename `VerifySSL: false` → `SkipVerifySSL: true` (and double-check the logic flip — verify is now ON by default) * [ ] Update your Go toolchain to Go 1.26 or newer * [ ] Replace `NewBareClient(cfg)` with `NewClient(cfg)` + `SkipSystemInfo: true` * [ ] Review error checks on `Create`/`Update` paths (they no longer return `ErrNotFound`) * [ ] If your code implements the `Client` interface, add `SetSetting`, `VersionContext`, and `GetSystemInformationContext` * [ ] Move promoted logging calls (`c.Errorf(...)`) to the `c.Logger()` accessor * [ ] Remove any direct use of `CSRFInterceptor` or `CsrfHeader` ## Module path bumped to `/v2` [#module-path-bumped-to-v2] Every import path changes from `github.com/filipowm/go-unifi/unifi` to `github.com/filipowm/go-unifi/v2/unifi`. This is a Go-modules requirement for a new major version. ```go // go-unifi 1.x import "github.com/filipowm/go-unifi/unifi" ``` ```go // go-unifi 2.0.0 import "github.com/filipowm/go-unifi/v2/unifi" ``` Update the module and run a find-and-replace across your tree: ```bash go get github.com/filipowm/go-unifi/v2 find . -name '*.go' | xargs sed -i 's|filipowm/go-unifi/unifi|filipowm/go-unifi/v2/unifi|g' ``` ## Authentication & TLS [#authentication--tls] ### API key replaces username/password [#api-key-replaces-usernamepassword] **What changed.** Username/password authentication is gone. The `ClientConfig` fields `User`/`Password`/`RememberMe` and the type `UserPassCredentials` no longer exist. The `Login`/`LoginContext`/`Logout`/`LogoutContext` methods are removed from the `Client` interface. Old-style (classic) controllers — which only ever supported user/pass — are also unsupported; constructing a client against one returns `ErrOldStyleUnsupported` immediately. **Why.** API keys are more secure (no session expiry, no CSRF exposure, scoped credentials) and are the path Ubiquiti is investing in. Removing the fallback keeps the auth surface small and predictable. ```go // go-unifi 1.x — the field was always URL:, never BaseURL: cfg := &unifi.ClientConfig{ URL: "https://unifi.localdomain", User: "admin", Password: "secret", } ``` ```go // go-unifi 2.0.0 cfg := &unifi.ClientConfig{ URL: "https://unifi.localdomain", APIKey: "your-api-key", // obtain from Control Plane → Admins & Users } ``` **Check your code.** ```bash grep -r 'User:\|Password:\|RememberMe:\|UserPassCredentials' . grep -r '\.Login(\|\.Logout(' . ``` API keys require UniFi Network **9.0.114** or newer on a UniFi OS (new-style) controller. See [Authentication](/docs/getting-started/authentication) for how to create one. ### TLS verification now ON by default [#tls-verification-now-on-by-default] **What changed.** `ClientConfig.VerifySSL bool` was renamed **and inverted** to `SkipVerifySSL bool`. The zero value now means *verify certificates* (secure by default). In 1.x the zero value `VerifySSL: false` meant *skip verification* — the exact opposite. **Why.** The old name was a footgun: leaving it at zero gave you an insecure connection by accident. The renamed, inverted field means safe code requires no action; unsafe code is explicit and logged at WARN. ```go // go-unifi 1.x — zero value silently skipped verification cfg := &unifi.ClientConfig{VerifySSL: false} // verification OFF ``` ```go // go-unifi 2.0.0 — zero value verifies; set true only for self-signed certs cfg := &unifi.ClientConfig{SkipVerifySSL: true} // disable for self-signed cert (logs a warning) ``` `grep -r 'VerifySSL' .` — any hit that set `VerifySSL: false` was getting no TLS verification and will now get full verification (good for most callers). If your controller uses a self-signed cert you **must** add `SkipVerifySSL: true`, or `NewClient` will fail with a TLS error. ### CSRF handling removed [#csrf-handling-removed] **What changed.** With username/password auth gone, the `CSRFInterceptor` and its token logic are removed. The `CsrfHeader` constant is also gone. **Why.** CSRF tokens were session-cookie artifacts tied to user/pass auth. API-key auth doesn't use session cookies, so CSRF management is irrelevant. ```go // go-unifi 1.x c.AddInterceptor(&unifi.CSRFInterceptor{}) // type is gone in 2.0.0 // go-unifi 2.0.0 — simply remove the line ``` **Check your code.** `grep -r 'CSRFInterceptor\|CsrfHeader' .` ## Official API surface (additive, recommended) [#official-api-surface-additive-recommended] The Official API (`integration/v1`) is the surface Ubiquiti now maintains as a versioned, stable contract, and it's the forward-looking surface to reach for on new code where it covers the resource. But the choice is **per-resource and deliberate** — the two surfaces use different identifiers and models, so don't mechanically migrate every covered call. The Internal surface stays supported for everything the Official API doesn't yet cover. See [Choosing a surface](/docs/guides/choosing-a-surface) for how to decide which surface owns which resource. **What changed.** `c.Official()` returns a fluent client for the UniFi official OpenAPI (`integration/v1`). This is new in 2.0.0 and is purely additive — no existing code changes. ```go // Official API — new in 2.0.0; requires controller ≥ 10.1.78 with API-key auth info, err := c.Official().Info().Get(ctx) id, err := c.Official().Sites().ResolveID(ctx, "default") // returns uuid.UUID for the site page, err := c.Official().Networks().ListPage(ctx, id, nil) // body is an official.FirewallPolicyCreateOrUpdate pol, err := c.Official().Firewall().CreatePolicy(ctx, id, body) ``` All resource-group methods (Networks, Firewall, Devices, …) accept `siteId uuid.UUID` as their first argument. `Sites().ResolveID` maps a familiar site name (`"default"`, `"lab"`, …) to the required UUID. If you already have a UUID string, use `uuid.Parse("xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx")`. Operations on `c.Official()` return `ErrOfficialAPIUnavailable` if the controller is below `10.1.78`, is old-style, or the `GET /v1/info` probe fails (a rejected API key surfaces here). Set `DisableOfficialAPI: true` in `ClientConfig` to opt out entirely (operations then fail fast with `ErrOfficialAPIDisabled`). **Error handling.** The Official surface uses the same error types as the Internal surface. Errors returned through the default transport are `*unifi.ServerError`, and HTTP 404 responses satisfy `errors.Is(err, unifi.ErrNotFound)`: ```go id, err := c.Official().Sites().ResolveID(ctx, "default") if errors.Is(err, unifi.ErrNotFound) { // site not found } var serverErr *unifi.ServerError if errors.As(err, &serverErr) { log.Printf("controller error %d: %s", serverErr.StatusCode, serverErr.Message) } ``` In 2.0.0 the Internal surface remains the default — calling a resource method directly on `c` is identical to calling it on `c.Internal()`. The default is expected to flip to Official in 3.0.0. New to the two surfaces? Read [Choosing a surface](/docs/guides/choosing-a-surface) and the [Official API guide](/docs/guides/official-api). ## Go version [#go-version] **What changed.** The module requires Go 1.26 or later (`go 1.26.0` in `go.mod`). **Why.** The Official API surface uses range-over-func iterators (`iter.Seq2`), which need Go ≥ 1.23. The module pins `1.26` as the project's supported toolchain baseline — not because any single language feature demands it — so contributors and consumers build on one known-good floor. **Check your code.** Run `go version` — if you're below 1.26 you'll see a build error immediately. Update your toolchain: ```bash go install golang.org/dl/go1.26.0@latest && go1.26.0 download ``` ## Client interface additions [#client-interface-additions] **What changed.** The `Client` interface has three new methods. If you maintain a custom type that implements `Client` (rare — most callers use the concrete client returned by `NewClient`), you must add these: ```go // Implement all three if you have a custom Client implementation SetSetting(ctx context.Context, site string, key string, reqBody any) (any, error) VersionContext(ctx context.Context) (string, error) GetSystemInformationContext(ctx context.Context) (*SysInfo, error) ``` `LoginContext`/`LogoutContext` were briefly added during the 2.0.0 work and then removed along with the rest of username/password auth. **Why.** `SetSetting` was on the concrete struct but missing from the interface. The `*Context` variants add proper context propagation for callers who need cancellation/timeouts on lifecycle calls. **Check your code.** This only affects you if you have a type that satisfies `unifi.Client` by listing the methods (e.g. a hand-written mock). The generated `ClientMock` (moq) is regenerated automatically. ### Logger is no longer promoted onto the client [#logger-is-no-longer-promoted-onto-the-client] **What changed.** The `Client` interface no longer **embeds** `Logger`; it exposes a single accessor `Logger() Logger`. The 10 logging methods (`Trace`/`Debug`/`Info`/`Warn`/`Error` and their `*f` variants) are therefore no longer promoted onto a `Client` value — call them through the accessor. ```go // go-unifi 1.x c.Errorf("boom: %v", err) ``` ```go // go-unifi 2.0.0 c.Logger().Errorf("boom: %v", err) ``` The `Logger` interface and the `LoggingLevel` enum are unchanged. The default logger is now backed by `log/slog`; `NewSlogLogger(*slog.Logger) Logger` wraps a caller-supplied logger. See [Logging](/docs/advanced/logging) for the full picture. ## Error handling [#error-handling] ### `meta.rc=="error"` on HTTP 200 now surfaces as `*ServerError` [#metarcerror-on-http-200-now-surfaces-as-servererror] **What changed.** The UniFi v1 API sometimes returns HTTP 200 with `meta.rc == "error"` — a soft application-level failure. In 1.x this was caught in exactly one place (`CreateUser`) and silently swallowed everywhere else. In 2.0.0 it is detected centrally, so **every** decoded `{meta,data}` 200 with `rc=="error"` surfaces as a `*ServerError` carrying the controller's `rc`/`msg`, enriched with status/method/URL. **Why.** Silent swallowing of soft errors was a correctness hazard. Surfacing them uniformly lets callers make explicit decisions. ```go // Same code as before — but rc=="error" now produces *ServerError n := &unifi.Network{Name: "lab"} _, err := c.CreateNetwork(ctx, "default", n) var serverErr *unifi.ServerError if errors.As(err, &serverErr) { log.Printf("controller error: %s", serverErr.Message) } ``` **Check your code.** If you have error checks that assumed success whenever `err == nil` after a resource call, they are now correct. If you specifically checked for `nil` and then inspected the response body yourself, you can remove that logic. Requests that pass `nil` as the response body (all generated deletes and fire-and-forget `cmd/` operations) skip this check — the body isn't inspected when no response struct is expected. ### 404 responses now satisfy `errors.Is(err, ErrNotFound)` [#404-responses-now-satisfy-errorsiserr-errnotfound] **What changed.** Previously only hand-written list/get wrappers returned `ErrNotFound`. In 2.0.0, `(*ServerError).Is` maps any `*ServerError` with `StatusCode == 404` to the `ErrNotFound` sentinel, so a genuine HTTP 404 from **any** endpoint satisfies `errors.Is(err, unifi.ErrNotFound)`. **Why.** Consistent error semantics: "not found" means `ErrNotFound`, regardless of which endpoint produced the 404. ```go // Same check; now covers all 404s, not just specific resources _, err := c.GetNetwork(ctx, "default", "missing-id") if errors.Is(err, unifi.ErrNotFound) { // resource doesn't exist } ``` **Check your code.** If you were checking `err != nil && !errors.Is(err, ErrNotFound)` on get paths, the behavior is now wider — any 404 will match. This is correct and desirable. ### `Create`/`Update` no longer return `ErrNotFound` on unexpected responses [#createupdate-no-longer-return-errnotfound-on-unexpected-responses] **What changed.** The v1-REST template used to return `ErrNotFound` when a successful create/update response contained a data array with a length other than 1. That was semantically wrong (you just created something — it can't "not be found"). In 2.0.0 these return a descriptive error instead: `fmt.Errorf("unexpected response: expected 1 , got %d", n)`. **Why.** `ErrNotFound` should only mean "the resource doesn't exist at that path." Using it on a create path confused callers and hid real bugs. ```go // go-unifi 1.x — ErrNotFound could (incorrectly) come back from a create _, err := c.CreateNetwork(ctx, "default", n) if errors.Is(err, unifi.ErrNotFound) { /* this branch could fire on create */ } ``` ```go // go-unifi 2.0.0 — ErrNotFound will NOT come from Create/Update _, err := c.CreateNetwork(ctx, "default", n) // non-nil err means failure; errors.Is(err, ErrNotFound) is always false here ``` **Check your code.** `grep -r 'ErrNotFound' .` — any `errors.Is(err, ErrNotFound)` that sits on a `Create`/`Update` call path was dead code (the controller was returning an application error, not a 404). Remove those branches or replace with a generic `err != nil` check. ## Types and methods [#types-and-methods] ### `NewBareClient` replaced by `NewClient` with `SkipSystemInfo: true` [#newbareclient-replaced-by-newclient-with-skipsysteminfo-true] **What changed.** The `NewBareClient` function is removed. Its purpose was to create a client without the eager `GetSystemInformation()` round-trip. You now do this with the standard `NewClient` and the `SkipSystemInfo: true` field. **Why.** A separate constructor created two code paths to maintain. The `SkipSystemInfo` field achieves the same behaviour without the duplication. ```go // go-unifi 1.x c, err := unifi.NewBareClient(&unifi.ClientConfig{ URL: "https://unifi.localdomain", APIKey: "your-api-key", }) ``` ```go // go-unifi 2.0.0 c, err := unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.localdomain", APIKey: "your-api-key", SkipSystemInfo: true, // skip the eager sysinfo check; errors surface on first API call }) ``` **Check your code.** `grep -r 'NewBareClient' .` ### New `Patch` method [#new-patch-method] **What changed.** The `Client` interface (and the concrete client) now exposes a `Patch` method alongside the existing `Get`/`Post`/`Put`/`Delete`. It sends an HTTP PATCH request for partial updates. **Why.** PATCH is the standard verb for partial updates, and the escape-hatch surface (`Do`/`Get`/`Post`/`Put`/`Delete`) was missing it. It is most useful against endpoints that actually accept PATCH — notably the Official `integration/v1` surface and any new-style endpoint documented to support partial updates. ```go // New in 2.0.0 — partial update via PATCH against a PATCH-capable endpoint patch := struct { Name string `json:"name"` }{Name: "updated"} var resp map[string]any err := c.Patch(ctx, "/proxy/network/integration/v1/sites//...", patch, &resp) ``` `Patch` sends a literal HTTP PATCH — the target endpoint must accept it. Most legacy Internal REST resources (e.g. `networkconf`) expect `PUT`; use `Put` for those. This is an additive change — no existing code is broken. See [Raw HTTP requests](/docs/advanced/raw-http) for the full escape-hatch surface. ### `UseLocking` is a no-op [#uselocking-is-a-no-op] **What changed.** `ClientConfig.UseLocking` is deprecated and has no effect. `net/http.Client` is goroutine-safe; requests run concurrently and are not serialized. **Why.** The old mutex serialisation was a performance hazard with no correctness benefit once the underlying HTTP client became goroutine-safe. ```go // go-unifi before 1.11.0 — serialised requests; a no-op since 1.11.0 cfg := &unifi.ClientConfig{UseLocking: true} ``` ```go // go-unifi 2.0.0 — field retained for source compat but ignored; remove it cfg := &unifi.ClientConfig{} // concurrent by default ``` **Check your code.** `grep -r 'UseLocking' .` — you can remove the field; setting it has no effect. Share **one** client across goroutines (see [Concurrency](/docs/advanced/concurrency)). ## Further reading [#further-reading] The authoritative changelog with status, provenance, and code for every break. The go-unifi release ↔ controller version matrix. Do/Get/Post/Put/Patch/Delete, path-resolution rules, and interceptors. # From paultyng/go-unifi (/docs/migrating/from-paultyng) `filipowm/go-unifi` started as a fork of [`paultyng/go-unifi`](https://github.com/paultyng/go-unifi). The **resource methods are the same**, so most of your code keeps working unchanged. What differs is how you construct and authenticate the client — and you gain a larger API surface, structured configuration, and the Official UniFi OpenAPI on top. ## Client construction [#client-construction] The biggest change is moving from a manually-configured struct to a single `NewClient` constructor. ```go client := unifi.Client{} client.SetBaseURL("https://unifi.localdomain") // Optional: configure TLS by hand if skipTLSVerify { httpClient := &http.Client{ Transport: &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, }, } jar, _ := cookiejar.New(nil) httpClient.Jar = jar client.SetHTTPClient(httpClient) } // Login is required if err := client.Login(ctx, username, password); err != nil { return nil, err } ``` ```go client, err := unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.localdomain", APIKey: "your-api-key", }) if err != nil { log.Fatalf("create client: %v", err) } ``` `NewClient` validates the connection and your API key up front, so there is no separate `Login()` step. In 2.0.0 the **only** supported authentication is an [API key](/docs/getting-started/authentication), available from UniFi Network controller **9.0.114** and newer. ## Key differences [#key-differences] | Topic | paultyng/go-unifi | filipowm/go-unifi | | ------------------ | ------------------------------------------------------- | ------------------------------------------------------------------------------------------- | | **Construction** | Manual struct + setters (`SetBaseURL`, `SetHTTPClient`) | `NewClient(&ClientConfig{...})` | | **Client type** | A concrete `struct` | An [interface](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#Client) (mockable) | | **Authentication** | Username/password via explicit `Login()` | API key only; handled during construction | | **TLS config** | Hand-built `http.Client` + `cookiejar` | `SkipVerifySSL` flag, or `HttpTransportCustomizer` / `HttpRoundTripperProvider` | | **API errors** | `unifi.APIError` | [`unifi.ServerError`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#ServerError) | | **Extras** | — | Validation modes, request/response interceptors, configurable logging, the Official API | ## TLS verification [#tls-verification] TLS verification is now **on by default** (secure by default). To skip verification — for example against a controller with a self-signed certificate — set `SkipVerifySSL: true`. The zero value (`false`) verifies, and disabling verification logs a warning on every client build: ```go client, err := unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.localdomain", APIKey: "your-api-key", SkipVerifySSL: true, }) ``` For finer control you can replace the transport entirely with `HttpRoundTripperProvider`, or tweak the default one with `HttpTransportCustomizer` — see [Configuration](/docs/advanced/configuration). ## Error handling [#error-handling] Replace any reference to `unifi.APIError` with [`unifi.ServerError`](/docs/guides/error-handling). It carries the controller's status, method, URL, message, and code: ```go n := &unifi.Network{Name: "lab"} _, err := c.CreateNetwork(ctx, "default", n) var serverErr *unifi.ServerError if errors.As(err, &serverErr) { log.Printf("controller error %d: %s", serverErr.StatusCode, serverErr.Message) } ``` A genuine HTTP 404 from any endpoint also satisfies `errors.Is(err, unifi.ErrNotFound)`. ## What you gain [#what-you-gain] Beyond the resource methods you already use, `filipowm/go-unifi` adds: A second surface — the official UniFi OpenAPI (`integration/v1`) — reached via `c.Official()`. `SoftValidation`, `HardValidation`, or `DisableValidation` for request bodies, set via `ClientConfig.ValidationMode`. Pluggable request/response interceptors for headers, tracing, and custom auth. A configurable `slog`-backed logger you can wire to your own handler. ## Migration steps [#migration-steps] Replace the import `github.com/paultyng/go-unifi/unifi` with `github.com/filipowm/go-unifi/v2/unifi`. Swap manual struct construction for `NewClient` with a `ClientConfig`. Provide an `APIKey` — username and password are no longer accepted. Remove explicit `Login()` calls; `NewClient` authenticates during construction. TLS verification is on by default. Add `SkipVerifySSL: true` only if your controller uses a self-signed certificate. Replace `unifi.APIError` with `unifi.ServerError` in your error handling. The rest of your code — the resource methods — should continue to work as before. Already on `filipowm/go-unifi` 1.x and upgrading to 2.0? Use the [1.x upgrade guide](/docs/migrating/from-1.x) instead — it covers every 2.0.0 breaking change in detail. # Migrating (/docs/migrating) There are two ways you can arrive at go-unifi 2.0.0. Pick the guide that matches where you're coming from. You already use `filipowm/go-unifi` and want to move to 2.0. A task-oriented walkthrough of every breaking change, with grep hints and before/after code. You use the upstream `paultyng/go-unifi`. The resource methods are the same — only client construction and authentication differ. ## What changed in 2.0.0 [#what-changed-in-200] The headline changes, all covered in detail by the [1.x upgrade guide](/docs/migrating/from-1.x): * **API-key authentication only.** Username/password login was removed. You authenticate with an [API key](/docs/getting-started/authentication) on a UniFi OS console running **UniFi Network 9.0.114** or newer (a Network Application version, distinct from the UniFi OS version). * **Module path is now `/v2`.** Import `github.com/filipowm/go-unifi/v2/unifi`. * **TLS verification is on by default.** The old `VerifySSL` field became [`SkipVerifySSL`](/docs/reference/configuration-types) — renamed *and* inverted. * **A second API surface.** `c.Official()` exposes the official UniFi OpenAPI (`integration/v1`) alongside the legacy Internal API. It's additive — see [Choosing a surface](/docs/guides/choosing-a-surface). * **Go 1.26** is the minimum toolchain. Want the exhaustive, status-tracked changelog with provenance for every change? See [Breaking changes](/docs/migrating/breaking-changes). ## Where to go next [#where-to-go-next] The authoritative 2.0.0 changelog: every behavior and signature change, its impact, and what's deferred to 3.0.0. Which controller versions each go-unifi release supports. Create an API key and confirm your controller meets the version floor. # Client (/docs/reference/client) The `Client` interface is the single entry point to a UniFi controller. You build one with `NewClient` and hold on to it for the lifetime of your program — it is safe for concurrent use by many goroutines, so share **one** client rather than creating a client per request. ## NewClient [#newclient] ```go func NewClient(config *ClientConfig) (Client, error) ``` `NewClient` validates the [`*ClientConfig`](/docs/reference/configuration-types), builds the HTTP transport, and — unless `SkipSystemInfo` is `true` — makes one eager round-trip (`GetSystemInformation`) to confirm the URL is reachable and the API key is valid. That makes a bad key or unreachable host **fail fast at construction** rather than on your first real call. On any error it returns a `nil` `Client`, so a `err != nil` check fully protects you from a nil-panic. ```go title="main.go" package main import ( "context" "log" "github.com/filipowm/go-unifi/v2/unifi" ) func main() { c, err := unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.example.com", APIKey: "your-api-key", }) if err != nil { log.Fatalf("create client: %v", err) } ctx := context.Background() if _, err := c.ListNetwork(ctx, "default"); err != nil { log.Fatalf("list networks: %v", err) } } ``` `NewClient` returns the `Client` **interface**, not a concrete struct. That makes it trivial to mock in tests — the package ships a generated `ClientMock`. See [Testing](/docs/guides/testing). ## The Client interface [#the-client-interface] `Client` embeds [`InternalClient`](/docs/reference/internal-api/resources) (every legacy resource method — `ListNetwork`, `CreateUser`, `GetSystemInformation`, …) and adds the transport, lifecycle, and surface-accessor methods below. | Method | Signature | Purpose | | ---------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------- | | `BaseURL` | `BaseURL() string` | The normalized controller base URL the client was built with. | | `Version` | `Version() string` | Cached controller version; fetched once on first use. Swallows fetch errors (returns `""`). | | `VersionContext` | `VersionContext(ctx) (string, error)` | Like `Version` but honors `ctx` and **surfaces** the fetch error. | | `Logger` | `Logger() Logger` | The [`Logger`](/docs/reference/configuration-types#logging) the client was built with. | | `Internal` | `Internal() InternalClient` | The Internal (legacy) API surface. Its methods are also reachable directly on `c`. | | `Official` | `Official() official.Client` | The [Official OpenAPI](/docs/reference/official-api) surface (controller 10.1.78+). | | `Do` | `Do(ctx, method, apiPath, reqBody, respBody) error` | Send an arbitrary request; decode the response into `respBody`. | | `Get` | `Get(ctx, apiPath, reqBody, respBody) error` | GET helper over `Do`. | | `Post` | `Post(ctx, apiPath, reqBody, respBody) error` | POST helper over `Do`. | | `Put` | `Put(ctx, apiPath, reqBody, respBody) error` | PUT helper over `Do`. | | `Patch` | `Patch(ctx, apiPath, reqBody, respBody) error` | PATCH helper over `Do`. | | `Delete` | `Delete(ctx, apiPath, reqBody, respBody) error` | DELETE helper over `Do`. | For the full embedded resource surface, see the [`Client` godoc](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#Client). ### Lifecycle methods [#lifecycle-methods] ```go func exampleLifecycle(ctx context.Context, c unifi.Client) error { _ = c.BaseURL() // e.g. "https://unifi.example.com" // Version() swallows fetch errors (returns ""); VersionContext surfaces them. _ = c.Version() v, err := c.VersionContext(ctx) if err != nil { return fmt.Errorf("controller version: %w", err) } // The logger the client was built with. c.Logger().Debugf("controller %s is running %s", c.BaseURL(), v) return nil } ``` ### The two API surfaces [#the-two-api-surfaces] `Internal()` and `Official()` return the [Internal](/docs/reference/internal-api/resources) and [Official](/docs/reference/official-api) clients. In 2.0.0 the Internal surface is the **default**: every resource method is reachable directly on `c`, so `c.ListNetwork(...)` and `c.Internal().ListNetwork(...)` are equivalent. ```go func exampleSurfaces(ctx context.Context, c unifi.Client) error { // Internal (legacy) resource methods — also callable directly on c. internal := c.Internal() if _, err := internal.ListNetwork(ctx, "default"); err != nil { return fmt.Errorf("list networks: %w", err) } // Official OpenAPI surface (controller 10.1.78+); sites are addressed by UUID. siteID, err := c.Official().Sites().ResolveID(ctx, "default") if err != nil { return fmt.Errorf("resolve site id: %w", err) } fmt.Println("official site id:", siteID) return nil } ``` See [Choosing a surface](/docs/guides/choosing-a-surface) for how to decide between them. ### Raw transport methods [#raw-transport-methods] When no typed method exists for an endpoint, drop down to `Do`/`Get`/`Post`/`Put`/`Patch`/`Delete`. Each takes an `apiPath` (resolved against the controller base URL), an optional request body, and a pointer to decode the response into. ```go func exampleRawRequest(ctx context.Context, c unifi.Client) error { var resp struct { Data []struct { Name string `json:"name"` } `json:"data"` } // apiPath is resolved against the controller base URL. if err := c.Get(ctx, "s/default/stat/health", nil, &resp); err != nil { return fmt.Errorf("raw GET: %w", err) } return nil } ``` See [Raw HTTP access](/docs/advanced/raw-http) for the full treatment, including file uploads. Interceptors are registered up front through [`ClientConfig.Interceptors`](/docs/reference/configuration-types) — there is no `AddInterceptor` on the `Client` interface. See [Interceptors](/docs/advanced/interceptors). ## See also [#see-also] Every `ClientConfig` field and the configuration enums. The error model and the sentinels `NewClient` and resource methods return. TLS, timeouts, and the rest of the connection options. # Configuration types (/docs/reference/configuration-types) `ClientConfig` is the single struct you pass to [`NewClient`](/docs/reference/client#newclient). Only `URL` and `APIKey` are required; everything else is optional and secure-by-default. This page is the field-by-field reference; for the task-oriented walkthrough see [Configuration](/docs/advanced/configuration). ## ClientConfig [#clientconfig] ', }, Interceptors: { description: 'Request/response interceptors applied to every call. Deduplicated by concrete type (a duplicate is dropped with a WARN).', type: '[]ClientInterceptor', default: 'nil', }, HttpTransportCustomizer: { description: 'Callback to customize the default *http.Transport (timeouts, idle conns, TLS, proxy).', type: 'HttpTransportCustomizer', default: 'nil', }, HttpRoundTripperProvider: { description: 'Replaces the entire transport with the returned RoundTripper. Takes precedence; TLS becomes caller-managed (SkipVerifySSL ignored).', type: 'func() http.RoundTripper', default: 'nil', }, ErrorHandler: { description: 'Custom handler for non-2xx HTTP responses. Defaults to the built-in handler that builds a *ServerError.', type: 'ResponseErrorHandler', default: 'DefaultResponseErrorHandler', }, CustomValidators: { description: 'Extra validation rules registered on top of the built-in set. Build regex rules with NewCustomRegexValidator.', type: '[]CustomValidator', default: 'nil', }, UseLocking: { description: 'DEPRECATED no-op since 1.11.0. The client is goroutine-safe and no longer serializes requests; retained only for source compatibility.', type: 'bool', default: 'false', }, }" /> `HttpTransportCustomizer` and `HttpRoundTripperProvider` are mutually exclusive. If both are set, `HttpRoundTripperProvider` wins (and a WARN reminds you that TLS is then entirely your responsibility). See [Configuration → customizing the HTTP client](/docs/advanced/configuration). ## ValidationMode [#validationmode] `ValidationMode` controls what happens when a request body fails the built-in [struct validation](/docs/advanced/validation) before it is sent. | Constant | Behavior | | ------------------- | ------------------------------------------------------------------------------------ | | `SoftValidation` | **Default.** Validation failures are logged as warnings; the request still goes out. | | `HardValidation` | Validation failures become an error and the request is **not** sent. | | `DisableValidation` | No validation is performed. | ## APIStyle [#apistyle] `APIStyle` selects which controller API style the client uses. The zero value probes the controller over the network at construction time; pinning a style skips that probe and enables fully offline construction (pair it with `SkipSystemInfo: true`). | Constant | Behavior | | -------------- | ------------------------------------------------------------------------------------------------------------------- | | `APIStyleAuto` | **Default.** Auto-detect by probing the controller. | | `APIStyleNew` | Force the new (UniFi OS / proxy) style without probing. | | `APIStyleOld` | The legacy classic style — **unsupported**; `NewClient` returns [`ErrOldStyleUnsupported`](/docs/reference/errors). | ```go func exampleOffline() (unifi.Client, error) { return unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.example.com", APIKey: "your-api-key", APIStyle: unifi.APIStyleNew, // pin the style; skip the probe SkipSystemInfo: true, // skip the eager system-info call }) } ``` ## Logging [#logging] The client logs through the `Logger` interface (10 leveled methods: `Trace`/`Debug`/`Info`/`Warn`/`Error` and their `*f` formatting variants). When `ClientConfig.Logger` is `nil` the client uses an `slog`-backed default at `InfoLevel`. Two constructors are provided: | Constructor | Returns | | -------------------------------------- | -------------------------------------------------------------------------- | | `NewDefaultLogger(level LoggingLevel)` | A `log/slog` text logger to stderr. `DisabledLevel` yields a no-op logger. | | `NewSlogLogger(l *slog.Logger)` | Wraps your own `*slog.Logger` in the `Logger` interface. | `LoggingLevel` values, in order: `DisabledLevel`, `TraceLevel`, `DebugLevel`, `InfoLevel` (default), `WarnLevel`, `ErrorLevel`. ```go func exampleConfig() (unifi.Client, error) { return unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.example.com", APIKey: "your-api-key", Timeout: 30 * time.Second, SkipVerifySSL: false, // verify certificates (secure default) ValidationMode: unifi.HardValidation, // reject invalid request bodies APIStyle: unifi.APIStyleAuto, // probe the controller (default) Logger: unifi.NewDefaultLogger(unifi.WarnLevel), UserAgent: "my-app/1.0", }) } ``` For the full `Logger` and `ClientConfig` godoc see [`ClientConfig`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#ClientConfig) and [`Logger`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#Logger). For deeper how-tos see [Logging](/docs/advanced/logging) and [Validation](/docs/advanced/validation). # Surface Coverage (/docs/reference/coverage) This is an **exhaustive** map of every UniFi resource the library exposes across its two client surfaces — the **Legacy Internal API** (`c.Internal()`, `unifi.InternalClient`) and the **Official OpenAPI** (`c.Official()`, `official.Client`). Every legacy resource type and every official resource surface (except the `Supporting` enum/lookup helper) appears below — either as its own row or merged with its counterpart when both surfaces model the same real-world resource. Use it to decide which surface to reach for a given resource. New to the split? Start with [Choosing a surface](/docs/guides/choosing-a-surface). This table reflects the live accessors in `unifi/official/client.generated.go` and the resource types in `unifi/*.generated.go` at the time of last regeneration. It is maintained with the `unifi-coverage-matrix` skill and refreshed when the surfaces change. ## Legend [#legend] | Mark | Meaning | | ---- | ---------------------------------------------------------------------------------------------------- | | ✅ | Covered — full CRUD, Get+Update for a singleton, or the natural scope of this resource is accessible | | ⚠️ | Partially covered — some operations exist but the surface is incomplete | | ❌ | Not covered — no equivalent on this surface | ## Networking [#networking] | Resource | Official API | Legacy Internal API | Comments | | ---------------------- | :----------: | :-----------------: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | DHCP Options | ❌ | ✅ | | | DNS Records | ✅ | ✅ | Official API exposes these as "DNS Policies", whose union variants are A/AAAA/CNAME/etc. records — the same entries the legacy DNS Records resource manages; only the naming differs. | | Dynamic DNS | ❌ | ✅ | | | Networks | ✅ | ✅ | | | Port Forwarding | ❌ | ✅ | | | Routing | ❌ | ✅ | | | Traffic Matching Lists | ✅ | ❌ | New in Official API; no legacy equivalent. | ## Firewall & Security [#firewall--security] | Resource | Official API | Legacy Internal API | Comments | | ----------------- | :----------: | :-----------------: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ACL Rules | ✅ | ❌ | New in Official API; no legacy equivalent. | | Content Filtering | ❌ | ✅ | | | Firewall Groups | ❌ | ✅ | Part of the legacy rule-based firewall model. | | Firewall Policies | ✅ | ⚠️ | Legacy `FirewallZonePolicy` provides zone-based policies with full CRUD (the direct counterpart of Official `Firewall.Policy`); Official additionally exposes policy ordering and patch operations absent from the legacy surface. | | Firewall Rules | ❌ | ✅ | Legacy rule-based firewall model (pre-zone); superseded by zone-based Firewall Policies on the Official side. | | Firewall Zones | ✅ | ✅ | | ## WiFi [#wifi] | Resource | Official API | Legacy Internal API | Comments | | --------------------- | :----------: | :-----------------: | ------------------------------------------------------------------------- | | AP Groups | ❌ | ✅ | | | Broadcast Groups | ❌ | ✅ | | | Channel Plan | ❌ | ✅ | | | WiFi Networks (SSIDs) | ✅ | ✅ | Official `WifiBroadcasts` and legacy `WLAN` model the same SSID resource. | | WLAN Groups | ❌ | ✅ | | ## Devices & Ports [#devices--ports] | Resource | Official API | Legacy Internal API | Comments | | --------------- | :----------: | :-----------------: | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | Devices | ⚠️ | ✅ | Official covers adoption, listing, statistics, and device/port actions; legacy `Device` resource provides richer per-device configuration (full CRUD). | | Port Profiles | ❌ | ✅ | | | Virtual Devices | ❌ | ✅ | | ## Clients & Users [#clients--users] | Resource | Official API | Legacy Internal API | Comments | | ----------------------- | :----------: | :-----------------: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | RADIUS Accounts | ❌ | ✅ | | | RADIUS Profiles | ❌ | ✅ | Official API exposes RADIUS profiles read-only via the excluded `Supporting` lookup helper, not as a managed resource. | | User Groups | ❌ | ✅ | | | Users (Network Clients) | ⚠️ | ✅ | Legacy `User` manages known client devices with full CRUD (aliases, fixed IPs, WLAN groups); Official `Clients` covers connected-client listing and network actions only. | ## Hotspot & Guest [#hotspot--guest] | Resource | Official API | Legacy Internal API | Comments | | ------------------------- | :----------: | :-----------------: | ------------------------------------------------------------------------------------------------------------------------------------- | | Hotspot 2.0 Configuration | ❌ | ✅ | | | Hotspot Operators | ❌ | ✅ | | | Hotspot Packages | ❌ | ✅ | | | Hotspot Vouchers | ✅ | ❌ | Voucher create/get/list/delete is new in Official; legacy models the hotspot via packages, operators, and Hotspot 2.0 config instead. | ## Maps, Floorplans & Media [#maps-floorplans--media] | Resource | Official API | Legacy Internal API | Comments | | --------------- | :----------: | :-----------------: | -------- | | Heat Map Points | ❌ | ✅ | | | Heat Maps | ❌ | ✅ | | | Maps | ❌ | ✅ | | | Media Files | ❌ | ✅ | | | Spatial Records | ❌ | ✅ | | ## System & Operations [#system--operations] | Resource | Official API | Legacy Internal API | Comments | | --------------- | :----------: | :-----------------: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Controller Info | ✅ | ❌ | Official provides a dedicated controller info endpoint (`Info.Get`); legacy has no equivalent resource. | | Dashboard | ❌ | ✅ | | | Schedule Tasks | ❌ | ✅ | | | Sites | ✅ | ✅ | Both surfaces cover sites; Legacy `InternalClient` provides full Sites CRUD (`CreateSite`, `DeleteSite`, `GetSite`, `ListSites`, `UpdateSite`). Official uses UUIDs as identifiers while Legacy uses site names; use `Sites().ResolveID` to map name→UUID when working across both surfaces. | | Tags | ❌ | ✅ | | ## Settings (singleton configuration) [#settings-singleton-configuration] These are singleton configuration endpoints exposed by the legacy `InternalClient` as `GetSetting*` / `UpdateSetting*` (Get + Update — the full natural scope of a singleton). The Official API exposes no settings endpoints. See the [Settings catalogue](/docs/reference/internal-api/settings) for the typed accessors. | Resource | Official API | Legacy Internal API | Comments | | -------------------------------------------------- | :----------: | :-----------------: | -------- | | Auto Speedtest (SettingAutoSpeedtest) | ❌ | ✅ | | | Baresip / VoIP (SettingBaresip) | ❌ | ✅ | | | Broadcast (SettingBroadcast) | ❌ | ✅ | | | Connectivity (SettingConnectivity) | ❌ | ✅ | | | Country (SettingCountry) | ❌ | ✅ | | | Dashboard (SettingDashboard) | ❌ | ✅ | | | DNS over HTTPS / DoH (SettingDoh) | ❌ | ✅ | | | DPI (SettingDpi) | ❌ | ✅ | | | Element Adoption (SettingElementAdopt) | ❌ | ✅ | | | EtherLighting (SettingEtherLighting) | ❌ | ✅ | | | Evaluation Score (SettingEvaluationScore) | ❌ | ✅ | | | Global AP (SettingGlobalAp) | ❌ | ✅ | | | Global NAT (SettingGlobalNat) | ❌ | ✅ | | | Global Switch (SettingGlobalSwitch) | ❌ | ✅ | | | Guest Access (SettingGuestAccess) | ❌ | ✅ | | | IPS/IDS (SettingIps) | ❌ | ✅ | | | LCM / LCD Monitor (SettingLcm) | ❌ | ✅ | | | Locale (SettingLocale) | ❌ | ✅ | | | Magic Site-to-Site VPN (SettingMagicSiteToSiteVpn) | ❌ | ✅ | | | mDNS (SettingMdns) | ❌ | ✅ | | | Management (SettingMgmt) | ❌ | ✅ | | | NetFlow (SettingNetflow) | ❌ | ✅ | | | Network Optimization (SettingNetworkOptimization) | ❌ | ✅ | | | NTP (SettingNtp) | ❌ | ✅ | | | Porta (SettingPorta) | ❌ | ✅ | | | Radio AI (SettingRadioAi) | ❌ | ✅ | | | RADIUS (SettingRadius) | ❌ | ✅ | | | Roaming Assistant (SettingRoamingAssistant) | ❌ | ✅ | | | Remote Syslog (SettingRsyslogd) | ❌ | ✅ | | | SNMP (SettingSnmp) | ❌ | ✅ | | | SSL Inspection (SettingSslInspection) | ❌ | ✅ | | | Super Cloud Access (SettingSuperCloudaccess) | ❌ | ✅ | | | Super Events (SettingSuperEvents) | ❌ | ✅ | | | Super Firmware Update (SettingSuperFwupdate) | ❌ | ✅ | | | Super Identity (SettingSuperIdentity) | ❌ | ✅ | | | Super Mail (SettingSuperMail) | ❌ | ✅ | | | Super Management (SettingSuperMgmt) | ❌ | ✅ | | | Super SDN (SettingSuperSdn) | ❌ | ✅ | | | Super SMTP (SettingSuperSmtp) | ❌ | ✅ | | | Teleport (SettingTeleport) | ❌ | ✅ | | | Traffic Flow (SettingTrafficFlow) | ❌ | ✅ | | | USG / Gateway (SettingUsg) | ❌ | ✅ | | | USW / Switch (SettingUsw) | ❌ | ✅ | | # Errors (/docs/reference/errors) go-unifi returns three kinds of error: **sentinels** you match with `errors.Is`, a structured **`ServerError`** for controller-side failures, and a client-side **`ValidationError`** for request bodies rejected before they are sent. All returned errors are wrapped with `%w`, so `errors.Is` / `errors.As` see through the wrapping. For the task-oriented guide see [Error handling](/docs/guides/error-handling). ## Sentinels [#sentinels] | Sentinel | Meaning | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `ErrNotFound` | The resource does not exist (a real HTTP 404, or a single-resource `Get` that returned empty data). List methods return an empty slice and nil error, not `ErrNotFound`. | | `ErrOldStyleUnsupported` | `NewClient` targeted a classic (old-style) controller. API-key auth needs UniFi Network **9.0.114+**. | | `ErrOfficialAPIUnavailable` | The Official API cannot run against this controller — an old-style (classic) controller, a failed `GET /v1/info` probe (a rejected API key surfaces here), or a version below **10.1.78**. | | `ErrOfficialAPIDisabled` | The Official API was opted out via [`ClientConfig.DisableOfficialAPI`](/docs/reference/configuration-types). | Match them with `errors.Is`: ```go func exampleNotFound(ctx context.Context, c unifi.Client) { _, err := c.GetNetwork(ctx, "default", "nonexistent-id") switch { case err == nil: // found case errors.Is(err, unifi.ErrNotFound): fmt.Println("network not found") default: fmt.Println("lookup failed:", err) } } ``` `ErrNotFound` covers both a genuine HTTP 404 and the "empty result" case: a single-resource `Get` whose lookup came back empty. A list endpoint that returns no items is **not** `ErrNotFound` — it yields an empty slice and a `nil` error. A `*ServerError` with a 404 status also satisfies `errors.Is(err, unifi.ErrNotFound)`. These surface at different points: `ErrOldStyleUnsupported` comes from `NewClient` at construction, while the two Official-API sentinels come from the **first `Official()` operation** — the capability gate probes lazily, so they never surface from `NewClient`. Classify whichever error you are holding: ```go func exampleAPISentinels(err error) { switch { case errors.Is(err, unifi.ErrOldStyleUnsupported): fmt.Println("classic controller; upgrade to 9.0.114+ for API-key auth") case errors.Is(err, unifi.ErrOfficialAPIUnavailable): fmt.Println("controller too old for the Official API (needs 10.1.78+)") case errors.Is(err, unifi.ErrOfficialAPIDisabled): fmt.Println("Official API disabled via DisableOfficialAPI") } } ``` ## ServerError [#servererror] A controller that returns a non-2xx response (or a soft `rc:"error"` envelope on a 200) yields a `*ServerError`. It carries the request context and any structured validation details the controller reported. `ServerError` implements `error` and an `Is` method so a 404 maps to `ErrNotFound`. Each entry in `Details` is a `ServerErrorDetails`: | Type | Fields | | ----------------------- | --------------------------------------------------------- | | `ServerErrorDetails` | `Message string`, `ValidationError ServerValidationError` | | `ServerValidationError` | `Field string`, `Pattern string` | `Meta` (`RC string`, `Message string`) is a **separate** type — the raw v1 `{ "meta": { "rc", "msg" } }` envelope the handler parses — not a field of `Details`. Reach the structured error with `errors.As`: ```go func exampleServerError(ctx context.Context, c unifi.Client) { _, err := c.CreateNetwork(ctx, "default", &unifi.Network{}) var serr *unifi.ServerError if errors.As(err, &serr) { fmt.Printf("status=%d code=%s msg=%s\n", serr.StatusCode, serr.ErrorCode, serr.Message) for _, d := range serr.Details { fmt.Printf(" detail=%s field=%s pattern=%s\n", d.Message, d.ValidationError.Field, d.ValidationError.Pattern) } } } ``` See the canonical definitions on [`ServerError`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#ServerError). ## ValidationError [#validationerror] When [`ValidationMode`](/docs/reference/configuration-types#validationmode) is `HardValidation`, a request body that fails the built-in struct validation is rejected **before** any HTTP call with a `*ValidationError`. It carries the underlying validator error in `Root` (reachable via `Unwrap`) and a per-field map in `Messages`. | Type | Fields | | ----------------- | ------------------------------------------ | | `ValidationError` | `Root error`, `Messages map[string]string` | ```go func exampleValidationError(err error) { var verr *unifi.ValidationError if errors.As(err, &verr) { for field, msg := range verr.Messages { fmt.Printf("%s: %s\n", field, msg) } } } ``` `ValidationError` is a **client-side** error (the request never reached the controller); `ServerError` is the controller's own rejection. Under the default `SoftValidation` mode, validation failures are only logged — you get a `ServerError` instead if the controller rejects the body. ## See also [#see-also] Task-oriented patterns: retries, not-found handling, and reacting to validation details. How struct validation works and how to register custom rules. NewClient and the methods that return these errors. # Reference (/docs/reference) This section is a **curated** reference. It documents the parts of the API you reach for most — how to build a client, every configuration field, the error model, and the two API surfaces — with the structure, defaults, and gotchas you cannot get from a flat symbol list. For exhaustive, always-current detail (every resource struct, every field, every method signature), the source of truth is **[pkg.go.dev](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi)**. These pages link there liberally rather than re-typing hundreds of generated fields that would drift out of date. Most resource types are **generated** from the controller's own API definitions, so they are far better browsed on pkg.go.dev than transcribed here. The curated pages cover the hand-written, stable surface; pkg.go.dev covers the long tail. ## Pages [#pages] `NewClient`, the `Client` interface, and its transport/lifecycle methods. Every `ClientConfig` field plus the `ValidationMode`, `APIStyle`, and logging enums. `ServerError`, `ValidationError`, and the `Err*` sentinels you match with `errors.Is`. What each surface covers, resource by resource, and where they overlap. The `c.Official()` surface and the OpenAPI endpoint-by-endpoint reference. The legacy resource methods, the settings catalogue, and feature constants. ## Conventions [#conventions] * Every client method takes a `context.Context` as its first argument. * Internal (legacy) methods address a site by its **name** string (`"default"`); the Official surface addresses a site by `uuid.UUID` (resolve it with `c.Official().Sites().ResolveID`). See [Choosing a surface](/docs/guides/choosing-a-surface). * Errors are wrapped with `%w`; match sentinels and typed errors with `errors.Is` / `errors.As`. See [Errors](/docs/reference/errors). # Feature constants (/docs/reference/internal-api/feature-constants) The [`features`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi/features) package is a small set of string constants naming the controller's feature flags. They exist so you don't hand-type the raw `SCREAMING_SNAKE_CASE` names when probing capabilities. Pass them to the Internal API's feature methods (names are **case-insensitive**): * `IsFeatureEnabled(ctx, site, name) (bool, error)` * `GetFeature(ctx, site, name) (*DescribedFeature, error)` * `ListFeatures(ctx, site) ([]DescribedFeature, error)` ```go title="features.go" package main import ( "context" "fmt" "log" "github.com/filipowm/go-unifi/v2/unifi" "github.com/filipowm/go-unifi/v2/unifi/features" ) func main() { ctx := context.Background() c, err := unifi.NewClient(&unifi.ClientConfig{URL: "https://unifi.example.com", APIKey: "your-api-key"}) if err != nil { log.Fatal(err) } enabled, err := c.IsFeatureEnabled(ctx, "default", features.ZoneBasedFirewall) if err != nil { log.Fatalf("check feature: %v", err) } fmt.Println("zone-based firewall enabled:", enabled) } ``` These constants are plain `string` values — they only name a flag. Whether the flag exists or is enabled is up to the controller; always probe with `IsFeatureEnabled` / `GetFeature` rather than assuming. ## Constants [#constants] | Constant | String value | | ------------------------------------------ | ------------------------------------- | | `features.AdBlocking` | `AD_BLOCKING` | | `features.AllUnifiDevicesPage` | `ALL_UNIFI_DEVICES_PAGE` | | `features.CustomDohServers` | `CUSTOM_DOH_SERVERS` | | `features.Hotspot2Passpoint` | `HOTSPOT2_PASSPOINT` | | `features.IgmpProxy` | `IGMP_PROXY` | | `features.IpExclusionFromLeases` | `IP_EXCLUSION_FROM_LEASES` | | `features.Ips` | `IPS` | | `features.IpsEtPro` | `IPS_ET_PRO` | | `features.IpsSignatureReport` | `IPS_SIGNATURE_REPORT` | | `features.IpsecFqdn` | `IPSEC_FQDN` | | `features.Ipv4ActiveLeaseReporting` | `IPV4_ACTIVE_LEASE_REPORTING` | | `features.LegacyUiSupported` | `LEGACY_UI_SUPPORTED` | | `features.LimitIpsCategories` | `LIMIT_IPS_CATEGORIES` | | `features.LiveDeviceUpdates` | `LIVE_DEVICE_UPDATES` | | `features.LockAp` | `LOCK_AP` | | `features.NatPool` | `NAT_POOL` | | `features.Netflow` | `NETFLOW` | | `features.OspfDefaultRouteAnnouncement` | `OSPF_DEFAULT_ROUTE_ANNOUNCEMENT` | | `features.OspfRouting` | `OSPF_ROUTING` | | `features.OpenVpnClient` | `OPENVPN_CLIENT` | | `features.OpenVpnClientTrafficRoutes` | `OPENVPN_CLIENT_TRAFFIC_ROUTES` | | `features.OpenVpnEncryptionCiphers` | `OPENVPN_ENCRYPTION_CIPHERS` | | `features.OpenVpnRemoteDisconnect` | `OPENVPN_REMOTE_DISCONNECT` | | `features.OpenVpnServer` | `OPENVPN_SERVER` | | `features.RadiusBatchUsers` | `RADIUS_BATCH_USERS` | | `features.RadiusProfiles` | `RADIUS_PROFILES` | | `features.RadiusServer` | `RADIUS_SERVER` | | `features.ScorePage` | `SCORE_PAGE` | | `features.SdwanHubSpoke` | `SDWAN_HUB_SPOKE` | | `features.SdwanMesh` | `SDWAN_MESH` | | `features.SpeedTest` | `SPEED_TEST` | | `features.StaticDns` | `STATIC_DNS` | | `features.SwitchBgpRouting` | `SWITCH_BGP_ROUTING` | | `features.SwitchCustomAclRules` | `SWITCH_CUSTOM_ACL_RULES` | | `features.SwitchGlobalAclRules` | `SWITCH_GLOBAL_ACL_RULES` | | `features.Teleport` | `TELEPORT` | | `features.TrafficMap` | `TRAFFIC_MAP` | | `features.TrafficRouteKillSwitch` | `TRAFFIC_ROUTE_KILL_SWITCH` | | `features.TrafficRoutes` | `TRAFFIC_ROUTES` | | `features.TrafficRoutesIpsecS2sVpn` | `TRAFFIC_ROUTES_IPSEC_S2S_VPN` | | `features.TrafficRoutesOpenVpnS2sVpn` | `TRAFFIC_ROUTES_OPENVPN_S2S_VPN` | | `features.TrafficRuleAndRouteRegions` | `TRAFFIC_RULE_AND_ROUTE_REGIONS` | | `features.TrafficRuleRateLimiting` | `TRAFFIC_RULE_RATE_LIMITING` | | `features.TrafficRuleSchedules` | `TRAFFIC_RULE_SCHEDULES` | | `features.UdapiGetBlocks` | `UDAPI_GET_BLOCKS` | | `features.UcoreAutolinkDeviceUpdates` | `UCORE_AUTOLINK_DEVICE_UPDATES` | | `features.UcorePartialDeviceUpdates` | `UCORE_PARTIAL_DEVICE_UPDATES` | | `features.UidRadius` | `UID_RADIUS` | | `features.UidRadiusGroupPolicy` | `UID_RADIUS_GROUP_POLICY` | | `features.UidVpn` | `UID_VPN` | | `features.UidVpnAllowWanLocal` | `UID_VPN_ALLOW_WAN_LOCAL` | | `features.UidVpnOverrideDns` | `UID_VPN_OVERRIDE_DNS` | | `features.UidVpnStrictClientCommonName` | `UID_VPN_STRICT_CLIENT_COMMON_NAME` | | `features.UidVpnSupportUdp` | `UID_VPN_SUPPORT_UDP` | | `features.UidWifi` | `UID_WIFI` | | `features.UidWifiIot` | `UID_WIFI_IOT` | | `features.UidWifiRadiusGroupPolicy` | `UID_WIFI_RADIUS_GROUP_POLICY` | | `features.UnboundWanMonitor` | `UNBOUND_WAN_MONITOR` | | `features.UserDefinedNatRules` | `USER_DEFINED_NAT_RULES` | | `features.VisualProgramming` | `VISUAL_PROGRAMMING` | | `features.WanDhcpRequestCos` | `WAN_DHCP_REQUEST_COS` | | `features.WanDhcpv6Stateless` | `WAN_DHCPV6_STATELESS` | | `features.WanDsLite` | `WAN_DS_LITE` | | `features.WanLoadBalancingDistributedMode` | `WAN_LOAD_BALANCING_DISTRIBUTED_MODE` | | `features.WifiConfigCreated` | `WIFI_CONFIG_CREATED` | | `features.WifiMimo` | `WIFI_MIMO` | | `features.WireguardVpnClient` | `WIREGUARD_VPN_CLIENT` | | `features.WireguardVpnServer` | `WIREGUARD_VPN_SERVER` | | `features.ZoneBasedFirewall` | `ZONE_BASED_FIREWALL` | | `features.ZoneBasedFirewallMigration` | `ZONE_BASED_FIREWALL_MIGRATION` | ## See also [#see-also] `GetFeature`, `IsFeatureEnabled`, and `ListFeatures` in context. The authoritative, always-current constant list. # Internal API (/docs/reference/internal-api) The **Internal API** is the legacy surface and the **default** one: every resource is exposed as a plain `` method directly on the `unifi.Client` (also reachable via [`c.Internal()`](/docs/guides/choosing-a-surface)) — `ListNetwork`, `CreateUser`, `UpdateWLAN`, and so on. It addresses a site by its **name** string (`"default"`), not a UUID. Most of these resource types are **generated** from the controller's own API definitions, so the pages here map the surface — the resource catalogue, the settings types, and the feature-flag constants — and link to [pkg.go.dev](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi) for the exhaustive, always-current field lists. See [Choosing a surface](/docs/guides/choosing-a-surface) for how it compares to the Official API. ## Pages [#pages] The resource methods (`ListNetwork`, `CreateUser`, …), grouped by domain, with their CRUD verbs and extras. The controller settings types and their `Setting*Key` constants. The `features` package: controller feature-flag name constants. # Resources (/docs/reference/internal-api/resources) The Internal (legacy) API exposes every resource as plain methods on the client (also reachable via [`c.Internal()`](/docs/guides/choosing-a-surface)). They follow a strict naming convention, so this page maps the **resources** rather than transcribing hundreds of generated struct fields — for those, browse [the `unifi` package on pkg.go.dev](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi). ## The convention [#the-convention] Most resources expose the same five methods, named ``: | Verb | Signature | Returns | | -------- | -------------------------- | -------------- | | `Create` | `Create(ctx, site, *R)` | `(*R, error)` | | `Get` | `Get(ctx, site, id)` | `(*R, error)` | | `List` | `List(ctx, site)` | `([]R, error)` | | `Update` | `Update(ctx, site, *R)` | `(*R, error)` | | `Delete` | `Delete(ctx, site, id)` | `error` | So the `Network` row below means `CreateNetwork`, `GetNetwork`, `ListNetwork`, `UpdateNetwork`, `DeleteNetwork`. Every method takes a `context.Context` first and a **site name** string (`"default"`); `Get` returns [`ErrNotFound`](/docs/reference/errors) when the resource doesn't exist, while `List` returns an empty slice (and a `nil` error) when there are none. In the tables below, **CRUD** is shorthand for all five; deviations are spelled out. ```go title="resources.go" package main import ( "context" "errors" "fmt" "log" "github.com/filipowm/go-unifi/v2/unifi" ) func main() { ctx := context.Background() c, err := unifi.NewClient(&unifi.ClientConfig{URL: "https://unifi.example.com", APIKey: "your-api-key"}) if err != nil { log.Fatal(err) } networks, err := c.ListNetwork(ctx, "default") if err != nil { log.Fatalf("list networks: %v", err) } for _, n := range networks { fmt.Printf("%s (%s)\n", n.Name, n.Purpose) } // Device and User add convenience by-MAC variants. dev, err := c.GetDeviceByMAC(ctx, "default", "00:11:22:33:44:55") if err != nil && !errors.Is(err, unifi.ErrNotFound) { log.Fatal(err) } _ = dev } ``` ## Networking [#networking] | Resource | Methods | Notes | | --------------- | ------- | ----------------------------------- | | `Network` | CRUD | LANs, VLANs, WAN, and VPN networks. | | `PortForward` | CRUD | Port-forwarding rules. | | `Routing` | CRUD | Static routes. | | `DHCPOption` | CRUD | Custom DHCP options. | | `DNSRecord` | CRUD | Controller DNS records. | | `DynamicDNS` | CRUD | Dynamic-DNS service configs. | | `RADIUSProfile` | CRUD | RADIUS auth/accounting profiles. | ## Firewall [#firewall] | Resource | Methods | Notes | | -------------------- | -------------------------------- | -------------------------------------------------------- | | `FirewallRule` | CRUD + `ReorderFirewallRules` | Classic ruleset rules; reorder by ruleset. | | `FirewallGroup` | CRUD | Address/port groups referenced by rules. | | `FirewallZone` | CRUD + `ListFirewallZoneMatrix` | Zone-based firewall zones; matrix lists zone-pair state. | | `FirewallZonePolicy` | CRUD + `ReorderFirewallPolicies` | Zone-based firewall policies. | | `ContentFiltering` | Create / List / Update / Delete | **No `Get`** — fetch via `ListContentFiltering`. | ## Wireless [#wireless] | Resource | Methods | Notes | | -------------- | ------- | -------------------------------------- | | `WLAN` | CRUD | Wireless networks (SSIDs). | | `WLANGroup` | CRUD | Groups of WLANs assigned to APs. | | `APGroup` | CRUD | Access-point groups. | | `ChannelPlan` | CRUD | Saved RF channel plans. | | `Hotspot2Conf` | CRUD | Hotspot 2.0 / Passpoint configuration. | ## Devices [#devices] | Resource | Methods | Notes | | ---------------- | ------------------------------------------------------ | -------------------------------------------------------------- | | `Device` | CRUD + `GetDeviceByMAC`, `AdoptDevice`, `ForgetDevice` | Adopt/forget take a **MAC**; `GetDeviceByMAC` looks up by MAC. | | `PortProfile` | CRUD | Switch-port profiles. | | `VirtualDevice` | CRUD | Virtual / logical devices. | | `Tag` | CRUD | Device tags. | | `BroadcastGroup` | CRUD | Broadcast (PA) groups of devices. | ## Clients & Users [#clients--users] | Resource | Methods | Notes | | ----------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | | `User` | CRUD + `GetUserByMAC`, `DeleteUserByMAC`, `BlockUserByMAC`, `UnblockUserByMAC`, `KickUserByMAC`, `OverrideUserFingerprint` | "Users" are known clients; the extras act by **MAC**. | | `UserGroup` | CRUD | Bandwidth-limit groups. | | `Account` | CRUD | RADIUS / guest accounts. | ## Sites & System [#sites--system] | Resource | Methods | Notes | | -------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | | `Site` | `CreateSite(desc)`, `GetSite(id)`, `ListSites`, `UpdateSite(name, desc)`, `DeleteSite(id)` | **No `site` arg** (sites are global); list is `ListSites`; mutations return `[]Site`. | | System info | `GetSystemInformationContext(ctx)`, `GetSystemInfo(ctx, id)`, `GetSystemInformation()` | Returns `*SysInfo`. `GetSystemInformation` is the only method without a `context`. | | Features | `ListFeatures`, `GetFeature(name)`, `IsFeatureEnabled(name)` | Controller feature flags; names are the [feature constants](/docs/reference/internal-api/feature-constants). | | `Dashboard` | CRUD | Saved dashboards. | | `ScheduleTask` | CRUD | Scheduled tasks. | | Traffic flows | `GetTrafficFlows(ctx, site, req)` | Site-scoped analytics query; `req` is a `*TrafficFlowsRequest`. | | Settings | `GetSetting` / `SetSetting` + typed pairs | See the [Settings catalogue](/docs/reference/internal-api/settings). | ## Layout & Media [#layout--media] | Resource | Methods | Notes | | --------------- | ------- | --------------------------------- | | `Map` | CRUD | Floor-plan / topology maps. | | `HeatMap` | CRUD | Coverage heatmaps. | | `HeatMapPoint` | CRUD | Individual heatmap survey points. | | `SpatialRecord` | CRUD | Spatial survey records. | | `MediaFile` | CRUD | Uploaded media references. | ## Guest portal & hotspot [#guest-portal--hotspot] | Resource | Methods | Notes | | ---------------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | `HotspotOp` | CRUD | Hotspot operators. | | `HotspotPackage` | CRUD | Hotspot payment packages. | | `PortalFile` | `ListPortalFiles`, `GetPortalFile`, `UploadPortalFile`, `UploadPortalFileFromReader`, `DeletePortalFile` | Upload-based — **no `Create`/`Update`**; upload from a path or an `io.Reader`. | This map is curated for orientation. The exact field set of each resource struct — and any rarely used helper — is authoritative on [pkg.go.dev](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi). The full method list also lives on the [`InternalClient` interface](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#InternalClient). ## See also [#see-also] The `Setting*` catalogue and its typed `Get`/`Update` pairs. The newer `c.Official()` surface, addressed by site UUID. `ErrNotFound`, `ServerError`, and how to match them. # Settings (/docs/reference/internal-api/settings) UniFi controller settings are keyed singletons, not list resources: each *kind* of setting (NTP, mDNS, management, …) has exactly one record per site. go-unifi exposes them two ways — a **generic** key-based pair and a **typed** pair per setting kind. All settings methods take a `context.Context` and a **site name** string (`"default"`). ## Typed accessors (recommended) [#typed-accessors-recommended] For each setting kind `X` there is a matching struct `Setting` and a method pair: ```go GetSetting(ctx, site) (*Setting, error) UpdateSetting(ctx, site, *Setting) (*Setting, error) ``` The typical edit is read-modify-write — fetch, change a field, write back: ```go title="settings.go" package main import ( "context" "log" "github.com/filipowm/go-unifi/v2/unifi" ) func main() { ctx := context.Background() c, err := unifi.NewClient(&unifi.ClientConfig{URL: "https://unifi.example.com", APIKey: "your-api-key"}) if err != nil { log.Fatal(err) } ntp, err := c.GetSettingNtp(ctx, "default") if err != nil { log.Fatalf("get ntp setting: %v", err) } // ...mutate ntp fields here... if _, err := c.UpdateSettingNtp(ctx, "default", ntp); err != nil { log.Fatalf("update ntp setting: %v", err) } } ``` The verbs are asymmetric: reads are `GetSetting`, writes are **`UpdateSetting`** — but the generic write (below) is **`SetSetting`**, not `UpdateSetting`. ## Generic accessors [#generic-accessors] When you want to work by key (e.g. a setting kind without a typed wrapper, or dynamic code), use the generic pair: ```go GetSetting(ctx, site, key) (*Setting, any, error) SetSetting(ctx, site, key, reqBody any) (any, error) ``` `GetSetting` returns the bare [`Setting`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi#Setting) envelope (`ID`, `SiteID`, `Key`) plus the decoded fields as an `any` — which is a `*Setting` pointer you type-assert. Pass the matching `SettingKey` constant: ```go title="generic_setting.go" package main import ( "context" "fmt" "log" "github.com/filipowm/go-unifi/v2/unifi" ) func main() { ctx := context.Background() c, err := unifi.NewClient(&unifi.ClientConfig{URL: "https://unifi.example.com", APIKey: "your-api-key"}) if err != nil { log.Fatal(err) } setting, fields, err := c.GetSetting(ctx, "default", unifi.SettingMgmtKey) if err != nil { log.Fatal(err) } mgmt, ok := fields.(*unifi.SettingMgmt) if !ok { log.Fatalf("unexpected fields type for key %q", setting.Key) } fmt.Println(mgmt.SiteID) } ``` Both pairs decode the same way: the key resolves to a generated factory that returns a fresh `*Setting`. An unknown key yields an `unexpected key` error; a key absent from the controller's response yields [`ErrNotFound`](/docs/reference/errors). ## Catalogue [#catalogue] Every kind below has a `Setting` struct, a `SettingKey` constant, and a typed `GetSetting` / `UpdateSetting` pair. Field-level detail lives on [pkg.go.dev](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi). | `X` (type / method suffix) | `SettingKey` value | Covers | | -------------------------- | ------------------------ | ---------------------------------------- | | `AutoSpeedtest` | `auto_speedtest` | Scheduled automatic speed tests. | | `Baresip` | `baresip` | VoIP (baresip) integration. | | `Broadcast` | `broadcast` | Broadcast / PA audio. | | `Connectivity` | `connectivity` | Uplink connectivity monitoring. | | `Country` | `country` | Site country / regulatory domain. | | `Dashboard` | `dashboard` | Dashboard preferences. | | `Doh` | `doh` | DNS-over-HTTPS. | | `Dpi` | `dpi` | Deep packet inspection. | | `ElementAdopt` | `element_adopt` | UniFi Element adoption. | | `EtherLighting` | `ether_lighting` | Switch port LED / ether-lighting. | | `EvaluationScore` | `evaluation_score` | Network experience score. | | `GlobalAp` | `global_ap` | Global access-point defaults. | | `GlobalNat` | `global_nat` | Global NAT behaviour. | | `GlobalSwitch` | `global_switch` | Global switch defaults. | | `GuestAccess` | `guest_access` | Guest portal / hotspot access. | | `Ips` | `ips` | Intrusion prevention / detection. | | `Lcm` | `lcm` | Device LCD / LCM screen. | | `Locale` | `locale` | Site locale / timezone. | | `MagicSiteToSiteVpn` | `magic_site_to_site_vpn` | Auto site-to-site VPN. | | `Mdns` | `mdns` | Multicast DNS (Bonjour) forwarding. | | `Mgmt` | `mgmt` | Device management (SSH, etc.). | | `Netflow` | `netflow` | NetFlow export. | | `NetworkOptimization` | `network_optimization` | Automatic network optimization. | | `Ntp` | `ntp` | NTP servers. | | `Porta` | `porta` | Hotspot portal customization. | | `RadioAi` | `radio_ai` | AI RF / radio optimization. | | `Radius` | `radius` | Built-in RADIUS server. | | `RoamingAssistant` | `roaming_assistant` | Client roaming assistance. | | `Rsyslogd` | `rsyslogd` | Remote syslog. | | `Snmp` | `snmp` | SNMP agent. | | `SslInspection` | `ssl_inspection` | SSL inspection. | | `SuperCloudaccess` | `super_cloudaccess` | Controller-wide remote (cloud) access. | | `SuperEvents` | `super_events` | Controller-wide event retention. | | `SuperFwupdate` | `super_fwupdate` | Controller-wide firmware-update policy. | | `SuperIdentity` | `super_identity` | Controller identity / name. | | `SuperMail` | `super_mail` | Controller-wide mail. | | `SuperMgmt` | `super_mgmt` | Controller-wide management. | | `SuperSdn` | `super_sdn` | Controller-wide SDN / cloud integration. | | `SuperSmtp` | `super_smtp` | Controller-wide SMTP server. | | `Teleport` | `teleport` | Teleport VPN. | | `TrafficFlow` | `traffic_flow` | Traffic-flow analytics. | | `Usg` | `usg` | Gateway (USG / UDM) settings. | | `Usw` | `usw` | Switch (USW) settings. | The `Super*` kinds are controller-wide rather than per-site, but you still read and write them with the same site-scoped methods (use `"default"`). ## See also [#see-also] The list-style resource methods (`ListNetwork`, `CreateUser`, …). `ErrNotFound` and the `unexpected key` error from settings. Every `Setting` struct and its fields. # Official API (/docs/reference/official-api) The **Official API** is the controller's own [UniFi Network API](https://developer.ui.com) (`integration/v1`), reached from any `unifi.Client` via `c.Official()`. It is a separate, fluent surface — one accessor per resource group — that addresses sites by `uuid.UUID` rather than the legacy site-name string. It lives in package [`github.com/filipowm/go-unifi/v2/unifi/official`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi/official). The Official surface requires controller **10.1.78** or newer and API-key auth (API keys themselves are accepted from 9.0.114). The first call probes `GET /v1/info` once; if the controller is too old, unreachable, or you opted out with `DisableOfficialAPI`, the call fails fast with [`ErrOfficialAPIUnavailable`](/docs/reference/errors) or `ErrOfficialAPIDisabled`. See [Choosing a surface](/docs/guides/choosing-a-surface). ## Resource groups [#resource-groups] `c.Official()` returns an [`official.Client`](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi/official#Client), exposing exactly one accessor per resource group. Each accessor returns a small interface (e.g. `NetworksClient`) whose methods map 1:1 to Official API operations. | Accessor | Group interface | What it covers | | ------------------------ | ---------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `Sites()` | `SitesClient` | Local sites; `ResolveID` (name → `uuid.UUID`). | | `Info()` | `InfoClient` | Controller application info (`GET /v1/info`). | | `Networks()` | `NetworksClient` | Networks: CRUD plus `GetReferences`. | | `Clients()` | `ClientsClient` | Connected clients: list, get, `ExecuteConnectedAction`. | | `Devices()` | `DevicesClient` | Adopted/pending devices, statistics, port and device actions. | | `Firewall()` | `FirewallClient` | Firewall zones, policies, and policy ordering. | | `ACLs()` | `ACLsClient` | Access-control (ACL) rules and rule ordering. | | `DNSPolicies()` | `DNSPoliciesClient` | DNS policies: CRUD. | | `WifiBroadcasts()` | `WifiBroadcastsClient` | WiFi broadcasts (SSIDs): CRUD. | | `Hotspot()` | `HotspotClient` | Hotspot vouchers: create, list, get, delete. | | `TrafficMatchingLists()` | `TrafficMatchingListsClient` | Traffic-matching lists: CRUD. | | `Supporting()` | `SupportingClient` | Read-only catalogues: countries, DPI apps/categories, device tags, RADIUS profiles, WANs, VPN servers and tunnels. | For the full method set and request/response types of each group, browse [the `official` package on pkg.go.dev](https://pkg.go.dev/github.com/filipowm/go-unifi/v2/unifi/official), or the [endpoint-by-endpoint OpenAPI reference](/docs/reference/official-api/api). ## The uniform method shape [#the-uniform-method-shape] Every operation follows the same conventions: * **Context first, site UUID next.** Site-scoped methods take `(ctx, siteId uuid.UUID, …)`. A few global ones (`Info().Get`, `Devices().ListPendingPage`, `Supporting().ListCountriesPage`, `Supporting().ListDpiApplicationsPage`) take no site. * **Single-item CRUD** returns a pointer to the typed resource and an error: `Get`, `Create`, `Update`, `Delete` (some groups use noun-suffixed names — `Firewall().CreatePolicy`, `ACLs().GetRule`, `Hotspot().CreateVouchers`). * **Listing comes in two flavours** — the `…Page`/`…All` convention (see below). * **Errors are wrapped with `%w`.** With the default transport they are [`*unifi.ServerError`](/docs/reference/errors) values, so `errors.Is(err, unifi.ErrNotFound)` detects 404s. Resolve a site name to its UUID once, then call any group: ```go title="official_list.go" package main import ( "context" "fmt" "log" "github.com/filipowm/go-unifi/v2/unifi" ) func main() { ctx := context.Background() c, err := unifi.NewClient(&unifi.ClientConfig{ URL: "https://unifi.example.com", APIKey: "your-api-key", }) if err != nil { log.Fatal(err) } // Official methods take a uuid.UUID, not the "default" site name. siteID, err := c.Official().Sites().ResolveID(ctx, "default") if err != nil { log.Fatalf("resolve site: %v", err) } page, err := c.Official().Networks().ListPage(ctx, siteID, nil) if err != nil { log.Fatalf("list networks: %v", err) } fmt.Printf("page of %d, %d total\n", page.Count, page.TotalCount) for _, n := range page.Items { fmt.Println(n.Name) } } ``` `ResolveID` fetches the full site list on first miss and caches the name → UUID mapping, so repeated lookups don't round-trip. If you already hold a UUID string, skip it and use `uuid.Parse`. ## Pagination primitives [#pagination-primitives] List endpoints return a `{offset, limit, count, totalCount, data}` envelope. The package exposes three primitives — all generic over the item type `T`: ### `ListOptions` — bound a single page [#listoptions--bound-a-single-page] ```go type ListOptions struct { Offset int Limit int Filter string // optional server-side filter expression } ``` Passed to every `…Page` method (a `nil` is allowed). The page size is capped at **200** (`maxPageLimit`): * `nil` opts → the first page at the maximum size (200 items), unfiltered. * A non-positive or over-200 `Limit` is clamped to 200; a negative `Offset` becomes 0. * `Filter` is forwarded verbatim as the `filter` query parameter — see the [filter DSL](/docs/reference/official-api/api#filtering). ### `Page[T]` — one page of results [#paget--one-page-of-results] ```go type Page[T any] struct { Items []T // the decoded data slice Offset int Limit int Count int // items in this page TotalCount int // items across all pages } ``` ### The `…Page` / `…All` convention [#the-page--all-convention] Each listable resource exposes a pair: * **`…Page(ctx, siteId, *ListOptions) (Page[T], error)`** — fetch exactly one page. You drive the offset/limit yourself. Use it when you want explicit control or only need the first page. * **`…All(ctx, siteId, filter string) iter.Seq2[T, error]`** — a Go 1.23 range-over-func iterator that lazily drains every page on demand (200 per fetch), forwarding `filter` on every request. `range` it and `break` to stop early — no further request is issued. ```go title="official_stream.go" package main import ( "context" "fmt" "log" "github.com/filipowm/go-unifi/v2/unifi" ) func main() { ctx := context.Background() c, err := unifi.NewClient(&unifi.ClientConfig{URL: "https://unifi.example.com", APIKey: "your-api-key"}) if err != nil { log.Fatal(err) } siteID, err := c.Official().Sites().ResolveID(ctx, "default") if err != nil { log.Fatal(err) } // Stream every network; the error is the second range value. for n, err := range c.Official().Networks().ListAll(ctx, siteID, "") { if err != nil { log.Fatalf("list networks: %v", err) } fmt.Println(n.Name) } } ``` ### `Collect` — drain an iterator into a slice [#collect--drain-an-iterator-into-a-slice] `official.Collect` is the explicit opt-in for callers who genuinely want every item in memory rather than streaming. It short-circuits on the first error. ```go func collectNetworks(ctx context.Context, c unifi.Client, siteID uuid.UUID) ([]official.NetworkOverview, error) { return official.Collect(c.Official().Networks().ListAll(ctx, siteID, "")) } ``` Some groups name their list pair after the noun: `Firewall().ListPoliciesPage` / `ListPoliciesAll`, `ACLs().ListRulesPage` / `ListRulesAll`, `Clients().ListConnectedPage` / `ListConnectedAll`, `Devices().ListAdoptedPage` / `ListAdoptedAll` and `ListPendingPage` / `ListPendingAll`. The shape is identical — only the verb noun differs. ## See also [#see-also] When to reach for the Official API vs the Internal one, and how site IDs differ. The Official API rendered endpoint-by-endpoint, each with its Go equivalent. `ServerError`, `ErrNotFound`, and the Official-API gate sentinels. Every group interface, method signature, and request/response type. # UniFi API (OpenAPI) (/docs/reference/official-api/api) This section renders the **Official UniFi Network API** — the controller's own OpenAPI document (`integration/v1`, spec version **10.1.85**) — endpoint by endpoint. go-unifi calls this surface for you through [`c.Official()`](/docs/reference/official-api); the pages here are the canonical HTTP-level reference, and **each one shows the equivalent Go call**. Each UniFi application exposes these endpoints locally, per site, for analytics and control of that application. For high-level insight across *all* your sites instead, see the separate [UniFi Site Manager API](https://developer.ui.com). ## Base path [#base-path] The OpenAPI document declares its server as `/integration` and versions every path under `/v1` (e.g. `/v1/sites/{siteId}/networks`). On a UniFi OS controller the integration API is mounted under `/proxy/network`, so the full runtime base path is: ```text /proxy/network/integration/v1 ``` You never assemble these paths yourself — the [`official`](/docs/reference/official-api) wrappers build them from the site UUID and resource IDs. ## Authentication [#authentication] Every request authenticates with an **API key**, sent in the `X-Api-Key` HTTP header (exact casing). Generate one from the **Integrations** section of your UniFi application. go-unifi attaches it automatically from `ClientConfig.APIKey` — see [Authentication](/docs/getting-started/authentication). API-key auth requires controller **9.0.114** or newer; the Official API surface specifically requires **10.1.78** or newer. Username/password auth is not supported. ## Filtering [#filtering] Some `GET` and `DELETE` list endpoints accept a `filter` query parameter for server-side querying. Each endpoint that supports it documents its filterable properties. In Go, set [`official.ListOptions.Filter`](/docs/reference/official-api#pagination-primitives) (or pass the `filter` argument to a `…All` iterator); the string is forwarded verbatim. ```go title="filter.go" package main import ( "context" "log" "github.com/filipowm/go-unifi/v2/unifi" "github.com/filipowm/go-unifi/v2/unifi/official" ) func main() { ctx := context.Background() c, err := unifi.NewClient(&unifi.ClientConfig{URL: "https://unifi.example.com", APIKey: "your-api-key"}) if err != nil { log.Fatal(err) } siteID, err := c.Official().Sites().ResolveID(ctx, "default") if err != nil { log.Fatal(err) } page, err := c.Official().Clients().ListConnectedPage(ctx, siteID, &official.ListOptions{ Filter: "and(type.eq('WIRELESS'), access.authorized.eq(true))", }) if err != nil { log.Fatal(err) } log.Printf("%d matching clients", page.Count) } ``` ### Syntax [#syntax] The filter DSL is URL-safe and has three kinds of expression: 1. **Property expressions** — `.()`, e.g. `id.eq(123)`, `name.isNotNull()`, `createdAt.in(2025-01-01, 2025-01-05)`. 2. **Compound expressions** — combine with `and(...)` / `or(...)`, e.g. `and(name.isNull(), createdAt.gt(2025-01-01))`. 3. **Negation** — `not()`, e.g. `not(name.like('guest*'))`. ### Property types [#property-types] | Type | Example | Syntax | | ----------- | -------------------------------------- | ----------------------------------------------------- | | `STRING` | `'Hello, ''World''!'` | Wrap in single quotes; escape a quote by doubling it. | | `INTEGER` | `123` | Must start with a digit. | | `DECIMAL` | `123.321` | Digit-leading; may include a `.`. | | `TIMESTAMP` | `2025-01-29T12:39:11Z` | ISO 8601 date or date-time. | | `BOOLEAN` | `true`, `false` | — | | `UUID` | `550e8400-e29b-41d4-a716-446655440000` | Standard 8-4-4-4-12 form. | | `SET(...)` | `[1, 2, 3]` | A set of unique values. | ### Functions [#functions] | Function | Args | Meaning | Types | | ------------------------------------------------- | ---- | -------------------- | -------------------------------------------------------------- | | `isNull` / `isNotNull` | 0 | (not) null | all | | `eq` / `ne` | 1 | (not) equals | `STRING`, `INTEGER`, `DECIMAL`, `TIMESTAMP`, `BOOLEAN`, `UUID` | | `gt` / `ge` / `lt` / `le` | 1 | ordering comparisons | `STRING`, `INTEGER`, `DECIMAL`, `TIMESTAMP`, `UUID` | | `like` | 1 | pattern match | `STRING` | | `in` / `notIn` | 1+ | (not) one of | `STRING`, `INTEGER`, `DECIMAL`, `TIMESTAMP`, `UUID` | | `isEmpty` | 0 | empty set | `SET` | | `contains` | 1 | set membership | `SET` | | `containsAny` / `containsAll` / `containsExactly` | 1+ | set membership | `SET` | In a `like` pattern, `.` matches a single character, `*` matches any number of characters, and `\` escapes a literal `.` or `*` — e.g. `name.like('guest*')` matches `guest1` and `guest100`. ## Error model [#error-model] Failed requests return a standard JSON error body: ```json { "statusCode": 401, "statusName": "UNAUTHORIZED", "code": "api.authentication.missing-credentials", "message": "Missing credentials", "timestamp": "2024-11-27T08:13:46.966Z", "requestPath": "/integration/v1/sites/123", "requestId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" } ``` `code` is a stable machine-readable identifier; `statusName` mirrors the HTTP status; and on a `500`, `requestId` lets you correlate the failure with the controller's server log. With go-unifi's default transport these surface as [`*unifi.ServerError`](/docs/reference/errors), so `errors.Is(err, unifi.ErrNotFound)` detects 404s and `errors.As` exposes the structured fields. ## Endpoints [#endpoints] The pages in this section are grouped by API tag — **Application Info, Sites, UniFi Devices, Clients, Networks, WiFi Broadcasts, Hotspot, Firewall, Access Control (ACL Rules), DNS Policies, Traffic Matching Lists, and Supporting Resources** — and listed in the sidebar. Every operation page documents its HTTP method, path, parameters, and schemas, alongside the matching `c.Official()` Go call. The Go surface: `c.Official()` accessors, method shape, and pagination. When to use the Official API instead of the Internal one. # Get Wifi Broadcast Details (/docs/reference/official-api/api/wifi-broadcasts/getWifiBroadcastDetails) Retrieve detailed information about a specific Wifi. # Delete Wifi Broadcast (/docs/reference/official-api/api/wifi-broadcasts/deleteWifiBroadcast) Delete an existing Wifi Broadcast from the specified site. # Update Wifi Broadcast (/docs/reference/official-api/api/wifi-broadcasts/updateWifiBroadcast) Update an existing Wifi Broadcast on the specified site. # List Wifi Broadcasts (/docs/reference/official-api/api/wifi-broadcasts/getWifiBroadcastPage) Retrieve a paginated list of all Wifi Broadcasts on a site. # Create Wifi Broadcast (/docs/reference/official-api/api/wifi-broadcasts/createWifiBroadcast) Create a new Wifi Broadcast on the specified site. # Get Traffic Matching List (/docs/reference/official-api/api/traffic-matching-lists/getTrafficMatchingList) Get an exist traffic matching list on a site. # Delete Traffic Matching List (/docs/reference/official-api/api/traffic-matching-lists/deleteTrafficMatchingList) Delete an exist traffic matching list on a site. # Update Traffic Matching List (/docs/reference/official-api/api/traffic-matching-lists/updateTrafficMatchingList) Update an exist traffic matching list on a site. # List Traffic Matching Lists (/docs/reference/official-api/api/traffic-matching-lists/getTrafficMatchingLists) Retrieve all traffic matching lists on a site. # Create Traffic Matching List (/docs/reference/official-api/api/traffic-matching-lists/createTrafficMatchingList) Create a new traffic matching list on a site. # Get Network Details (/docs/reference/official-api/api/networks/getNetworkDetails) Retrieve detailed information about a specific network. # Delete Network (/docs/reference/official-api/api/networks/deleteNetwork) Delete an existing network on a site. # Update Network (/docs/reference/official-api/api/networks/updateNetwork) Update an existing network on a site. # List Networks (/docs/reference/official-api/api/networks/getNetworksOverviewPage) Retrieve a paginated list of all Networks on a site. # Create Network (/docs/reference/official-api/api/networks/createNetwork) Create a new network on a site. # Get Network References (/docs/reference/official-api/api/networks/getNetworkReferences) Retrieve references to a specific network. # Get Firewall Zone (/docs/reference/official-api/api/firewall/getFirewallZone) Get a firewall zone on a site. # Delete Custom Firewall Zone (/docs/reference/official-api/api/firewall/deleteFirewallZone) Delete a custom firewall zone from a site. # Update Firewall Zone (/docs/reference/official-api/api/firewall/updateFirewallZone) Update a firewall zone on a site. # Get Firewall Policy (/docs/reference/official-api/api/firewall/getFirewallPolicy) Retrieve specific firewall policy. # Patch Firewall Policy (/docs/reference/official-api/api/firewall/patchFirewallPolicy) Patch an existing firewall policy on a site. # Delete Firewall Policy (/docs/reference/official-api/api/firewall/deleteFirewallPolicy) Delete an existing firewall policy on a site. # Update Firewall Policy (/docs/reference/official-api/api/firewall/updateFirewallPolicy) Update an existing firewall policy on a site. # Get User-Defined Firewall Policy Ordering (/docs/reference/official-api/api/firewall/getFirewallPolicyOrdering) Retrieve user-defined firewall policy ordering for a specific source/destination zone pair. # Reorder User-Defined Firewall Policies (/docs/reference/official-api/api/firewall/updateFirewallPolicyOrdering) Reorder user-defined firewall policies for a specific source/destination zone pair. # List Firewall Zones (/docs/reference/official-api/api/firewall/getFirewallZones) Retrieve a list of all firewall zones on a site. # Create Custom Firewall Zone (/docs/reference/official-api/api/firewall/createFirewallZone) Create a new custom firewall zone on a site. # List Firewall Policies (/docs/reference/official-api/api/firewall/getFirewallPolicies) Retrieve a list of all firewall policies on a site. # Create Firewall Policy (/docs/reference/official-api/api/firewall/createFirewallPolicy) Create a new firewall policy on a site. # Get DNS Policy (/docs/reference/official-api/api/dns-policies/getDnsPolicy) Retrieve specific DNS policy. # Delete DNS Policy (/docs/reference/official-api/api/dns-policies/deleteDnsPolicy) Delete an existing DNS policy on a site. # Update DNS Policy (/docs/reference/official-api/api/dns-policies/updateDnsPolicy) Update an existing DNS policy on a site. # List DNS Policies (/docs/reference/official-api/api/dns-policies/getDnsPolicyPage) Retrieve a paginated list of all DNS policies on a site. # Create DNS Policy (/docs/reference/official-api/api/dns-policies/createDnsPolicy) Create a new DNS policy on a site. # Get ACL Rule (/docs/reference/official-api/api/access-control-(acl-rules)/getAclRule) # Delete ACL Rule (/docs/reference/official-api/api/access-control-(acl-rules)/deleteAclRule) Delete an existing user defined ACL rule on a site. # Update ACL Rule (/docs/reference/official-api/api/access-control-(acl-rules)/updateAclRule) Update an existing user defined ACL rule on a site. # Get User-Defined ACL Rule Ordering (/docs/reference/official-api/api/access-control-(acl-rules)/getAclRuleOrdering) Retrieve user-defined ACL rule ordering on a site. # Reorder User-Defined ACL Rules (/docs/reference/official-api/api/access-control-(acl-rules)/updateAclRuleOrdering) Reorder user-defined ACL rules on a site. # List ACL Rules (/docs/reference/official-api/api/access-control-(acl-rules)/getAclRulePage) Retrieve a paginated list of all ACL rules on a site. # Create ACL Rule (/docs/reference/official-api/api/access-control-(acl-rules)/createAclRule) Create a new user defined ACL rule on a site. # List Vouchers (/docs/reference/official-api/api/hotspot/getVouchers) Retrieve a paginated list of Hotspot vouchers. # Generate Vouchers (/docs/reference/official-api/api/hotspot/createVouchers) Create one or more Hotspot vouchers. # Delete Vouchers (/docs/reference/official-api/api/hotspot/deleteVouchers) Remove Hotspot vouchers based on the specified filter criteria. # Get Voucher Details (/docs/reference/official-api/api/hotspot/getVoucher) Retrieve details of a specific Hotspot voucher. # Delete Voucher (/docs/reference/official-api/api/hotspot/deleteVoucher) Remove a specific Hotspot voucher. # List Adopted Devices (/docs/reference/official-api/api/unifi-devices/getAdoptedDeviceOverviewPage) Retrieve a paginated list of all adopted devices on a site, including basic device information. # Adopt Devices (/docs/reference/official-api/api/unifi-devices/adoptDevice) Adopt a device to a site. # Execute Port Action (/docs/reference/official-api/api/unifi-devices/executePortAction) Perform an action on a specific device port. The request body must include the action name and any applicable input arguments. # Execute Adopted Device Action (/docs/reference/official-api/api/unifi-devices/executeAdoptedDeviceAction) Perform an action on an specific adopted device. The request body must include the action name and any applicable input arguments. # Get Adopted Device Details (/docs/reference/official-api/api/unifi-devices/getAdoptedDeviceDetails) Retrieve detailed information about a specific adopted device, including firmware versioning, uplink state, details about device features and interfaces (ports, radios) and other key attributes. # Remove (Unadopt) Device (/docs/reference/official-api/api/unifi-devices/removeDevice) Removes (unadopts) an adopted device from the site. If the device is online, it will be reset to factory defaults. # Get Latest Adopted Device Statistics (/docs/reference/official-api/api/unifi-devices/getAdoptedDeviceLatestStatistics) Retrieve the latest real-time statistics of a specific adopted device, such as uptime, data transmission rates, CPU and memory utilization. # List Devices Pending Adoption (/docs/reference/official-api/api/unifi-devices/getPendingDevicePage) Retrieve a paginated list of devices pending adoption, including basic device information. # Execute Client Action (/docs/reference/official-api/api/clients/executeConnectedClientAction) Perform an action on a specific connected client. The request body must include the action name and any applicable input arguments. # List Connected Clients (/docs/reference/official-api/api/clients/getConnectedClientOverviewPage) Retrieve a paginated list of all connected clients on a site, including physical devices (computers, smartphones) and active VPN connections. # Get Connected Client Details (/docs/reference/official-api/api/clients/getConnectedClientDetails) Retrieve detailed information about a specific connected client, including name, IP address, MAC address, connection type and access information. # List Local Sites (/docs/reference/official-api/api/sites/getSiteOverviewPage) Retrieve a paginated list of local sites managed by this Network application. # List WAN Interfaces (/docs/reference/official-api/api/supporting-resources/getWansOverviewPage) Returns available WAN interface definitions for a given site, # List Site-To-Site VPN Tunnels (/docs/reference/official-api/api/supporting-resources/getSiteToSiteVpnTunnelPage) Retrieve a paginated list of all site-to-site VPN tunnels on a site. # List VPN Servers (/docs/reference/official-api/api/supporting-resources/getVpnServerPage) Retrieve a paginated list of all VPN servers on a site. # List Radius Profiles (/docs/reference/official-api/api/supporting-resources/getRadiusProfileOverviewPage) Returns available RADIUS authentication profiles, including configuration origin metadata. # List Device Tags (/docs/reference/official-api/api/supporting-resources/getDeviceTagPage) Returns all device tags defined within a site, which can be used for WiFi Broadcast assignments. # List DPI Application Categories (/docs/reference/official-api/api/supporting-resources/getDpiApplicationCategories) Returns predefined Deep Packet Inspection (DPI) application categories used for traffic identification and filtering. # List DPI Applications (/docs/reference/official-api/api/supporting-resources/getDpiApplications) Lists DPI-recognized applications grouped under categories. Useful for firewall or traffic analytics integration. # List Countries (/docs/reference/official-api/api/supporting-resources/getCountries) Returns ISO-standard country codes and names, # List Switch Stacks (/docs/reference/official-api/api/switching/getSwitchStackPage) Retrieve a paginated list of all Switch Stacks on a site. # Get Switch Stack (/docs/reference/official-api/api/switching/getSwitchStack) Retrieve Switch Stack details. # List MC-LAG Domains (/docs/reference/official-api/api/switching/getMcLagDomainPage) Retrieve a paginated list of all MC-LAG Domains on a site. # Get MC-LAG Domain (/docs/reference/official-api/api/switching/getMcLagDomain) Retrieve MC-LAG Domain details. # List LAGs (/docs/reference/official-api/api/switching/getLagPage) Retrieve a paginated list of all LAGs (Link Aggregation Groups) on a site. # Get LAG Details (/docs/reference/official-api/api/switching/getLag) Retrieve LAG details. # Get Application Info (/docs/reference/official-api/api/application-info/getInfo) Retrieve general information about the UniFi Network application.