Metadata-Version: 2.4
Name: keyban-api-client
Version: 4.0.0
Summary: Python client for the Keyban DPP Passport API
Author-email: Keyban <support@keyban.io>
License: MIT License
        
        Copyright (c) 2024 Keyban
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
        
Project-URL: Homepage, https://keyban.io
Keywords: keyban,dpp
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.31.0
Requires-Dist: pydantic>=2.0.0
Requires-Dist: typing-extensions>=4.0.0
Requires-Dist: cryptography>=41.0.0
Requires-Dist: rfc8785>=0.1.4
Provides-Extra: dev
Requires-Dist: pytest>=7.0.0; extra == "dev"
Requires-Dist: python-dotenv>=0.19.0; extra == "dev"
Dynamic: license-file

# Keyban API Client

Python client for the Keyban DPP Passport API. Create and manage item-granularity passports with on-chain certification on Starknet, field-level encryption, clear-content hash anchoring for trustless verification, and typed Pydantic models. Reads are granularity-agnostic.

## Features

- **Item passports** — create and update passports certified on Starknet; items are the granularity that certifies the free-form `data` blob
- **Clear-content hash anchoring** — anchor the hash of the PLAINTEXT `data` on-chain so end users can verify an encrypted passport from the clear certificate file (verify-dpp drag-and-drop)
- **On-chain certification** — automatic W3C Verifiable Credential issuance (P-256 `ecdsa-jcs-2019`) anchored on Starknet, signed content uploaded to IPFS
- **Selective certification** — `certified_paths` to choose which fields from `data` go into the certificate; updating non-certified fields skips re-certification
- **Field-level encryption** — SHA-256 hashing (integrity) or AES-256-GCM (reversible) via `PassportData`
- **Typed responses** — Pydantic v2 models; `py.typed` marker for mypy users

## Install

```bash
pip install keyban-api-client
```

Requires Python 3.9+.

## Quick start

```python
from uuid import UUID
from keyban_api_client import PassportClient

client = PassportClient(api_key="your-api-key")
# base_url defaults to https://api.prod.keyban.io — pass it explicitly for staging/local:
#   PassportClient(api_key="...", base_url="https://api.staging.keyban.io")

app_id = UUID("00000000-0000-0000-0000-000000000000")  # ← replace with your application UUID

passport = client.create_passport_item(
    application=app_id,
    network="StarknetMainnet",
    item_number="my-item-001",
    data={"brand": "Acme", "gtin": "3760001000001"},
    certified_paths=["brand", "gtin"],
)

print(f"Passport ID: {passport.id}")
print(f"Token ID:    {passport.token_id}")       # derived immediately
print(f"IPFS CID:    {passport.ipfs_cid}")       # None right after creation — see below
```

### Certification is asynchronous

When `data` is provided on an item passport, the backend queues a certification job. The HTTP response returns immediately with `token_id` populated, `ipfs_cid` still `None`, and `certification_status="pending"`. The worker then:

1. Builds a W3C Verifiable Credential (`credentialSubject` = byte-for-byte certified content)
2. Uploads the signed VC to IPFS → populates `ipfs_cid`
3. Publishes a certification event on Starknet with the CID, a SHA-256 canonical content hash, and the certifier public key
4. Marks the passport as `certification_status="certified"` and stamps `certified_at`

Latency depends on the network: a few seconds on devnet, typically 15–20 seconds on Mainnet. Poll on `certification_status` to detect the transition (`"pending"` → `"certified"`, or `"error"` if all retries are exhausted):

```python
import time

while True:
    p = client.get_passport(passport.id)
    if p.certification_status == "certified":
        break
    if p.certification_status == "error":
        raise RuntimeError("certification failed")
    time.sleep(3)
```

Re-certification (triggered by an `update_passport_item` that changes the certified content) flips `certification_status` back to `"pending"` until the new on-chain anchor lands; on success, `ipfs_cid` and `certified_at` are updated to the new values. If the update does not change the certificate content (e.g. you touched only a field outside `certified_paths`), the status stays `"certified"` and the CID is unchanged — no re-certification is triggered.

## Encryption

