Wialon API Python Client That Works

Siarhei Havarunou – CEO

A modern Python client for the Wialon Remote API: login, search, live events with avl_evts — plus which libraries are alive and which died in 2013.

A minimal Wialon Remote API client in Python, with live events over avl_evts

Every few months someone asks which Python library to use for Wialon. The honest answer is uncomfortable: the two libraries with the most recognisable names — python-wialon and php-wialon on the wialon GitHub org — have been untouched since roughly 2013. They still work for basic calls, but they predate half the API surface and none of the operational knowledge. This guide gives you a minimal client you own, in about seventy lines, then the five behaviours that decide whether it survives contact with a real account, and finally an honest map of the library landscape.

The protocol, so the client makes sense

Wialon is form-encoded POST, not JSON REST: every call goes to wialon/ajax.html with an svc field naming the service, a params field carrying JSON, and a sid field once you hold a session. There are no paths, no verbs and no status codes to read — an HTTP 200 with {"error":7} in the body is how access denial arrives. (The full mechanics are on the Postman setup page.) That is the whole protocol, which is why a wrapper is a session object around requests and nothing more.

import requests

class Wialon:
    def __init__(self, host="https://hst-api.wialon.com", token=None):
        self.host = host
        self.base = host + "/wialon/ajax.html"
        self.sid = None
        self.token = token

    def call(self, svc, params=None, sid=True):
        data = {"svc": svc, "params": params or {}}
        if sid:
            data["sid"] = self.sid
        r = requests.post(self.base, data=data, timeout=30)
        r.raise_for_status()
        return r.json()

    def login(self):
        res = self.call("token/login", {"token": self.token}, sid=False)
        if "error" in res:
            raise RuntimeError(f"login failed: {res}")
        self.sid = res["eid"]
        return self.sid

    def events(self):
        # avl_evts is its own path, not an svc against ajax.html.
        r = requests.post(f"{self.host}/avl_evts", data={"sid": self.sid}, timeout=30)
        r.raise_for_status()
        return r.json()

Seventy lines is a feature, not a limitation: there is no magic to debug at 3 a.m. when a sync stalls. Generate the token in the Wialon UI with the narrowest access flags that cover your reads — the token page has the flag table — and keep it out of the code, in an environment variable.

Which host you are actually talking to

hst-api.wialon.com is the Hosting endpoint, and it is the right default only if your account lives on Wialon Hosting. A Wialon Local installation answers on the partner’s own domain, and a token minted against one host means nothing against the other — the session is issued by the server that holds the account. That is why host is a constructor argument above rather than a module constant: the day you add a second customer on a different deployment, the only thing that should change is the value you pass in.

Two related mistakes are worth naming now. The first is hardcoding hst-api.wialon.com in a client you intend to sell to partners, which quietly limits your product to one deployment. The second is treating the UI host and the API host as the same string; they are related but not interchangeable, and copying a browser URL into a client config produces a 404 that reads like a network fault.

Search first, message second

Two calls carry most integrations. core/search_items finds things; messages/load_interval reads their history:

w = Wialon(token="...")
w.login()

units = w.call("core/search_items", {
    "spec": {
        "itemsType": "avl_unit",
        "propName": "sys_name",
        "propValueMask": "*",
        "sortType": "sys_name",
    },
    "force": 1,
    "flags": 1,
    "from": 0,
    "to": 0,
})
unit_id = units["items"][0]["id"]

msgs = w.call("messages/load_interval", {
    "itemId": unit_id,
    "timeFrom": 1726000000,
    "timeTo": 1726086400,
    "flags": 0,
    "flagsMask": 0xFF00,
    "loadCount": 10000,
})

