Skip to main content

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-ID can 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.

Two hints ride along when the field declares them:

total = schema.field("total")  # a money field
total.currency_field  # 'currency' -- or None
total.currency_of(invoice, default="CAD")  # 'USD' on that invoice
schema.field("subject").placeholder_text("en")  # 'E.g. printer down'

currency_field matters: a money field may keep its currency in another field of the record. Ignore it and an invoice in USD is formatted in the workspace's currency.

Build a form from available_choice_values, not choice_values. A choice behind a capability the workspace has not bought is served marked unavailable rather than removed — a record may already hold it, so choice_values (for reading) keeps it; writing it is refused.

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.

Which sites a token may use — the same rule for a service account and for a token bound to a person:

The token Sites it may use
has sites assigned those sites
has none assigned the organisation's main site only
has an admin or owner role any site of the organisation

Without X-Site-ID, a service-account token reads its first assigned site (the main site if it is among them, then by site code), or the main site when none is assigned. One request reads one site: to cover several, send one per site with with_site(). Records with no site are visible from every site, and a record the token creates lands on the active site.

⚠️ Fewer rows, no error. Until Qlaris delivery 11, a service-account token ignored sites entirely and read every site's rows. It now follows the rule above: a token with no assigned site sees the main site only — a 200 with fewer rows. Assign it the sites it needs, in the web interface.

A site it may not use is refused. An X-Site-ID that is unknown, inactive, of another organisation, or outside the token's sites answers 403 SITE_NOT_ALLOWED (SiteNotAllowed) — on reads too. (Before that delivery the server fell back to a default site and answered 200, with the wrong rows.) The SDK still checks the UUID's shape before sending.

Two IP allowlists. The token's own allowed_cidrs answers 401; the organisation's security allowlist answers 403 IP_NOT_ALLOWED — and it now applies to service-account tokens too.

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 with client.entities.schema(...).

A field the organisation's customisation hides from a system entity is different: filtering on it is refused — FilterOnHiddenField (400 FILTER_ON_HIDDEN_FIELD), on every route that filters. It used to be applied; an integration that relied on it now gets the 400 and must drop that filter. Only fields your token's own rights hide stay silently ignored.

What contains means depends on the field's cardinality, which schema.field(slug).cardinality (or .is_list) tells you:

cardinality Field types __contains=v __in=a,b equality
one text and every single-value type v is a case-sensitive substring of the text the value is one of a, b exact
many multi_select, checkboxes, a many-to-many relation v is one whole item of the list holds at least one of a, b 400 ListFieldEquality

icontains is the case-insensitive substring search. chips, segmented, radio and select hold one value. (Before Qlaris delivery 11, contains was a substring on lists too, which made membership fuzzy; 0.5.1 documented that state.)

A many-to-many relation (e.g. work_center.departments) reads as a list of UUIDs, and the key is absent until someone fills it in: read it with record.get("departments") or [] — absent and [] mean the same. Filter it with departments__contains="<uuid>", write it as a list. updo codegen types it list[str], and names its target (schema.field(...).target_entity). A customer-portal member's schema carries neither cardinality nor target_entity; for it, a relation is generated str with a note.

Numeric fields compare numbers (number, decimal, currency): equality, __in and ranges, so total="12.5" matches a stored 12.50. A range value that is not a number is a 400; an empty one is ignored (no filter at all). ordering= on a numeric field sorts by the number, with empty values last in both directions.

A time field is a wall clock stored as the text HH:MM (24-hour, zero-padded) — never 08:05:00. Pass a datetime.time and the SDK sends it in that shape, in filters and writes alike; a time with seconds or with a timezone is refused, because the server would drop the seconds silently and has no zone to keep. A site field stores the site's UUID: its code is accepted on write, but a filter compares the stored UUID, so filtering by code finds nothing.

