Click here to book a no-obligation intro call!

Posted by Markus Wals

weclappy 1.0: the Python client that truly understands the weclapp API

API Open Source Python weclapp weclappy

weclappy 1.0: the Python client that truly understands the weclapp API

As of today, weclappy 1.0.1 is on PyPI. It is the first stable release of our Python client for the weclapp REST API, and it is more than a version number: weclappy implements weclapp's complete load management contract, never retries a write it can't vouch for, and turns the quirks of API v2 (custom fields, referencedEntities, additionalProperties, pagination) into perfectly normal Python objects.

This article is deliberately long. It is meant to be the reference we would have wanted ourselves when we built our first weclapp integrations in Python. Here you'll find the short version of the docs, practical examples from our daily work at wals.pro, and worked calculations that show why the individual decisions make sense.

weclappy is independent and community-maintained, not a weclapp product. At wals.pro we build fulfillment integrations, integrations and AI tools around weclapp. The library is the piece of infrastructure all of that runs on.

Five lines that show it all

from weclappy import Weclapp

client = Weclapp.for_tenant("acme", api_key)
orders = client.get_all(
    "salesOrder",
    {"status-eq": "ORDER_CONFIRMED", "includeReferencedEntities": "customerId"},
)
print(orders[0].customer.name)  # customerId is resolved to the referenced party

Behind these five lines this happens: sort=id is set, page 1 is read, /count is queried if needed, the remaining pages run in parallel under an adaptive controller, duplicates and gaps are detected, and orders[0].customer finds itself via referencedEntities. You write only the business logic.

Why Python, and why you no longer need your own client

Before we get into the technology, a word on the values behind weclappy, because they explain most of the design decisions.

Python is quick to build and still scales. A script that reconciles article prices this afternoon can be a FastAPI application with webhook endpoints next week, without changing the language or the client. We run weclappy in Cloud Run, in containers and in plain cron jobs. One pip install, one container, done. No build step, no generated code, no SDK generator whose output you have to re-check after every API update.

Lightweight and documented. Two runtime dependencies (requests, urllib3), Python 3.12+, py.typed, mypy strict, 97% test coverage including an in-process fake server that simulates queueing, 429 and dropped connections. The README is the documentation, and it describes every decision with its reasoning.

Built for working with AI. In our experience, AI assistants write the most reliable integration code in Python, and a typed, fully documented client turns that into a real lever: you give the assistant the README, and it uses get_all, iter_keyset or to_payload() correctly instead of inventing its own pagination. We experience this daily, because our weclapp MCP server sits on exactly this library.

The weclapp API is supported completely and cleanly. Official endpoints, custom fields in all eleven value types, referencedEntities, additionalProperties, file upload and download, method calls like createSalesInvoice, plus the unofficial endpoints clearly labeled with a fallback.

And from that follows the actual point: it's no longer worth building your own client. Not because sending an HTTP request to weclapp would be hard. But because a client that holds up in production needs a long list of behaviors that you only understand after the third incident.

Ten capabilities a production-ready weclapp client needs, from queue headers and retry budgets to write protection and typed errors, all included in weclappy 1.0.

How weclapp manages load, and what that means for your code

The most important thing first, because it explains most integration problems: weclapp has no rate limit per minute. weclapp limits the number of concurrently active requests per tenant. The number is not published and can vary per tenant and over time. Requests above this limit land in a queue, currently for up to about 30 seconds, after which weclapp responds with HTTP 429.

Load is accounted for as request time, not request count. A few large requests are cheaper than many small ones.

Every response to a request that waited in the queue carries two headers:

Header Meaning
X-Weclapp-Wait-Ms Time the request spent in weclapp's queue. A report, not an instruction.
X-Weclapp-Wait-Reason concurrency (too many requests active), load (overall load too high) or both. Appears on 2xx and 429.

And you can set two headers yourself:

Header Meaning
X-Weclapp-Wait-Timeout-Ms Longest queue wait time you accept. Can only lower the server default.
X-Weclapp-Request-Timeout-Ms Longest server-side processing time. Beyond that weclapp returns a 400 request_timeout.

