updo-sdk
Python client for the Updo360 public API (Qlaris ERP) — synchronous and asynchronous.
pip install updo-sdk
from updo import UpdoClient
with UpdoClient(token="sk_live_…") as client:
print(client.me().tenant_slug)
for product in client.entity("product").iterate(where={"status": "active"}):
print(product["sku"], product["sale_price"])
What you need to know before you start
The public API lives under https://api.updo.pro/api/public/v1/ and accepts
exactly one thing: a personal access token (sk_live_… in production,
sk_test_… for the sandbox twin of that workspace).
- One token = one workspace. The binding is inside the token, so there is
no tenant header to send (the SDK never sends one: an
X-Tenant-IDcan only cause a 401). - The data model is defined per tenant. Entities and their fields are
created in the Atelier (labelled Workshop in the English UI), so no static
class can describe them. You discover them at runtime (
client.entities) or generate typed models (updo codegen). - A token can only call
/api/public/v1/. The internal/api/v1/routes track whatever the product's own UI needs and carry no stability contract; the server deliberately rejects them when the caller is a token.
Create a token in Updo (Settings → API tokens). The secret is shown only once.
Installation
pip install updo-sdk # library
pip install "updo-sdk[cli]" # + a TOML parser for `updo --profile` on 3.10
Python ≥ 3.10. Only dependency: httpx.
The updo command is installed either way — the [cli] extra only adds
tomli, so that updo --profile can read a TOML config file on Python 3.10
where tomllib is not yet in the standard library. Importing the package never
touches the CLI: import updo pulls in httpx and nothing else.
A runnable end-to-end example ships in the source distribution as
examples/quickstart.py.
Configuration
client = UpdoClient(token="sk_live_…") # api.updo.pro
client = UpdoClient(token="sk_test_…", base_url="http://localhost:8000")
client = UpdoClient() # $UPDO_API_TOKEN / $UPDO_BASE_URL
| Parameter | Default | Role |
|---|---|---|
token |
$UPDO_API_TOKEN |
sk_… token |
base_url |
$UPDO_BASE_URL, otherwise https://api.updo.pro |
API root |
timeout |
30.0 |
seconds (or an httpx.Timeout) |
max_retries |
3 |
number of retries |
on_approval |
"raise" |
"return" to receive an object instead of an exception on a 202 |
site_id |
$UPDO_SITE_ID |
the site every request is scoped to — see Multi-site |
http_client |
None |
your own httpx.Client (pool, proxy, mTLS…) |
The base URL is normalised: https://api.updo.pro, https://updo.pro/,
http://localhost:8000 and even …/api/v1 (pasted from the frontend config)
all lead to …/api/public/v1.
Discovering the model
for entity in client.entities.list():
print(entity.slug, entity.display("fr")) # 'product', 'Produit'
schema = client.entities.schema("product") # cached
for field in schema.fields:
print(field.slug, field.field_type, "writable" if field.writable else "read-only")
schema.fields contains only the fields this token is allowed to read, and
writable is read from the server's serialiser: a form built from it cannot
offer a field the server would refuse.
Why the entity list can come back empty
Authorization is deny-by-default, and the index deliberately omits entities
the token cannot read rather than listing them as forbidden — so a token with no
grants sees a 200 and an empty workspace, not an error. That looks like a
broken API and is really a missing grant. What each case looks like:
| What you see | What it means | Fix |
|---|---|---|
200, results: [] |
no grant on any entity | grant the token's role read on the entities, in the access manager |
403 ACCESS_DENIED on schema/ |
same, for that entity | idem |
index lists an entity, its schema/ returns 404 |
the entity belongs to a module whose subscription has lapsed — the index does not check subscriptions, the schema route does | reactivate the module, or pass --entity explicitly |
403 with policy_id: token:scope_ceiling |
the token's own scopes are narrower than its roles |
reissue the token with scopes: [], or add data:<slug>:read |
| fewer fields than expected | per-field read permissions | expected: two tokens legitimately see two different shapes |
updo codegen without --entity skips a lapsed-module entity and reports it on
stderr rather than aborting the run. Three curl calls settle where you stand:
curl -H "Authorization: Bearer $UPDO_API_TOKEN" https://api.updo.pro/api/public/v1/me/
curl -H "Authorization: Bearer $UPDO_API_TOKEN" https://api.updo.pro/api/public/v1/data/
Multi-site
An Updo organisation can hold several sites, and a request resolves to exactly
one — that is what decides which rows you see and, on a write, where the record
lands. The server picks in this order: the X-Site-ID header, then the member's
default site, then the organisation's main site.
client = UpdoClient(token="sk_live_…", site_id="0f4d2b5e-1c3a-4e6f-9a8b-7c6d5e4f3a2b")
# One credential, several sites, one connection pool:
for site in (montreal, quebec):
for row in client.with_site(site).entity("stock").iterate():
...
site_id also reads $UPDO_SITE_ID. with_site(None) clears the site rather
than falling back to that variable.
⚠️ A service-account token ignores the site completely. Measured against a real Qlaris: for a token whose
userisNone— the canonical machine mode — the server never populates its site context, so the site filter is not applied at all. Such a token reads every site's rows, as thoughall_sites=Truewere permanently on, and records it creates are stamped with no site, making them visible from every site. The header is honoured only by a token bound to a user, and then only if the token's own roles allow that site — the token's roles override the membership's for that check. Until the server changes, use a user-bound token for anything site-scoped.
A wrong site id does not fail. An unknown, malformed, foreign, inactive or forbidden site makes the server fall back to the main site and answer
200. Nothing in the status, the headers or the record envelope reveals it — the envelope never carriessite_id. The SDK therefore validates the UUID before sending, which is the only moment the mistake can still be an error.
There is no way to list sites through the public API: the whole sites
surface is internal, and an API token is refused there. Take the site UUIDs from
the web interface.
all_sites=True on list() / iterate() wins over a configured site — the
server short-circuits its site filter before it looks at the active site.
Reading records
A record is flat: {id, created_at, updated_at, <field>: value…}. Business
fields are read like a dictionary; technical columns remain attributes (so an
entity that happens to define a field named id does not shadow the record's
own identifier).
products = client.entity("product")
page = products.list(page_size=50, ordering="-sale_price")
print(page.count, len(page.results))
product = products.get("0f4d2b5e-1c3a-4e6f-9a8b-7c6d5e4f3a2b")
product.id, product.created_at # metadata
product["sku"], product.get("name") # business fields
for p in products.iterate(where={"status": "active"}): # walks every page, ordered
...
products.count(where={"status": "draft"})
products.first(where={"sku": "ABC-123"})
Filters
products.list(
where={
"sku": "ABC-123", # equality
"name__icontains": "croquette", # substring, case-insensitive
"sale_price__gte": 10, # ≥
"status__in": ["active", "draft"], # list
"barcode__isnull": True, # empty field
}
)
Suffixes: gte lte gt lt contains icontains in isnull. Conditions
are combined with AND. Expression variant:
from updo import F
products.list(where=F(status="active") & F(sale_price__gte=10))
Two server pitfalls worth knowing. A filter on an unknown field returns 400. A filter on a field the token is not allowed to read is silently ignored — this is intentional (otherwise
?data__salaire__gte=would become an oracle for guessing a masked value), but it means a result set can be wider than expected with no error to signal it. When in doubt, check the field withclient.entities.schema(...).
containsis not a substring test. On the server it is PostgreSQL's JSONB containment operator@>, soname__contains="crok"matches nothing at all against"croquette"— and returns a cheerful 200 while doing it. Useicontainsfor substrings. WhatcontainsIS good for is membership in a list-valued field:tags__contains="promo"finds records whosemulti_selectincludes that choice.
An __in list cannot carry a value containing a comma: the server splits on
commas and strips each part, so ["Dupont, Jean", "Tremblay"] would arrive as
three wrong terms. The SDK refuses such a value rather than sending it wrong.
Dates and times need an explicit offset. A naive datetime is refused by
the SDK rather than sent, because the server reads a naive value in the
tenant's timezone — the same code would then mean different instants in
different workspaces:
from datetime import datetime, timezone
products.list(where={"released_on__gte": datetime(2026, 1, 1, tzinfo=timezone.utc)})
Search, ordering, relations
products.list(search="chien") # free text over text fields
products.list(ordering=["-sale_price", "name"])
products.list(expand="supplier") # populates record.expanded
Automatic value typing
By default, values come back raw — the JSON exactly as sent. With the schema loaded, dates and decimals are promoted to Python objects:
products = client.entity("product", coerce=True) # loads the schema once
p = products.get(record_id)
p["sale_price"] # Decimal('12.50') — exact, not a float
p["released_on"] # datetime.date(2026, 1, 15)
Writing
Values this SDK hands you can be sent straight back: Decimal and date are
rendered the way the server reads them (money as a string, so a cent cannot be
lost to a float). record.to_json_dict() gives a payload ready for update(),
with the server-owned id / created_at / updated_at left out.
p = products.create({"sku": "ABC-123", "name": "Croquettes", "sale_price": 12.50})
products.update(p.id, {"sale_price": 13.90}) # PATCH — recommended
products.replace(p.id, {...}) # PUT
products.delete(p.id)
Fields of type
passwordread back masked (••••••••). Sending a freshly-read record back throughreplace()would therefore overwrite the real secret.record.is_masked("field")detects it; preferupdate().
Writes subject to approval
A write can be put on hold for approval (HTTP 202) instead of being applied. By
default the SDK raises ApprovalRequired — returning an object with no id
would let the calling code carry on as if the write had happened.
from updo import ApprovalRequired
try:
products.create({...})
except ApprovalRequired as pending:
print(pending.approval_request_id)
# or, to handle the case without an exception:
client = UpdoClient(token="sk_live_…", on_approval="return")
result = client.entity("invoice").create({...}) # Record or ApprovalPending
Analytics and export
agg = products.aggregate(group_by="status", metrics=["count", "sum:sale_price"])
for row in agg.results:
print(row["group"], row["count"])
agg = products.aggregate(group_by="created_at", bucket="month", metrics=["count"])
pv = products.pivot(rows="status", cols="category", metric="count")
pv.cell("active", "chien")
pv.to_rows() # ready for csv.DictWriter or pandas
od = products.query(
select=["sku", "sale_price"], orderby=[("sale_price", "desc")], top=100, count=True
)
od.value, od.count # $top is capped at 500 server-side
products.export("csv", dest="products.csv", where={"status": "active"})
products.export("xlsx", dest="products.xlsx")
data = products.export("csv") # without dest: the bytes
The export honours filters, search and ordering, and ignores pagination.
The server truncates an export at 10 000 rows — no header, no marker, no error: a 40 000-record entity yields a perfectly well-formed CSV holding the first 10 000. The SDK therefore counts first and raises rather than hand you a file that is quietly a quarter of your data. Pass
check_complete=Falseto skip the count, and useiterate()— which has no cap — for anything larger.
Payment links
Turn an invoice into a payer-facing URL, server to server. The payer needs no account.
link = client.payments.create_link(invoice_id, expires_days=30)
send_to_customer(link.url)
client.payments.revoke_links(invoice_id) # returns how many were cancelled
⚠️
link.urlis a bearer credential. Whoever holds it can open the payment form for that invoice.PaymentLinkmasks it inrepr()for the same reason the client masks your API token — read.urlexplicitly when you mean to hand it to the payer, and treat it like a password everywhere else. If one leaks:revoke_links(), thencreate_link()again, which mints a genuinely new secret.
You cannot set a price. There is no amount, no currency and no
return_url — the amount comes from the invoice and is recomputed on every
payer visit, and the currency is the workspace's payment_currency setting.
Those parameters are absent from the signature rather than accepted and ignored.
Retrying is safe. The server keeps at most one active link per invoice and returns the existing one instead of minting a second secret, so a duplicated request cannot produce two collectable URLs. A new secret appears only when the previous link expired, was revoked, or was settled — and when two mints for the same invoice race, since the server has no unique constraint there. Mint one invoice at a time rather than in parallel.
expires_days (1–365, server default 60) applies only to a fresh mint; on a
reuse it is silently ignored. This endpoint cannot extend an existing link.
link.amount_due is a Decimal, but indicative: the server sends it as a
JSON double and recomputes the real amount when the payer arrives. Do not
reconcile against it.
The invoice must be payable — a draft, cancelled, voided or already-paid
invoice, or one with a zero balance, raises ValidationError.
Webhooks
Subscribing
hook = client.webhooks.create(
url="https://my-service.example.com/updo", # https required
event_pattern="invoice.*", # or "invoice.paid", "*.created"
slug="invoice-paid",
secret="whsec_…", # signs every delivery
)
client.webhooks.update(hook.id, is_active=False)
client.webhooks.delete(hook.id)
Patterns accept a wildcard per segment: invoice.*, *.created. Events are
<entity>.created / .updated / .deleted, plus the platform events
(document.signed, workflow.approval_requested…).
Verifying a received delivery
from updo.webhooks import parse_event, verify_signature
@app.post("/updo")
def receive(request):
raw = request.body # the BYTES, before any JSON parsing
if not verify_signature(
SECRET,
raw,
request.headers.get("X-Qlaris-Signature"),
timestamp=request.headers.get("X-Qlaris-Timestamp"),
):
return 401
event = parse_event(raw, headers=request.headers)
if already_processed(event.delivery_id): # stable across retries
return 200
process(event.event, event.data)
return 200
⚠️ Sign the raw bytes. Updo signs exactly what it puts on the wire (
json.dumps(sort_keys=True, separators=(",",":"))). Re-parsing then re-serialising the JSON changes key order and whitespace: the signature will no longer match — and you will reject a payload that was perfectly genuine.
Reply 2xx quickly: a response ≥ 400 is retried up to 3 times with an increasing delay, and the codes 400/401/403/404/405/410/422 are treated as final (the delivery is abandoned).
Delivery log
for delivery in client.webhooks.iterate_deliveries(status="failed"):
print(delivery.event_name, delivery.response_code, delivery.error)
Errors
from updo import (
UpdoError,
UpdoAPIError,
AuthenticationError,
PermissionDenied,
NotFoundError,
ValidationError,
ConflictError,
PlanLimitExceeded,
RateLimitError,
ServerError,
ApprovalRequired,
)
try:
products.create({"sku": ""})
except ValidationError as exc:
print(exc.code) # 'BUSINESS_RULE_VIOLATION'
print(exc.field_errors) # {'sku': ['This field is required.']}
except PermissionDenied as exc:
print(exc.policy_id) # e.g. 'token:scope_ceiling'
The server speaks two error dialects (the platform envelope
{code, detail, field_errors} and DRF's raw form); the SDK normalises them, so
exc.code / exc.detail / exc.field_errors are always readable.
| Status | Exception | Typical case |
|---|---|---|
| 401 | AuthenticationError |
unknown or expired token, IP refused, call outside /api/public/ |
| 402 | PlanLimitExceeded |
subscription quota reached |
| 403 | PermissionDenied |
ABAC refusal, token scope ceiling, module disabled |
| 404 | NotFoundError |
entity or record does not exist |
| 400 / 422 | ValidationError |
business rule, invalid value, unknown parameter |
| 409 | ConflictError |
protected deletion, separation-of-duties conflict |
| 429 | RateLimitError |
throttling — exc.retry_after |
| 5xx | ServerError |
platform-side outage |
Retries
The SDK retries automatically, with exponential backoff and jitter:
- 429 on every method (
Retry-Afterhonoured) — the request was rejected before any side effect, so replaying it is safe; - 5xx and network drops only on idempotent methods (GET/PUT/DELETE). A 500 after a POST can mean the write succeeded and only the response was lost: replaying it would create a duplicate.
Four throttle buckets apply to a token at once: 1000/h per token, 10000/h per workspace, 3000/h per user, and a 120/min burst limit.
Asynchronous client
Same surface, same guarantees:
import asyncio
from updo import AsyncUpdoClient
async def main():
async with AsyncUpdoClient(token="sk_live_…") as client:
products = await client.entity("product")
async for p in products.iterate(where={"status": "active"}):
print(p["sku"])
asyncio.run(main())
Only difference: client.entity() is a coroutine (it may have to load the
schema).
Typed models (codegen)
The OpenAPI document cannot describe a record's fields — they are defined per tenant. So they are generated from the real workspace:
updo codegen --entity product --entity invoice --out my_app/updo_models.py
from my_app.updo_models import Product
p = Product.from_record(client.entity("product").get(id))
p.sale_price # Decimal | None, with IDE autocompletion
p.status # Literal["active", "draft", "archived"] | None
new_product = Product(sku="ABC-123", name="Croquettes")
client.entity("product").create(new_product.to_payload()) # writable fields only
Regenerate after any change to the model in the Atelier. --no-timestamp makes
the output stable byte for byte, useful if the file is version-controlled.
The codegen needs nothing but a token that can read the entity — no builder rights, no Atelier licence. What it cannot do for you is the bootstrap: minting the token and defining the entities are admin gestures on the internal surface, so they happen in the UI. See Why the entity list can come back empty.
What the generated types promise, and what they don't
| Field type | Generated as | Note |
|---|---|---|
select radio segmented chips |
Literal[...] |
narrowed to the declared choices |
multi_select checkboxes |
list[Literal[...]] |
|
decimal currency |
Decimal |
exact, never a float |
date datetime |
date / datetime |
|
relation member |
str |
the wire carries a string, so this is what you get |
computed json |
Any |
the server does not publish a result type |
Two caveats the public schema itself cannot resolve, because it does not publish the discriminant:
- Relation cardinality. A to-many relation carries a list of ids, but the
schema publishes no
cardinality, so every relation is annotatedstrand the field's docstring says so. Check the entity in the Atelier before writing to a relation. - Translatable fields read back as a locale map (
{"fr": ..., "en": ...}) rather than a string, and nothing in the schema marks them.
Use FieldSpec.choice_labels(lang) when you need the human labels behind a
choice field rather than its raw keys.
Command line
The short version is below; docs/cli.md in the source distribution is the
full guide, with
output for every command and a troubleshooting section.
export UPDO_API_TOKEN=sk_live_…
updo whoami
updo entities
updo schema product
updo get product --where status=active --where sale_price__gte=10 --limit 20
updo get product --table --columns sku,name,sale_price
updo count product --where status=draft
updo create product --data '{"sku":"ABC-123","name":"Croquettes"}'
updo update product <id> --file patch.json
updo delete product <id>
updo export product --format xlsx --out products.xlsx
updo aggregate product --group-by status --metrics count,sum:sale_price
updo pivot product --rows status --cols category
updo query product --select sku,sale_price --orderby 'sale_price desc' --top 50
updo webhooks list
updo webhooks create --url https://my-service.example.com/updo --event 'invoice.*'
updo webhooks deliveries --status failed
updo codegen --out models.py
updo openapi --out openapi.json
Output is JSON when stdout is redirected and a readable table when it is a
terminal; override with --json / --table. Exit codes: 0 success, 2 API
error, 3 write pending approval.
Profiles in ~/.config/updo/config.toml (or %APPDATA%\updo\config.toml):
[default]
token = "sk_live_…"
[sandbox]
token = "sk_test_…"
base_url = "http://localhost:8000"
updo --profile sandbox entities
The token is never displayed again: every output passes it through a mask
(sk_live_ab…yz).
Covered surface
All 18 public routes, in full — a contract test checks this against the OpenAPI document served by the platform.
| Route | SDK method |
|---|---|
GET /me/ |
client.me() |
GET /data/ |
client.entities.list() |
GET /data/{slug}/schema/ |
client.entities.schema(slug) |
GET · POST /data/{slug}/ |
.list() .iterate() .create() |
GET · PUT · PATCH · DELETE /data/{slug}/{id}/ |
.get() .replace() .update() .delete() |
GET /data/{slug}/aggregate/ |
.aggregate() |
GET /data/{slug}/pivot/ |
.pivot() |
GET /data/{slug}/query/ |
.query() |
GET · POST /webhooks/ |
client.webhooks.list() .create() |
GET · PUT · PATCH · DELETE /webhooks/{id}/ |
.get() .replace() .update() .delete() |
GET /webhooks/deliveries/[{id}/] |
.deliveries() .delivery() |
GET /schema/ |
client.openapi() |
For any route that is not modelled, the escape hatch keeps authentication and retries:
client.request("GET", "some/future/route", params={"x": 1})
Development
python -m venv .venv && .venv/Scripts/pip install -e ".[dev,cli]"
pytest # offline, simulated transport
ruff check . && ruff format --check .
mypy
Integration tests against a real instance (optional):
UPDO_BASE_URL=http://localhost:8000 UPDO_API_TOKEN=sk_test_… pytest -m integration
Use a mode: "test" token: it is bound to a disposable twin of the workspace,
so write tests do not touch production.
Refresh the contract test fixture after an API change:
updo openapi --out tests/data/openapi.json
License
MIT. The full text ships as LICENSE in the source distribution.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file updo_sdk-0.2.0.tar.gz.
File metadata
- Download URL: updo_sdk-0.2.0.tar.gz
- Upload date:
- Size: 131.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.15
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6602f774f07ddd7ebf13027cf2c4cfe8b26db1c847928f1cd2c41761e3fbfec5
|
|
| MD5 |
4d6e136cf295ae1398b2567b9ddbba07
|
|
| BLAKE2b-256 |
d1f18dbe328ee88467fd926db7605e75f946a87bd9b3e308f637a8bbada40343
|
File details
Details for the file updo_sdk-0.2.0-py3-none-any.whl.
File metadata
- Download URL: updo_sdk-0.2.0-py3-none-any.whl
- Upload date:
- Size: 73.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.15
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
04c49e979c2be659b8b9d6babc209e760d4aca5ed4c45aff53042e77d1b3ce45
|
|
| MD5 |
a3d77638284ac1088748afd374b27107
|
|
| BLAKE2b-256 |
f01236ecc0b79890a953e94f96f6edab34bab93f5dc44234e73e755f1ba28b0a
|