Skip to main content

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 qpher

Requirements: 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
)
ParameterTypeDefaultDescription
api_keystrrequiredYour Qpher API key (starts with qph_)
base_urlstrhttps://api.qpher.aiAPI base URL
timeoutint30Request timeout in seconds
max_retriesint3Number of retries for transient errors (429, 502, 503, 504)
Protect your API key

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",
)
ParameterTypeRequiredDescription
plaintextbytesYesThe data to encrypt
key_versionintYesKey version to use (must be active)
algorithmstrNo"Kyber768" (default) or "X-Wing" (hybrid, Pro/Enterprise)
modestrNo"standard" (default) or "deterministic"
saltbytesNoRequired 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
ParameterTypeRequiredDescription
ciphertextbytesYesThe encrypted payload to decrypt
key_versionintYesKey version used during encryption (active or retired)
algorithmstrNo"Kyber768" (default) or "X-Wing" (hybrid, Pro/Enterprise)

Returns: DecryptResult with fields plaintext (bytes), key_version (int), algorithm (str), and request_id (str).

Key version is always required

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
ParameterTypeRequiredDescription
symmetric_key / wrapped_keybytesYesThe symmetric key to wrap, or the wrapped key to unwrap
key_versionintYesKey version (wrap: active only; unwrap: active or retired)
algorithmstrNo"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
ParameterTypeRequiredDescription
kem_ciphertextbytesYes (decapsulate)The KEM ciphertext from encapsulate
key_versionintYesKey version to use
algorithmstrNo"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"
ParameterTypeRequiredDescription
plaintextbytesYesData to encrypt (encrypt_local)
envelopeEncryptedEnvelopeYesThe envelope from encrypt_local (decrypt_local)
key_versionintYesKey version to use (encrypt_local)
algorithmstrNo"Kyber768" (default) or "X-Wing" (hybrid)
True client-side encryption

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",
)
ParameterTypeRequiredDescription
messagebytesYesThe message to sign
key_versionintYesSigning key version (must be active)
algorithmstrNo"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
ParameterTypeRequiredDescription
messagebytesYesThe original message
signaturebytesYesThe signature to verify
key_versionintYesKey version used during signing (active or retired)
algorithmstrNo"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"
ParameterTypeRequiredDescription
hash_valuebytesYesPre-computed hash (SHA-256: 32B, SHA-384: 48B, SHA-512: 64B)
hash_algorithmstrYes"SHA-256", "SHA-384", or "SHA-512"
key_versionintYesSigning key version (must be active)
algorithmstrNo"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
ParameterTypeRequiredDescription
hash_valuebytesYesThe same hash that was signed
hash_algorithmstrYes"SHA-256", "SHA-384", or "SHA-512"
signaturebytesYesThe signature to verify
key_versionintYesKey version used during signing (active or retired)
algorithmstrNo"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
ParameterTypeRequiredDescription
algorithmstrYes"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
ParameterTypeRequiredDescription
algorithmstrNoFilter by "Kyber768", "Dilithium3", "X-Wing", or "Composite-ML-DSA"
statusstrNoFilter 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).

Key lifecycle

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 CodeStatusMeaning
ERR_AUTH_001401Invalid or missing API key
ERR_RATE_001429Rate limit exceeded (auto-retried)
ERR_KEM_005404Key version not found
ERR_INVALID_001400Invalid request parameters
ERR_FORBIDDEN_001403Operation not allowed by policy
ERR_SERVICE_001503Service 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:

VariablePurpose
QPHER_API_KEYAPI key (used if api_key parameter is omitted)
QPHER_BASE_URLBase 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)
Security

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