Exact matches are case-sensitive. The server compares the stored JSON value as-is, without normalising it. That matters most for codes the platform normalises on write: a jurisdiction field stores ISO codes in uppercase (CA for a country, CA-QC for a subdivision — never a bare QC, which would be ambiguous with California's US-CA), so where={"work_jurisdiction": "ca-qc"} returns zero rows and no error. Pass "CA-QC".

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 password read back masked (••••••••). Sending a freshly-read record back through replace() would therefore overwrite the real secret. record.is_masked("field") detects it; prefer update().

Locked records

An entity can declare record rules: once stored, a record may be frozen (readonly, a change of any field outside the rule's exceptions is refused) or protected from deletion (no_delete, also by cascade). A refused update or delete raises RecordRuleRefused (400 RECORD_RULE) — the record is locked, not your payload::

try:
    invoices.update(invoice_id, {"total": 120})
except RecordRuleRefused as exc:
    print(exc.field_errors)   # the locked fields; empty on a refused delete
    print(exc.detail)         # names the rule, or what stops the delete

It is never retried, and a refused delete is refused before any approval is requested, so no ApprovalPending comes back for a locked record. Creating a record is not judged by a rule. A record unlocks only when the field its rule reads changes (a status, say) through the flow its author declared.

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

An approved request is executed — later, by the server, in the name of the person who made it and with that person's rights on the day of the approval (an update is refused if a field it changes was modified in the meantime). The 202 is therefore a promise, not a dead end. Follow the outcome through the record's own webhooks: there is no public route to read an approval.

Only a token bound to a person can have a write parked. Two cases cannot be parked, and are refused before anything is written with a 403 — both are ApprovalNotPossible (a PermissionDenied), so one except handles them, and retrying the same call cannot succeed:

Code Exception When Way through
SERVICE_ACCOUNT_CANNOT_AWAIT_APPROVAL ServiceAccountCannotAwaitApproval a service-account token creates, updates, replaces or deletes a record whose write needs an approval — nothing is parked, nothing written an approved request runs in a person's name, and a service account has nobody to wait for the decision: a person makes the change (or a token bound to a user, whose write is then parked), or the integration is exempted from the policy
APPROVAL_REQUIRED_ELSEWHERE ApprovalRequiredElsewhere an approval-governed action on a route that cannot carry one: uploading or deleting a record image, creating or revoking a payment link make the change on the record itself (which parks it), then retry once approved
from updo import ApprovalNotPossible, ApprovalRequired

try:
    invoices.update(invoice_id, {"status": "sent"})
except ApprovalRequired as pending:
    queue_for_follow_up(pending.approval_request_id)  # parked: will run once approved
except ApprovalNotPossible as exc:
    alert_ops(exc.code)  # nothing was written

Branch on exc.code (or the class), never on exc.detail: it is a sentence in the caller's language and not part of the contract.


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.

products.export("csv", dest="short.csv", export_fields=["sku", "name", "sale_price"])

export_fields picks the columns and their order. Omitted, every readable column comes out in the entity's own order — relation ids included, which is what keeps the file re-importable. An unknown slug and one this token may not read get the same 400, on purpose. Two values are refused locally because the server would turn them into a silent surprise: an empty list, which reaches it as "not given" and exports every column, and a slug containing a comma, which it would split into two.

The server truncates an export at 10 000 rows, with no error: a 40 000-record entity yields a perfectly well-formed CSV holding the first 10 000. Its only sign is a response header, X-Ql-Export-Truncated: 1. The SDK reads it before writing a byte and raises ExportTruncated — dest is left untouched, and it costs one request (0.4 counted first: two requests, and a race between them). Pass check_complete=False to take the truncated file deliberately, and use iterate() — which has no cap — for anything larger. A Qlaris that predates this header sends none, and the check then cannot see a truncation.

An export is a file for people, not a copy of the data. So that a spreadsheet does not run a typed value as a formula, a text cell starting with =, +, -, @, a tab or a carriage return is written with a ' in front — headers included; a number written as text ("-3.50") is left alone. A phone number +1 514 555 0100 therefore exports as '+1 514 555 0100, and the change cannot be undone reliably. In XLSX the apostrophe is a real character of the cell. Read data with iterate() or query(). The XLSX bytes also differ on every call (a zip timestamp): to detect a change, compare the records, never a hash of the file.

Interrupted downloads. The server streams exports (no Content-Length), so a long one can break off half-way; a GET is then replayed. The SDK never lets a partial body survive that: a dest path is written to a temporary sibling and renamed into place only when complete (an existing file is untouched by a failure), and a seekable handle is rewound before the replay. A handle that cannot seek — a pipe, a socket — is not replayed: DownloadInterrupted carries bytes_written, and what it received must be discarded.


Record PDFs (invoices, quotes, labels…)

Render any record with one of the PDF templates designed for its entity — an invoice first of all.

invoices = client.entity("invoice")

for template in invoices.documents():
    print(template.slug, template.display("fr"))

pdf = invoices.pdf(invoice_id)  # the bytes
invoices.pdf(invoice_id, template="invoice", dest="out/")  # -> out/INV-00042.pdf
invoices.pdf(invoice_id, language="en", dest="invoice-en.pdf")

dest takes a file path, a binary handle, or an existing directory — the file is then named after the record's reference as the server suggests it (the invoice number). That name is re-sanitised before it touches your disk: a header never chooses the directory.

A fresh render on every call. Nothing is archived server-side — no issued document, no stored file — so polling this is a read, and two calls can differ if the record changed in between.

template may be omitted when the entity has exactly one usable PDF template. With several, the server refuses to guess, and the exception tells you which exist:

from updo import TemplateRequired

try:
    pdf = invoices.pdf(invoice_id)
except TemplateRequired as exc:
    pdf = invoices.pdf(invoice_id, template=exc.templates[0])

language ("en", "fr-CA") overrides the render language; omitted, the template's own language field (the customer's, typically) decides, then the workspace default. The SDK checks it before sending, because the server ignores a malformed value instead of refusing it — language="fr_CA" would quietly produce a PDF in the default language.

⚠️ The template reads more than the record. An invoice prints its lines and its customer, and each of those entities needs the same right as GET data/<slug>/. When one is missing, the whole document is refused — PermissionDenied with exc.resources naming what to grant (["data:invoice_line"]). Rows a row-level policy hides are left out of the document without an error.

A record that does not exist and one this token may not read both answer 404 RECORD_NOT_FOUND, by design. A 503 PDF_RENDER_FAILED is retried like any 503 on a GET — safe here, since the route writes nothing.


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.url is a bearer credential. Whoever holds it can open the payment form for that invoice. PaymentLink masks it in repr() for the same reason the client masks your API token — read .url explicitly when you mean to hand it to the payer, and treat it like a password everywhere else. If one leaks: revoke_links(), then create_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.


Record images

The one surface here that publishes to the open internet. Upload and publish are a single call: a 201 means the image is attached and its URL is live.

images = client.entity("product").images(product_id)

image = images.upload(
    "photos/moulin.png",
    category="photo",
    alt_text={"fr": "Le moulin en hiver", "en": "The mill in winter"},
)
print(image.public_url)  # paste straight into <img src="...">
for image in images.list():
    print(image.position, image.category, image.public_url)

images.delete(image.id)

upload() takes a path, raw bytes, or a handle opened in binary mode. The file is read into memory — the server caps uploads at 10 MiB — so that a throttled request can be replayed without uploading an empty body.

category is required, whatever the schema says

The server's OpenAPI marks it optional. It is not: publication checks the category against the entity's public_media whitelist, and that whitelist is built by a normaliser that strips blank entries — so "" can never be a member, and omitting the category is a guaranteed 409, not a default. The SDK requires it so the refusal happens at the call site instead of a round trip later.

The whitelist is not readable through the public API. The schema endpoint returns fields, and this declaration is a property of the entity. Today the only ways to learn which categories publish are to open the entity in the Workshop, or to try and catch PublicationRefused.

What the SDK refuses before sending

Each of these also comes back from the server — but only after the whole file has gone up, and only as a bare code that names the lock rather than the fix:

Refused locally Because
an SVG, PDF, BMP, TIFF, ICO, HEIC or ZIP the server publishes only PNG, JPEG, GIF and WebP, and it decodes the bytes rather than trusting the file name
over 10 MiB the server's ceiling
position outside 0–32767 it lands in a smallint
alt text over 125 characters, or over 24 languages the domain's bounds
a non-UUID record id the server's 404 would not say which id was wrong

SVG is excluded deliberately, not by oversight: it is a document format that executes script when served inline, and a published blob is served with no origin of its own. Rasterise it first.

Publication refusals

from updo import PublicationRefused

try:
    images.upload(photo, category="contract")
except PublicationRefused as exc:
    if exc.is_configuration:
        alert_ops(exc.code)  # nothing the caller can resend will fix it
    else:
        convert_and_retry()

PublicationRefused subclasses ConflictError, so an existing except ConflictError keeps working. Its .code is one of five stable, machine-readable values the server documents for branching:

exc.code What to do
public_media_not_declared the entity publishes nothing at all; add a public_media declaration in the Workshop
public_media_category_not_publishable name the category in that whitelist
public_media_not_an_image send different bytes — the only refusal the caller can fix
public_media_unavailable the deployment has no public container; an operator matter
public_media_sandbox_refused a sandbox twin may not publish — its URLs would go dead on a real site

Every refusal is decided before anything is stored, so a 409 has written no blob and attached no row. Retrying after fixing the cause needs no cleanup.

Three warnings

Uploading is not idempotent. Unlike a payment link, nothing de-duplicates here: two calls with the same file attach two images. Only the transport's 429 replay is safe, because a throttle refuses before the server has done anything.

Publishing stores the bytes twice, and you are billed for both. A published image is a copy into the public container; the private original stays where it was. The workspace's metered storage counts the total, so a bulk import of product photos costs twice what the files weigh. That is the platform's real cost, not a rounding artefact — worth knowing before looping upload() over a catalogue.

Un-publishing is not retroactive. delete() stops new public reads, but a CDN or a browser that already fetched the blob may keep serving it. Treat a published image as published for good — which is exactly why the server refuses to publish anything but decoded raster images.

image.public_url is not masked in repr(), unlike PaymentLink.url. It carries no authority beyond "show these bytes" and exists to be embedded publicly; hiding it would obstruct the only thing anyone does with it.


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 / .restored, plus the platform events (document.signed, workflow.approval_requested…). The payload of a record event is the record itself, {"id", <fields>…}.

Keeping a copy in sync? Subscribe to .restored too. A record taken back out of the recycle bin sends <entity>.restored — not .created. An integration that deletes its copy on .deleted and listens to nothing else keeps a record deleted that is alive again in Updo. invoice.* receives it; an exact invoice.deleted subscription does not.

<entity>.updated is not only a user's edit: it is also sent when the server rewrites a record itself — e.g. a relation emptied because the record it pointed at was deleted. The payload is the record after that rewrite.

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,
    TemplateRequired,
    FieldsRequired,
    ExportTruncated,
    ConflictError,
    PublicationRefused,
    ApprovalNotPossible,
    SiteNotAllowed,
    EntityNotCreatable,
    CascadeDeleteRefused,
    PlanLimitExceeded,
    DownloadInterrupted,
    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, source IP outside the token's own allowlist, call outside /api/public/
402 PlanLimitExceeded subscription quota reached
403 PermissionDenied ABAC refusal, token scope ceiling, module disabled; exc.resources / exc.feature on a PDF; exc.code == "IP_NOT_ALLOWED" when the source IP is outside the organisation's security allowlist
403 EntityNotCreatable the entity's records are made by the platform (e.g. a public holiday entitlement): nobody creates one, whatever their rights — exc.entity names it
403 CascadeDeleteRefused a delete would remove or rewrite dependent records this token may not delete or change (or only through an approval): nothing is deleted — deal with those records first; exc.detail says which
403 SiteNotAllowed X-Site-ID names a site this token may not use — unknown, inactive, foreign or not assigned (see Multi-site)
403 ApprovalNotPossible the action needs an approval this request cannot wait for — ServiceAccountCannotAwaitApproval or ApprovalRequiredElsewhere (see Writes subject to approval)
404 NotFoundError entity or record does not exist
400 / 422 ValidationError business rule, invalid value, unknown parameter
400 TemplateRequired several PDF templates fit — exc.templates lists them
400 FilterOnHiddenField a data__<field> filter on a field the organisation's customisation hides — refused on every route that filters (it used to apply); a field your token's own rights hide is still ignored in silence
400 RecordRuleRefused a record rule locks the record: an update of a frozen field, or a delete of a protected record. Not a payload error — field_errors names the locked fields (none on a delete)
400 SecretNotQueryable a filter or a sort on a secret (password) field — refused, never ignored; drop that parameter
400 SecretTokenRefused an encrypted token (enc: / enc2:) sent as a field value — a secret reads back masked, so never send a value back that you did not type
400 ListFieldEquality an equality filter on a list-valued field (cardinality many) — use __contains or __in
400 FieldsRequired a state change needs fields — exc.missing_slugs, exc.unwritable (the ones this token cannot fill), exc.step
409 ConflictError protected deletion, separation-of-duties conflict
409 PublicationRefused a public_media declaration forbids publishing an image — a ConflictError subclass carrying a stable .code
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-After honoured) — 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 annotated str and 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.