Four details in that first call decide what you get back. itemsType picks the catalogue — avl_unit for units, avl_resource for resources, avl_unit_group for groups, user for users. propName and propValueMask are the filter, and sys_name with * is the idiom for “everything I am allowed to see”. sortType gives you a stable order, which matters more than it looks: without it, paging through a large account can show you the same unit twice and miss another. And flags is not a boolean but a data-flag bitmask — 1 returns the base set (id, name, class, measure units), while sensors, counters and last position are separate bits, each costing response size. The units reference carries the full table; the rule to internalise is to request the narrowest mask that answers your question, because the difference between flag 1 and a greedy mask on a three-thousand-unit account is measured in megabytes per poll.

Paging is the second thing to get right. from and to are row indices, not timestamps, and the response tells you where you are: totalItemsCount alongside indexFrom and indexTo. Asking for 0 to 0 returns a single row, which is fine for the example above and wrong for production; walk the index in blocks of a few hundred and stop when indexTo reaches totalItemsCount.

Two traps live in the second call. First, flagsMask selects which message kinds come back and flags says which of them you want: the documented pair for data messages is mask 0xFF00 with flags 0x0000, and asking for everything on a busy unit returns megabytes you did not want. Second, loadCount caps the batch: loop with advancing timeFrom instead of raising it, or one long-lived unit will blow the five-minute report budget and hand you zero rows that read as a quiet week.

Live events without polling yourself to death

For near-real-time, do not poll load_interval on a timer. Subscribe the session with core/update_data_flags, then poll avl_evts — Wialon’s own event channel. Mind the address: avl_evts is not an svc against wialon/ajax.html but a path of its own, {host}/avl_evts?sid=<sid>, which is why the client needs the second method above rather than another call():

import time

w.call("core/update_data_flags", {
    "spec": [{"type": "type", "data": "avl_unit", "flags": 1, "mode": 0}],
})

while True:
    for e in w.events().get("events", []):
        handle(e)  # your code: queue it, do not process inline
    time.sleep(1)

The spec entry is worth reading closely. type says how you are selecting items — "type" subscribes to a whole class, and the alternative selects a specific collection of ids, which is what you want once the account is large enough that “every unit” is more than you need. flags is the same data-flag mask as in the search, so the events you receive carry only the fields you asked for. mode 0 replaces whatever the session was subscribed to, which makes resubscription after a reconnect a single idempotent call rather than a diff you have to compute.

Each event carries an item id, a type and a payload: a new message, an item update, a deletion. The response also carries the server’s own clock, and using it rather than your process’s clock removes a whole class of “the event arrived before it happened” bugs on machines with drifting time.

Two limits shape this loop, both published: no more than 10 avl_evts requests per 10 seconds per session, and ten simultaneous API requests per session in total. A one-second loop already spends the whole event budget, so this session should do nothing else — run extractions on a second session. And the loop above is deliberately thin: it moves events into a queue and returns to the poll. Processing inline is how integrations fall behind during busy hours and then hammer the API catching up — which is exactly the failure mode the resilient sync design article is about. If you need true push instead of a poll, read why Wialon has no webhooks before architecting around that absence.

The session dies quietly, on a clock

A Wialon session is not a bearer token with a long life. It is killed after five minutes without a request, which means an integration that runs one job an hour holds a dead session for fifty-five minutes of every sixty and discovers it at the worst moment. The event loop above never notices, because it polls every second; a nightly batch notices every night.

There are three defences and you want all of them. Keep the session warm if the process is long-lived — any cheap call inside the window will do, and avl_evts is the conventional one because it doubles as the thing you were already polling. Treat error 1 as an instruction to re-login exactly once and replay the call, never as something to retry in a loop. And refresh the token itself well inside the hundred-day inactivity rule described on the token page, because an expired session and a deleted token both surface as error 1 while needing completely different fixes.

The pattern that fails in production is the shared long-lived session: one login at process start, one sid handed to every worker, no re-authentication path. It works until the first network blip, and then every worker fails simultaneously with the same code and nothing in your application changed to explain it.

Errors, classified before they are retried