weclapp explicitly asks clients to throttle proactively as soon as wait times rise, instead of waiting for a 429, to back off exponentially after a 429, and to automatically retry only safe methods. That is exactly what weclappy does.

Worked example: why “just go parallel” backfires

Take a tenant with an (assumed, unpublished) limit of 5 concurrent requests and requests that each need 2 seconds of processing.

Your client sends at the same time Last request waits in the queue Result
5 0 s everything immediately
25 4 waves × 2 s = 8 s goes through, but every response reports concurrency
100 19 waves × 2 s = 38 s from about 30 s: HTTP 429 for the rest

A naive thread pool with 100 workers is therefore no faster than one with 5, it just produces queue time and 429 responses. And the worst part: every 429 after a full queue has already occupied the tenant for 30 seconds. You don't know the limit, it changes, and it is shared by all of the tenant's integrations. The only stable way is to read the headers and align your own concurrency with them.

Adaptive concurrency: the AIMD controller

weclappy keeps a target per client (or per tenant, if you share the controller): the number of parallel reads currently allowed. Every read takes a permit before sending and returns it with the response. The target is adjusted once per epoch, where an epoch is target completed responses long.

Event Effect on the target
Start 2
Upper limit 10 (Weclapp(max_concurrency=...))
Wait reason concurrency or wait time ≥ 250 ms −1 (at most one decrease per epoch)
Wait reason load or wait time ≥ 2,000 ms halved, rounded up (at most one decrease per epoch)
HTTP 429 1, plus a shared cooldown of at least 2 s during which no request goes out
5xx or transport error no change, but no growth in this epoch
Full epoch, window fully used, no decrease, no error +1

Step line of the target concurrency over completed responses: growth from 2 to 10, decrease by one on four simultaneous concurrency hints, halving on load, crash to 1 on HTTP 429, then recovery. Generated with the library's real controller.

Two details in this graphic are the difference between a controller that works day to day and one that annoys:

At most one decrease per epoch. Responses arrive in bursts. If four concurrently running reads all report load, they describe the same moment. A controller that halves per response would drop from 10 to 1 in a single round (10 → 5 → 3 → 2 → 1). weclappy reacts once, observes the effect, and then reacts again if necessary. In the graphic you can see this at “4× wait reason: concurrency → only −1”.

Growth only on saturation. The target only grows if the caller actually used the slots. A sequential caller doesn't drive the target up, and after a 429 the controller recovers instead of staying at 1 forever (a bug our internal 0.7.0 branch had, which was never released).

Writes hold no permit, but wait for an active 429 cooldown. A request into a queue that has just answered 429 only gets the next 429.

Sharing one controller per tenant

The limit applies per tenant, not per process. If several clients (say a webhook handler and a sync job in the same process) serve the same tenant, they share one controller:

from weclappy import ConcurrencyController, ConcurrencySettings, Weclapp

shared = ConcurrencyController(ConcurrencySettings(max_concurrency=8))

sync = Weclapp.for_tenant("acme", api_key, concurrency=shared)
webhooks = Weclapp.for_tenant("acme", api_key, concurrency=shared)

This pays off twice: the clients don't compete against each other for the queue, and a warm controller doesn't start at 2 every time (see the worked example in the next section).

Pagination you can trust

get_all is the method you'll use most often, and in 1.0 with the default threaded="auto" it does the following:

  1. If params contains neither sort nor orderBy, weclappy sets sort=id. Offset pages without a stable order shift rows across page boundaries.
  2. Page 1 is read sequentially.
  3. If page 1 is short, the read is done: no count, no thread pool, exactly one request. Most small reads cost exactly one request.
  4. Otherwise weclappy queries GET {entity}/count with the filter part of the parameters. If the count exceeds max_records, the read aborts before another page is read.
  5. Pages 2 to N run in parallel under the controller, at most min(max_workers, target) at the same time.
  6. The pages are merged in page order. A duplicate ID across pages or fewer rows than counted raises WeclappPaginationError: the data changed during the read, you should repeat the read.

