# Python SDK

The Python SDK is a Python 3.9+ client for resolving receipt recipients and issuing end-to-end encrypted receipts and credit notes. It also provides client-encrypted receipt downloads, receipt-envelope decryption, integrity helpers, and store management. Models are Pydantic classes generated from the published OpenAPI contract.

The SDK preserves Cheqi's zero-knowledge boundary: your integration supplies the definitive receipt values, the SDK encrypts them locally, and Cheqi receives ciphertext rather than the plaintext receipt body. The SDK does not calculate, enrich, or reconcile receipt values.

Source code and releases: [cheqi-io/cheqi-sdk-python](https://github.com/cheqi-io/cheqi-sdk-python)

## Requirements

- Python 3.9 or newer
- an API key, or an OAuth access token with the required permissions
- a base64-encoded PKCS#8 RSA private key when using recipient-side decryption


## Installation

The implementation documented here is version `2.0.0`.

```bash
pip install cheqi-sdk==2.0.0
```

## Initialization

With an API key:

```python
import os

from cheqi import CheqiSDK, Environment

sdk = CheqiSDK(
    environment=Environment.SANDBOX,
    api_key=os.environ["CHEQI_API_KEY"],
)
```

With per-call OAuth access tokens only, omit `api_key` and pass the token with [`RequestOptions`](#request-options-and-oauth).

Standard environments configure both the API endpoint and customer-facing receipt origin:

| Environment | API endpoint | Receipt origin |
|  --- | --- | --- |
| `Environment.SANDBOX` | `https://sandbox.api.cheqi.io` | `https://sandbox.receipt.cheqi.io` |
| `Environment.TEST` | `https://test.api.cheqi.io` | `https://test.receipt.cheqi.io` |
| `Environment.PRODUCTION` | `https://api.cheqi.io` | `https://receipt.cheqi.io` |


For a custom deployment, configure both URLs if you use download receipts:

```python
sdk = CheqiSDK(
    custom_api_endpoint="http://localhost:8080",
    receipt_download_base_url="http://localhost:5190",
    api_key=os.environ["CHEQI_API_KEY"],
)
```

The constructor also accepts `private_key`, `timeout_seconds`, `max_retries`, and an `http_client` (`httpx.Client`). The SDK leaves an injected client open; a client created by the SDK is closed by `sdk.close()` or when the SDK is used as a context manager.

## Request options and OAuth

Every API method takes a keyword-only `options: RequestOptions`. Use it to act on behalf of a connected company with its OAuth access token, or to override the timeout or retry count for one call. Without an access token, the call uses the configured `api_key`; with neither, it fails with `AUTHENTICATION_FAILED` before any request is sent.

```python
from cheqi import RequestOptions

options = RequestOptions.with_access_token(access_token)
result = sdk.receipt_service.issue_receipt(identification, payload, options=options)
```

The same `RequestOptions` can also set `timeout_seconds` and `max_retries` for a single call, for example `RequestOptions(access_token=access_token, timeout_seconds=60)`.

Company access tokens are short-lived and bound to one Grant, so an integration that serves several companies holds one token per Grant. Request one with the client-credentials grant:

```python
from cheqi.models.generated.client_credentials_token_request import ClientCredentialsTokenRequest

token = sdk.oauth_service.request_access_token(ClientCredentialsTokenRequest(
    client_id=client_id,
    client_secret=client_secret,
    grant_id=str(grant_id),
    scope="receipt:issue",
))

# token.access_token, token.expires_in
```

The SDK does not cache or refresh tokens. Store each token with its expiry in your own token store, keyed by Grant and scope, request a new one before it expires, and pass the current token with `RequestOptions`. See [OAuth 2.0](/authentication/oauth).

## Issue a receipt

Use the models in `cheqi.models` for identification details and the definitive receipt payload:

```python
from datetime import datetime, timezone

from cheqi.models import (
    CardDetails,
    IdentificationDetails,
    PaymentDetails,
    PaymentType,
    Product,
    ReceiptPayload,
    Tax,
    UnitCode,
)

identification = IdentificationDetails(
    payment_type=PaymentType.CARD_PAYMENT,
    card_details=CardDetails(
        payment_account_reference="PAR123456789",
        card_provider="VISA",
    ),
    recipient_email="customer@example.com",
)

product = Product(
    name="Coffee beans",
    brand_name="Cheqi Coffee",
    identifier="SKU-COFFEE-001",
    quantity=1,
    base_quantity=1,
    unit_code=UnitCode.C62,
    unit_price="10.00",
    subtotal="10.00",
    total="12.10",
).add_tax(21, "VAT", "10.00", "2.10")

payload = ReceiptPayload(
    document_number="POS-2026-0001",
    issue_date=datetime.now(timezone.utc),
    currency="EUR",
    receipt_subtotal="10.00",
    total_before_tax="10.00",
    total_tax_amount="2.10",
    total_amount="12.10",
    taxes_applied=True,
    products=[product],
    taxes=[Tax(rate=21, type="VAT", taxable_amount="10.00", amount="2.10", label="VAT 21%")],
    payment_details=PaymentDetails(
        payment_means_code="48",
        description="Card payment",
        card_provider="VISA",
        card_last_four="4242",
        merchant_id="MID-123",
        payment_terminal_id="TID-456",
    ),
)

result = sdk.receipt_service.issue_receipt(identification, payload)

if result.is_accepted:
    print("Receipt accepted:", result.cheqi_receipt_id, result.delivery_route_type)
elif result.download_envelope_required:
    # Generate the final ReceiptEnvelope locally, then complete the fallback.
    pass
```

`ReceiptPayload` is definitive. Supply `taxes_applied`, totals, line values, payment presentation, and jurisdictional data yourself. `Product.add_tax(...)` only appends the supplied tax; it does not calculate it. `IdentificationDetails` is only matching and local fallback context; the SDK does not copy it into `ReceiptPayload.payment_details` on the digital route.

### Card payment with only the last four digits

When the terminal has no PAR or other reliable customer identifier, do not place the four digits in `IdentificationDetails.card_details` as a substitute for matching. Keep `payment_type`, and include the digits in the definitive encrypted payload's `payment_details.card_last_four` as shown above:

```python
qr_identification = IdentificationDetails(payment_type=PaymentType.CARD_PAYMENT)

qr_result = sdk.receipt_service.issue_receipt(qr_identification, payload)

if qr_result.download_url:
    # Render the full URL as a QR code or customer-facing link.
    pass
```

Enable download fallback for this route. The SDK can return one client-encrypted URL without a customer match; show it as a QR at a terminal or as a link and QR on a webshop page. See [Web Wallet](/receipts/qr-collection).

### Store association

`issue_receipt` takes keyword-only `store_id` and `options`:

```python
# Configured API key
sdk.receipt_service.issue_receipt(identification, payload)

# Store ID and the access token of the connected company
sdk.receipt_service.issue_receipt(
    identification,
    payload,
    store_id=store_id,
    options=RequestOptions.with_access_token(access_token),
)
```

## Delivery routes and fallbacks

`ReceiptResult.delivery_route_type` is the selected route and `ReceiptResult.state` the checkout outcome (`DIGITAL_SUBMITTED`, `DIGITAL_PENDING`, `DIGITAL_EXPIRED`, `DOWNLOAD_FALLBACK`, or `EMAIL_FALLBACK`):

- `DIGITAL`: the SDK encrypts the exact serialized payload independently for every matched owner device and submits it immediately.
- `DOWNLOAD_FALLBACK`: the SDK creates and uploads a client-encrypted download when `IdentificationDetails.payment_type` is available. Otherwise, it sets `download_envelope_required`.


An email address in `IdentificationDetails` can identify an existing Cheqi user for `DIGITAL` delivery. Otherwise, the enabled download fallback gives the customer a receipt link they can open in the browser wallet.

If recipient resolution returns `routeFound: false`, `issue_receipt` raises `CheqiSDKError` with error code `CUSTOMER_NOT_FOUND`; it does not return a synthetic customer-not-found result.

### Recover an uncertain digital submission

If `issue_receipt` returns `DIGITAL_PENDING`, retain its `match_id` and the same definitive `ReceiptPayload`. Persist `result.prepared_download` before showing its URL (it includes the secret URL fragment), upload it, and resume the match rather than resolving the customer and issuing a second receipt:

```python
sdk.receipt_service.upload_prepared_download(result.prepared_download)

resumed = sdk.receipt_service.resume_receipt(match_id, payload, store_id=store_id)
```

The SDK checks the original match before submitting again. It returns the original acceptance result if the receipt was already accepted, or a pending result with `retry_after_seconds` while processing continues. An expired, unsubmitted match cannot be resumed. See [Sending Receipts](/receipts/sending#submission-rules).

### Explicit download receipt

For a known customer-without-Cheqi flow, skip matching:

```python
result = sdk.receipt_service.issue_download_receipt(
    IdentificationDetails(payment_type=PaymentType.CASH),
    payload,
)

print(result.download_url)
```

The content key is generated locally and remains in the URL fragment; Cheqi receives only the download ID, ciphertext, and template hash. Anyone with the complete URL can decrypt the receipt, so deliver it through an appropriate customer-facing channel.

If the matched fallback needs a caller-generated final envelope, complete it with:

```python
completed = sdk.receipt_service.complete_download_fallback(
    result,
    receipt_envelope,
    template_hash=template_hash,
)
```

Your integration owns durable storage, scheduling, retry, monitoring, and retention for deferred download work. See [Download links](/receipts/download-fallback) for the encryption and recovery contract.

## Matching and lower-level encryption

Use the service APIs when you need to control matching, encryption, or submission separately:

```python
resolution = sdk.matching_service.match_customer(identification, options=options)

envelope = sdk.receipt_service.generate_encrypted_receipt_envelope(
    resolution, payload, store_id=store_id,
)

response = sdk.receipt_service.submit_encrypted_receipt(envelope, options=options)
```

`generate_encrypted_receipt_envelope` also accepts a retrieved `MATCHED` status from `get_match`. The caller must honor the selected route and preserve identical definitive plaintext across device encryptions.

## Credit notes

Merchant-issued credit notes use the same recipient-resolution and per-device encryption model:

```python
result = sdk.credit_note_service.issue_credit_note(
    identification,
    parent_cheqi_receipt_id,
    definitive_credit_note_payload,
    store_id=store_id,
    options=RequestOptions.with_access_token(access_token),
)
```

The payload can be a Pydantic model, a dictionary, or an already serialized JSON string; it is submitted unchanged through the separate encrypted credit-note endpoint.

Customer return requests are separate from merchant-issued credit notes. Decrypt, deserialize, and validate a polling or webhook request in one call:

```python
request = sdk.decryption_service.decrypt_credit_note_initiation_request(
    encrypted_request,
    private_key_base64,
)
```

The validation covers identifiers, positive quantities, reason codes, note lengths, refund preference, and bank-account requirements. Your integration remains responsible for eligibility, remaining quantities, accepted outcomes, taxes, and the definitive refund. See [Receiving Return Requests](/creditnote/receiving-returns) and [Issuing Credit Notes](/creditnote/issuing-credit-notes).

## Receipt and webhook decryption

`decrypt_receipt` accepts an `EncryptedReceiptDeliveryResponse` from polling or a `WebhookReceiptEnvelope` from `data.encryptedReceipt` or `data.encryptedCreditNote`:

```python
envelope = sdk.decryption_service.decrypt_receipt(encrypted_receipt_delivery, private_key_base64)
```

The plaintext is already a complete `ReceiptEnvelope`; no backend-context merge step is required. For company-expense deliveries, its optional `expense_submission` field contains the attribution metadata.

## Verification

The verification service implements RFC 8785 canonical JSON hashing and Exclusive XML Canonicalization 1.0 hashing:

```python
verification = sdk.verification_service

cheqi_hash = verification.calculate_cheqi_receipt_hash(cheqi_receipt_json)
ubl_hash = verification.calculate_ubl_hash(ubl_purchase_receipt_xml)

json_matches = verification.verify_json_hash(cheqi_receipt_json, cheqi_hash)
xml_matches = verification.verify_xml_hash(ubl_purchase_receipt_xml, ubl_hash)
```

## Store management

Store operations use `UUID` identifiers and require an OAuth access token with the relevant store permissions:

```python
stores = sdk.store_service
options = RequestOptions.with_access_token(access_token)

store = stores.create_store(company_id, create_store_request, options=options)
all_stores = stores.get_stores(company_id, options=options)
active_stores = stores.get_active_stores(company_id, options=options)
selected = stores.get_store(company_id, store_id, options=options)
updated = stores.update_store(company_id, store_id, create_store_request, options=options)

stores.activate_store(company_id, store_id, options=options)
stores.deactivate_store(company_id, store_id, options=options)
stores.delete_store(company_id, store_id, options=options)
```

## Errors

SDK errors derive from `CheqiSDKError`; HTTP failures raise `CheqiApiError`. Both retain the error code, HTTP status, and correlation ID where available:

```python
from cheqi import CheqiSDKError

try:
    sdk.receipt_service.issue_receipt(identification, payload, options=options)
except CheqiSDKError as error:
    print(error, error.error_code, error.http_status_code)
    if error.correlation_id:
        print("Correlation ID:", error.correlation_id)
```

## Available services

`CheqiSDK` exposes services and configuration as properties:

- `receipt_service`
- `credit_note_service`
- `matching_service`
- `encryption_service`
- `decryption_service`
- `download_service`
- `verification_service`
- `store_service`
- `company_service`
- `webhook_service`
- `oauth_service`
- `api_client`
- `config`