An unofficial Python library and CLI for tracking SmartPosti (Estonia, Latvia, Lithuania) and Posti (Finland) parcels. It also watches parcels and tells you when new tracking events appear.
$ smartposti-track watch JJFI12345678901234567 --interval 300
NEW JJFI12345678901234567 2026-10-29 06:46 The item is in sorting
NEW JJFI12345678901234567 2026-10-29 11:02 The item has arrived at the parcel locker
Disclaimer. This project is not affiliated with or endorsed by Posti Group. It uses the same public endpoints as the tracking page on smartposti.ee, and they are not documented. They may change or stop working without notice. Please poll responsibly (every few minutes, not every second).
- No browser needed. Plain HTTP via
requests, with no Selenium or Playwright. - Library first. Typed
ShipmentandEventmodels, and a small exception hierarchy. - Watcher with pluggable parts. You choose where seen events are stored (memory, JSON file, or your own store) and what happens when one arrives (print, Telegram, email, webhook, and so on).
- CLI for quick lookups, and a
--oncemode for cron or Windows Task Scheduler. - Localized events in
en,et,fi,lv,lt.
pip install smartposti-trackerFor development:
git clone https://github.com/kuttim/smartposti-tracker.git
cd smartposti-tracker
pip install -e ".[dev]"Requires Python 3.9+.
# Current status and full history
smartposti-track status JJFI12345678901234567
# Raw API response as JSON
smartposti-track status JJFI12345678901234567 --json
# Poll every 5 minutes and print only new events (Ctrl+C to stop)
smartposti-track watch CODE1 CODE2 --interval 300 --bell
# Check once and exit. Good for cron or Task Scheduler
smartposti-track watch CODE --once --state ~/.smartposti_state.json
# Events in Estonian
smartposti-track --locale et status CODEpython -m smartposti_tracker ... also works.
On the first watch check, existing events are only recorded, not reported.
Pass --notify-existing to print them too.
from smartposti_tracker import TrackingClient, ShipmentNotFound
client = TrackingClient(locale="en")
try:
shipment = client.get_shipment("JJFI12345678901234567")
except ShipmentNotFound:
print("No such parcel")
else:
print(shipment.status) # "IN_TRANSPORT"
for event in shipment.events: # oldest first
print(event.timestamp, event.description)
print(shipment.latest_event)shipment.raw and event.raw hold the original API data.
from smartposti_tracker import JsonFileStore, Watcher
watcher = Watcher(store=JsonFileStore("state.json"))
@watcher.add_handler
def on_new_events(shipment, events):
for e in events:
print(f"{shipment.tracking_code}: {e.description}")
watcher.run(["JJFI12345678901234567"], interval=600) # blocks
# or: watcher.check("CODE") -> list of new events, for your own schedulerThe library has three small, replaceable pieces:
| Piece | What it does | How to replace it |
|---|---|---|
TrackingClient |
Talks to the API | Pass your own requests.Session (proxies, retries), or query= to request more GraphQL fields |
StateStore |
Remembers which events were already seen | Any object with get_seen(code) and set_seen(code, keys) |
| Event handlers | React to new events | Any callable handler(shipment, new_events) |
A handler is just a function. Here is one that posts to a webhook:
import requests
def to_webhook(shipment, events):
requests.post("https://example.com/hook", json={
"code": shipment.tracking_code,
"status": shipment.status,
"events": [{"time": e.timestamp.isoformat(), "text": e.description} for e in events],
})
Watcher(handlers=[to_webhook]).run(["CODE"])class RedisStore:
def __init__(self, redis):
self.r = redis
def get_seen(self, code):
# Return None if this code was never checked, so the first check stays silent
if not self.r.exists(f"seen:{code}"):
return None
return {k.decode() for k in self.r.smembers(f"seen:{code}")}
def set_seen(self, code, keys):
pipe = self.r.pipeline()
pipe.delete(f"seen:{code}")
pipe.sadd(f"seen:{code}", "__checked__", *keys)
pipe.execute()import requests
from requests.adapters import HTTPAdapter, Retry
session = requests.Session()
session.mount("https://", HTTPAdapter(max_retries=Retry(total=3, backoff_factor=2)))
client = TrackingClient(session=session)The default query asks only for status and events. To get delivery details,
pickup point, and similar data, pass your own GraphQL query that still returns
consumerSearchShipments.hits, then read the extra fields from shipment.raw.
See examples/ for complete scripts, including a
Telegram notifier and a
SQLite store.
Linux/macOS (cron), every 10 minutes:
*/10 * * * * smartposti-track watch CODE --once --state $HOME/.smartposti_state.json >> $HOME/parcels.logWindows (Task Scheduler):
schtasks /Create /SC MINUTE /MO 10 /TN "Parcel tracker" `
/TR "smartposti-track watch CODE --once --state %USERPROFILE%\smartposti_state.json"The smartposti.ee tracking page loads its data in the browser in two steps. This library repeats those two requests:
POST https://auth-service.posti.fi/api/v1/anonymous_tokenreturns a short-lived anonymousid_tokenand arole_tokens[0].token. No account is needed.POST https://graphql.posti.fi/graphqlruns theconsumerSearchShipmentsquery, withauthorization: <role token>andx-posti-token: Bearer <id_token>.
Tokens are cached until shortly before they expire. A 401 or 403 response causes a fresh token on the next call.
If Posti changes this flow, open the tracking page with your browser's dev tools
open (Network tab, filter graphql) to see the new requests, then
open an issue.
pip install -e ".[dev]"
ruff check .
pytest # offline, API is mocked
SMARTPOSTI_TEST_CODE=<code> pytest -m live # optional real-API smoke testSee CONTRIBUTING.md.