Protect sensitive fields locally before sending them to the API:

| Algorithm | Reversible | Use case |
|-----------|------------|----------|
| `sha256`  | No  | Integrity / existence proof |
| `aes-256-gcm` | Yes | Confidential data (decrypt with the key) |

```python
import base64, os
from keyban_api_client import PassportData

# SHA-256 (one-way hash) — encryption_key is NOT set on this path
data = PassportData.create_encrypted(
    confidential_paths=["supplier_id"],
    enc_algorithm="sha256",
    name="Public name",
    supplier_id="SECRET-123",
)
# data.model_dump() → {"name": "Public name", "supplier_id": "encrypted:sha256:<hex>"}

# AES-256-GCM (symmetric, reversible). Omit `enc_key` to let the SDK generate one;
# read it back via `data.encryption_key` (only set on the AES path).
data = PassportData.create_encrypted(
    confidential_paths=["serial_number", "brand.supplier_id"],  # dot-notation supported
    enc_algorithm="aes-256-gcm",
    name="Public",
    serial_number="SN-CONFIDENTIAL",
    brand={"name": "Public", "supplier_id": "SECRET"},
)
key_to_persist = data.encryption_key  # base64-encoded 32 bytes — store this in your secret manager
# data.model_dump() → {"serial_number": "encrypted:aes-256-gcm:<b64>", ...}

passport = client.create_passport_item(
    application=app_id,
    network="StarknetMainnet",
    item_number="enc-item",
    data=data.model_dump(),
    certified_paths=["serial_number"],
)
```

> **Do not log the AES key.** Treat `data.encryption_key` like any other secret — if it leaks into logs, the chiffré can be reversed.

### Payload format

Encrypted values carry an `encrypted:<algorithm>:<payload>` prefix.

- **SHA-256** payload is hex of `sha256(canonical_json_value)`. Irreversible — used for proof of existence/integrity only.
- **AES-256-GCM** payload is `base64(version || nonce || ciphertext || tag)` where `version = 0x01`, `nonce` is 12 bytes, and the AEAD tag (16 bytes) is appended to the ciphertext by `cryptography`'s AESGCM. AAD is the literal byte string `b"v1:aes-256-gcm"`.

**Security**: `create_encrypted` raises `ValueError` if a `confidential_paths` entry does not exist in the data. This prevents silently shipping unencrypted secrets on a typo.

## Item passports & clear-content hash

Item passports certify their `data` blob on-chain — use them when each passport
is a unique object (one certificate per lead, one passport per physical unit).
When `data` fields are encrypted client-side, the backend can only hash the
ciphertext; pass `content_hash` so the on-chain event anchors the hash of the
**plaintext** instead, making the certificate verifiable by its holder:

```python
import json
from keyban_api_client import PassportClient, PassportData

pd = PassportData.create_encrypted(
    confidential_paths=["first_name", "last_name"],
    enc_algorithm="aes-256-gcm",
    name="ADO-LEAD-001",
    segment="ASSURANCE-EMPRUNT",
    first_name="Jane",
    last_name="Doe",
)

passport = client.create_passport_item(
    application=app_id,
    network="StarknetMainnet",
    item_number="ADO-LEAD-001",
    data=pd.model_dump(),          # encrypted payload, stored by Keyban
    content_hash=pd.content_hash,  # hash of the CLEAR data, anchored on-chain
)

# Hand THIS file to the end user (plus pd.encryption_key, shared separately):
# dropping it in verify-dpp recomputes the same hash and finds the product.
with open("certificate.json", "w") as f:
    json.dump(pd.clear_data, f, ensure_ascii=False)
```

How the hash is computed (`compute_content_hash(data)`): JCS canonicalization
(RFC 8785) of `{"type": "Data", "data": data}`, SHA-256, then the first byte is
masked `& 0x07` so the value fits a Starknet felt252. Identical byte-for-byte
to the backend and verify-dpp implementations.

Rules to keep verification working:

- **Always derive the hash from the data you send** — pass
  `pd.content_hash`, never a cached value. A stale hash sent with new data is
  undetectable server-side and breaks verification for the end user.
