Everything zlt knows about the router's undocumented JSON API. Derived from
the device's own served JavaScript (/js/service.js, /js/util.js,
/js/config/ufi/config.js) and confirmed against a live device.
This is the reference to read if you are porting zlt to different firmware or
debugging why a call behaves differently on your hardware. For installing and
using zlt, see the README.
All of it lives behind one class, ZltClient in zlt/client.py. The CLI and
web layers never re-implement any of it.
Built and live-verified against one device: the MTN ZLT T10D MAX, a ZTE
NV8645 CPE (cr_version: CPE_NV8645_230A_E_QX_CAN-P42U17-20250703,
DEVICE: "ufi" in its own config.js).
The reqproc API, the goformId=LOGIN nonce-salted SHA-256 password scheme,
and the CSRFToken/get_token mechanism are shared across a wider family of
ZTE firmware used in many rebranded 4G/LTE CPE and MiFi routers, so zlt will
likely connect, log in, and read status on similar devices with little or no
change.
Do not assume the field-level details carry over:
- The network-mode key is not consistent even within this device family. See
Reading back the configured mode below,
where this device needed
net_selectrather than the key its own JS suggested. - Session handling differs by build. This device authenticates via a
randomcookie. Other ZTE variants (some Safaricom-branded ZTE M30S Pro units, per community documentation) bind the session to the client's IP with no cookie at all. Verify which model applies before assuming the cookie logic works as-is. BearerPreferencevalues, status key names, and lockout thresholds may vary by firmware version even on nominally the same hardware.
Start with the read-only commands (zlt status, zlt get <cmd>) before
zlt login. They need no authentication and will quickly show whether the API
shape matches.
When porting, the device-specific constants and the _classify_flag /
_read_ussd_data / _decode_* / _sms_* helpers at the top of client.py are
the single point of change.
- Reads:
GET /reqproc/proc_get?isTest=false[&multi_data=1]&cmd=<comma,separated,keys>→ JSON. Response keys echo the requestedcmdnames.multi_data=1is sent whenever more than onecmdis requested. - Writes:
POST /reqproc/proc_post, bodyisTest=false&goformId=<ACTION>&...&CSRFToken=<token>,Content-Type: application/x-www-form-urlencoded; charset=UTF-8. - Headers replicated from the web UI:
Referer: <host>/index.html,X-Requested-With: XMLHttpRequest. - Session: carried by a cookie named
random, set by a successfulLOGINPOST. Stored server-side per-cookie, not IP-bound: an unauthenticated request from the same machine gets empty/unauthenticated results even while the web UI is independently logged in; only presenting the actual session cookie authenticates.
1. GET proc_get?isTest=false&cmd=get_random_login -> {"random_login": "<nonce>"}
2. username = Base64( plaintext_username )
password = Base64( sha256_hex( random_login + plaintext_password ) )
token = GET proc_get?isTest=false&cmd=get_token (raw value; empty is valid pre-login)
3. POST proc_post:
isTest=false
goformId=LOGIN
username=<base64>
password=<base64>
CSRFToken=<token or empty>
sha256_hexis a lowercase hex digest; the whole hex string is then Base64-encoded (not the raw digest bytes).- Success:
result == "0"(fresh login) orresult == "4"(already logged in). Either counts as authenticated and the session cookie is cached. - Any other
resultis a rejected login (wrong password, etc.) and raisesLoginError.
GET proc_get?isTest=false&cmd=get_token -> {"token": "<value>"} (or {"get_token": "<value>"})
- Used raw as the
CSRFTokenfield on every POST (no hashing). - Empty (
"") before login is valid and accepted for theLOGINPOST itself; a non-empty value appears once a session cookie is presented, and is fetched fresh before every subsequent write.
Write: POST goformId=SET_BEARER_PREFERENCE&BearerPreference=<value>, success is
result == "success".
| CLI mode | BearerPreference value |
Web UI label |
|---|---|---|
auto |
NETWORK_auto |
Automatic |
lte, 4g |
Only_LTE |
4G Only |
4g3g |
TD_W_LTE |
4G/3G Only |
wcdma, 3g |
TD_W |
3G Only |
gsm, 2g |
Only_GSM |
2G Only |
All five values are live-verified against the real device (not just read from config JS).
Note wcdma/3g maps to TD_W, not Only_WCDMA.
An important corrected finding. The web UI's own JS reads a batch of keys to
display the configured mode: current_network_mode, net_select_mode, m_netselect_save, m_netselect_contents, net_select, ppp_status, modem_main_state. On this device/firmware, most of those come back empty even
when authenticated. net_select_mode and m_netselect_save are not
reliable. The key that actually holds the configured preference on this hardware
is net_select (e.g. net_select: "NETWORK_auto").
zlt net get / zlt net set query NET_KEYS = ["current_network_mode", "net_select_mode", "m_netselect_save", "net_select"] and resolve the configured value
with net_select checked first, falling back to net_select_mode then
m_netselect_save only if net_select is empty (for forward-compatibility with other
firmware builds). If you're porting this to a different ZTE/ZLT firmware, verify which
of these keys is actually populated on your device before trusting the fallback order.
- Open (no login required):
network_type(LTE/WCDMA/GSM),rssi(dBm),signalbar(0-5),lte_rsrq(dB),lte_pci,ppp_status. - Auth-only (empty until logged in):
lte_rsrp(dBm),lte_band,lte_snr(dB). zlt statusrequests the open set unconditionally, and additionally requests the auth-only set (attempting a login first), falling back to the open-only view with a note if there's no password configured or login fails.
Derived from the device's sendSMS / getSMSMessages / getSmsStatusInfo in
js/service.js and getCurrentTimeString / encodeMessage / getEncodeType in
js/util.js, then confirmed against the live device. All of it needs a session.
Read the inbox (cmd=sms_data_total, with its own query parameters
alongside cmd):
GET /reqproc/proc_get?isTest=false&cmd=sms_data_total&page=0
&data_per_page=500&mem_store=1&tags=10&order_by=order by id desc
→ {"messages": [{"id","number","content","tag","date","draft_group_id"}, ...]}contentis UCS2 hex (UTF-16BE, 4 hex digits per unit). NUL padding is stripped.numberis plain text, not hex ("121","MTNN").tag"1"is an unread inbox message;"2"/"3"/"4"are the outgoing folders.datecame back comma-separated on this device (26,07,24,15,28,20,+4). The stock UI's own parser also accepts semicolons, so both are handled.data_per_pageis advisory: asked for 3, the device returned 10. The limit is applied again client-side.
Send (costs money; confirmed by sending a real message):
POST /reqproc/proc_post
goformId=SEND_SMS & Number=<plain> & sms_time=<YY;MM;DD;HH;MM;SS;+TZ>
& MessageBody=<UCS2 hex> & ID=-1 & encode_type=<GSM7_default|UNICODE>then poll until the network answers:
GET /reqproc/proc_get?isTest=false&cmd=sms_cmd_status_info&sms_cmd=4
→ sms_cmd_status_result: "3" sent, "2" failed, anything else keep waitingencode_typeisGSM7_defaultwhen every character is in the GSM 03.38 basic set (lifted verbatim from the device'sGSM7_Table), elseUNICODE. This decides the message's cost: 160 characters per part versus 70. Note thatéis in the GSM7 set; it takes a genuinely foreign character to forceUNICODE.MessageBodyis always UCS2 hex regardless ofencode_type.- Idle slot quirk:
sms_cmd_status_infoanswers{"messages": []}, with no status key at all, when nothing is queued on that slot. A missing status is treated as pending, so a slow send is not misreported as a failure.
Mark read and delete (from the device's own setSmsRead and deleteMessage):
POST /reqproc/proc_post
goformId=SET_MSG_READ & msg_id=<659;658;> & tag=0
→ result: "success", answered immediately
POST /reqproc/proc_post
goformId=DELETE_SMS & msg_id=<659;658;>
→ result: "success", then poll sms_cmd_status_info with sms_cmd=6msg_idis the ids joined with;and a trailing;. Both device functions build it that way.tag=0is read. The device offers no way back to unread.- Delete confirms on the same status field and the same
"3"/"2"codes as send, differing only in thesms_cmdslot:6for delete,4for send. The two share one poll here rather than growing a second copy. ALL_DELETE_SMSalso exists on the device and is deliberately unused: Select all plus Delete clears the inbox without a second, blunter code path.- An id carrying a
;would widen the operation to messages the caller never picked, so ids are rejected rather than sanitised.
sms_unread_num is deliberately unused. It was observed reporting 0 while
the inbox still held 29 rows tagged unread. The unread count is derived from the
rows instead, so the dashboard badge cannot disagree with the list beneath it.
psw_fail_num_str: attempts remaining before lockout (not a failure counter). Empty response defaults to5(MAX_LOGIN_COUNT).login_lock_time: lockout duration in seconds once attempts are exhausted. Empty response defaults to300.- Guard: before any login attempt,
zltreads both keys and refuses to proceed (LockedOut) if attempts remaining< 2, printing the state and pointing at the web UI to reset. No password is ever guessed or retried blindly: the encoding is exact, so a correct login succeeds on the first try.
zlt post / net set first ensure a session (ensure_session(): log in only if the
current get_token comes back empty). If a subsequent write's result matches a
best-effort marker set (no_session, session_error, need_login, not_login, -1),
the client re-logs in once and retries the write; a second failure raises. These markers
are a backstop only. The primary "am I authenticated" check is always
token() != "".