tuya
2026 — A Go library that lets any Go program read and control Tuya smart devices, for itself or for each of its users
01 Problem
Tuya is a cloud platform that many smart device brands build on, and its API, the interface a program calls, lets software read and switch those devices. To use it, a developer makes a cloud project on Tuya, which comes with an Access ID and an Access Secret. Every app that talks to Tuya then has to set up the same things before its first command. It has to sign every request with that secret, fetch an access token, a short-lived key, and fetch a new one when Tuya stops accepting it, and send everything to the data center, the regional server, where the project lives. An app with its own users also has to remember which Tuya account belongs to which of them.
I had this issue when I was working on Zee, an AI agent for the smart home, and Avagenc Chat, the group chat it lives in, at Avagenc. Between September 2025 and May 2026 I wrote the same signing and token code four times there, in a token API, an IoT API, and two versions of Zee, and each copy sat in the middle of code that was about something else.
02 Solution
go.trueardian.com/tuya is an open source Go library that does that setup once, a library being code other programs import rather than a program that runs on its own. I started it on 18 June 2026 on Go's standard library alone, without a Tuya SDK underneath. On 7 August 2026 it dropped the assumption that its caller is an AI agent, since an HTTP backend, a batch job, or a command line tool needs the same thing.
It has two layers. The tuya package is the client, for any program with a cloud project, even one with no users at all. The appaccount package sits on top of it for apps with their own users and remembers which Tuya account each user linked, in Postgres or Firestore. An app hands the client its Access ID, its Access Secret, and its data center, and keeps its own code about what it does.
client, err := tuya.New(accessID, accessSecret, "https://openapi-sg.iotbing.com")
devices, err := client.UserDevices(ctx, tuyaUID)
status, err := client.DeviceStatus(ctx, deviceID)
err = client.SendCommands(ctx, deviceID, []tuya.DataPoint{{Code: "switch_1", Value: true}})03 Signing requests
Tuya has to know that a request really comes from the project, so every request carries a signature, a code computed from the request and the Access Secret that only someone holding the secret can produce. Tuya computes it again on its side and refuses the request as 1004, sign invalid, when the two differ.
The string to sign is the method, the SHA-256 of the body, an empty line, and the path with its query. In front of it go the Access ID, the access token, the time in milliseconds, and a nonce, a random value used once. The HMAC-SHA256 of all of it, keyed with the Access Secret, goes out in upper-case hex as the sign header. The token request is signed the same way with an empty token.
Two details were found against the live API. On 7 August 2026 it turned out that Tuya sorts the query before it checks the signature, so an unsorted query fails as 1004, and a test now keeps the client's query sorted. On 17 August every call naming more than one ID failed as 1004 in the Singapore data center, because Tuya checks the query after decoding it and the client had escaped the commas between IDs as %2C. The client now leaves those commas bare.
func hmacSign(accessID, accessSecret, accessToken, method, path string, body []byte) (*signature, error) {
timestamp := strconv.FormatInt(time.Now().UnixNano()/int64(time.Millisecond), 10)
hash := sha256.New()
hash.Write(body)
contentSha256 := hex.EncodeToString(hash.Sum(nil))
stringToSign := method + "\n" + contentSha256 + "\n\n" + path
nonceBytes := make([]byte, 16)
if _, err := rand.Read(nonceBytes); err != nil {
return nil, fmt.Errorf("generate nonce: %w", err)
}
nonce := hex.EncodeToString(nonceBytes)
tuyaStr := accessID + accessToken + timestamp + nonce + stringToSign
mac := hmac.New(sha256.New, []byte(accessSecret))
mac.Write([]byte(tuyaStr))
sign := strings.ToUpper(hex.EncodeToString(mac.Sum(nil)))
return &signature{
Sign: sign,
Timestamp: timestamp,
Nonce: nonce,
SignMethod: "HMAC-SHA256",
AccessToken: accessToken,
}, nil
}04 Managing access tokens
The access token belongs to the whole project, not to one user. New fetches the first one straight away, so a wrong Access ID or Secret fails at startup instead of on the first device call, and every call after that reuses it.
The client never asks a clock whether the token is still good. When Tuya answers 1010, its code for a token it no longer accepts, the client fetches a new token and sends the same request again with a fresh signature. A second 1010 comes back as the error instead of another try.
Both rules came from fixes. Until 6 August 2026 the retry sent the same token Tuya had just refused, because the client trusted the expiry it had cached, so the retry now forces a new token. On 15 August a check of the expiry time before each request was added and removed the same day, because Tuya refuses tokens that the client's own clock still calls valid, so only Tuya decides when a token is dead.
const tokenExpiredTuyaErrorCode = 1010
if tuyaResp.Code == tokenExpiredTuyaErrorCode && attempt == 0 {
if err := c.refreshToken(ctx); err != nil {
return nil, fmt.Errorf("refresh token after error %d: %w", tuyaResp.Code, err)
}
continue
}
return nil, fmt.Errorf("request to %s: %w", fullURL, &APIError{Code: tuyaResp.Code, Msg: tuyaResp.Msg})05 Controlling devices
UserDevices lists the devices of one Tuya account, DeviceStatus and DeviceProperties read one device, and SendCommands sets its data points. A data point is one thing a device reports or is told, a code and a value like switch_1 and true.
A switch with several gangs has one data point per gang, and the names people gave each gang sit behind a separate endpoint. WithChannelNames fetches them for devices whose category can have more than one channel, all at the same time, and a device that fails is reported next to the others instead of stopping them. Since 16 August 2026 it is an option on each call, so the extra request per device shows where it is asked for.
Tuya's name field means the name a person gave the device in the list of a user's devices and the factory name in the list of a space's devices, which was checked against the live API on 8 August 2026. So the two lists return two different types, UserDevice and SpaceDevice.
An endpoint that is not wrapped yet goes through Do, the same signed request one level down, which hands back the result as raw JSON.
devices, err := client.UserDevices(ctx, tuyaUID, tuya.WithChannelNames())
properties, err := client.DeviceProperties(ctx, deviceID, nil)
raw, err := client.Do(ctx, http.MethodGet, "/v1.0/devices/"+deviceID, nil)06 Managing spaces
Spaces are a tree of places in the cloud project, each with a parent and a root, for products where devices belong to a place, like a hotel room, rather than to a person.
The client creates, reads, renames and deletes spaces, lists the children and resources of one, asks whether one space contains another, and pages through the devices in a set of spaces.
Tuya confirms a rename or a delete with true, so a false or missing result comes back as ErrNotApplied instead of being taken as done. SpaceHasDevice reads resources 200 at a time and gives up with an error after 50 pages instead of paging forever.
hotel, err := client.CreateSpace(ctx, "Hotel", 0, "")
room, err := client.CreateSpace(ctx, "Room 1", hotel, "twin")
inside, err := client.SpaceHasDevice(ctx, room, deviceID)07 Linking user accounts
An app with its own users lets each of them link their Tuya account, the one they use in a Tuya app, and Tuya knows that account by its UID. So the app holds one Tuya UID per user. The appaccount package stores the app's user, the owner, against that UID, looks it up again on every call, and hands the call to the client, so the app talks to Tuya in its own IDs.
An owner with no link, or a link with an empty UID, comes back as ErrNotLinked before anything is sent to Tuya.
Calls on one device need no owner, because a device ID already means something to Tuya. HasDevice says whether a device is in the owner's own list and blocks nothing, since a device someone shared with them is missing from that list even though they may use it.
The homes of a Tuya account live in Tuya's older asset system, and every space endpoint refuses their IDs with 40001900, as a probe of the live API showed on 15 August 2026. So appaccount works with devices and not with spaces.
The link lives in a store. The postgres store takes anything that can Exec and Query, so a pgx pool or a single connection both fit. WithAutoMigrate creates the tuya_app_accounts table at startup, and without it the store only checks that the table is there and fails at startup if it is not. The firestore store keeps one document per owner in a tuya_app_accounts collection, or in one named with WithCollection. In both, Link replaces the UID of an owner already on file, and Unlink really deletes the record. Writing another store means three methods, Get, Link, and Unlink, and answering ErrNotLinked for an owner with no link.
store, err := postgres.NewAppAccountStore(ctx, pool, postgres.WithAutoMigrate())
app := appaccount.NewService(client, store)
acc, err := app.Link(ctx, owner, tuyaUID)
devices, err := app.Devices(ctx, owner)
if errors.Is(err, appaccount.ErrNotLinked) {
return err
}
owned, err := app.HasDevice(ctx, owner, deviceID)
err = app.SendCommands(ctx, deviceID, []tuya.DataPoint{{Code: "switch_1", Value: true}})08 Error handling
Tuya answers a failed request with a numeric code and a message. The client handles 1010 itself and returns every other code as a *tuya.APIError, found with errors.As, for the caller to decide on.
The codes met while building it include 1004 for a signature Tuya does not accept, 28841107 for a project called through the wrong data center, which still hands out a token and then refuses every call, and 40001900 for a space the project cannot see. The library's own errors are ErrNotLinked, for an owner with no linked account, and ErrNotApplied, for a rename or a delete Tuya did not confirm.
devices, err := app.Devices(ctx, owner)
var apiErr *tuya.APIError
switch {
case errors.Is(err, appaccount.ErrNotLinked):
return askToLink(owner)
case errors.As(err, &apiErr) && apiErr.Code == 28841107:
return fmt.Errorf("check the data center URL: %w", err)
}09 Limits
It does not run Tuya's account authorization flow, so Link assumes the app already got the Tuya UID from somewhere.
Linking a person's own Tuya account to a cloud project needs a cloud partnership with Tuya, and that applies to every app that uses the library. Without one, each account has to be linked by hand.
It wraps the devices and spaces a project needs most, not all of Tuya, and Do reaches the rest. It never decides who may use a device, and it caches no answer.
A way to give an owner a place instead of an account, built on Tuya's spatial model, was added on 7 August 2026 and removed on 16 August to be designed again, and it is on the roadmap.
10 Use cases
go.trueardian.com/agentkit, an open source library that turns services into tools for Google's Agent Development Kit, gives an agent four tools on top of this library, get_account, list_devices, device_status, and send_commands, each using the agent session's user as the owner. list_devices asks for channel names, so the model can match the name a person gave a gang to its switch code.
Zee, an AI agent for the smart home, uses those tools, and Avagenc Chat, the group chat Zee lives in, keeps each person's linked Tuya account in Firestore through the library.
The smart hospitality platform behind Undisclosed Apartment, an apartment tower still under construction, reaches the building's devices through the library and arranges its places as Tuya spaces. Its demo API first reached real hardware in the Singapore data center on 8 August 2026, once it used the address openapi-sg.iotbing.com, because openapi.tuyasg.com does not resolve.
import tuyaadk "go.trueardian.com/agentkit/tuya/adk"
tools, err := tuyaadk.Tools(app)tuya
Any Go app reads and controls Tuya devices with one import, with signing, access tokens, and each user's linked account already handled