Skip to main content

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

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:

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

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

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:

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.

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

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

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_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) -> PassportGranularity 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) -> PassportWhen 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) -> SelfHostedDraftBuilds + 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) -> strPins 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

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.
SelfHostedDraftReturned 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.
KeybanAPIErrorException 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_PATTERNCompiled regex a valid content hash must match (^0x0[0-7][0-9a-f]\{62\}$).

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 creates "item" only; 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.
certificate_uriOptional[str]https URL of a self-hosted (deletable) signed VC, set instead of ipfs_cid when the issuer hosts the credential.
certificate_signatureOptional[str]The self-hosted VC's signature (proof.proofValue), anchored on-chain so verify-dpp pins the served credential.
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

The client targets these DPP endpoints:

EndpointMethod
/v1/dpp/passportsGET, POST
/v1/dpp/passports/:idGET, PATCH
/v1/dpp/passports/build-vcPOST (used by prepare_self_hosted_item)
/v1/dpp/opt-in-formsPOST (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.x3.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_modelcreate_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

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:

  • 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.0Passport.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.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.