Regenerate after a module upgrade. A generated model is a snapshot of the schema. When a module gains a choice (a select value) or a field, a model generated earlier still loads the new data — nothing is validated at runtime — but its Literal[...] annotations are stale, so typed code is checked against the old choices. Values inside a json field are passed through untouched: their numbers are JSON floats, not Decimal.


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

Every documented public route — a contract test checks this against the OpenAPI document served by the platform, and fails the build on any operation the SDK does not reach.

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()
POST · DELETE /payment-links/ client.payments.create_link() .revoke_links()
GET · POST /data/{slug}/{id}/images/ .images(id).list() .upload()
DELETE /data/{slug}/{id}/images/{image_id}/ .images(id).delete()
GET /data/{slug}/{id}/pdf/ .pdf(id)
GET /data/{slug}/documents/ .documents()
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.

Metadata

Release files for updo-sdk 0.6.4

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for updo-sdk 0.6.4
File Size Uploaded Provenance
updo_sdk-0.6.4.tar.gz 218.4 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for updo-sdk 0.6.4
File Interpreter ABI Platform
updo_sdk-0.6.4-py3-none-any.whl Python 3 none any Details

Total release size: 321.8 kB

Release files / updo_sdk-0.6.4.tar.gz

Download URL updo_sdk-0.6.4.tar.gz
Size 218.4 kB
Tags Source
SHA-256 checksum
How to use checksums
3542fdfd13b5da33cebd9e7cc6be97551ab264697951821117f6a143aab285cb
BLAKE2b-256 checksum
How to use checksums
07f42a1cb240b621eecfda7accbfa7c2f8dca42614d970698c813ff9f05876ef
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.16

Release files / updo_sdk-0.6.4-py3-none-any.whl

Download URL updo_sdk-0.6.4-py3-none-any.whl
Size 103.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
091d1fe77ce51bc355756648ee9e7fc4f45209f1ed46c22d24dc2886f8bb6068
BLAKE2b-256 checksum
How to use checksums
ba1844b5afe7f4c3a418ec1188c2ac933008140d43a31ae885e43c0670e01f56
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.16

Security for updo-sdk 0.6.4

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page