Skip to main content

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 certificationcertified_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

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:

  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):

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:

AlgorithmReversibleUse case
sha256NoIntegrity / existence proof
aes-256-gcmYesConfidential 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_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.

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 eq on granularity and modelNumber. Other field/operator combinations return HTTP 400. field uses the backend (camelCase) name — modelNumber, not model_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 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)

MethodSignatureNotes
list_passports(filters=None, current_page=1, page_size=10) -> PassportListResponseAny granularity. page_size > 100HTTP 400 from the backend (no client-side check).
get_passport(passport_id: UUID) -> PassportAny 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) -> PassportGranularity is always model.
update_passport_model(passport_id, *, data=None, certified_paths=None) -> PassportRe-certification triggers automatically when the certificate content changes.
close()Closes the HTTP session. Also supports with PassportClient(...) as client:.

Public models

SymbolRole
PassportResponse model for a passport — see full field list below
PassportListResponse\{data: List[Passport], total: int\}
PassportDataHelper 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.
KeybanAPIErrorException raised on any non-2xx HTTP response. Network-level failures are raised as requests.exceptions.* and not wrapped.

Passport fields

FieldTypeNotes
idUUIDPassport identifier.
applicationobject with .id: UUIDApplication the passport belongs to. Access via passport.application.id. The concrete class is internal and not part of the public API.
networkstre.g. "StarknetMainnet", "StarknetSepolia". The SDK forwards the string unchanged; the backend is the source of truth for the accepted set.
granularitystr"model", "batch", or "item". This SDK only creates "model"; reads are agnostic.
model_numberOptional[str]Identifier for model granularity.
batch_numberOptional[str]Identifier for batch granularity.
item_numberOptional[str]Identifier for item granularity.
dataOptional[Dict[str, Any]]Arbitrary passport data.
certified_pathsOptional[List[str]]Dot-notation paths selecting which fields of data go into the certificate. Empty/None ⇒ the entire data is certified.
token_idOptional[str]On-chain identifier, populated immediately on create.
ipfs_cidOptional[str]IPFS CID of the signed VC. None right after create/update — see the "Certification is asynchronous" section.
certification_statusOptional[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_atOptional[datetime]UTC timestamp of the last successful certification, None until the cert job completes. Read-only.
allowed_claim_emailOptional[str]Email allowed to claim this passport (item granularity only).
created_atdatetimeReturned as-is from the backend; expected to be timezone-aware UTC.
updated_atdatetimeReturned as-is from the backend; expected to be timezone-aware UTC.

API endpoints

All operations go through /v1/dpp/passports:

EndpointMethod
/v1/dpp/passportsGET, POST
/v1/dpp/passports/:idGET, 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.02.0.0
DppClientPassportClient
DppPassportPassport
ProductFieldsPassportData
ProductClient (alias)removed — use PassportClient

Removed (with replacement guidance)

RemovedReplacement
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
CreatePassportRequestPass fields directly as keyword arguments to create_passport_model(...)
UpdatePassportRequestPass 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, ApplicationUnused in public flows; made internal or removed
playground.pyUse 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.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:

# 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:

  • 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.1create_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: DppClientPassportClient, ProductFieldsPassportData. See the migration section above.

License

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