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")
app_id = UUID("00000000-0000-0000-0000-000000000000") # ← replace with your application UUID
passport = client.create_passport_model(
application=app_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, ipfs_cid still None, and certification_status="pending". 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
- Marks the passport as
certification_status="certified"and stampscertified_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):
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_model 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) |
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_model(
application=app_id,
network="StarknetMainnet",
model_number="enc-model",
data=data.model_dump(),
certified_paths=["serial_number"],
)
Do not log the AES key. Treat
data.encryption_keylike 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)whereversion = 0x01,nonceis 12 bytes, and the AEAD tag (16 bytes) is appended to the ciphertext bycryptography's AESGCM. AAD is the literal byte stringb"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
# Filter values are strings — convert datetimes with .isoformat(), UUIDs with str(...)
filters = [
FilterOperator(field="granularity", operator="eq", value="model"),
FilterOperator(field="modelNumber", operator="eq", value="my-model-001"),
]
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 > 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
Today the public passport endpoint only accepts
eqongranularityandmodelNumber. Other field/operator combinations return HTTP 400.fielduses the backend (camelCase) name —modelNumber, notmodel_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:
passport = client.create_passport_model(
application=app_id,
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 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 whereKeybanAPIErroris raised). Catchrequests.RequestExceptionalongsideKeybanAPIErrorif 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_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 — 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. |
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. |
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 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. |
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
All operations go through /v1/dpp/passports:
| Endpoint | Method |
|---|---|
/v1/dpp/passports | GET, POST |
/v1/dpp/passports/:id | GET, PATCH |
Migrating to 2.0
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
pip install --upgrade keyban-api-client
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("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".
# 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.
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:
# 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 alwayspublishedfrom the user's perspective; the SDK injectsstatus: "published"internally on create where the backend currently requires it. certifiedPaths(renamedcertified_pathsin 2.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. - Move custom-field reads from
passport.<field>topassport.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:
- 2.0.2 — Documentation pass: filter example aligned with the operators the backend actually accepts (
eqongranularity/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_modelre-injectsstatus: "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.