Calling an ATOP Interface Without a Named Wrapper

The HTTP services between a device and the Tuya cloud are collectively called ATOP interfaces. Each is uniquely identified by two fields: its api name and version, for example, tuya.device.upgrade.get v4.4.

The SDK provides named wrappers for some of these interfaces (iot_ota_*, iot_dp_*, and others), returning typed structs. The cloud exposes far more callable ATOP interfaces than the SDK wraps. To prevent SDK scheduling from blocking application development, the SDK provides the generic call iot_atop_call(): supply an api, version, and JSON request body, and receive the result field as a JSON string.

Signing, AES-GCM request-body encryption, TLS, host resolution, and Envelope parsing all happen inside the SDK. Device keys never leave the SDK.

First Check for a Named Wrapper​

Do not reimplement an interface through the generic call when it already has a named wrapper. Named wrappers additionally handle cloud response variants and have unit-test coverage:

ATOP apiversionNamed wrapper
tuya.device.upgrade.get4.4iot_ota_check_upgrade()
tuya.device.versions.update4.1iot_ota_report_version()
tuya.device.upgrade.status.update4.1iot_ota_report_status()
tuya.device.schema.newest.get1.0Queried automatically inside the DP layer
thing.ai.agent.token.get1.0iot_client_get_session_token()
tuya.device.qrcode.info.get1.1iot_get_qrcode_info()
thing.device.opensdk.active2.0iot_client_init_on_boarding()
tuya.device.meta.save1.0Called internally during Activation
tuya.device.info.sync1.0iot_get_binding_status(), binding status query without a client

iot_get_binding_status(&request, &status) returns IOT_BINDING_STATUS_BOUND (still bound), IOT_BINDING_STATUS_UNBOUND (binding removed), or IOT_BINDING_STATUS_FACTORY_RESET (factory reset requested). Read status only after OPRT_OK. An absent or unknown cloud status returns OPRT_INVALID_RESULT; cloud rejections and transport failures return their own error codes. Call it from the application loop because the HTTPS request blocks. The application owns credential erasure and provisioning after removal.

Call iot_init() or iot_init_default() first, then supply the saved devid and secret_key. No iot_client_t, MQTT connection, or local_key is required, so the query remains available when client initialization fails:

iot_binding_status_request_t request = {
.devid = saved_devid,
.secret_key = saved_secret_key,
.region = AY,
.env = PROD,
.cacert = ca_certificate,
};
iot_binding_status_t status;
int rc = iot_get_binding_status(&request, &status);

The query uses the region's ATOP hostname directly without an IoT-DNS lookup. Set host (a hostname without a scheme) and port to override it. With an existing client, iot_client_get_binding_status(client, &status) reuses its resolved endpoint and TLS settings. If first activation has not returned device credentials, uuid / authkey alone cannot call this interface.

When to Request a Named Wrapper​

The generic call is an escape hatch, not the preferred option. If an interface meets any of the following criteria, it is worth adding to the SDK as a named wrapper; issues are welcome:

  • Reused across products — two or more product lines need it. Otherwise, adding it to the SDK amounts to putting one product's application logic in the SDK.
  • Non-trivial protocol semantics — it has a state machine, multi-step sequencing, or cloud response shapes that require tolerant parsing. Reimplementing this knowledge inevitably creates inconsistencies.
  • Must participate in SDK internal state — it needs to change fields in iot_client_t or trigger SDK callbacks. The application layer cannot access internal state, so this can only be implemented inside the SDK.

When none of these criteria apply, keeping the interface in the application layer and invoking it through the generic call is an appropriate final design, not technical debt.

API​

#include "iot_atop.h"

typedef struct {
const char *api; /* Example: "tuya.device.upgrade.get"; required */
const char *version; /* Example: "4.4"; required */
const char *data; /* Request body as a JSON object string; NULL or "" means "{}" */
} iot_atop_request_t;

typedef struct {
char *result; /* JSON text representing the result field; NULL if the cloud omitted it */
char error_code[48]; /* Cloud errorCode; "" on success */
char error_msg[128]; /* Cloud errorMsg; "" on success */
int32_t server_time; /* Server time t from the Envelope */
} iot_atop_response_t;

int iot_atop_call(iot_client_t *client,
const iot_atop_request_t *request,
iot_atop_response_t *response);
void iot_atop_response_free(iot_client_t *client, iot_atop_response_t *response);

