Keyban API Client
Python client for the Keyban DPP Passport API. Create and manage model-granularity passports with on-chain certification on Starknet, field-level encryption, and typed Pydantic models.
Features
- Model passports — create and update passports certified on Starknet
- On-chain certification — automatic W3C Verifiable Credential issuance (P-256
ecdsa-jcs-2019) anchored on Starknet, signed content uploaded to IPFS - Selective certification —
certified_pathsto choose which fields fromdatago 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.typedmarker for mypy users
Install
pip install keyban-api-client
Requires Python 3.9+.
Quick start
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")
passport = client.create_passport_model(
application=UUID("your-application-id"),
network="StarknetMainnet",
model_number="my-model-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 a model passport, the backend queues a certification job. The HTTP response returns immediately with token_id populated but ipfs_cid still None. The worker then:
- Builds a W3C Verifiable Credential (
credentialSubject= byte-for-byte certified content) - Uploads the signed VC to IPFS → populates
ipfs_cid - Publishes a certification event on Starknet with the CID, a SHA-256 canonical content hash, and the certifier public key
This typically completes in 15–20 seconds on Mainnet. On subsequent client.get_passport(id) calls, ipfs_cid will be populated. The same asynchrony applies to update_passport_model when the certificate content changes — the response returns the previous CID; the new one appears on a later fetch. If the update does not change the certificate content (e.g. you touched only a field outside certified_paths), the CID stays the same — no re-certification is triggered.
A first-class helper to wait for certification status will land in a future version.
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) |
import base64, os
from keyban_api_client import PassportData
# SHA-256 (one-way hash)
data = PassportData.create_encrypted(
confidential_paths=["supplier_id"],
enc_algorithm="sha256",
name="Public name",
supplier_id="SECRET-123",
)
# AES-256-GCM (symmetric, reversible)
key = base64.b64encode(os.urandom(32)).decode("ascii")
data = PassportData.create_encrypted(
confidential_paths=["serial_number", "brand.supplier_id"], # dot-notation supported
enc_algorithm="aes-256-gcm",
enc_key=key, # omit to auto-generate; read it back via data.encryption_key
name="Public",
serial_number="SN-CONFIDENTIAL",
brand=\{"name": "Public", "supplier_id": "SECRET"\},
)
print(f"Save this key: \{data.encryption_key\}")
passport = client.create_passport_model(
application=UUID("..."),
network="StarknetMainnet",
model_number="enc-model",
data=data.model_dump(),
certified_paths=["serial_number"],
)
Encrypted values carry a encrypted:<algorithm>:<payload> prefix. AES-GCM payloads are versioned (version byte 0x01) and use AAD 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.
Filtering, pagination, listing
from keyban_api_client import FilterOperator
# Backend-supported operators: eq, contains, gt, lt, gte, lte, ne
# Filter values are strings — convert datetimes with .isoformat()
filters = [
FilterOperator(field="granularity", operator="eq", value="model"),
FilterOperator(field="modelNumber", operator="contains", value="lead"),
]
page = client.list_passports(filters=filters, current_page=1, page_size=50)
for p in page.data:
print(f"- \{p.id\} \{p.granularity\} \{p.model_number\}")
print(f"total matching: \{page.total\}")
# Paginate (page_size is capped at 100 by the backend)
all_passports = []
cursor = 1
while True:
resp = client.list_passports(current_page=cursor, page_size=100)
all_passports.extend(resp.data)
if len(resp.data) < 100:
break
cursor += 1
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:
passport = client.create_passport_model(
application=UUID("..."),
network="StarknetMainnet",
model_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_model(
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_model(
passport.id,
data=\{"brand": "NewBrand", "gtin": "3760001000001", "notes": "updated memo"\},
)
# Change the selection itself — re-certification triggers
client.update_passport_model(passport.id, certified_paths=["brand", "gtin", "notes"])
The backend only re-certifies when the resulting certificate content actually changes (content hash differs).
Error handling
from keyban_api_client import PassportClient, KeybanAPIError
with PassportClient(api_key="...") as client:
try:
passport = client.get_passport(some_uuid)
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", ...\}
KeybanAPIError inherits from requests.HTTPError. Its __str__ yields "HTTP \{status\} \{title\}: \{detail\}" so print(e) is self-explanatory; structured access via .status_code and .detail remains available for programmatic flows.
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. |
get_passport | (passport_id: UUID) -> Passport | Any granularity. |
create_passport_model | (*, application, network, model_number, data=None, certified_paths=None) -> Passport | Granularity is always model. |
update_passport_model | (passport_id, *, data=None, certified_paths=None) -> Passport | Re-certification triggers automatically when the certificate content changes. |
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 with automatic date/datetime → ISO conversion and create_encrypted(...) |
FilterOperator | \{field: str, operator: str, value: str\} |
KeybanAPIError | Exception raised on any non-2xx HTTP response. |
Passport fields
| Field | Type | Notes |
|---|---|---|
id | UUID | Passport identifier. |
application | UUID or nested object | Application the passport belongs to. Access via passport.application.id. |
network | str | e.g. "StarknetMainnet", "StarknetSepolia". |
granularity | str | "model", "batch", or "item". This SDK only creates "model"; 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. |
allowed_claim_email | Optional[str] | Email allowed to claim this passport (item granularity only). |
created_at | datetime | Timezone-aware UTC. |
updated_at | datetime | Timezone-aware UTC. |
API endpoints
All operations go through /v1/dpp/passports:
| Endpoint | Method |
|---|---|
/v1/dpp/passports | GET, POST |
/v1/dpp/passports/:id | GET, PATCH |
Migrating to 2.0.0
2.0.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.0 steps below.
Quick upgrade
pip install --upgrade keyban-api-client==2.0.0
Before / after
# 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("..."),
network="StarknetMainnet",
granularity="model",
modelNumber="my-model",
data=\{"brand": "Acme"\},
certifiedPaths=["brand"],
))
client.close()
# AFTER (2.0.0)
from keyban_api_client import PassportClient, PassportData
client = PassportClient(api_key="...") # base_url defaults to prod
passport = client.create_passport_model(
application=UUID("..."),
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, or pytest test_client.py -m api for an end-to-end smoke test |
Constructor signature
PassportClient.__init__ swaps parameter order: api_key comes first, base_url is optional and defaults to "https://api.prod.keyban.io".
# 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.valueis nowstr. Non-string values raise a Pydantic validation error. Convert explicitly:value=str(x),value=dt.isoformat().page_size > 100is no longer silently capped. The backend returnsHTTP 400with a clear error. Usepage_size<=100and paginate.- Missing
confidential_pathsraises.PassportData.create_encrypted(confidential_paths=["typo"], ...)now raisesValueErrorinstead 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". Attributese.status_codeande.detailare unchanged. If your code parsesstr(e), switch to the structured attributes.networkis no longer validated client-side. The backend validates; the SDK passes the string through, so new networks work without a client upgrade.statusis no longer sent in request bodies. Was injected as"published"in 1.0.0; the backend did not require it.
From 0.0.x
Apply the 0.0.x → 1.0.0 steps first:
- Remove any
statusfield from create/update calls. Passports are alwayspublished. certifiedPaths(renamedcertified_pathsin 2.0.0) is available again on model passports. Omit or pass an empty list to certify the fulldata.- The old "product" vocabulary (
ProductClient,ProductFields,CreateProductRequest, …) has been fully replaced. Follow the renames table above.
Then apply the 1.0.0 → 2.0.0 changes listed in this document.
Changelog
See CHANGELOG.md for the full history.
License
MIT — this client is part of the DAP (Digital Asset Platform) by Keyban project.