- **Updates must resend the hash**: `update_passport_item(id, data=...,
  content_hash=...)` in the SAME call. Updating content without a hash makes
  the backend fall back to its server-computed (ciphertext) hash.
- The hash covers the **full clear blob** — a `certified_paths`-filtered
  subset will not match what the verify-dpp drop zone recomputes.
- JCS constraints: `NaN`/`Infinity` floats and integers beyond ±(2**53 − 1)
  raise `ValueError` (JavaScript cannot canonicalize them either). Use strings
  for large numeric identifiers.

## Filtering, pagination, listing

```python
from keyban_api_client import FilterOperator

# Filter values are strings — convert datetimes with .isoformat(), UUIDs with str(...)
filters = [
    FilterOperator(field="product.idGranularity", operator="eq", value="item"),
    FilterOperator(field="product.itemNumber", operator="eq", value="my-item-001"),
]
page = client.list_passports(filters=filters, current_page=1, page_size=50)

for p in page.data:
    print(f"- {p.id} {p.item_number}")
print(f"total matching: {page.total}")

# Paginate. page_size > 100 returns HTTP 400 (not silently capped).
all_passports = []
cursor = 1
while True:
    resp = client.list_passports(current_page=cursor, page_size=100)
    all_passports.extend(resp.data)
    if not resp.data or len(all_passports) >= resp.total:
        break
    cursor += 1
```

> The public passport endpoint accepts `eq` on `application.id`, `status`, `product.idGranularity`, `product.modelNumber`, `product.batchNumber`, `product.itemNumber`, `mintedTo.id`, and `contains` on `product.name`. Other field/operator combinations return HTTP 400. `field` uses the backend (camelCase) path — `product.itemNumber`, not `item_number`.

## Selective certification with `certified_paths`

By default (or with `certified_paths=[]`) the full `data` object is certified. Pass specific paths to certify only a subset — updating non-certified fields won't trigger re-certification:

```python
passport = client.create_passport_item(
    application=app_id,
    network="StarknetMainnet",
    item_number="selective",
    data={"brand": "Acme", "gtin": "3760001000001", "notes": "internal memo"},
    certified_paths=["brand", "gtin"],
)

# Update a non-certified field — NO re-certification
client.update_passport_item(
    passport.id,
    data={"brand": "Acme", "gtin": "3760001000001", "notes": "updated memo"},
)

# Update a certified field — re-certification triggers; new ipfs_cid after the job runs
client.update_passport_item(
    passport.id,
    data={"brand": "NewBrand", "gtin": "3760001000001", "notes": "updated memo"},
)

# Change the selection itself — re-certification triggers
client.update_passport_item(passport.id, certified_paths=["brand", "gtin", "notes"])
```

The backend only re-certifies when the resulting certificate content actually changes (content hash differs).

## Self-hosted (deletable) certificates

By default the signed certificate is uploaded to IPFS (immutable). To host it
yourself — so you can **delete** it later (GDPR / data-protection) — prepare a
draft: the platform builds and signs the exact VC *without* uploading it, you
host those bytes at your own `https://` URL, then anchor that URL on-chain.

```python
# `pd`: a PassportData built as in "Item passports & clear-content hash" above.
draft = client.prepare_self_hosted_item(
    application=app_id,
    network="StarknetMainnet",
    item_number="ADO-LEAD-001",
    data=pd.model_dump(),          # same content rules as create_passport_item
    content_hash=pd.content_hash,
)

cert_url = "https://certs.example.com/ADO-LEAD-001.jsonld"
# 1. Host draft.certificate (the signed VC, a JSON-LD dict) at cert_url using
#    YOUR OWN infra (S3, a static server, …) — it must be reachable over https.
upload_to_your_host(cert_url, draft.certificate)   # <- your code, not the SDK

# 2. Anchor that URL on-chain — reuses the exact content captured above:
passport = draft.create(certificate_uri=cert_url)
# ...or point an existing passport at the hosted credential:
# passport = draft.update(passport_id, certificate_uri=cert_url)

# Poll passport.certification_status as usual ("certified" once anchored).
```

