Using the SDK
This guide covers the parts of the SDK that don't change from endpoint to endpoint:
how the client is put together, how the fluent request builders are shaped, how to
read a response, and how to handle errors. The API surface itself (1,500+ endpoints,
2,700+ schemas under
openapi/
and
models/)
is generated directly from omada-open-api-sec.json by
Kiota, so once you know the pattern below it
applies uniformly to every resource the controller exposes — sites, devices, clients,
WLANs, VPN, hotspot/voucher management, and so on.
For authentication specifics, see Authentication & TLS. For regenerating the client, see Regenerating the SDK in the Contributor Guide. For running the BDD suite against a live controller, see Testing against a live controller, also in the Contributor Guide.
Installation
go get github.com/NerdIT-Tech/tplink-omada-sdk-for-go
The root package is tplinkomadasdkforgo; import it under the alias sdk, as the
examples in this repo do:
import sdk "github.com/NerdIT-Tech/tplink-omada-sdk-for-go"
Constructing a client
Two constructors build an *sdk.OmadaApiClient:
client, err := sdk.NewWithClientCredentials(baseURL, clientID, clientSecret, omadacID, httpClient)
client, err := sdk.NewWithAccessToken(baseURL, accessToken, httpClient)
baseURLis the controller's Open API base URL, e.g."https://192.168.1.1:8043".httpClientmay benilfor a default, certificate-verifying client, orsdk.NewInsecureHTTPClient()for a self-signed local controller. You can also pass your own*http.Clientfor custom TLS, proxies, or timeouts.- Both constructors wrap whatever
*http.Clientyou pass with a response-normalizing transport (see Error handling below) — you don't need to do anything for that to take effect.
See authentication.md for the tradeoffs between the two and the
non-standard Authorization header scheme the controller requires.
The fluent request builder pattern
Every controller endpoint is reachable as a chain of method calls starting from the
client, mirroring the URL path. Path segments become methods; path parameters
(the {omadacId}, {siteId}, etc. in the OpenAPI paths) become ByXxx(id) methods
that take the ID as an argument. The chain ends with an HTTP-verb method —
Get, Post, Patch, Put, or Delete — that actually issues the request.
// GET /openapi/v1/{omadacId}/sites/{siteId}/devices
client.Openapi().V1().ByOmadacId(omadacID).Sites().BySiteId(siteID).Devices().Get(ctx, config)
Reading that chain against the path: Openapi() → /openapi, V1() → /v1,
ByOmadacId(omadacID) → /{omadacId}, Sites() → /sites,
BySiteId(siteID) → /{siteId}, Devices() → /devices. This mapping is completely
mechanical, so if you know the REST path from the
Omada Open API documentation
you can find its builder by walking the path segment by segment through your editor's
autocomplete on client.Openapi().V1()... — no need to search the generated source by
hand. Nested/related operations hang off the same builder as sibling methods, e.g.
Sites() also exposes .Copy(), .Maintenance(), .MultiImport(), .Statistic(),
.Tags(), and .Template() for /sites/copy, /sites/maintenance, etc.
Every verb method has the same shape:
func (m *XxxRequestBuilder) Get(
ctx context.Context,
requestConfiguration *XxxRequestBuilderGetRequestConfiguration,
) (models.SomeResponseable, error)
requestConfiguration may be nil if the endpoint takes no query parameters, headers,
or options. Where it exists, it wraps:
QueryParameters— a struct specific to that endpoint, e.g.Page,PageSize,SearchKey,SortsNamefor a listing endpoint. All fields are pointers; only set the ones you need. Watch for endpoints where the controller treats a QueryParameters field as effectively required even though the Go type makes it optional — e.g.page/pageSizeon the devices listing endpoint. Omitting them compiles fine but the controller responds with a plain "Bad Request" instead of its usual structured error envelope (a documented, unfixed limitation — seebdd/features/devices.feature's@known-limitationscenario).Headers/Options— request-level overrides; rarely needed for normal use.
Endpoints that take a body (Post, Patch, Put) take it as an explicit typed
parameter before requestConfiguration, e.g.
Sites().Post(ctx, createSiteEntity, config).
Query parameters and pagination
Listing endpoints follow a consistent shape: Page and PageSize (both *int32,
page numbers start at 1), often SearchKey for a fuzzy match, and SortsXxx fields
(*string, "asc"/"desc") per sortable column.
page, pageSize := int32(1), int32(50)
resp, err := client.Openapi().V1().ByOmadacId(omadacID).Sites().BySiteId(siteID).Devices().
Get(ctx, &openapi.V1ItemSitesItemDevicesRequestBuilderGetRequestConfiguration{
QueryParameters: &openapi.V1ItemSitesItemDevicesRequestBuilderGetQueryParameters{
Page: &page,
PageSize: &pageSize,
},
})
The response's Result carries pagination metadata alongside the page of data — see
Reading a response. A simple loop to walk every page:
for page := int32(1); ; page++ {
resp, err := client.Openapi().V1().ByOmadacId(omadacID).Sites().Get(ctx,
&openapi.V1ItemSitesRequestBuilderGetRequestConfiguration{
QueryParameters: &openapi.V1ItemSitesRequestBuilderGetQueryParameters{
Page: &page, PageSize: &pageSize,
},
})
if err != nil {
return err
}
if resp.GetErrorCode() != nil && *resp.GetErrorCode() != 0 {
return fmt.Errorf("controller error %d: %s", *resp.GetErrorCode(), *resp.GetMsg())
}
data := resp.GetResult().GetData()
if len(data) == 0 {
break // no more pages
}
for _, site := range data {
// use site
}
}
Reading a response
Every operation returns two things: a generated response object and a Go error. The
error return is a transport-level failure only — DNS, connection refused, TLS,
context cancellation, or a non-2xx status the SDK couldn't map to a known envelope. A
nil error does not mean the call succeeded at the application level.
The Omada Open API wraps every response — success or failure — in the same envelope:
{"errorCode": 0, "msg": "Success.", "result": { /* endpoint-specific payload, or absent */ }}
Generated response types (named OperationResponseXxxable, e.g.
OperationResponseGridVOSiteSummaryInfoable) mirror this exactly with three getters:
GetErrorCode() *int32—0(or the envelope's absence, e.g.Postcalls with no body describing a result use a genericOperationResponseable) means success; non-zero is a controller-side error.GetMsg() *string— human-readable message, always populated on error.GetResult() Xxxable— the typed payload. Endpoint-specific; for listing endpoints this is aGridVOXxxablewithGetData() []Xxxable,GetCurrentPage() *int32,GetCurrentSize() *int32, andGetTotalRows() *int64.
Always check both, in this order:
resp, err := client.Openapi().V1().ByOmadacId(omadacID).Sites().Get(ctx, config)
if err != nil {
return fmt.Errorf("listing sites: %w", err) // transport failure
}
if resp == nil {
return errors.New("no response") // see note below
}
if code := resp.GetErrorCode(); code == nil || *code != 0 {
msg := ""
if resp.GetMsg() != nil {
msg = *resp.GetMsg()
}
return fmt.Errorf("controller rejected the request: %s", msg)
}
sites := resp.GetResult().GetData() // safe: errorCode == 0 implies a result
All model fields are pointers (Kiota's convention for distinguishing "absent" from
"zero value" in JSON). Nil-check before dereferencing — GetMsg(), GetSiteId(),
etc. can all be nil, particularly on error paths or optional fields. GetResult()
and GetData() return nil/an empty slice rather than panicking when there's nothing
there, so len(resp.GetResult().GetData()) is safe even on an empty page — but only
call GetResult() on a response you've already confirmed has errorCode == 0,
since some non-success envelopes don't carry a result at all.
Error handling
This SDK's client.go fixes two inconsistencies observed in the live controller's
error responses, so that every controller-side error reaches you through the normal
resp.GetErrorCode()/resp.GetMsg() envelope described above, rather than as a
missing result or an opaque transport error:
- Some error responses omit
Content-Typeentirely, which would otherwise make the generated adapter silently return(nil, nil). - Some error responses (observed for a missing
Authorizationheader) wrap the usual{errorCode, msg}envelope in a non-2xx HTTP status instead of the200every other controller error uses, which would otherwise surface as a generic "unexpected status code"errorwith the real reason discarded.
Practical implications for your error handling:
- Prefer checking
resp.GetErrorCode()over inspectingerr's text — the envelope is the controller's structured, documented error channel;erris reserved for cases the envelope mechanism can't represent (network failures, or the one documented limitation where a required-but-technically-optional query parameter is omitted and the controller returns a bare, envelope-free "Bad Request"). - An empty access token fails locally, before any request is sent, as
auth.ErrEmptyToken— check witherrors.Is(err, auth.ErrEmptyToken)if you need to distinguish "never authenticated" from a real transport failure. - Sending the standard
Authorization: Bearer <token>scheme is not a mistake you'll make directly (the SDK'sauth.AuthenticationProvideralways sendsAuthorization: AccessToken=<token>), but if you ever seeerrorCode -44112("access token has expired") on a token you know is fresh, that misleading message is the controller's response to the wrong header scheme — see Authentication & TLS.
Building request bodies
Post/Patch/Put bodies are generated structs constructed with a NewXxx()
constructor and populated via SetXxx(*T) setters (mirroring the GetXxx() getters on
response models — pointers throughout, so wrap literals with sdk helpers like
&value or a local variable):
site := models.NewCreateSiteEntity()
name := "Branch Office 12"
timezone := "America/New_York"
site.SetName(&name)
site.SetTimeZone(&timezone)
resp, err := client.Openapi().V1().ByOmadacId(omadacID).Sites().Post(ctx, site, nil)
Required vs. optional fields aren't enforced by the Go type system (everything is a
pointer); consult the field's doc comment in the generated model
(models/create_site_entity.go in this example) or the Omada Open API reference for
which fields the controller actually requires.
Worked example: list sites, then that site's devices
package main
import (
"context"
"fmt"
"log"
sdk "github.com/NerdIT-Tech/tplink-omada-sdk-for-go"
"github.com/NerdIT-Tech/tplink-omada-sdk-for-go/openapi"
)
func main() {
ctx := context.Background()
client, err := sdk.NewWithClientCredentials(
"https://192.168.1.1:8043",
"<clientID>", "<clientSecret>", "<omadacID>",
sdk.NewInsecureHTTPClient(),
)
if err != nil {
log.Fatal(err)
}
page, pageSize := int32(1), int32(10)
sitesResp, err := client.Openapi().V1().ByOmadacId("<omadacID>").Sites().
Get(ctx, &openapi.V1ItemSitesRequestBuilderGetRequestConfiguration{
QueryParameters: &openapi.V1ItemSitesRequestBuilderGetQueryParameters{
Page: &page, PageSize: &pageSize,
},
})
if err != nil {
log.Fatalf("listing sites: %v", err)
}
if code := sitesResp.GetErrorCode(); code == nil || *code != 0 {
log.Fatalf("controller rejected the request: %v", sitesResp.GetMsg())
}
sites := sitesResp.GetResult().GetData()
if len(sites) == 0 {
log.Fatal("no sites managed by this controller")
}
siteID := *sites[0].GetSiteId()
devicesResp, err := client.Openapi().V1().ByOmadacId("<omadacID>").
Sites().BySiteId(siteID).Devices().
Get(ctx, &openapi.V1ItemSitesItemDevicesRequestBuilderGetRequestConfiguration{
QueryParameters: &openapi.V1ItemSitesItemDevicesRequestBuilderGetQueryParameters{
Page: &page, PageSize: &pageSize,
},
})
if err != nil {
log.Fatalf("listing devices: %v", err)
}
if code := devicesResp.GetErrorCode(); code == nil || *code != 0 {
log.Fatalf("controller rejected the request: %v", devicesResp.GetMsg())
}
for _, d := range devicesResp.GetResult().GetData() {
name, mac, status := "", "", int32(0)
if d.GetName() != nil {
name = *d.GetName()
}
if d.GetMac() != nil {
mac = *d.GetMac()
}
if d.GetStatus() != nil {
status = *d.GetStatus()
}
fmt.Printf("%s (%s) status=%d\n", name, mac, status)
}
}
(Device status is an enum: 0 disconnected, 1 connected, 2 pending, 3
heartbeat missed, 4 isolated — see the doc comment on DeviceInfo.GetStatus in
models/device_info.go.)
Finding the builder and model for an endpoint you need
With 2,118 generated files under openapi/ there's no index worth hand-maintaining;
instead, navigate the same way the fluent chain does:
- Start typing
client.Openapi().V1().ByOmadacId(omadacID).in an editor with Go completion and let autocomplete list the available top-level resources (Sites(),Devices()action groups underDevices/Clients, etc.). - Chain further; each method name matches its OpenAPI path segment (camelCased). Verb
methods (
Get/Post/...) appear once you're at a leaf resource. - If you know the REST path already (from the Omada Open API reference PDF or
omada-open-api-sec.json), its builder file is named by concatenating the path segments, e.g./openapi/v1/{omadacId}/sites/{siteId}/devices→openapi/v1_item_sites_item_devices_request_builder.go(itemmarks each{...}path parameter). - Request/response model types live in
models/, named after the OpenAPI schema (snake_cased filename, e.g.SiteSummaryInfo→models/site_summary_info.go). Every model typeXxxhas a matchingXxxableinterface — that's what request builder signatures use. go docworks against generated code same as hand-written: e.g.go doc ./openapi V1ItemSitesRequestBuilderorgo doc ./models SiteSummaryInfo.
Testing your integration
See Testing against a live controller for running the
bdd/ Godog suite against a live controller, and client_test.go for unit tests
covering the response-normalization transport and auth edge cases in isolation (using
httptest, no live controller needed) — both are good references for exercising your
own code against this SDK without a real Omada deployment on hand.