How to integrate a Python service
This guide is for Python integrations: a Django/Flask back-office, a Celery worker, a data-pipeline step, or a one-off migration script. Keyban ships an official Python client (keyban-api-client) that wraps the DPP Passport REST API; this guide uses it. For Loyalty or any other surface, call the REST API directly with httpx or requests — the URL surface is identical to the Node guide.
At a glance
- keyban-api-client (DPP)PassportClient — list, get, create, update passport models.
- No webhook helperKeyban doesn't emit outbound webhooks today. Polling only.
- Your Python serviceHolds the X-Api-Key. Talks to Keyban over HTTPS.
- No Loyalty in clientLoyalty endpoints are not wrapped — call them through `requests`.
1. Install the client
The Python client targets Python 3.9+. Install it from PyPI:
pip install keyban-api-client
# or
poetry add keyban-api-client
2. Configure the client
Like every back-office integration, the API key lives in your secret store, not in the codebase.
import os
from keyban_api_client import PassportClient
client = PassportClient(
base_url=os.environ.get("KEYBAN_API", "https://api.prod.keyban.io"),
api_key=os.environ["KEYBAN_API_KEY"],
)
Create the key from Organization → API keys in the Admin Console. The supported scopes and the permissions shape are listed in the Permissions reference.
3. List, read, and create passports
PassportClient exposes synchronous methods only — there is no async variant today. The core four are shown below (list_passports, get_passport, create_passport_item, update_passport_item).
from uuid import UUID
from datetime import date
from keyban_api_client import PassportClient, PassportData, FilterOperator
# List with a filter
response = client.list_passports(
filters=[FilterOperator(field="modelNumber", operator="eq", value="WAL-LEATHER-2026")],
current_page=1,
page_size=10,
)
print(response.total, len(response.data))
# Read one
passport = client.get_passport(passport_id=UUID("550e8400-e29b-41d4-a716-446655440000"))
# Create an item (writes are item-granularity since 3.0)
data = PassportData(name="Heritage Leather Wallet", manufacturing_date=date(2026, 4, 1))
new_item = client.create_passport_item(
application=UUID("..."),
network="BaseSepolia",
item_number="WAL-LEATHER-2026-0001",
model_number="WAL-LEATHER-2026",
data=data.model_dump(),
certified_paths=["name"],
)
# Update an item — resend the FULL data object: PATCH replaces `data`
# wholesale, a partial dict would drop the other fields (here the certified `name`)
updated = PassportData(name="Heritage Leather Wallet", manufacturing_date=date(2026, 5, 1))
client.update_passport_item(
passport_id=new_item.id,
data=updated.model_dump(),
)
Need to create more than a handful of passports? Skip the per-row loop and use the bulk import endpoint covered in Section 4 — one HTTP call, asynchronous processing, idempotent on re-upload.
4. Bulk-import a CSV catalogue
For initial catalogue migrations or periodic ERP/PIM syncs, POST /v1/imports accepts a JSON array of passports and processes them asynchronously. The client doesn't wrap this endpoint — call it through requests. The body shape is the same CreateDppPassportDto discriminated union as the unitary POST /v1/dpp/passports, but the application is passed as a query parameter, not in each item.
import csv
import os
import time
import requests
KEYBAN_API = os.environ.get("KEYBAN_API", "https://api.prod.keyban.io")
APP_ID = os.environ["KEYBAN_APP_ID"]
HEADERS = {"X-Api-Key": os.environ["KEYBAN_API_KEY"]}
# 1. Build the JSON array from the CSV
rows = []
with open("catalogue.csv", newline="") as f:
for row in csv.DictReader(f):
rows.append({
"granularity": "model",
"network": "BaseSepolia",
"modelNumber": row["sku"],
"name": row["name"],
"data": {"gtin": row["gtin"], "material": row.get("material", "")},
"certifiedPaths": ["gtin"],
})
# 2. Submit the upload — synchronous response carries the job envelope
job = requests.post(
f"{KEYBAN_API}/v1/imports",
params={"applicationId": APP_ID, "entityName": "DppPassport"},
json=rows,
headers=HEADERS,
).json()
job_id = job["id"]
print(f"Job {job_id} queued — {len(rows)} rows")
# 3. Poll the report until every child row is in a terminal state
while True:
report = requests.get(
f"{KEYBAN_API}/v1/imports/{job_id}/report",
params={"currentPage": 1, "pageSize": len(rows)},
headers=HEADERS,
).json()
terminal = sum(1 for c in report["data"] if c["status"] in ("completed", "failed"))
if terminal == report["total"]:
break
print(f" {terminal}/{report['total']} done")
time.sleep(2)
# 4. Surface the failures
for child in report["data"]:
if child["status"] == "failed":
print(f"row {child['entityData']['modelNumber']} → {child['error']}")
Key properties of the import endpoint:
- Idempotent: the unique tuple
(application, granularity, modelNumber, batchNumber, itemNumber)triggers an upsert. Re-running the same export is a no-op when nothing changed, deterministic update otherwise. - Per-row validation: a malformed row fails its own child job (
status: "failed",error: "...") — the rest of the upload keeps processing. The HTTP200only means "upload accepted"; correctness is verified through the report. - Resumable: a partially-failed import can be re-submitted as-is once the offending rows are fixed.
For Server-Sent Events streaming and the full failure-handling reference, see Bulk import in the PHP guide — same payload shape, the only platform-specific bit is the HTTP client.
5. Handle errors
keyban-api-client raises a single exception type — KeybanAPIError, a subclass of requests.HTTPError. The structured error body is parsed and exposed on error.detail:
from keyban_api_client import KeybanAPIError
try:
client.create_passport_item(application=..., network=..., item_number=..., data={}, certified_paths=[])
except KeybanAPIError as exc:
# exc.status_code is the HTTP status
# exc.detail is the parsed JSON body (status, title, detail, errors[])
log.error(
"Keyban call failed (status %s): %s — %s",
exc.status_code,
exc.detail.get("title"),
exc.detail.get("detail"),
)
if exc.status_code == 429:
time.sleep(int(exc.response.headers.get("Retry-After", "1")))
There are no per-category subclasses (no RateLimitedError, no ValidationError) — branch on status_code and on the fields of the parsed detail. The full strategy is in How to handle Keyban API errors.
6. Loyalty and other surfaces — call REST directly
The client only wraps DPP Passports. For Loyalty (mint, reward tiers, orders), call the REST API with requests:
import os, requests
KEYBAN_API = os.environ.get("KEYBAN_API", "https://api.prod.keyban.io")
HEADERS = {"X-Api-Key": os.environ["KEYBAN_API_KEY"]}
# Mint loyalty points
r = requests.post(
f"{KEYBAN_API}/v1/loyalty/account/{account_id}/mint",
json={"amount": 600},
headers=HEADERS,
)
r.raise_for_status()
print(r.json()) # {"newamount": 600}
Reference
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST | /v1/dpp/passports | X-Api-Key | Create a DPP passport (one row). Wrapped by client.create_passport_item. |
GET | /v1/dpp/passports | X-Api-Key | List passports. Wrapped by client.list_passports. |
POST | /v1/imports | X-Api-Key | Bulk-import DPP passports (async, upsert). Not wrapped — call directly. |
GET | /v1/imports/:jobId/report | X-Api-Key | Per-row status report for an import job. |
POST | /v1/loyalty/account/:id/mint | X-Api-Key | Mint points (call directly — not wrapped). |
Related
- PHP integration — Create a passport — same JSON shape, works as a reference for any language.
- How to handle Keyban API errors
- Python SDK reference (auto-generated)