The on-chain event anchors both the `certificate_uri` **and** the VC's signature
(`proof.proofValue`), so verify-dpp pins the served credential to its on-chain
record — a swapped or tampered document fails verification. A `SelfHostedDraft`
is single-use and fails closed if the prepared credential is unsigned.

`certificate_uri` / `certificate_signature` are also exposed directly on
`create_passport_item()` / `update_passport_item()` (and on `Passport`) for
callers signing/hosting through their own pipeline — but `prepare_self_hosted_item`
is the recommended path: it guarantees the hosted bytes and the on-chain content
hash match. See `scripts/demo_self_hosted_cert.py` for a runnable example.

## Opt-in form upload

`upload_opt_in_form()` pins a consent-form image (PNG/JPEG/WebP) to IPFS and
returns an `ipfs://<cid>` URI. Add it under the reserved `certificate.opt_in_form`
path **before** computing `content_hash`, so the anchored hash covers the form
and verify-dpp can display it:

```python
uri = client.upload_opt_in_form("opt-in.png")   # or: content=<bytes>, media_type="image/jpeg"

pd = PassportData(name="ADO-LEAD-001", certificate={"opt_in_form": uri})
passport = client.create_passport_item(
    application=app_id,
    network="StarknetMainnet",
    item_number="ADO-LEAD-001",
    data=pd.model_dump(),
    content_hash=pd.content_hash,
)
```

`certificate.opt_in_form` is auto-kept in the signed VC even under selective
`certified_paths`. See `scripts/demo_adomos_lead.py` for the full lead flow
(encryption + opt-in form + self-hosting).

## Error handling

```python
from uuid import UUID
import requests
from keyban_api_client import PassportClient, KeybanAPIError

with PassportClient(api_key="...") as client:
    try:
        passport = client.get_passport(UUID("00000000-0000-0000-0000-000000000000"))
    except KeybanAPIError as e:
        print(e)                 # e.g. "HTTP 404: Not Found"
        print(e.status_code)     # 404
        print(e.detail)          # full RFC 7807 body: {"status", "title", "detail", ...}
    except requests.RequestException as e:
        # DNS failures, connection refused, read timeouts — see note below
        print(f"network error: {e}")
```

`KeybanAPIError` inherits from `requests.HTTPError` and is raised on **any non-2xx HTTP response**. Its `__str__` renders as `"HTTP {status} {title}: {detail}"`, with one shortcut: when `title == detail` (common for `404 Not Found`, `401 Unauthorized`, …) the message collapses to `"HTTP {status}: {title}"`. Structured access via `.status_code` and `.detail` remains available for programmatic flows.

> **Network-level failures are not wrapped.** Connection refused, DNS failures, TLS errors, and read timeouts surface as raw `requests.exceptions.*` (they never reach the HTTP layer where `KeybanAPIError` is raised). Catch `requests.RequestException` alongside `KeybanAPIError` if you need a single safety net.

## API reference

### `PassportClient(api_key, base_url="https://api.prod.keyban.io", api_version="v1", timeout=30)`

| Method | Signature | Notes |
|---|---|---|
| `list_passports` | `(filters=None, current_page=1, page_size=10) -> PassportListResponse` | Any granularity. `page_size > 100` → `HTTP 400` from the backend (no client-side check). |
| `get_passport` | `(passport_id: UUID) -> Passport` | Any granularity. The `UUID` type is advisory — strings are forwarded as-is and only validated server-side. |
| `create_passport_item` | `(*, application, network, item_number, model_number=None, product_name=None, data=None, certified_paths=None, content_hash=None, certificate_uri=None, certificate_signature=None) -> Passport` | Granularity is always `item`. `content_hash` (from `PassportData.content_hash` or `compute_content_hash`) is anchored on-chain in place of the server-computed hash. `certificate_uri`/`certificate_signature` self-host the credential (prefer `prepare_self_hosted_item`). No claim parameters — items created here are never minted. |
| `update_passport_item` | `(passport_id, *, data=None, certified_paths=None, content_hash=None, certificate_uri=None, certificate_signature=None) -> Passport` | When changing `data`, resend the matching `content_hash` in the same call. |
| `prepare_self_hosted_item` | `(*, application, network, item_number, model_number=None, product_name=None, data=None, certified_paths=None, content_hash=None) -> SelfHostedDraft` | Builds + signs the VC (via `build-vc`) without uploading; host `draft.certificate`, then `draft.create/update(certificate_uri=...)`. |
| `upload_opt_in_form` | `(file_path=None, *, content=None, media_type="image/png", filename=None) -> str` | Pins an opt-in form image to IPFS; returns `ipfs://<cid>`. Provide exactly one of `file_path` or `content`. |
| `close()` | — | Closes the HTTP session. Also supports `with PassportClient(...) as client:`. |

