EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / Network / Control TP-Link Omada OC200 via REST API with Python
Network

Control TP-Link Omada OC200 via REST API with Python

by Ton-Or ·20 Jul 2026·8 min read
Control TP-Link Omada OC200 via REST API with Python

The Omada web interface looks good, but repeatedly configuring DHCP or port-forward rules across multiple sites by clicking through individual fields wastes time. I dug into which REST API the web interface uses to talk to the controller, then wrote Python to call it directly. The most common mistake is not the endpoint, but a single token that has to go in two places.

I use a TP-Link OC200 at home as a controller for access points and a gateway, and I have recurring tasks such as configuring DHCP reservations and port-forward rules. Opening the web interface and clicking through each field is slow, so I opened DevTools to see which APIs it calls. I found that everything runs through REST API v2 at /{controllerId}/api/v2/..., and I could control it all with Python. In this article, I walk through the actual flow I use, from login through writing back with PATCH.

The controller IP, controller id, user/pass, and site id in this article are mock examples. The flow and request structure are the real ones I have run successfully.

Login: one token that must go in two places

The point that trips people up most is the CSRF token. When you log in, the controller returns a token, and that token must be included both in the HTTP header named Csrf-Token and in the query string ?token= of every subsequent request. Leave either one out, and the controller will respond that the session is invalid.

Another issue is that the OC200 comes with a self-signed cert, so SSL verification has to be disabled; otherwise, requests will throw an exception before the request even gets through:

import requests, json, urllib3
urllib3.disable_warnings()

BASE = 'https://10.0.0.2'                             # IP ของ OC200
CONTROLLER_ID = 'c0ffee00112233445566778899aabbcc'    # ดูได้จาก URL ตอนเปิดหน้า login
API = f'{BASE}/{CONTROLLER_ID}/api/v2'
USERNAME = 'admin'
PASSWORD = '<PASS>'

session = requests.Session()
session.verify = False                                # OC200 ใช้ self-signed cert

# === Login ===
r = session.post(f'{API}/login',
                 json={'username': USERNAME, 'password': PASSWORD},
                 allow_redirects=False)
data = r.json()
print(data['msg'])                                    # -> Success.
token = data['result']['token']

# token ก้อนเดียวกันนี้ ต้องแนบ 2 ที่ทุก request ที่ตามมา
session.headers.update({'Csrf-Token': token})         # ที่ 1: HTTP header
params = {'token': token}                             # ที่ 2: query string ?token=

I include allow_redirects=False in every call because if the path is even slightly wrong, the controller sends a 302 redirect to the login page, and requests follows it until you get a confusing error. Disabling redirects exposes the actual status so you can see where things went wrong.

Auth flowPOST /loginusername + password→result.tokenCSRF token→Attach in 2 placesheader + ?token=→GET / PATCH succeeds

To quickly check whether you can reach the controller before writing a long Python script, use curl to log in and inspect the token response (you need -k because of the self-signed cert):

curl -sk https://10.0.0.2/c0ffee00112233445566778899aabbcc/api/v2/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"<PASS>"}'
# {"errorCode":0,"msg":"Success.","result":{"token":"...."}}
verify=False has a cost

You can disable verification on a dev machine, but in production you should export the controller's cert and point requests to it for proper verification (session.verify = '/path/oc200.pem'). Otherwise, anyone who intercepts the connection can see the token and take over the entire controller. And do not hardcode passwords in the script; read them from an environment variable.

Find the Site ID first — everything lives under /sites/{id}

Omada organizes settings by site. Before you can touch any device or network, you need the siteId of the site you want to work with. The /sites endpoint returns a paginated list, so you must always send currentPage and currentPageSize. I only have one site at home, so I take the first one:

r = session.get(f'{API}/sites',
                params={**params, 'currentPage': 1, 'currentPageSize': 10},
                allow_redirects=False)
site = r.json()['result']['data'][0]
SITE_ID  = site['id']
SITE_API = f'{API}/sites/{SITE_ID}'
print(site['name'], SITE_ID, site.get('timeZone'))

Once you have SITE_API, all the remaining paths extend from there. The pattern is fairly predictable — setting/lan/... is for networking, setting/wireless/... is for WiFi, and setting/firewall/... is for the firewall.

Fetch the actual data: devices / SSID / DHCP / port-forward

Before writing anything back, I like to GET everything and inspect the full structure. Almost all list endpoints take the same pagination parameters as /sites, so I collect them in a dict and loop through the requests in one pass:

