Metadata-Version: 2.5
Name: billkit-eu
Version: 0.2.0
Summary: Official Python SDK for BillKit: a Stripe-Billing-shape multi-tenant SaaS API on Mollie.
Project-URL: Homepage, https://billkit.eu
Project-URL: Documentation, https://docs.billkit.eu
Project-URL: Source, https://github.com/billkit-eu/billkit-python
Project-URL: Issues, https://github.com/billkit-eu/billkit-python/issues
Project-URL: Changelog, https://github.com/billkit-eu/billkit-python/blob/main/CHANGELOG.md
Author-email: BillKit <sdk@billkit.eu>
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: billing,billkit,eu,mollie,saas,stripe,subscriptions
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Office/Business :: Financial
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: httpx>=0.28.0
Description-Content-Type: text/markdown

# BillKit Python SDK

Async + sync client for [BillKit](https://billkit.eu), the Stripe-Billing-shape multi-tenant SaaS API on Mollie.

## Install

```bash
pip install billkit-eu
# or
uv add billkit-eu
```

Requires Python 3.11+. The distribution is `billkit-eu` because the bare
`billkit` name on PyPI belongs to an unrelated project. The import name is
unaffected:

```python
import billkit
```

## Quick start

```python
from billkit import BillKit

client = BillKit(api_key="sk_test_...")

customer = client.customers.create(email="ada@example.com", name="Ada Lovelace")
product = client.products.create(
    name="Pro",
    description="Hosted billing for SaaS",
    marketing_features=["Checkout", "Subscriptions"],
)
price = client.prices.create(
    product_id=product["id"],
    amount_cents=999,
    currency="EUR",
    interval="month",
    trial_days=14,
    payment_methods=["creditcard", "directdebit"],
)
session = client.checkout_sessions.create(
    customer_id=customer["id"],
    price_id=price["id"],
    success_url="https://app.example.com/success",
    cancel_url="https://app.example.com/cancel",
)
print(session["url"])  # redirect the user here
```

## One-shot (mandate-less) payments

A one-shot is a single charge with no subscription, mandate or renewals: the Stripe PaymentIntent shape, mapped onto Mollie. Create it, redirect to `redirect_url`, and settle terminal state via the `one_shot_payment.succeeded` / `.failed` webhook events.

```python
payment = client.one_shot_payments.create(
    customer_id=customer["id"],
    amount_cents=2500,
    currency="EUR",
    method="ideal",
    success_url="https://shop.example.com/thanks",
    refund_window_days=14,  # optional; 0 disables refunds, default is 30
)
print(payment["redirect_url"])  # redirect the payer here

# Later, refund it within its window (omit amount_cents for a full refund):
client.refunds.create(one_shot_payment_id=payment["id"])
# ...or refund part of it. A charge can carry several partials:
client.refunds.create(one_shot_payment_id=payment["id"], amount_cents=500)
```

## Finding paused subscriptions

`status` and `renewal_state` answer different questions, and only one of them knows about pausing. `status` is where the subscription stands with its payments (`incomplete`, `trialing`, `active`, `past_due`, `canceled`). `renewal_state` is what happens when the current period ends (`auto_renew`, `paused`, `canceling`, `stopped`). Pausing sets `renewal_state` and leaves `status` at `active`, because the customer has paid for the period they are in:

```python
paused = client.subscriptions.list(renewal_state="paused")

# Both filters take a comma-separated list, and carry onto every page:
for sub in client.subscriptions.iter(status="active,past_due", page_size=100):
    ...
```

`status="paused"` is not an accepted value and raises `InvalidRequestError`.

## Retiring something, and deleting something

`delete()` exists on `customers` and `webhook_endpoints`, and it returns `{"id": ..., "object": ..., "deleted": True}` rather than the object: it has left the API, so there is nothing to hand back. A deleted endpoint takes its delivery rows with it, because those are readable only through the endpoint that owns them; the events stay in `client.events`, which is the record of what you were sent.

The catalogue is retired through its update route instead, because it stays readable afterwards. Prices, products, tax rates and coupons take `active=False`. Each of them has to survive: subscriptions renew against a price by id, an invoice records the VAT percentage a tax rate produced, and a redeemed coupon is part of what a customer was charged.

`status="disabled"` on a webhook endpoint is the other half of the pair, not a substitute for deleting. It stops delivery and keeps the endpoint, its secret and its history, and it can be turned back on.

A price accepts `active` and nothing else, because the amount, currency and interval are fixed at creation. `active` itself moves both ways: it decides what new checkouts may buy, not what anyone was charged. Re-sending the value it already has is a no-op, so a retry is safe.

```python
# Stop selling a price. It stays readable; customers on it keep renewing.
archived = client.prices.update(price["id"], active=False)
assert archived["active"] is False

# Stop sending to an endpoint, without losing its signing secret.
client.webhook_endpoints.update(endpoint["id"], status="disabled")

# Remove a customer. Refused while they hold a subscription that can
# still charge them.
client.customers.delete(customer["id"])  # -> {"deleted": True, ...}
```

## Async

```python
from billkit import AsyncBillKit

async with AsyncBillKit(api_key="sk_test_...") as client:
    customer = await client.customers.create(email="ada@example.com")
```

## Configuration

```python
from billkit import BillKit, RetryPolicy

client = BillKit(
    api_key="sk_test_...",  # or set BILLKIT_API_KEY
    base_url="https://api.billkit.eu",  # override for self-hosted
    timeout=30.0,  # seconds, or pass httpx.Timeout
    retry_policy=RetryPolicy(
        max_attempts=5,
        max_retry_after_seconds=10.0,  # cap 429 Retry-After sleeps
    ),
)
```

The SDK auto-generates an `Idempotency-Key` for every mutating call, so 5xx and short `Retry-After` 429 retries are safe: the server replays the original response when an earlier attempt completed. Pass `idempotency_key=` to coalesce retries across process restarts.

## Errors

```python
from billkit import BillKit, ResourceMissingError, RateLimitError, BillKitError

client = BillKit(api_key="sk_test_...")
try:
    customer = client.customers.retrieve("cus_doesnt_exist")
except ResourceMissingError:
    print("Customer is gone")
except RateLimitError as exc:
    print(f"Rate limited; retry in {exc.retry_after}s")
except BillKitError as exc:
    print(f"BillKit error {exc.status_code}: {exc.message}")
```

All errors inherit from `BillKitError`. Subclasses: `APIConnectionError`, `APIError`, `ServerError`, `AuthenticationError`, `PermissionError`, `ResourceMissingError`, `InvalidRequestError`, `ConflictError`, `RateLimitError`.

## Logging

The SDK is **silent by default**. It owns one logger, `logging.getLogger("billkit")`, with a `NullHandler` attached, and it never calls `basicConfig`, never sets a level, and never adds a handler to a logger it doesn't own. Your logging config is yours.

Turn it on from your application:

```python
import logging

logging.basicConfig()
logging.getLogger("billkit").setLevel(logging.DEBUG)
```

```
DEBUG:billkit:BillKit request POST https://api.billkit.eu/v1/customers (attempt 1/3)
DEBUG:billkit:BillKit response POST https://api.billkit.eu/v1/customers -> 503 in 84ms (request_id=req_9f2a)
WARNING:billkit:BillKit retrying POST https://api.billkit.eu/v1/customers after HTTP 503 (attempt 1) in 500ms
DEBUG:billkit:BillKit response POST https://api.billkit.eu/v1/customers -> 200 in 91ms (request_id=req_9f2b)
```

- **DEBUG**: one line per attempt, one per response (status, elapsed ms, `X-Request-Id`; quote that id to support).
- **WARNING**: one line per retry, with the reason and the delay before the next attempt.

**Never logged:** your API key or the `Authorization` header; request and response **bodies** (they carry customer PII); the **query string** (list filters carry values like `email=`); only the path is logged. The final failure isn't logged either: it's raised as a typed `BillKitError` carrying the status, request id and retry-after, and logging it here too would hand you a duplicate you can't suppress.

### One caveat: httpx's own request line

The promise above covers records **this SDK** writes. `httpx`, the HTTP client underneath, writes its own at `INFO`, and it includes the full URL:

```
INFO:httpx:HTTP Request: GET https://api.billkit.eu/v1/customers?email=ada@example.com "HTTP/1.1 200 OK"
```

`basicConfig()` plus a `DEBUG` level on `billkit` is enough to surface it, so turning BillKit's logging on would otherwise put customer emails in your logs from a logger BillKit never touched. There is no per-client switch for it in httpx.

So when you opt this SDK in, it raises the `httpx` and `httpcore` loggers to `WARNING` — **only** if you have not set a level on them yourself, and **only** for the `httpx` client the SDK created. Both exceptions are deliberate:

- If you have configured `httpx` logging, you made a decision and a billing SDK does not get to overrule it. Silence the request line yourself, or accept the query strings.
- If you passed your own `httpx_client=`, you own its logging as much as its connection pooling.

The check happens when the client is constructed, so configure your logging before you build a `BillKit` / `AsyncBillKit` (the usual startup order).

The logger object is exported if you'd rather wire it up directly:

```python
from billkit import logger

logger.addHandler(my_handler)
```

## Webhook verification

```python
from billkit import WebhookSignature, WebhookVerificationError

# In your FastAPI / Flask / Django handler:
try:
    event = WebhookSignature.verify(
        payload=request.body,
        signature_header=request.headers.get("BillKit-Signature"),
        secret=os.environ["BILLKIT_WEBHOOK_SECRET"],
    )
except WebhookVerificationError:
    return Response(status_code=400)

if event["type"] == "subscription.created":
    handle_new_subscription(event["data"])
```

The verifier enforces a 5-minute timestamp tolerance (replay protection) and constant-time HMAC compare. Pass `tolerance_seconds=` to customise.

## Development

```bash
uv sync --all-extras --dev
uv run pytest
uv run ruff check
uv run mypy src
```

## License

Proprietary.
