Skip to main content

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

  1. keyban-api-client (DPP)PassportClient — list, get, create, update passport models.
  2. No webhook helperKeyban doesn't emit outbound webhooks today. Polling only.
  3. Your Python serviceHolds the X-Api-Key. Talks to Keyban over HTTPS.
  4. No Loyalty in clientLoyalty endpoints are not wrapped — call them through `requests`.
The keyban-api-client wraps the DPP Passport API. Loyalty and other surfaces are not covered by the client — call them through plain 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 four synchronous methods. There is no async variant today.

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 a model
data = PassportData(name="Heritage Leather Wallet", manufacturing_date=date(2026, 4, 1))
new_model = client.create_passport_model(
application=UUID("..."),
network="StarknetSepolia",
model_number="WAL-LEATHER-2026",
data=data.model_dump(),
certified_paths=["name"],
)

# Update a model
client.update_passport_model(
passport_id=new_model.id,
data={"manufacturing_date": "2026-05-01"},
)

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": "StarknetSepolia",
"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 HTTP 200 only 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_model(application=..., network=..., model_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

MethodPathAuthPurpose
POST/v1/dpp/passportsX-Api-KeyCreate a DPP passport (one row). Wrapped by client.create_passport_model.
GET/v1/dpp/passportsX-Api-KeyList passports. Wrapped by client.list_passports.
POST/v1/importsX-Api-KeyBulk-import DPP passports (async, upsert). Not wrapped — call directly.
GET/v1/imports/:jobId/reportX-Api-KeyPer-row status report for an import job.
POST/v1/loyalty/account/:id/mintX-Api-KeyMint points (call directly — not wrapped).