Example​

#include "iot_atop.h"

uint32_t timestamp = (uint32_t)(client->pal->wal_clock_time() / 1000U);
char body[192];
snprintf(body, sizeof(body),
"{\"schemaId\":\"%s\",\"version\":\"\",\"t\":%u}",
schema_id, (unsigned)timestamp);

iot_atop_request_t req = { .api = "tuya.device.schema.newest.get",
.version = "1.0",
.data = body };
iot_atop_response_t resp = {0};

/* Error paths use the SDK's public log facade log_tag_* (callable from application
* code; same compile-time ceiling and dispatch as SDK logs) */
int rc = iot_atop_call(client, &req, &resp);
if (rc == OPRT_OK) {
if (resp.result != NULL) {
my_parse(resp.result); /* result is JSON text; use any parser */
}
} else if (rc == OPRT_ATOP_BUSINESS_ERROR) {
/* The request reached the cloud but was rejected — error_code explains why */
log_tag_error("app", "rejected: %s (%s)", resp.error_code, resp.error_msg);
} else {
/* Transport-layer failure: DNS / TLS / HTTP / decryption */
log_tag_error("app", "call failed: %d", rc);
}

iot_atop_response_free(client, &resp); /* Call on every path, including failures */

Return Values: Distinguishing Cloud Rejection from Connection Failure​

This is the most important point when using the generic call. The SDK does not know the interface being invoked, so it cannot determine business success on your behalf. The cloud's own errorCode is the only reliable indicator:

Return valueMeaningHandling
OPRT_OKThe cloud accepted the callresult contains the result JSON, or is NULL if the cloud omitted result, which is valid
OPRT_ATOP_BUSINESS_ERRORThe request reached the cloud but the cloud rejected itInspect error_code / error_msg and handle them according to the interface documentation
OPRT_INVALID_PARAMETERThe parameters are invalid, or data is not a JSON objectRejected locally; no network request was sent
OPRT_UNINITIALIZEDThe device does not yet have Activation credentialsComplete Activation first
OtherTransport-layer failure (DNS / TLS / HTTP / decryption)May be retried

error_code[0] == '\0' is equivalent to "business success."

Limitations​

Only activated devices are supported. The generic call signs requests with devid + secret_key. Activation itself uses uuid + authkey, which is a separate path and must still go through iot_client_init_on_boarding(). Calling the generic call on a client without credentials returns OPRT_UNINITIALIZED.

A single response cannot exceed 4096 bytes, including HTTP headers. The status line, response headers, and encrypted response body share one fixed-size buffer (AGENTIC_KIT_RESPONSE_BUFFER_SIZE; default and tuning notes in modules/iot-client/include/iot_client_config_defaults.h), with no segmented continuation reads. On overflow, the underlying coreHTTP returns HTTPInsufficientMemory, which the SDK collapses to OPRT_COMMUNICATION_ERROR—the same return value as a broken socket. Because coreHTTP's own explanatory logging is compiled out in this project (HTTP_DO_NOT_USE_CUSTOM_CONFIG), the only field symptom is a single "transport-layer failure," and retrying will not help. After accounting for response headers, base64 expansion, and the ATOP envelope, the practical limit for decrypted JSON is approximately 2.8 KB. This is ample for interfaces that return fixed-length fields, but interfaces returning lists or a DP schema whose length grows with the product can easily exceed it. If you encounter such an interface, open an issue; the change that adds its named wrapper must also raise this constant.

The request body is passed through unchanged. The SDK does not rewrite it, so you must provide every field required by the interface—including the t timestamp field that most ATOP interfaces require in the request body. The SDK validates only that the body can be parsed as a JSON object. This turns a typo into an immediate OPRT_INVALID_PARAMETER rather than spending an HTTPS round trip to receive an ambiguous cloud rejection.

result is a string, not a cJSON object. This keeps cJSON out of the SDK's public ABI, avoids binding the application layer to the SDK's cJSON version, and makes memory ownership explicit: iot_atop_response_free() releases it.

The device clock must be reasonably accurate. The signature includes a timestamp, and the cloud rejects it when the device clock differs too much. resp.server_time is the time returned by the cloud, as a Unix timestamp in seconds, and can be used to correct the local clock.

© 2026 Tuya Inc.Tuya Physical AI · Agentic-kit