### Public models

| Symbol | Role |
|---|---|
| `Passport` | Response model for a passport — see full field list below |
| `PassportListResponse` | `{data: List[Passport], total: int}` |
| `PassportData` | Helper to build the `data` dict — accepts arbitrary keyword arguments (`extra='allow'`); converts `date` to ISO strings and exposes `create_encrypted(...)`. **Note:** `datetime` values are truncated to date (the time component is dropped) — pre-format with `dt.isoformat()` if you need full timestamps. |
| `SelfHostedDraft` | Returned by `prepare_self_hosted_item`. `.certificate` (signed VC to host), `.create(*, certificate_uri)`, `.update(passport_id, *, certificate_uri)`. Single-use; reuses the prepared content so the hosted VC matches the on-chain hash. |
| `FilterOperator` | `{field: str, operator: str, value: str}` — values are forwarded with no client-side validation. |
| `KeybanAPIError` | Exception raised on any non-2xx HTTP response. Network-level failures are raised as `requests.exceptions.*` and not wrapped. |
| `compute_content_hash` | `(data: Dict) -> str` — felt252-masked SHA-256 of the JCS-canonicalized clear data; what `contentHash` must contain. |
| `CONTENT_HASH_PATTERN` | Compiled regex a valid content hash must match (`^0x0[0-7][0-9a-f]{62}$`). |

### `Passport` fields

| Field | Type | Notes |
|---|---|---|
| `id` | `UUID` | Passport identifier. |
| `application` | object with `.id: UUID` | Application the passport belongs to. Access via `passport.application.id`. The concrete class is internal and not part of the public API. |
| `network` | `str` | e.g. `"StarknetMainnet"`, `"StarknetSepolia"`. The SDK forwards the string unchanged; the backend is the source of truth for the accepted set. |
| `granularity` | `str` | `"model"`, `"batch"`, or `"item"`. This SDK creates `"item"` only; reads are agnostic. |
| `model_number` | `Optional[str]` | Identifier for model granularity. |
| `batch_number` | `Optional[str]` | Identifier for batch granularity. |
| `item_number` | `Optional[str]` | Identifier for item granularity. |
| `data` | `Optional[Dict[str, Any]]` | Arbitrary passport data. |
| `certified_paths` | `Optional[List[str]]` | Dot-notation paths selecting which fields of `data` go into the certificate. Empty/`None` ⇒ the entire `data` is certified. |
| `token_id` | `Optional[str]` | On-chain identifier, populated immediately on create. |
| `ipfs_cid` | `Optional[str]` | IPFS CID of the signed VC. `None` right after create/update — see the "Certification is asynchronous" section. |
| `certificate_uri` | `Optional[str]` | https URL of a self-hosted (deletable) signed VC, set instead of `ipfs_cid` when the issuer hosts the credential. |
| `certificate_signature` | `Optional[str]` | The self-hosted VC's signature (`proof.proofValue`), anchored on-chain so verify-dpp pins the served credential. |
| `certification_status` | `Optional[str]` | `"pending"` while the certify job is queued or retrying, `"certified"` once the on-chain transaction succeeds, `"error"` once all retries are exhausted. Read-only. |
| `certified_at` | `Optional[datetime]` | UTC timestamp of the last successful certification, `None` until the cert job completes. Read-only. |
| `allowed_claim_email` | `Optional[str]` | Email allowed to claim this passport (item granularity only). |
| `created_at` | `datetime` | Returned as-is from the backend; expected to be timezone-aware UTC. |
| `updated_at` | `datetime` | Returned as-is from the backend; expected to be timezone-aware UTC. |

