Python SDK
The Qpher Python SDK is a thin wrapper around the Qpher REST API. All cryptographic operations execute server-side â the SDK handles authentication, serialization, retries, and error mapping.
Installationâ
pip install qpherRequirements: Python 3.9 or later. No native dependencies.
Client Setupâ
from qpher import Qpher, QpherError
client = Qpher(
api_key="qph_your_key_here",
base_url="https://api.qpher.ai", # default
timeout=30, # seconds, default: 30
max_retries=3, # default: 3
)
| Parameter | Type | Default | Description |
|---|---|---|---|
api_key | str | required | Your Qpher API key (starts with qph_) |
base_url | str | https://api.qpher.ai | API base URL |
timeout | int | 30 | Request timeout in seconds |
max_retries | int | 3 | Number of retries for transient errors (429, 502, 503, 504) |
Never hard-code API keys in source files. Load them from environment variables or a secrets manager:
client = Qpher(api_key=os.environ["QPHER_API_KEY"])
KEM Encryption and Decryptionâ
Qpher uses Kyber768 (ML-KEM-768) for key encapsulation. The SDK accepts raw bytes for plaintext and returns bytes for ciphertext. Base64 encoding on the wire is handled automatically.
Encryptâ
result = client.kem.encrypt(
plaintext=b"sensitive data to protect",
key_version=1,
)
print(result.ciphertext) # bytes â the encrypted payload
print(result.key_version) # int â the key version used
print(result.request_id) # str â UUID for tracing
Hybrid encryption (Pro/Enterprise): Pass
algorithm="X-Wing"for hybrid X25519 + ML-KEM-768:result = client.kem.encrypt(
plaintext=b"sensitive data to protect",
key_version=1,
algorithm="X-Wing",
)
| Parameter | Type | Required | Description |
|---|---|---|---|
plaintext | bytes | Yes | The data to encrypt |
key_version | int | Yes | Key version to use (must be active) |
algorithm | str | No | "Kyber768" (default) or "X-Wing" (hybrid, Pro/Enterprise) |
mode | str | No | "standard" (default) or "deterministic" |
salt | bytes | No | Required when mode="deterministic" |
Returns: EncryptResult with fields ciphertext (bytes), key_version (int), algorithm (str), and request_id (str).
Decryptâ
result = client.kem.decrypt(
ciphertext=encrypted_ciphertext,
key_version=1,
)
print(result.plaintext) # bytes â the original data
print(result.request_id) # str
| Parameter | Type | Required | Description |
|---|---|---|---|
ciphertext | bytes | Yes | The encrypted payload to decrypt |
key_version | int | Yes | Key version used during encryption (active or retired) |
algorithm | str | No | "Kyber768" (default) or "X-Wing" (hybrid, Pro/Enterprise) |
Returns: DecryptResult with fields plaintext (bytes), key_version (int), algorithm (str), and request_id (str).
Qpher does not support implicit "latest" key selection. You must specify the key_version for every operation. This prevents silent key-mismatch errors and ensures auditability.
Key Wrappingâ
Wrap and unwrap existing symmetric keys (AES, HMAC) using quantum-safe KEM. See the Key Wrap / Unwrap guide for details.
# Wrap a symmetric key
result = client.kem.wrap(
symmetric_key=aes_key, # bytes â 16, 24, 32, 48, or 64 bytes
key_version=1,
)
print(result.wrapped_key) # bytes â store this safely
print(result.key_version) # int
print(result.algorithm) # str â "Kyber768"
# Unwrap to recover the original key
result = client.kem.unwrap(
wrapped_key=result.wrapped_key,
key_version=1,
)
print(result.symmetric_key) # bytes â the original key
| Parameter | Type | Required | Description |
|---|---|---|---|
symmetric_key / wrapped_key | bytes | Yes | The symmetric key to wrap, or the wrapped key to unwrap |
key_version | int | Yes | Key version (wrap: active only; unwrap: active or retired) |
algorithm | str | No | "Kyber768" (default) or "X-Wing" (hybrid) |
Raw KEM (Encapsulate / Decapsulate)â
Low-level KEM operations for advanced use cases. These give you the raw shared secret and KEM ciphertext without the KEM-DEM wrapper.
# Encapsulate â generates a shared secret and KEM ciphertext
result = client.kem.encapsulate(key_version=1)
print(result.shared_secret) # bytes â 32-byte shared secret
print(result.kem_ciphertext) # bytes â send this to the decapsulator
print(result.key_version) # int
# Decapsulate â recovers the shared secret
result = client.kem.decapsulate(
kem_ciphertext=kem_ciphertext,
key_version=1,
)
print(result.shared_secret) # bytes â same 32-byte shared secret
| Parameter | Type | Required | Description |
|---|---|---|---|
kem_ciphertext | bytes | Yes (decapsulate) | The KEM ciphertext from encapsulate |
key_version | int | Yes | Key version to use |
algorithm | str | No | "Kyber768" (default) or "X-Wing" (hybrid) |
Client-Side Encryptionâ
Encrypt data locally so that plaintext never leaves your environment. The SDK encapsulates a shared secret via the API, then performs AES-256-GCM encryption locally.
# Encrypt locally â plaintext never sent to Qpher
envelope = client.kem.encrypt_local(
plaintext=b"ultra-sensitive data",
key_version=1,
)
# envelope contains: kem_ciphertext, iv, aes_ciphertext, key_version, algorithm
# Decrypt locally
plaintext = client.kem.decrypt_local(envelope)
print(plaintext) # b"ultra-sensitive data"
| Parameter | Type | Required | Description |
|---|---|---|---|
plaintext | bytes | Yes | Data to encrypt (encrypt_local) |
envelope | EncryptedEnvelope | Yes | The envelope from encrypt_local (decrypt_local) |
key_version | int | Yes | Key version to use (encrypt_local) |
algorithm | str | No | "Kyber768" (default) or "X-Wing" (hybrid) |
With encrypt_local / decrypt_local, your plaintext never leaves your environment. Only the KEM ciphertext (for shared secret derivation) is sent to the Qpher API. The AES-256-GCM encryption happens entirely in your process.
Digital Signaturesâ
Qpher uses Dilithium3 (ML-DSA-65) for post-quantum digital signatures.
Signâ
result = client.signatures.sign(
message=b"document content to sign",
key_version=1,
)
print(result.signature) # bytes â the Dilithium3 signature
print(result.key_version) # int
print(result.request_id) # str
Hybrid signatures (Pro/Enterprise): Pass
algorithm="Composite-ML-DSA"for hybrid ECDSA + ML-DSA-65:result = client.signatures.sign(
message=b"document content to sign",
key_version=1,
algorithm="Composite-ML-DSA",
)
| Parameter | Type | Required | Description |
|---|---|---|---|
message | bytes | Yes | The message to sign |
key_version | int | Yes | Signing key version (must be active) |
algorithm | str | No | "Dilithium3" (default) or "Composite-ML-DSA" (hybrid, Pro/Enterprise) |
Returns: SignResult with fields signature (bytes), key_version (int), and request_id (str).
Verifyâ
result = client.signatures.verify(
message=b"document content to sign",
signature=signature_bytes,
key_version=1,
)
print(result.valid) # bool â True if the signature is valid
print(result.key_version) # int
print(result.request_id) # str
| Parameter | Type | Required | Description |
|---|---|---|---|
message | bytes | Yes | The original message |
signature | bytes | Yes | The signature to verify |
key_version | int | Yes | Key version used during signing (active or retired) |
algorithm | str | No | "Dilithium3" (default) or "Composite-ML-DSA" (hybrid, Pro/Enterprise) |
Returns: VerifyResult with fields valid (bool), key_version (int), algorithm (str), and request_id (str).
Note:
"X-Wing"and"Composite-ML-DSA"are available on Pro and Enterprise plans only. Composite ML-DSA signatures use Qpher's internal wire format and must be verified using Qpher's verify endpoint.
Sign Hashâ
Sign a pre-computed hash digest instead of the full message. Ideal for large files where the hash is computed locally. See the Hash-Based Signing guide for details.
import hashlib
# Compute hash locally
file_hash = hashlib.sha256(open("artifact.tar.gz", "rb").read()).digest()
# Sign the hash â only 32 bytes sent to Qpher, not the file
result = client.signatures.sign_hash(
hash_value=file_hash,
hash_algorithm="SHA-256",
key_version=1,
)
print(result.signature) # bytes â detached signature
print(result.signature_type) # str â "detached"
| Parameter | Type | Required | Description |
|---|---|---|---|
hash_value | bytes | Yes | Pre-computed hash (SHA-256: 32B, SHA-384: 48B, SHA-512: 64B) |
hash_algorithm | str | Yes | "SHA-256", "SHA-384", or "SHA-512" |
key_version | int | Yes | Signing key version (must be active) |
algorithm | str | No | "Dilithium3" (default) or "Composite-ML-DSA" (hybrid) |
Returns: SignHashResult with fields signature (bytes), key_version (int), algorithm (str), hash_algorithm (str), signature_type (str), and request_id (str).
Verify Hashâ
result = client.signatures.verify_hash(
hash_value=file_hash,
hash_algorithm="SHA-256",
signature=signature_bytes,
key_version=1,
)
print(result.valid) # bool â True if signature matches the hash
| Parameter | Type | Required | Description |
|---|---|---|---|
hash_value | bytes | Yes | The same hash that was signed |
hash_algorithm | str | Yes | "SHA-256", "SHA-384", or "SHA-512" |
signature | bytes | Yes | The signature to verify |
key_version | int | Yes | Key version used during signing (active or retired) |
algorithm | str | No | "Dilithium3" (default) or "Composite-ML-DSA" (hybrid) |
Returns: VerifyHashResult with fields valid (bool), key_version (int), algorithm (str), hash_algorithm (str), and request_id (str).
Key Managementâ
Manage PQC key lifecycle: generate, list, get, rotate, and retire keys.
Generate Keyâ
result = client.keys.generate(algorithm="Kyber768")
print(result.key_version) # int â the new key version
print(result.algorithm) # str â "Kyber768"
print(result.status) # str â "active"
print(result.public_key) # bytes â the public key (private key stays in Qpher)
print(result.created_at) # str â ISO-8601 timestamp
print(result.request_id) # str â UUID for tracing
| Parameter | Type | Required | Description |
|---|---|---|---|
algorithm | str | Yes | "Kyber768", "Dilithium3", "X-Wing", or "Composite-ML-DSA" |
Returns: GenerateResult with fields key_version (int), algorithm (str), status (str), public_key (bytes), created_at (str), and request_id (str).
List Keysâ
result = client.keys.list(algorithm="Kyber768")
print(f"Total keys: {result.total}")
for key in result.keys:
print(f"Version {key.key_version}: {key.status}")
# Version 1: retired
# Version 2: active
| Parameter | Type | Required | Description |
|---|---|---|---|
algorithm | str | No | Filter by "Kyber768", "Dilithium3", "X-Wing", or "Composite-ML-DSA" |
status | str | No | Filter by "active", "retired", or "archived" |
Returns: KeyListResult with fields keys (list[KeyInfo]), total (int), and request_id (str). Each KeyInfo has key_version (int), algorithm (str), status (str), created_at (str), and public_key (bytes).
Get Active Keyâ
key = client.keys.get_active(algorithm="Kyber768")
print(key.key_version) # int â the currently active version
print(key.algorithm) # str â "Kyber768"
print(key.public_key) # bytes â the public key
Get Key by Versionâ
key = client.keys.get(algorithm="Kyber768", key_version=1)
print(key.key_version) # int â 1
print(key.status) # str â "active", "retired", or "archived"
print(key.public_key) # bytes â the public key
print(key.created_at) # str â ISO-8601 timestamp
Rotate Keyâ
new_key = client.keys.rotate(algorithm="Kyber768")
print(new_key.key_version) # int â the new active version
print(new_key.old_key_version) # int â the previous version (now retired)
print(new_key.public_key) # bytes â the new public key
print(new_key.request_id) # str â UUID for tracing
Returns: RotateResult with fields key_version (int), algorithm (str), public_key (bytes), old_key_version (int), and request_id (str).
Retire Keyâ
result = client.keys.retire(algorithm="Kyber768", key_version=1)
print(result.status) # str â "retired"
# Key version 1 is now retired â decrypt/verify still work, encrypt/sign do not
Returns: RetireResult with fields key_version (int), algorithm (str), status (str), public_key (bytes), created_at (str), and request_id (str).
Retired keys can still decrypt and verify, but cannot encrypt or sign. Archived keys cannot perform any operations. Plan your rotation strategy accordingly. Key archival is intentionally not available in the SDK â it must be performed through the Portal UI to prevent accidental private key destruction.
Error Handlingâ
All API errors raise QpherError (or a subclass) with structured fields for programmatic handling.
from qpher import Qpher, QpherError
client = Qpher(api_key="qph_your_key_here")
try:
result = client.kem.encrypt(plaintext=b"data", key_version=999)
except QpherError as e:
print(e.error_code) # str â e.g., "ERR_KEM_005"
print(e.message) # str â human-readable description
print(e.status_code) # int â HTTP status code, e.g., 404
print(e.request_id) # str â UUID for support inquiries
Error Subclassesâ
The SDK provides specific exception subclasses for common error categories. All inherit from QpherError:
from qpher.errors import (
QpherError, # Base class for all Qpher errors
AuthenticationError, # 401 â invalid or missing API key
ValidationError, # 400 â invalid request parameters
NotFoundError, # 404 â resource not found
ForbiddenError, # 403 â operation not allowed by policy
RateLimitError, # 429 â rate limit exceeded
ServerError, # 500+ â internal server error
TimeoutError, # 504 â request timed out
ConnectionError, # 503 â service unavailable
)
You can catch specific error types for fine-grained handling:
from qpher.errors import NotFoundError, RateLimitError
try:
result = client.kem.encrypt(plaintext=b"data", key_version=999)
except NotFoundError:
print("Key version does not exist")
except RateLimitError:
print("Rate limit exceeded â SDK already retried")
except QpherError as e:
print(f"Other API error: {e.error_code}")
Common Error Codesâ
| Error Code | Status | Meaning |
|---|---|---|
ERR_AUTH_001 | 401 | Invalid or missing API key |
ERR_RATE_001 | 429 | Rate limit exceeded (auto-retried) |
ERR_KEM_005 | 404 | Key version not found |
ERR_INVALID_001 | 400 | Invalid request parameters |
ERR_FORBIDDEN_001 | 403 | Operation not allowed by policy |
ERR_SERVICE_001 | 503 | Service temporarily unavailable |
Retry Behaviorâ
The SDK automatically retries on transient errors (HTTP 429, 502, 503, 504) with exponential backoff (0.5s, 1s, 2s, capped at 10s). After max_retries attempts, the final QpherError is raised.
Full Round-Trip Exampleâ
This example demonstrates a complete encrypt-then-decrypt workflow with proper error handling.
import os
from qpher import Qpher, QpherError
def main():
# Initialize client
client = Qpher(
api_key=os.environ["QPHER_API_KEY"],
timeout=30,
max_retries=3,
)
try:
# 1. Get the active Kyber768 key
active_key = client.keys.get_active(algorithm="Kyber768")
print(f"Using key version: {active_key.key_version}")
# 2. Encrypt sensitive data
plaintext = b"Patient record: John Doe, DOB 1990-01-15"
encrypt_result = client.kem.encrypt(
plaintext=plaintext,
key_version=active_key.key_version,
)
print(f"Encrypted {len(plaintext)} bytes -> {len(encrypt_result.ciphertext)} bytes")
print(f"Request ID: {encrypt_result.request_id}")
# 3. Decrypt to recover the original data
decrypt_result = client.kem.decrypt(
ciphertext=encrypt_result.ciphertext,
key_version=encrypt_result.key_version,
)
# 4. Verify round-trip integrity
assert decrypt_result.plaintext == plaintext
print("Round-trip successful: plaintext matches")
except QpherError as e:
print(f"Qpher API error: {e.error_code} - {e.message}")
print(f"Status: {e.status_code}, Request ID: {e.request_id}")
except Exception as e:
print(f"Unexpected error: {e}")
if __name__ == "__main__":
main()
Sign and Verify Exampleâ
import os
from qpher import Qpher, QpherError
client = Qpher(api_key=os.environ["QPHER_API_KEY"])
# Get the active Dilithium3 signing key
signing_key = client.keys.get_active(algorithm="Dilithium3")
# Sign a document
document = b"Invoice #1234: $5,000.00 due 2026-03-01"
sign_result = client.signatures.sign(
message=document,
key_version=signing_key.key_version,
)
print(f"Signature length: {len(sign_result.signature)} bytes")
# Verify the signature
verify_result = client.signatures.verify(
message=document,
signature=sign_result.signature,
key_version=sign_result.key_version,
)
print(f"Signature valid: {verify_result.valid}") # True
# Verify with tampered message
verify_tampered = client.signatures.verify(
message=b"Invoice #1234: $50,000.00 due 2026-03-01",
signature=sign_result.signature,
key_version=sign_result.key_version,
)
print(f"Tampered signature valid: {verify_tampered.valid}") # False
Configuration Referenceâ
Environment Variablesâ
The SDK respects these environment variables as fallbacks:
| Variable | Purpose |
|---|---|
QPHER_API_KEY | API key (used if api_key parameter is omitted) |
QPHER_BASE_URL | Base URL (used if base_url parameter is omitted) |
Loggingâ
The SDK uses Python's standard logging module under the qpher logger name. To enable debug logging:
import logging
logging.getLogger("qpher").setLevel(logging.DEBUG)
Debug logging never prints your API key or response bodies containing ciphertext. Sensitive fields are redacted as [REDACTED].
Next Stepsâ
- Node.js SDK â if your backend uses JavaScript or TypeScript
- Go SDK â if your backend uses Go
- REST API â for raw HTTP access from any language
- API Reference â full OpenAPI specification