endpoints = {
    'devices':      f'{SITE_API}/devices',                       # AP / switch / gateway ที่ online
    'ssids':        f'{SITE_API}/setting/wireless/ssids',        # รายการ WiFi
    'lan_networks': f'{SITE_API}/setting/lan/networks',          # subnet / gateway / DHCP
    'dhcp_reserve': f'{SITE_API}/setting/lan/dhcpReservation',   # IP จอง MAC
    'port_forward': f'{SITE_API}/setting/firewall/portForwarding',
}

for name, url in endpoints.items():
    r = session.get(url,
                    params={**params, 'currentPage': 1, 'currentPageSize': 50},
                    allow_redirects=False)
    print(f'--- {name} ({r.status_code}) ---')
    if r.status_code == 200 and r.text:
        print(json.dumps(r.json()['result'], indent=2, ensure_ascii=False)[:800])

There is something to watch out for from this point on — the Omada API returns HTTP 200 almost all the time, even when the actual operation fails. The field that tells you the real result is errorCode in the body, not the status code.

Always read errorCode

The actual result is indicated by errorCode in the JSON body — 0 means success, anything else means failure (with a message in msg). If you only check status_code == 200, you will think the operation succeeded even though the controller rejected it, especially with PATCH.

Write back with PATCH — the read-only field trap

This is where I spent the most time. When changing LAN network settings — say, disabling the DHCP server on the OC200 because another DHCP server is already handing out addresses, and setting a new gateway subnet — the correct approach is to GET the entire existing object first, change only the fields you need, then PATCH the whole object back, rather than sending only the changed fields.

But be warned: if you GET the object and send it back unchanged, the controller returns a nonzero errorCode because the object includes read-only fields (id, site, resource, interface, etc.) that must not be sent back. You need to pop them out first:

NETWORK_ID = '65f1a2b3c4d5e6f708192a4c'

# 1) อ่าน object ปัจจุบันมาทั้งก้อน
r = session.get(f'{SITE_API}/setting/lan/networks',
                params={**params, 'currentPage': 1, 'currentPageSize': 10},
                allow_redirects=False)
current = r.json()['result']['data'][0]

# 2) แก้เฉพาะ field ที่ต้องการ
current['gatewaySubnet'] = '10.0.0.254/24'
current['dhcpSettings']['enable']      = False
current['dhcpSettings']['ipaddrStart'] = '10.0.0.100'
current['dhcpSettings']['ipaddrEnd']   = '10.0.0.200'
current['dhcpSettings']['ipRangePool'] = [
    {'ipaddrStart': '10.0.0.100', 'ipaddrEnd': '10.0.0.200'}
]

# 3) ถอด read-only field ทิ้งก่อน ไม่งั้น controller ปฏิเสธ
for key in ['id', 'site', 'origName', 'primary', 'resource',
            'interface', 'accessControlRule', 'rateLimit']:
    current.pop(key, None)

# 4) PATCH ทั้งก้อนกลับไปที่ network id เดิม
r = session.patch(f'{SITE_API}/setting/lan/networks/{NETWORK_ID}',
                  params=params, json=current, allow_redirects=False)
res = r.json()
print(r.status_code, res.get('errorCode'), res.get('msg'))   # -> 200 0 Success.
On GET

The controller returns all fields

Including id, site, resource, interface — convenient when reading, since you can see everything configured for this network.

On PATCH

Remove read-only fields first

Send those fields back and the controller returns a nonzero errorCode — pop them out, leaving only writable fields, then send the request.

The reason you need to GET, edit, and PATCH the whole object instead of sending a single field is that Omada's PATCH replaces the entire object rather than merging individual fields. If you send only dhcpSettings, the other fields you omit will be reset or disappear, so you need to take the original object, change only what is needed, and send it back in full.

Summary of the endpoints I use

The whole flow is short when laid out in sequence. Authenticate once to get a token, then reuse the same session for requests until the token expires:

endpoint map/api/v2/loginGet token→/api/v2/sitesGet siteId→/sites/{id}/devices+ setting/*→PATCH setting/lan/networks/{netId}

My regular work uses just a few paths — setting/lan/networks and setting/lan/dhcpReservation for networking, setting/firewall/portForwarding when opening a port to an internal machine (the publicIp/external values for these are 203.0.113.x in the examples), and setting/wireless/ssids when changing multiple WiFi settings at once. The request structure is the same throughout; only the path and body differ.

You can build on this to write a script that syncs configurations across multiple controllers. The basics are a token in two places + checking errorCode every time + removing read-only fields before PATCH. That is enough to control the entire OC200 without touching the web interface. If I have left anything out or made a typo, I will come back and fix it later.

Hit something like this? Stuck somewhere?

Come trade notes on the LinuxThai board

→ Continue on the board

Comments 0

No comments yet — be the first