### API endpoints

The client targets these DPP endpoints:

| Endpoint | Method |
|----------|--------|
| `/v1/dpp/passports` | GET, POST |
| `/v1/dpp/passports/:id` | GET, PATCH |
| `/v1/dpp/passports/build-vc` | POST (used by `prepare_self_hosted_item`) |
| `/v1/dpp/opt-in-forms` | POST (used by `upload_opt_in_form`) |

## Migrating to 3.0

The model write methods are gone: since the backend only certifies free-form
`data` on **item** passports, the model write path no longer produced any
certification (no VC, no IPFS CID, no on-chain event). Reads are unchanged.

| 2.x | 3.0 |
|---|---|
| `create_passport_model(model_number=...)` | `create_passport_item(item_number=...)` |
| `update_passport_model(id, ...)` | `update_passport_item(id, ...)` |
| `product_id=` (was a silent no-op) | removed |

Everything else (reads, `PassportData`, encryption, filters, errors) is
unchanged. While migrating, consider anchoring the clear-content hash — see
"Item passports & clear-content hash" above.

## Migrating to 2.0

> **Historical.** The `create_passport_model` / `update_passport_model`
> methods referenced below were the 2.x API and are removed in 3.0. Coming
> from 1.x or earlier, apply these steps then "Migrating to 3.0" above
> (`create_passport_model` → `create_passport_item`).

2.0 focuses the public surface on passport-model operations. If you are on
0.0.x, apply both the `0.0.x → 1.0.0` steps and the `1.0.0 → 2.0` steps below.

### Quick upgrade

```bash
pip install --upgrade keyban-api-client
```

### Before / after

```python
# BEFORE (1.0.0)
from keyban_api_client import DppClient, CreatePassportRequest, ProductFields

client = DppClient(
    base_url="https://api.prod.keyban.io",
    api_key="...",
)
passport = client.create_passport(CreatePassportRequest(
    application=UUID("00000000-0000-0000-0000-000000000000"),
    network="StarknetMainnet",
    granularity="model",
    modelNumber="my-model",
    data={"brand": "Acme"},
    certifiedPaths=["brand"],
))
client.close()

# AFTER (2.0)
from keyban_api_client import PassportClient, PassportData

client = PassportClient(api_key="...")  # base_url defaults to prod
passport = client.create_passport_model(
    application=UUID("00000000-0000-0000-0000-000000000000"),
    network="StarknetMainnet",
    model_number="my-model",
    data={"brand": "Acme"},
    certified_paths=["brand"],
)
client.close()
```

### Renames

| 1.0.0 | 2.0.0 |
|---|---|
| `DppClient` | `PassportClient` |
| `DppPassport` | `Passport` |
| `ProductFields` | `PassportData` |
| `ProductClient` (alias) | removed — use `PassportClient` |

### Removed (with replacement guidance)

| Removed | Replacement |
|---|---|
| `client.create_passport(data)` | `client.create_passport_model(**kwargs)` — kwargs-only; granularity always `model` |
| `client.update_passport(id, data)` | `client.update_passport_model(id, **kwargs)` |
| `client.delete_passport(id)` | No SDK replacement — model passports anchor on-chain records and are intended to remain addressable |
| `CreatePassportRequest` | Pass fields directly as keyword arguments to `create_passport_model(...)` |
| `UpdatePassportRequest` | Pass fields directly as keyword arguments to `update_passport_model(...)` |
| `create_filter(field, op, value)` | `FilterOperator(field=..., operator=..., value=...)` |
| `search_passports(client, filters)` | `client.list_passports(filters=filters).data` |
| `QueryParams`, `DynamicFieldDef`, `Application` | Unused in public flows; made internal or removed |
| `playground.py` | Use the examples in this README. End-to-end tests (`test_client.py -m api`) are kept in the source repository, not shipped on PyPI. |

### Constructor signature