Worked example: 50,000 sales orders

Bar chart of the model calculation for 50,000 sales orders: sequential 40.0 seconds, weclappy 1.0 with a cold controller 8.3 seconds, with a shared warm controller 5.1 seconds. Assumptions: 50 pages of 1,000 rows, 0.8 seconds per page, 0.3 seconds for count.

This is a model calculation, not a measurement, but it calculates exactly with the library's rules. Assumptions: 50 pages of 1,000 rows, 0.8 s per page, 0.3 s for /count, no queue wait time.

Variant Calculation Duration
weclappy 0.6, sequential 50 × 0.8 s 40.0 s
weclappy 1.0, cold controller Page 1 (0.8 s) + count (0.3 s) + waves with 2, 3, 4, … 10 parallel pages (9 × 0.8 s) 8.3 s
weclappy 1.0, warm shared controller Page 1 (0.8 s) + count (0.3 s) + 49 pages in 5 waves of 10 (5 × 0.8 s) 5.1 s

Three things stand out. First: the factor between sequential and parallel is just under five to eight, not ten, even though the upper limit is 10. The controller feels its way up, and that is intentional: it doesn't know what limit the tenant currently has. Second: a shared, already warm controller saves another three-plus seconds because the ramp-up phase is skipped. Third: all three variants generate the same request time on the server. Concurrency shortens your wait time, it doesn't reduce the load. For that you need the next section.

Worked example: 3,721 articles, three access patterns

Three metric tiles for 3,721 articles: 3,721 requests with one GET per article, 8 requests with get_by_ids in chunks of 500 IDs, 5 requests and 0.6 seconds with get_all on the weclapp sandbox.

The number on the right tile is measured: 3,721 articles in 0.6 s with 5 requests on the weclapp sandbox (API v2, October 2026). Four pages of 1,000 plus one /count, read in parallel.

The left tile is the pattern we see most often in hand-written clients: an ID list from another system, then one GET article/id/{id} per ID. 3,721 requests. Each one occupies a slot in the tenant queue, each one counts as request time.

The middle is get_by_ids: it reads the rows with id-in=[...] in parallel chunks. The chunk size comes from sandbox measurements: the edge in front of weclapp (Akamai) rejects URLs from about 8.9 KB with HTTP 400, 640 numeric IDs fit below that, 500 leaves headroom for longer IDs and further parameters. 3,721 ÷ 500 gives 8 chunks.

rows = client.get_by_ids("article", ids, {"properties": "id,articleNumber,name"})

A detail that would otherwise cost you an afternoon: id-in is paginated like any other list. Without an explicit pageSize you only get back the default page size of hits. weclappy sends pageSize=len(chunk) per chunk.

For large projections (many fields, items, references) there is strategy="ids", the pattern weclapp itself recommends: first read only the IDs with all filters, then the rows with the full projection via get_by_ids.

orders = client.get_all(
    "salesOrder",
    {
        "status-eq": "ORDER_CONFIRMED",
        "properties": "id,orderNumber,customerId,orderItems",
        "includeReferencedEntities": "customerId",
    },
    strategy="ids",
)

Exports that lose no row: iter_keyset

Offset pagination is not a snapshot. If a record is created or deleted during a long export, the page boundaries shift. weclappy detects duplicates and gaps, but doesn't repair them. For exports and reports there is therefore keyset pagination with sort=id and id-gt=<last ID>:

for party in client.iter_keyset("party", {"properties": "id,company", "partyType-eq": "CUSTOMER"}):
    export(party)

Keyset iteration is immune to inserts and deletes during the read, and with start_after="<id>" you resume an aborted export exactly where it stopped. This is the iterator for the nightly reconciliation into the data warehouse.

max_records as an emergency brake

client.get_all("article", {"active-eq": "true"}, max_records=50_000)

A filter that suddenly matches everything because of a typo is stopped before the first parallel request, not after a minute of full load on the tenant.

