Hard-won lessons from reverse-engineering the Omada Controller Web API v2. Each one cost us hours — so you don't have to.
You might assume an empty array means "match everything". It doesn't — the behavior is undefined and varies by controller version. Always specify protocols explicitly:
// BAD — unreliable
protocols: []
// GOOD — explicit TCP + UDP + ICMP
protocols: [6, 17, 1]
// Protocol numbers:
// 6 = TCP
// 17 = UDP
// 1 = ICMPUnlike REST conventions, the Omada Controller does not support partial updates. If you send only the fields you want to change, the missing fields get reset to defaults.
// WRONG — will wipe all other settings
await omada.apiCall('PATCH', '/setting/service/mdns/RULE_ID', {
status: false,
});
// CORRECT — GET first, modify, then PATCH with everything
const existing = await omada.apiCall('GET', '/setting/service/mdns');
const rule = existing.result.data[0];
rule.status = false;
await omada.apiCall('PATCH', `/setting/service/mdns/${rule.id}`, rule);The Omada Controller (especially hardware controllers like OC220) uses a self-signed HTTPS certificate. Node.js will reject the connection by default.
# Option A: Environment variable (recommended)
export NODE_TLS_REJECT_UNAUTHORIZED=0
# Option B: In code (set before any requests)
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';Note: This disables certificate verification for the entire Node.js process. In production, consider importing the controller's certificate instead.
The controller silently rejects ACL rules where sourceIds and destinationIds contain the same network ID. No error message — the rule just doesn't get created.
// FAILS SILENTLY — same network in source and destination
{
sourceIds: ['NETWORK_ID_A'],
destinationIds: ['NETWORK_ID_A', 'NETWORK_ID_B'],
}
// WORKS — filter out the source network
const allNetworks = [ID_A, ID_B, ID_C, ID_D];
const sourceId = ID_A;
const destinations = allNetworks.filter(id => id !== sourceId);The TPOMADA_SESSIONID cookie and CSRF token expire after approximately 30 minutes of inactivity. Error code: -1.
// For long-running scripts, re-authenticate periodically
try {
const result = await omada.apiCall('GET', '/devices');
} catch (e) {
// Session expired — reconnect
await omada.connect();
const result = await omada.apiCall('GET', '/devices');
}The CSRF token must be included both as a header and as a URL parameter. Missing either one results in a redirect to the login page (HTML response instead of JSON).
Header: Csrf-Token: {token}
URL param: ?token={token}
The omada-api-helper.js handles this automatically, but if you're building your own client, don't forget either one.
In the URL query parameter ?type=...:
gatewayor0= Gateway/router-level ACLswitch= Switch-level ACLeap= Access point-level ACL
In the request body type: 0:
0= Gateway ACL (the only observed value for gateway rules)
Don't confuse the two — the query parameter selects which ACL table to operate on, the body field describes the rule type.
Every GET endpoint that returns a list requires pagination parameters. Omitting them may return empty results or errors.
// BAD — may return nothing
await omada.apiCall('GET', '/setting/lan/networks');
// GOOD — always include pagination
await omada.apiCall('GET', '/setting/lan/networks?currentPage=1¤tPageSize=100');Gateway ACLs cannot filter by port directly. You can only filter by protocol (TCP/UDP/ICMP). To create port-specific rules:
- Create an IP/Port Group first (via the web UI or API)
- Reference it using
destinationType: 2and the group ID indestinationIds
// Gateway ACL with IP/Port Group target
{
sourceType: 0, // 0 = Network
sourceIds: ['YOUR_SOURCE_NETWORK_ID'],
destinationType: 2, // 2 = IP/Port Group
destinationIds: ['YOUR_IPGROUP_ID'], // References the group
// ...
}IP/Port Group creation (observed from DevTools):
// POST /setting/firewall/ipGroups
{
"name": "AirPlay-Ports",
"type": 1, // 1 = IP/Port Group
"ipList": [
{
"ip": "192.168.40.0/24", // Target subnet
"portList": ["7000-7100", "5353"] // Port ranges as strings
}
]
}Important: portList values are strings, not numbers. Port ranges use a hyphen: "7000-7100".
The API payload structure changed between controller versions. If you find old tutorials or scripts, they may use the legacy format which no longer works:
// OLD FORMAT (pre-5.x) — DOES NOT WORK on 6.x
{
srcType: 4,
srcNetworkId: '...',
dstType: 4,
dstNetworkId: '...',
direction: 0,
}
// NEW FORMAT (5.x / 6.x) — USE THIS
{
sourceType: 0,
sourceIds: ['...'], // Array, not single value
destinationType: 0,
destinationIds: ['...'], // Array, not single value
direction: { // Object, not integer
lanToWan: false,
lanToLan: true,
wanInIds: [],
vpnInIds: [],
},
}Key changes:
srcType→sourceType,dstType→destinationType- Single ID → Array of IDs (
sourceIds,destinationIds) direction: 0→direction: { lanToLan: true, ... }- Type value
4→0for networks
While there's no documented rate limit, rapid-fire requests can cause the controller to become unresponsive (especially hardware controllers like OC220). Add a small delay between sequential API calls:
for (const rule of rules) {
await omada.apiCall('POST', '/setting/firewall/acls', rule);
await new Promise(resolve => setTimeout(resolve, 200)); // 200ms pause
}Gateway ACL rules are evaluated top to bottom, first match wins. The index field in the API response indicates the rule's position. When creating rules, they're appended at the end by default.
Best practice: Create ALLOW rules first, then DENY rules. The controller doesn't provide an API to reorder rules after creation (you'd have to delete and recreate them).
When creating an SSID via POST /setting/wlans/{wlanGroupId}/ssids, using security: 2 (WPA2-only) causes "Invalid request parameters" or "General error". Always use security: 3 (WPA2/WPA3-mixed).
WPA2-only clients still connect fine — the AP negotiates WPA2 with them automatically. If you absolutely need WPA2-only, create with security: 3 and change via PATCH afterward (untested).
// BAD — fails on creation
{ security: 2, wpaPsk: [2], pmfMode: 1 }
// GOOD — works, supports both WPA2 and WPA3 clients
{ security: 3, wpaPsk: [2, 3], pmfMode: 3 }The correct endpoint for port profiles is:
GET/POST /setting/lan/profiles
Not /setting/switching/portProfiles — that's the old endpoint and returns -1600 Unsupported request path on newer controllers.
You can create a trunk profile without nativeNetworkId (all VLANs tagged, no native/untagged VLAN). The API accepts it. But you cannot assign it to any switch port — you'll get -1001 Invalid request parameters.
The Omada Controller requires every port to have a native (untagged) network. Use a trunk profile with the management VLAN as native instead:
// FAILS when assigned to ports (no native VLAN)
{ name: 'Trunk-noPVID', tagNetworkIds: [...all VLANs...] }
// WORKS (management VLAN as native, rest tagged)
{ name: 'Trunk-All', nativeNetworkId: 'MGMT_NETWORK_ID', tagNetworkIds: [...other VLANs...] }Like all Omada PATCH endpoints, switch ports require the full object. But you must also remove two read-only fields or the request fails:
const port = existingPorts.find(p => p.port === targetPort);
const payload = { ...port };
delete payload.portStatus; // read-only — causes "General error"
delete payload.portCap; // read-only — causes "General error"
payload.profileId = newProfileId;
await omada.apiCall('PATCH', `/switches/${mac}/ports/${targetPort}`, payload);Also: the URL uses the port number (1, 2, 3...), not the port ID string. Using the port ID gives -39701 This port does not exist.
After connecting a new device, POST /cmd/devices/adopt frequently returns -39000 This device does not exist on the first try. The device hasn't been discovered by the controller yet.
Solution: Wait 10–30 seconds and retry. The device needs to reach "Discovered" state (status 20) before adoption works. Usually succeeds on the 2nd or 3rd attempt.
async function adoptWithRetry(mac, maxRetries = 5) {
for (let i = 0; i < maxRetries; i++) {
const res = await omada.apiCall('POST', '/cmd/devices/adopt', { mac });
if (res.errorCode === 0) return res;
console.log(`Attempt ${i+1} failed, waiting 15s...`);
await new Promise(r => setTimeout(r, 15000));
}
throw new Error(`Adoption failed after ${maxRetries} attempts`);
}The band field in SSID configuration is a bitmask, not an enum:
| Value | Meaning |
|---|---|
1 |
2.4 GHz only |
2 |
5 GHz only |
3 |
2.4 GHz + 5 GHz (1+2) |
Creating an SSID with band: 0 returns "Invalid request parameters".
The radioSetting2g and radioSetting5g objects have both a channel and a freq field. You'd expect channel to control the Wi-Fi channel — it doesn't.
The channel field always reads "0" and setting it has no effect. The actual channel is controlled via the freq field (frequency in MHz):
// BAD — channel stays on Auto regardless
await omada.apiCall('PATCH', `/eaps/${mac}`, {
radioSetting2g: { ...existing, channel: "6" },
});
// GOOD — actually sets channel 6
await omada.apiCall('PATCH', `/eaps/${mac}`, {
radioSetting2g: { ...existing, freq: 2437 },
});Common frequency values:
| 2.4 GHz | 5 GHz |
|---|---|
| Ch 1 = 2412 MHz | Ch 36 = 5180 MHz |
| Ch 6 = 2437 MHz | Ch 52 = 5260 MHz |
| Ch 11 = 2462 MHz | Ch 100 = 5500 MHz |
| Auto = 0 | Ch 132 = 5660 MHz |
Set freq: 0 to return to auto channel selection.
You might expect to enable/disable SSIDs on specific APs using the standard EAP PATCH endpoint with ssidOverrides. It doesn't work. The PATCH returns errorCode: 0 (success) but silently discards the changes.
The correct endpoint is:
// BAD — returns success but ignores ssidOverrides
await omada.apiCall('PATCH', `/eaps/${mac}`, {
ssidOverrides: modifiedOverrides,
});
// GOOD — actually persists SSID enable/disable per AP
await omada.apiCall('PUT', `/eaps/${mac}/config/wlans`, {
wlanId: 'YOUR_WLAN_GROUP_ID', // from GET /setting/wlans
ssidOverrides: modifiedOverrides,
});Key details:
ssidEnable: true= SSID broadcasts on this APssidEnable: false= SSID disabled on this APenablefield must stayfalsefor all entries (it controls custom SSID renaming, not broadcast)- Setting
enable: truecauses error-39304 This SSID name already exists - The
wlanIdfield is required in the PUT body
Example: Disable "GuestNetwork" on a specific AP:
const ap = await omada.apiCall('GET', `/eaps/${mac}`);
const overrides = ap.result.ssidOverrides.map(o => ({
...o,
ssidEnable: o.globalSsid === 'GuestNetwork' ? false : o.ssidEnable,
}));
const wlanGroups = await omada.apiCall('GET', '/setting/wlans?currentPage=1¤tPageSize=100');
const wlanId = wlanGroups.result.data[0].id;
await omada.apiCall('PUT', `/eaps/${mac}/config/wlans`, {
wlanId,
ssidOverrides: overrides,
});