`PassportClient.__init__` swaps parameter order: `api_key` comes first, `base_url` is optional and defaults to `"https://api.prod.keyban.io"`.

```python
# 1.0.0 — base_url required and first
DppClient(base_url="...", api_key="...")

# 2.0.0 — api_key first; base_url optional
PassportClient(api_key="...")
PassportClient(api_key="...", base_url="https://api.staging.keyban.io")
```

### Write methods: kwargs-only, snake_case

`create_passport_model` and `update_passport_model` take named keyword arguments only. `granularity` is no longer part of the public surface (it is always `"model"` for these methods). Camel-case aliases (`modelNumber`, `certifiedPaths`) are not accepted — use `model_number`, `certified_paths`.

### Behavioral changes

- **`FilterOperator.value` is now `str`.** Non-string values raise a Pydantic validation error. Convert explicitly: `value=str(x)`, `value=dt.isoformat()`.
- **`page_size > 100` is no longer silently capped.** The backend returns `HTTP 400` with a clear error. Use `page_size<=100` and paginate.
- **Missing `confidential_paths` raises.** `PassportData.create_encrypted(confidential_paths=["typo"], ...)` now raises `ValueError` instead of logging a warning and leaving the data unencrypted — this prevents silent security failures.
- **`KeybanAPIError.__str__` format changed.** `print(e)` now renders `"HTTP 403 Forbidden: Invalid API key."` instead of just `"Forbidden"`. Attributes `e.status_code` and `e.detail` are unchanged. If your code parses `str(e)`, switch to the structured attributes.
- **`network` is no longer validated client-side.** The backend validates; the SDK passes the string through, so new networks work without a client upgrade.

### Read access on key passport fields

In 0.0.x, custom data fields lived at the top level of `Product`/`DppPassport` (`product.name`, `product.brand`, …). On `Passport`, custom data is nested under `data`:

```python
# BEFORE (0.0.x)
name = passport.name

# AFTER (2.0)
name = (passport.data or {}).get("name")
```

Anything not in the closed field list (`id`, `application`, `network`, `granularity`, `model_number`, `batch_number`, `item_number`, `data`, `certified_paths`, `token_id`, `ipfs_cid`, `allowed_claim_email`, `created_at`, `updated_at`) is now in `passport.data`.

### From 0.0.x

Apply the `0.0.x → 1.0.0` steps first:

- Move any `status`-driven workflow off the request body. Passports are always `published` from the user's perspective; the SDK injects `status: "published"` internally on create where the backend currently requires it.
- `certifiedPaths` (renamed `certified_paths` in 2.0) is available again on model passports. Omit or pass an empty list to certify the full `data`.
- The old "product" vocabulary (`ProductClient`, `ProductFields`, `CreateProductRequest`, …) has been fully replaced. Follow the renames table above.
- Move custom-field reads from `passport.<field>` to `passport.data["<field>"]` (see the section above).

Then apply the `1.0.0 → 2.0` changes listed in this document.

## Changelog

The full history is maintained in `CHANGELOG.md` in the source repository. Highlights:

- **3.0.0** — BREAKING: model write methods removed, writes are item-granularity (`create_passport_item` / `update_passport_item`). Added clear-content hash anchoring (`content_hash`, `compute_content_hash`, `PassportData.content_hash` / `.clear_data`). See "Migrating to 3.0" above.
- **2.1.0** — `Passport.certification_status` / `certified_at` added; recommended polling pattern switched to `certification_status == "certified"`.
- **2.0.2** — Documentation pass: filter example aligned with the operators the backend actually accepts (`eq` on `granularity` / `modelNumber`); AES payload format documented; `KeybanAPIError` `__str__` and network-error handling clarified; `passport.<field>` → `passport.data["<field>"]` migration note added.
- **2.0.1** — `create_passport_model` re-injects `status: "published"` to match the backend's Draft/Published flow (passports are certifiable immediately on create again).
- **2.0.0** — Public surface scoped to model-granularity passports. Renames: `DppClient` → `PassportClient`, `ProductFields` → `PassportData`. See the migration section above.

## License

MIT — this client is part of the DAP (Digital Asset Platform) by Keyban project.