Retries that fit the API, and writes that never run twice

In 0.6.0, a urllib3 Retry adapter hung on the client that retried POST, PUT and DELETE on 500, 502, 503, 504 and 429. That sounds harmless and isn't: a 5xx or a 429 after queue wait time doesn't prove that the write was not processed. weclapp may long since have booked the invoice before the response was lost. A repeated POST salesInvoice then creates a second invoice, a repeated stock booking books twice.

In 1.0 there is exactly one rule: a write is only retried if the connection demonstrably never came about.

Decision tree after a failed write: connection never established leads to a retry from the transient budget; 5xx, 429, read timeout or dropped connection lead to WeclappTransportError with outcome_unknown and a read-back plan; 4xx errors are raised typed.

Condition Reads Writes
Connection never established (DNS, refused, connect timeout) Retry, transient budget Retry, transient budget
Outcome unknown (read timeout, connection lost after sending) Retry never; WeclappTransportError with outcome_unknown=True
500, 502, 503, 504 Retry, transient budget never
429 Retry, rate-limit budget, starts the cooldown never (but waits for the cooldown)
400 request_timeout, 409 persistence Retry, problem budget never
3xx never followed; WeclappRedirectError never followed

The recipe for the unknown outcome: read the record back via a business key you control (customer number, external reference, a marker in the notes field).

import time
from weclappy import WeclappTransportError

def create_party_once(client, customer_number):
    try:
        return client.post(
            "party",
            {"partyType": "ORGANIZATION", "company": "Acme", "customerNumber": customer_number},
        )
    except WeclappTransportError as exc:
        if not exc.outcome_unknown:
            raise  # demonstrably never sent, safe to retry later
        for delay in (0, 2, 5, 10, 20, 30):
            time.sleep(delay)
            found = client.get("party", params={"customerNumber-eq": customer_number})
            if found:
                return found[0]  # the write went through
        raise  # still unknown: escalate instead of writing again

Two budgets, one cap

Line chart of wait times before retry attempts: rate-limit budget 2, 4, 8, 16, 32 seconds; transient budget 0.3, 0.6, 1.2 seconds; dashed cap line at 60 seconds. Next to it a tile: zero retries for POST, PUT, DELETE after sending.

Budget Default Wait time before attempt n (0-based)
Transient (5xx, network) 3 0.3 · 2ⁿ s plus up to 0.3 s jitter
Rate limit (429) 5 2 · 2ⁿ s plus up to 2 s jitter
Problem (request_timeout, persistence) 1 0.3 · 2ⁿ s plus jitter

Every wait time, including a Retry-After from the server, is capped at max_backoff (60 s). The reason is a real incident from development: a Retry-After: 3600 blocked all reads for an hour, an infinite value crashed every later acquire() with OverflowError. Both are closed in 1.0. Every logical request has its own counters, so a 429 doesn't consume the transient budget.

Timeouts with a margin

Setting Default Purpose
Client timeout 120 s requests timeout per attempt, also limits waiting for a permit
X-Weclapp-Wait-Timeout-Ms 30,000 longest accepted queue wait time
X-Weclapp-Request-Timeout-Ms 110,000 deliberately below the client timeout

The 10 seconds of margin are the point: without them, client and server give up at the same moment, and a write ends in an ambiguous read timeout instead of a definitive 400 request_timeout. If you set a shorter timeout per request, weclappy automatically lowers the request timeout header to 90% of the read time, so that an impatient client doesn't leave long-running executions behind on the server.

Entities: custom fields and references like normal fields

API v2 delivers custom fields as an array of attributeDefinitionId plus typed value, references as a separate referencedEntities section and computed values as additionalProperties next to the result. All correct, all tedious. weclappy flattens this onto a WeclappEntity, a dict subclass with attribute access.

