// writing · 2026-09-16

UniFi has two API key stores, and using the wrong one costs an hour

200 from the cloud API and 401 from every gateway endpoint, with both header styles. That specific combination has exactly one cause.

If you have ever generated a UniFi API key, watched it work perfectly against one host, and then watched the same key return 401 Unauthorized against every gateway endpoint — with both X-API-KEY and Bearer — you had the wrong kind of key.

The two stores

Key typeCreated atWorks againstFails against
Local (what you want) the gateway's own UI → Settings → Control Plane → Advanced → API /proxy/network/... on the gateway api.ui.com
Cloud (Site Manager) unifi.ui.com api.ui.com the gateway

The tell is precise, and it is worth memorising: 200 from api.ui.com/v1/hosts but 401 from every gateway endpoint. When you see that, stop probing endpoints. The key is fine; it is simply the cloud kind, and no amount of header variation will help.

Keep them in separate variables. If a script reads one variable for gateway work and a cloud key is sitting in it, you get 401 where you wanted an honest "no credentials configured", and you will debug the wrong layer.

Try the Integration API first

The modern surface lives under /proxy/network/integration/v1/, uses the same local key, and is where the configuration endpoints actually are:

GET /integration/v1/info                        # applicationVersion
GET /integration/v1/sites                       # site ids
GET /integration/v1/sites/<id>/devices
GET /integration/v1/sites/<id>/clients
GET /integration/v1/sites/<id>/networks
GET /integration/v1/sites/<id>/dns/policies    # the DNS record surface
GET /integration/v1/sites/<id>/firewall/policies

Responses are paged: {offset, limit, count, totalCount, data[]}.

The expensive lesson: we spent real time concluding that this firmware had no DNS-record endpoint at all, based on the classic API. It exists — as dns/policies on the Integration API. Probe both surfaces before you conclude an endpoint is absent.

Adding a site-level DNS record

POST /proxy/network/integration/v1/sites/<id>/dns/policies
{
  "type": "A_RECORD",
  "domain": "wiki.example.lan",
  "ipv4Address": "192.168.1.20",
  "ttlSeconds": 300,
  "enabled": true
}

Three things cost time here:

  1. All five fields are required, and the API reports them one at a time. Send {"type":"A_RECORD"} and it tells you the next missing one. You will make several requests before it accepts.
  2. The field is ipv4Address, not value. An unknown property returns Unknown request body property, which is a clear error once you know to look for it.
  3. Clear any per-client record for the same name first. The POST fails with api.dns.policy.validation.overlap-with-local-dns while a client still holds a local DNS record for that hostname.

A site-level record serves every client using the gateway as resolver, including VPN clients. A per-client record serves only that one client — which is usually not what you meant.

You cannot create an API key from the API

Keys are minted and revoked only in an authenticated admin UI session. Every plausible endpoint returns 404 on both surfaces. That is deliberate: a leaked key cannot mint more keys or revoke yours. The first key always comes from a human.

Handing a key to a script without exposing it

Never paste an API key into a chat window, a ticket, or an email. It lands in history, in logs, and in whatever the recipient's tooling does with conversation text. Write it to a 0600 file via a hidden prompt, then have the script verify it against the gateway and report only length and pass/fail — so a wrong type of key is caught immediately without the value being printed anywhere.

UniFi Network Pack — $29

The full API runbook, the VLAN and firewall-zone design with its access matrix, and the dual-WAN reality.

Get it

← All writing