Metadata-Version: 2.5
Name: distyra
Version: 1.10.0
Summary: Python client for the Distyra Transaction Enrichment API: EU-hosted merchant and counterparty recognition for European bank data, plus cash-flow underwriting
Project-URL: Homepage, https://www.distyra.eu
Project-URL: Documentation, https://api.distyra.com/docs
Project-URL: Pricing, https://www.distyra.eu/pricing
Project-URL: Coverage, https://api.distyra.com/v1/connect/coverage
Author: Brightfield Software
License: MIT
Keywords: counterparty,distyra,europe,gdpr,merchant enrichment,open banking,psd2,sepa,transaction categorization,transaction enrichment,underwriting
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Office/Business :: Financial
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: attrs>=22.2.0
Requires-Dist: httpx<0.29.0,>=0.23.0
Requires-Dist: python-dateutil<3,>=2.8.0
Description-Content-Type: text/markdown

# distyra

Python client for the [Distyra Transaction Enrichment API](https://api.distyra.com/docs): transaction enrichment and cash-flow underwriting for European lenders, built to read EU bank data correctly.

```bash
pip install distyra
```

## What Distyra is

Transaction enrichment and cash-flow underwriting for Europe, run on EU
infrastructure. It turns a raw bank descriptor into a merchant **or an
institutional counterparty** (tax authorities, pension funds, social-security
bodies, utilities), a category, and a transaction type.

- **Published pricing, no sales call.** 10,000 transactions a month free, then
  €0.003 each pay-as-you-go, or €99 a month for 100,000 included. Bank
  connections are €5 per connection per month, the first one free.
  Full table: <https://www.distyra.eu/pricing>
- **Optional PSD2 bank access** from the same vendor and the same API, so a
  connection can go straight through enrichment. The live bank and country list
  is at <https://api.distyra.com/v1/connect/coverage>.
- **Europe only.** The United Kingdom is **not** covered.
- **EU-hosted.** Compute, storage and edge are in the EU. One processing
  exception is declared and scoped in the DPA:
  <https://www.distyra.eu/dpa>


**Base URL:** `https://api.distyra.com` (the SDK default). The API and CDN stay on `.com`; only the Distyra website uses `.eu`.

## Quick start

```python
import os
from distyra import Distyra

client = Distyra(api_key=os.environ["DISTYRA_API_KEY"])

result = client.enrich(
    descriptor="ALBERT HEIJN 1234 AMSTERDAM NL",
    amount=-12.34,
    currency="EUR",
    mcc="5411",
)
# merchant is None for non-merchant transactions (bank fees, transfers), so guard it.
print(result.merchant.name if result.merchant else "(no merchant)")  # "Albert Heijn"
print(result.category.primary)     # "Retail & E-commerce"
print(result.category.source)      # "catalog" (curated) or "llm" (model-proposed)
```

## Batch enrichment

```python
batch = client.enrich_batch([
    {"descriptor": "ALBERT HEIJN 1234 AMSTERDAM NL", "amount": -12.34, "currency": "EUR"},
    {"descriptor": "NS GROEP B.V.", "amount": -4.50, "currency": "EUR"},
    {"descriptor": "NETFLIX.COM", "amount": -13.99, "currency": "EUR"},
])
for item in batch.items:
    print(item)
```

Up to 100 items per call; a failed item occupies its slot with an error envelope, the batch itself never fails.

## Handling unknown merchants (eventual consistency)

The first time a brand-new merchant is enriched, the result may be unresolved
(`result.resolution.status` other than `resolved`). The system then discovers and confirms
that merchant in the background, so the same descriptor resolves shortly after, for every
caller, with no model call.

Read `result.resolution.retry_after`:

- A number (seconds) means a confirmed answer is likely coming. Retry the same descriptor
  once after that delay, or just let your next natural re-sync pick it up.
- `None` means the result is definitive; no retry will help.

```python
result = client.enrich(descriptor="TIMECHIMP UREN UTRECHT NL", country_hint="NL")
if result.resolution.status != "resolved" and result.resolution.retry_after is not None:
    # retry once after result.resolution.retry_after seconds, or wait for your next sync
    ...
```

`resolution.retryable` is a different signal: `True` only for a transient backend error
(`timed_out` / `transient_error`), which you retry within seconds. Full guide:
<https://api.distyra.com/docs>.

## Underwriting

Statement files in, one consolidated analysis out. Multiple files for the same applicant (the "last 6 months as 6 monthly PDFs" case) consolidate into one analysis over the merged window:

```python
analysis = client.analyze_statement(
    ["jan.pdf", "feb.pdf", "mar.pdf"],
    applicant_id="loan-2026-0142",
    input_mode="statement_pdf",     # one format per analysis
    enrich=True,                    # optional: enrich every transaction
)
print(analysis.id, analysis.features)
```

JSON mode, when transactions are already on hand:

```python
analysis = client.analyze(
    applicant_id="loan-2026-0142",
    input_mode="raw",
    accounts=[{
        "account_id": "main",
        "currency": "EUR",
        "opening_balance": 1500.0,
        "transactions": [
            {"date": "2026-05-01", "amount": 2500.0, "descriptor_raw": "INVOICE 1001 ACME BV"},
            {"date": "2026-05-15", "amount": -1200.0, "descriptor_raw": "RENT MAY OFFICE"},
        ],
    }],
)
```

Fetch a stored analysis later with `client.get_analysis(analysis_id)`.

Note: enrichment is self-serve; underwriting endpoints are enabled per organization. Contact sales for access.

## Errors

Every non-2xx raises `DistyraError` with the parsed envelope:

```python
from distyra import Distyra, DistyraError

try:
    client.enrich(descriptor="...")
except DistyraError as e:
    print(e.status_code, e.error, e.detail)
```

## What this SDK sends about itself

Every request carries `User-Agent: distyra-sdk-python/<version>`, with the
version read from the installed distribution's metadata. It exists so Distyra
can tell an SDK call from a bare `httpx` or `requests` call: PyPI download
counts measure who downloaded the library, not who called with it. Distyra
records only the language and the minor version, as counters. The header itself
is not stored and not logged.

**Nothing requires it.** The API does not read it for authentication,
authorisation, routing or rate limiting, and a call without it behaves
identically. It is not part of the OpenAPI contract for that reason: it is a
header we send, not a header we accept.

Anything you pass through `httpx_kwargs` is applied after ours, so your own
value wins.

## Verifying webhooks

Distyra signs every webhook delivery with a Stripe-shape header:

```
X-Distyra-Signature: t=<unix>,v1=<hmac_sha256_hex>
```

`verify_webhook` checks it (HMAC-SHA256 over `"<t>.<raw_body>"`, keyed by the
endpoint's signing secret, with replay protection). Always verify against the
**raw request body**, before JSON parsing.

```python
from distyra import verify_webhook

# Flask
@app.post("/webhooks/distyra")
def distyra_webhook():
    raw = request.get_data()  # exact bytes, before request.get_json()
    if not verify_webhook(WEBHOOK_SECRET, raw, request.headers.get("X-Distyra-Signature")):
        abort(400)
    event = request.get_json()
    # ... handle event ...
    return "", 200
```

`verify_webhook(secret, raw_body, signature_header, *, tolerance_seconds=300)`
returns `True` only when the signature is valid and the timestamp is fresh. The
default replay window is 5 minutes. Stdlib only (`hmac`/`hashlib`).

## Beyond the wrapper

The full generated client (every endpoint, typed models) ships in the same distribution as `distyra_api_client`. The wrapper's `.raw` attribute is a ready-authenticated instance:

```python
from distyra_api_client.api.catalog import get_categories

categories = get_categories.sync(client=client.raw)
```

## Development

This package lives in the Distyra monorepo at `packages/saas-sdk-python/`. The `distyra_api_client` package is generated from the committed OpenAPI snapshot. Do not edit it by hand:

```bash
npm run snapshot -w @brightfield/saas-api   # refresh openapi.json from source
bash scripts/generate-python-sdk.sh         # regenerate distyra_api_client
bash scripts/test-python-sdk.sh             # build wheel + smoke
```
