Writing to a UniFi console
The unifi.* actions are the first things Reactor changes on a UniFi console. Everything
else the provider does is read-only, apart from creating its own Alarm Manager rule.
This page exists for one reason: to say plainly which parts of that write path have been seen working against real hardware and which have not. The short version is that the authentication is observed, the outlet write is observed, and everything else is inferred. Read the Alarm Manager notes first — the auth is the same, and this page does not repeat it.
⚠️ Only the outlet write has ever been made to a real console. Every other path and field name here comes from how UniFi’s own web UI is understood to drive the Network application, not from a capture. Treat those as version-fragile, and see the LIVE VERIFICATION section of the pull request that added them.
And the outlet write proves less than it looks like it does — see The outlet write.
Authentication — OBSERVED
Section titled “Authentication — OBSERVED”Identical to the Alarm Manager path, and reached through the same client in
internal/providers/unifi/alarm.go rather than a second implementation of it:
POST /api/auth/loginwith{"username": ..., "password": ...}→ aTOKENcookie (a JWT).- The CSRF token is the
csrfTokenclaim inside that JWT. - Every mutating request carries
x-csrf-token: <csrfToken>; without it, 403.
Two things follow, and both are decisions rather than details:
- The API key does not write.
X-API-KEYreadsstat/deviceandstat/healthperfectly well (Network 10.5), and the write path needs a UniFi OS local account instead. That is whyunifi.actions.*requires the sameUNIFI_USERNAME/UNIFI_PASSWORDpair the Alarm Manager registration does. - No session is held. Each action logs in, does its work, and
POST /api/auth/logouts. A UniFi OS session cookie is a bearer of the same authority as the password that made it, so caching one across reconciles would be exactly what this project refuses to do with the password itself. The cost is one extra round trip per action. The logout verb is INFERRED; a console that does not offer it simply lets the session age out, which is what would have happened anyway.
The endpoints
Section titled “The endpoints”Unlike the alarms API, these sit under the Network application’s /proxy/network prefix — the same
prefix the poller reads through — while authenticating at the UniFi OS layer above it.
| Method + path | Used by | Confidence |
|---|---|---|
GET /proxy/network/api/s/<site>/rest/wlanconf |
unifi.wlan.*, the read half |
inferred |
PUT /proxy/network/api/s/<site>/rest/wlanconf/<_id> |
unifi.wlan.*, the write |
inferred |
GET /proxy/network/api/s/<site>/stat/device |
unifi.poe.cycle, the port check |
observed as a read; the port_table fields below are not |
POST /proxy/network/api/s/<site>/cmd/devmgr |
unifi.poe.cycle, the command |
inferred |
GET /proxy/network/api/s/<site>/stat/device |
unifi.outlet.*, the outlet check |
observed as a read, and the outlet fields below with it |
PUT /proxy/network/api/s/<site>/rest/device/<_id> |
unifi.outlet.*, the write |
observed — accepted with HTTP 200 on 2026-08-15 |
POST /api/auth/logout |
ending the session | inferred |
stat/device is the one endpoint here the poller already reads on every cycle, so that it answers
is observed. Which fields a switch record carries is not: every committed capture is of a
gateway or a UPS, and testdata/unifi/README.md lists them.
Fields read, and what happens when one is missing
Section titled “Fields read, and what happens when one is missing”| Field | Read by | If absent |
|---|---|---|
_id |
WLAN write | refused — there is no address to PUT to |
name (WLAN) |
WLAN lookup | the WLAN is not found, and the refusal does not list the ones that are |
enabled |
WLAN read and write | refused — the state this action assumes is not the one the console describes |
mac |
PoE device lookup | the device is not found |
port_table[].port_idx |
PoE port lookup | the port is not found |
port_table[].name |
PoE drift check | refused |
port_table[].is_uplink |
PoE uplink floor | refused |
port_table[].port_poe |
PoE capability floor | refused |
port_table[].poe_enable |
PoE state check | allowed — capability is the load-bearing check |
_id (device) |
outlet write | refused — there is no address to PUT to |
outlet_table[].index |
outlet lookup | the outlet is not found |
outlet_table[].name |
outlet drift check | refused |
outlet_table[].relay_state |
outlet position | refused — nothing to compare the wanted position against |
outlet_table[].outlet_caps |
battery-backed floor | refused |
outlet_overrides |
outlet write | refused — Reactor will not compose one |
outlet_overrides[].index |
outlet write | refused |
The bold rows are the design decision worth arguing with. A missing field could have been treated as “not an uplink” and “probably fine”, and it is treated as a refusal instead, because a safety check that silently stops applying on some firmware is worse than one that declines out loud. If a real console turns out not to report them, the error says which field was missing — that is a code change and a bug report, not something to work around.
The WLAN write is a read-modify-write, and that is a real limitation
Section titled “The WLAN write is a read-modify-write, and that is a real limitation”rest/wlanconf offers no field-level update and no version to compare against. So the action reads
the WLAN record, changes exactly one key, and PUTs the whole thing back.
Two things bound what that shape can do wrong:
- Reactor sends back the object it just read, so it never invents a value for a field it does
not understand. The mock enforces this:
hack/mock-unifirejects a PUT whose body differs from the stored record in anything other thanenabled. - It does not write at all when the WLAN is already where the automation wants it, which is the common case for a repeated transition.
What it cannot bound: a change made in the UniFi UI between the read and the write is lost. That window is two adjacent requests wide. There is nothing in this API to make it smaller, and pretending otherwise would be worse than saying so.
The write is checked afterwards, too — the console answers a write with the object it stored, and a
200 that did not take is reported as a failure rather than assumed to be a success. That is the
failure mode an undocumented endpoint is most likely to have.
The PoE command
Section titled “The PoE command”{"cmd": "power-cycle", "mac": "<switch mac>", "port_idx": 7}Addressed by the console’s own port index rather than by a position in the table, because the table is not guaranteed to be ordered or complete.
Almost all of cyclePoEPort is the check rather than the command, and that is the right
proportion: the console will accept a cycle of the wrong port exactly as readily as the right one,
and Reactor would never hear about the difference. See the PoEPort type in api/v1alpha1 for why
a port is identified by a MAC, an index and a name.
The outlet write — observed once, and what that is worth
Section titled “The outlet write — observed once, and what that is worth”This is the only write in this document that has been made to real hardware, so it is worth being precise about what was learned and what was not.
What was done, on 2026-08-15:
PUT /proxy/network/api/s/<site>/rest/device/<_id>{"outlet_overrides": [ ...all eight outlets, one with relay_state changed... ]}→ HTTP 200What it established. The endpoint exists, accepts a body of exactly outlet_overrides, and
answers 200. Setting outlet 8 to relay_state: false changed only outlet 8 — outlets 5, 6 and
7, which share its relay_group, stayed on. relay_group is therefore a capability partition
(battery-backed versus surge-only, corroborated by outlet_caps) and not a switching bank. It was
restored afterwards and verified.
What it did not establish, and this is the important half. The outlet under test was empty. So the only evidence that anything happened is the console reporting back the value that was written to it. A console that recorded the override without ever driving the relay would produce exactly the same result. Nothing here proves the relay physically opens. The lamp test — plug something in, drive a transition, watch it go dark — is the first item in that pull request’s LIVE VERIFICATION, and until somebody runs it this is a capability the operator believes they have.
One further gap. That write authenticated with a plain X-API-KEY header, not with the cookie
session this page describes. Reactor uses the session anyway, because a second authentication
posture inside one write path is a second thing to get wrong, and because every check lives on the
session path already. That the session is accepted by rest/device is therefore inferred, not
observed — a UniFi OS session is strictly more privileged than an API key, so it should be, but
“should be” is what this page exists to flag.
The body, and why it is narrower than the WLAN one
Section titled “The body, and why it is narrower than the WLAN one”Unlike rest/wlanconf, this write is a single field. Reactor sends back the outlet_overrides
array the console just served, with exactly one entry’s relay_state changed — every other
outlet’s entry, and every other key on the addressed one, carried through untouched. It never
composes the array itself: a UPS reporting no outlet_overrides, or none for the outlet addressed,
is a refusal, because a document Reactor invented would state a position for every relay rather
than for the one asked about.
hack/mock-unifi enforces precisely that. It rejects a PUT whose body is not the array just read
with one relay_state changed, and names what else differed — which is the one check a real
console cannot make for you, and the change nobody would notice in review.
The identity is checked against outlet_table before any of it: see the Outlet type in
api/v1alpha1 for why an outlet is named by a MAC, an index and a name, and why one still
called Outlet 5 is refused outright.
Rehearsing it without hardware
Section titled “Rehearsing it without hardware”hack/mock-unifi serves and enforces all of the above:
make dev-mock
curl http://localhost:9443/wlan # what the console holdscurl -X POST 'http://localhost:9443/wlan?name=mock-guest&enabled=false'curl http://localhost:9443/poe # ports, and every cycle so farcurl http://localhost:9443/outlets # outlets, banks, and every write so far
# break each identity check on purpose, and watch Reactor refusecurl -X POST 'http://localhost:9443/poe?port=7&name=re-patched'curl -X POST 'http://localhost:9443/poe?port=7&uplink=true'curl -X POST 'http://localhost:9443/poe?port=7&poe=false'
# the outlet floors: a bank Reactor cannot read, and nothing to modifycurl -X POST 'http://localhost:9443/outlets?caps=false'curl -X POST 'http://localhost:9443/outlets?overrides=false'curl -X POST 'http://localhost:9443/outlets?outlet=5&label=bench' # name one, so it can be switchedThe WLAN records and the switch there are not captures and are labelled as such in the mock’s own output. They are built from the field names on this page. Registration working against the mock means Reactor sends what this page describes; it does not mean a console accepts it.
The outlet table is a capture; _id, outlet_caps and outlet_overrides are not, and are the
shape read off the real UPS on the date above. hack/capture-unifi.sh now projects all three, so
the next capture will carry them.
One deliberate asymmetry: the mock does not refuse a cycle of the uplink, an unnamed outlet, a battery-backed one, or anything an allowlist would have stopped. A real console would accept all of them without complaint, so a mock that refused them would let Reactor’s own refusals rot untested — and on real hardware, Reactor’s are the only guards there are.
What would settle this
Section titled “What would settle this”The equivalent of the failover runbook in testdata/unifi/README.md,
and it needs a console somebody is willing to change. In rough order of value:
- Does
PUT rest/wlanconf/<id>accept a full record and apply only the changed field? - Does
cmd/devmgracceptpower-cyclewithmac+port_idx, and on which device types? - Does a switch’s
port_tableactually carryis_uplink,port_poeandpoe_enableas booleans? - Does
POST /api/auth/logoutend the session, and does the console mind being logged in and out once per action? - Does the relay actually open? Plug a lamp into an allowlisted outlet, drive a transition, watch it go dark. This one is cheap, and it is the only thing that turns the outlet write from “the console agreed with itself” into a working feature.
- Does
rest/deviceaccept the cookie session as well as the API key? The one observed write usedX-API-KEY; Reactor sends the session.
Nothing here should be captured into testdata/ by hand. If a fixture is ever wanted for these,
it goes through hack/capture-unifi.sh with the allowlist extended deliberately, one field at a
time — a live device credential reached this repository’s history through a fixture shortcut once.