On the left the raw response of the weclapp API v2 with customAttributes array, referencedEntities and additionalProperties; on the right the same data as flat attributes on the WeclappEntity: order.lieferfenster, order.customer.company, order.totalGross, plus the way back via client.put.

  • Fields as attributes: order.orderNumber is order["orderNumber"]. Dict code keeps working.
  • Custom fields flat: Every customAttributes element appears as a field under its attributeKey. weclappy loads the definitions once per client, lazily, under a lock, at the first row that needs them. All eleven value types work in both directions.
  • References resolved: order.customer resolves order.customerId against referencedEntities, even if the bucket has a different name (customerId → party). Nested, it works the same way: order.orderItems[0].article.articleNumber.
  • additionalProperties merged: Per row as a field, entity.additional_properties lists them.
articles = client.get(
    "article",
    params={
        "properties": "id,articleNumber,unitId,unit:id,unit:name",
        "additionalProperties": "currentSalesPrice",
        "includeReferencedEntities": "unitId",
        "pageSize": 5,
    },
)
for article in articles:
    print(article.articleNumber, article.currentSalesPrice, article.unit.name)

And the way back is just as short. to_payload() is the only supported way into a write: it discards merged additionalProperties, rebuilds customAttributes, omits resolved reference objects and converts nested entities recursively. The write methods call it for you.

article = client.get("article", "4384", {"properties": "id,version,customAttributes"})
article.lieferfenster = "KW 43"   # an existing, writable custom field
client.put("article", article.id, article)

Two safety rules we built in deliberately: custom fields defined as readOnly raise locally before a request goes out. And the flat interface never adds a custom field that is missing from the entity. You send new attributes explicitly as a v2 customAttributes element. That way a typo in the attribute name can't cause a silent wrong booking.

Optimistic locking in four lines

from weclappy import WeclappOptimisticLockError

for _attempt in range(3):
    order = client.get("salesOrder", "4384", {"properties": "id,version,commission"})
    try:
        client.put("salesOrder", order.id, {"version": order.version, "commission": "B-17"})
        break
    except WeclappOptimisticLockError:
        continue  # re-read, apply the change again

put sends ignoreMissingProperties=true by default, so a partial payload only changes the fields it contains. For full replacement you pass {"ignoreMissingProperties": False}.

The hidden endpoints, clearly labeled

In its hidden OpenAPI document, weclapp lists endpoints that help enormously in everyday work but carry no compatibility promise. weclappy offers them, marks them as unofficial and recommends a fallback for each.

Method Endpoint What for
query() POST {entity}/query Filter expression in the body, so no URL limit for long id in [...] lists
query_count() POST {entity}/count Count with body filter
batch_query() POST batch/query up to 500 relative GET queries in one call
openapi(include_hidden=True) GET meta/openapi.yaml your tenant's OpenAPI document including hidden endpoints
from weclappy import WeclappAPIError

try:
    rows = client.query("article", filter=f"id in [{','.join(ids)}]", properties=["id", "name"], page_size=len(ids))
except WeclappAPIError:
    rows = client.get_by_ids("article", ids, {"properties": "id,name"})  # official fallback

What we measured on the sandbox: batch/query accepts at most 500 requests per call, the response does not come back in request order (weclappy sorts by index), and /id/{id} paths are rejected within a batch. POST {entity}/query sorts with orderBy, not with sort. All four count as reads: they hold a permit and are retried like GET.

Observability, without secrets in the log

Every physical attempt, including a failed one, calls your on_response hook with a RequestMetrics object: method, path without query string, status, duration, queue wait time, wait reason, correlation ID, attempt number, retry decision, current controller target.

from weclappy import RequestMetrics, Weclapp

def record(metrics: RequestMetrics) -> None:
    if metrics.wait_ms:
        print(f"{metrics.method} {metrics.path} wartete {metrics.wait_ms:.0f} ms ({metrics.wait_reason})")

client = Weclapp.for_tenant("acme", api_key, on_response=record)

client.stats aggregates everything, including request_seconds, the estimated server-side processing time, which is exactly the quantity in which weclapp measures load. This shows you which job really loads your tenant. Logs and metrics never contain the API key, never query strings, never bodies.

Extension points instead of monkeypatching