Wialon returns errors as an HTTP 200 with a code in the body, so your client must look for them explicitly — raise_for_status() will never fire. Codes fall into four groups, and the group decides the behaviour:

CodesClassWhat the client should do
1, 1011Session goneRe-login once, replay the call once, then give up
4Invalid inputDo not retry — this is a bug in your parameters
7Access deniedDo not retry — the token lacks a flag, or the item is not yours
10Concurrency limitBack off and retry; you exceeded the per-session ceiling
1001No messages in intervalNot an error — an empty result for that window
1005Execution time exceededSplit the interval and try the halves

The distinction that saves the most time is between 4 and 7. Error 4 means the request was malformed against the service — a missing field, a string where a number belongs, an id that does not parse. Error 7 means the request was well formed and you are not allowed to make it, which is almost always the token’s access flags rather than anything about the item. Retrying either one is pure waste, and retrying error 1 in a loop is how an egress address gets blocked. The error reference has the full table and the reason strings that qualify the codes.

One request instead of forty

When you need the same call for many items — read a counter on every unit, update a field across a fleet — core/batch sends them together:

res = w.call("core/batch", {
    "params": [
        {"svc": "core/search_item", "params": {"id": uid, "flags": 1}}
        for uid in unit_ids
    ],
    "flags": 0,
})

flags 0 runs every command regardless of failures and returns a result per command; flags 1 stops at the first error, after which the remaining commands return error 10. Prefer 0 for reads, so one bad id does not cost you the other thirty-nine results, and 1 for writes that must not half-apply.

Batching is also the cheapest fix for the concurrency ceiling below: forty calls in one request occupy one slot, not forty.

Concurrency belongs to the session, not to your pool

Three published limits govern how hard you can push, and all three are per session rather than per account or per IP: ten simultaneous API requests, three simultaneous heavy requests (message loading and report execution are the examples the documentation gives), and the ten avl_evts per ten seconds already mentioned. A worker pool of twenty threads sharing one session does not go twice as fast as ten — it starts collecting error 10.

The shape that works is one session per worker, with a token per worker rather than a token per run. Tokens are capped per user, so minting a fresh one on every execution eventually exhausts the allowance and the job starts failing to authenticate at all. And keep the event loop on a session of its own: it is cheap, it is constant, and sharing it with an extraction is how a nightly backfill starves your real-time feed.

What to log, from day one

Four fields make every later incident tractable: the service name, the elapsed milliseconds, the error code if any, and the interval bounds for anything that reads history. Add a short fingerprint of the session id — never the id itself — and you can tell “the session died” apart from “the call was wrong” in a log search rather than a debugging session. The one thing not to log is the token.

The library landscape, honestly mapped

  • python-wialon / php-wialon (wialon org, ~2013). Recognisable names, dead projects. Usable as reference for call shapes, not as dependencies.
  • py-aiowialon, wialon, wialon-sdk (PyPI, third-party). Async or convenience wrappers of varying freshness. Fine for scripts; audit the session handling before trusting one with production sync — token refresh and re-login on error 1/4 is where they usually cut corners.
  • Your own seventy lines (above). What we run in production behind a queue. You own the retry policy, the backoff and the logging, which are the parts that actually break.

The pattern across all three: the HTTP is trivial, the operations are not. Sessions expire, tokens get revoked after 100 idle days, heavy calls are limited three-at-once per session. Whoever owns your client code owns those behaviours — make sure that is you, not an abandoned package.

Where next

With a client and a queue in hand, the next questions are architectural: sync that survives rate limits, getting the data into PostgreSQL, and pushing it onward into an ERP. If that queue is starting to look like a product rather than a script, that is what our integration practice builds.

More from Asset Track

Let's connect

  • “Our client needed a data pipeline. It came back working, plus a few Wialon fixes we had not asked for. That client trusts us more now.”
    Faiz K. Customer Manager · Trakpro Limited