import requests
from weclappy import OutgoingRequest, RetryPolicy, Weclapp

def tag(request: OutgoingRequest) -> None:
    request.headers["X-Correlation-ID"] = f"sync-job-{request.attempt}"

client = Weclapp.for_tenant(
    "acme",
    api_key,
    session=requests.Session(),   # proxies, certificates, custom adapters
    before_request=tag,
    retry_policy=RetryPolicy(max_retries=2, rate_limit_retries=8, max_backoff=30.0),
)

Plus request() as a public escape hatch for endpoints without a helper, with the same load control, the same retries and the same parsing as every other method. The private methods _send_request and _check_response from 0.x are gone, and that's a good thing: everything they were needed for now goes through public, versioned interfaces.

Safety guardrails you don't have to switch off

  • Redirects are never followed. A 3xx raises WeclappRedirectError. A 307/308 can't repeat a write body, and the API key can't follow a redirect to a foreign host.
  • Same-origin guard. Endpoints are paths relative to base_url. Absolute URLs, foreign hosts, .. segments and fragments raise ValueError.
  • Path segments are encoded. An ID from a webhook payload containing /, ? or # can't reach a different endpoint.
  • Typed errors. WeclappNotFoundError, WeclappValidationError, WeclappOptimisticLockError, WeclappRateLimitError, WeclappPaginationError and more, all under WeclappAPIError with the parsed problem document. An except WeclappAPIError still catches everything.

Host anywhere, no fuss

Because weclappy only needs requests and urllib3 and is thread-safe, it runs wherever Python runs:

  • Cloud Run or a container anywhere: one client per process, shared by all threads, closed as a context manager.
  • Cloud Functions, Firebase Functions, Lambda, Azure Functions: one client at module level, so that the connection pool and the warm controller are preserved across invocations.
  • Web applications with FastAPI, Flask or Django: one client as an application resource, shared by webhook handlers and background jobs.
  • Cron and scripts: pip install weclappy, API key from the environment, done.
import os
from weclappy import Weclapp

client = Weclapp.for_tenant(os.environ["WECLAPP_TENANT"], os.environ["WECLAPP_API_KEY"])

def handler(event, context):
    open_orders = client.count("salesOrder", {"status-eq": "ORDER_CONFIRMED"})
    return {"openOrders": open_orders}

Always keep the API key in the environment or your platform's secret manager. weclappy sends it as AuthenticationToken and keeps it out of all logs, but an exc.response.request.headers still contains it. Don't serialize raw requests objects into error reporters.

Migrating from 0.x

1.0 breaks the API deliberately, once and cleanly. The complete list is in the README, the key points:

  • Python ≥ 3.12. 3.9 to 3.11 are no longer supported.
  • Package instead of module. Import public names only from weclappy.
  • Keyword-only. Every constructor argument after api_key, in get_all everything after params.
  • Renamings. id → entity_id, endpoint → entity. Positional use is unchanged, id= still works in 1.x with a DeprecationWarning.
  • get_all is now parallel (threaded="auto"). For strictly sequential reads use threaded=False.
  • Writes are never retried. Handle WeclappTransportError.outcome_unknown with a read-back.
  • Additional requests. A large get_all sends one /count, the first row with custom fields loads customAttributeDefinition once per client. Test doubles must answer both.

In applications, pin weclappy>=1.0,<2. The public API is frozen under semantic versioning from now on, deprecations run at least one minor release before removal, and the three newest CPython versions are supported.

What comes next

On the roadmap are bulk writes with the same outcome safety as single writes for 1.1 and an asynchronous variant for version 2. Both are outlooks, not commitments with a date.

Get started

python -m pip install weclappy
export WECLAPP_API_KEY="dein-api-key"

If you're planning a weclapp integration or want to stabilize an existing one that regularly runs into 429s or creates duplicate documents: let's talk for 30 minutes. Based on your jobs, we'll show you what concurrency your tenant can handle, and where weclappy takes code off your hands.

License: MIT. weclappy is an independent community project and has no connection to weclapp.