KEM API
The KEM (Key Encapsulation Mechanism) API provides post-quantum encryption and decryption using the ML-KEM-768 algorithm (FIPS 203; the API wire value is Kyber768). Qpher uses a hybrid KEM-DEM scheme: ML-KEM-768 encapsulates a shared secret, which is then used with HKDF-SHA256 and AES-256-GCM to encrypt your data.
Decapsulation with your private key happens inside Qpher's isolated key service (KMS-Orchestrator). Your private keys are never exposed or exported.
Pass "algorithm": "X-Wing" in encrypt/decrypt requests to use hybrid KEM (X25519 + ML-KEM-768).
If omitted, the default is "Kyber768" (PQC-only, backward-compatible).
X-Wing provides defense-in-depth by combining the post-quantum security of ML-KEM-768 with the battle-tested classical security of X25519. This construction follows the IETF CFRG X-Wing draft (draft-connolly-cfrg-xwing-kem).
See Security Architecture for details.
Pass "algorithm": "ML-KEM-1024" to use the NIST Category 5 (highest-strength)
ML-KEM parameter set â the KEM named in the NSA CNSA 2.0 suite. Most callers
select it via the SDK compliance_profile="cnsa-2.0" selector rather than per
call: the SDK then pins this algorithm on every request and raises on an
override. It also sends an X-Compliance-Profile: cnsa-2.0 header to signal
intent, but the API does not consume that header â the pinning is
client-side. This is algorithm alignment with CNSA 2.0, not a
compliance certification.
Encrypt now lets you omit key_version to use your tenant's active
key (the response returns the version that was used). If you pin a specific
key_version, it must reference an active key â a new tenant's active key is
usually higher than 1, so copying 1 verbatim returns ERR_NOT_FOUND_001. Find
your active version with GET /api/v1/kms/keys/active?algorithm=Kyber768 (use
algorithm=Dilithium3 for signing). Decrypt, encapsulate/decapsulate, and key
wrap/unwrap still require an explicit key_version.
Encryptâ
Encrypts the provided plaintext with your active key (or the active version you pin with key_version).
Server-side mode. Sent to Qpher over TLS, processed in memory, never stored.
Request bodyâ
| Field | Type | Required | Description |
|---|---|---|---|
plaintext | string (base64) | Yes | Base64-encoded data to encrypt. Maximum size: 1 MB. |
key_version | integer | No | The version of the ML-KEM-768 key to use. Omit to use your tenant's active key (the response returns the version used). If provided, it must reference an active key. |
algorithm | string | No | "Kyber768" (default) or "X-Wing" (hybrid, Starter+). If omitted, defaults to ML-KEM-768. |
mode | string | No | Encryption mode: "standard" (default) or "deterministic" (not yet in effect â see Deterministic Encryption). |
salt | string (base64) | Conditional | Base64-encoded salt. Required when mode is "deterministic". |
The API accepts mode "deterministic" with a salt, but still returns randomized ciphertext; do not rely on it for equality checks or deduplication yet.
plaintext is capped at ~1 MB (1,400,000 base64 characters). For larger data,
don't send it through /kem/encrypt â use key-wrap (/kem/key/wrap)
or the client-side encryption pattern (encapsulate + local
AES-256-GCM) so only a small wrapped key / shared secret transits the API, not your data.
Response (200 OK)â
| Field | Type | Description |
|---|---|---|
data.ciphertext | string (base64) | The encrypted data, base64-encoded. |
data.key_version | integer | The key version used for encryption. |
data.algorithm | string | "Kyber768" or "X-Wing", matching the algorithm used for the operation. |
Exampleâ
/api/v1/kem/encryptX-API-Key: qph_your_key_here
Content-Type: application/json{
"plaintext": "SGVsbG8sIFdvcmxkIQ=="
}{
"data": {
"ciphertext": "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU2Nzg5...",
"key_version": 1,
"algorithm": "Kyber768"
},
"request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"timestamp": "2026-01-15T10:30:00.000Z"
}curl -X POST https://api.qpher.ai/api/v1/kem/encrypt \
-H "X-API-Key: qph_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"plaintext": "SGVsbG8sIFdvcmxkIQ=="
}'key_version is now optional on encrypt and sign â omit it to use your tenant's active key (the response returns the version that was used). To pin a specific version, pass key_version (it must reference an active key).
Decrypt and verify still require the exact key_version that was used to encrypt.
Errorsâ
| HTTP Status | Error Code | Description |
|---|---|---|
| 400 | ERR_KEM_001 | Invalid encryption request â missing plaintext, invalid key_version, or bad encoding. |
| 401 | ERR_AUTH_001 | Missing or invalid API key. |
| 404 | ERR_NOT_FOUND_001 | Key version not found or key is not in active status. |
| 429 | ERR_RATE_LIMIT_001 | Rate limit exceeded for your plan tier. |
Decryptâ
Decrypts ciphertext that was previously encrypted with the KEM encrypt endpoint. The referenced key must be in active or retired status.
Request bodyâ
| Field | Type | Required | Description |
|---|---|---|---|
ciphertext | string (base64) | Yes | Base64-encoded ciphertext returned by the encrypt endpoint. |
key_version | integer | Yes | The key version that was used to encrypt this data. |
algorithm | string | No | "Kyber768" (default) or "X-Wing" (hybrid, Starter+). If omitted, defaults to ML-KEM-768. |
Response (200 OK)â
| Field | Type | Description |
|---|---|---|
data.plaintext | string (base64) | The decrypted data, base64-encoded. |
data.key_version | integer | The key version used for decryption. |
data.algorithm | string | "Kyber768" or "X-Wing", matching the algorithm used for the operation. |
Exampleâ
/api/v1/kem/decryptX-API-Key: qph_your_key_here
Content-Type: application/json{
"ciphertext": "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU2Nzg5...",
"key_version": 1
}{
"data": {
"plaintext": "SGVsbG8sIFdvcmxkIQ==",
"key_version": 1,
"algorithm": "Kyber768"
},
"request_id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"timestamp": "2026-01-15T10:30:01.000Z"
}curl -X POST https://api.qpher.ai/api/v1/kem/decrypt \
-H "X-API-Key: qph_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"ciphertext": "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU2Nzg5...",
"key_version": 1
}'Errorsâ
| HTTP Status | Error Code | Description |
|---|---|---|
| 400 | ERR_KEM_002 | Invalid decryption request â corrupt ciphertext or wrong key_version. |
| 401 | ERR_AUTH_001 | Missing or invalid API key. |
| 404 | ERR_NOT_FOUND_001 | Key version not found or key is in archived status. |
| 429 | ERR_RATE_LIMIT_001 | Rate limit exceeded for your plan tier. |
Encapsulateâ
Returns a KEM ciphertext and a 32-byte shared secret. The plaintext never leaves your environment. Use the shared secret as an AES-256-GCM key to encrypt data locally, then store the KEM ciphertext for later decapsulation.
With encapsulate/decapsulate, Qpher never sees your plaintext. You perform AES-256-GCM encryption locally using the shared secret. This is the most privacy-preserving mode.
Request bodyâ
| Field | Type | Required | Description |
|---|---|---|---|
key_version | integer | Yes | The version of the KEM key to use. Must reference an active key. |
algorithm | string | No | "Kyber768" (default) or "X-Wing" (hybrid, Starter+). |
Response (200 OK)â
| Field | Type | Description |
|---|---|---|
data.kem_ciphertext | string (base64) | The KEM ciphertext. Store this â you need it for decapsulate. |
data.shared_secret | string (base64) | 32-byte shared secret (base64). Use as your AES-256-GCM key. Ephemeral â Qpher does not store this. |
data.key_version | integer | The key version used. |
data.algorithm | string | "Kyber768" or "X-Wing". |
Exampleâ
curl -X POST https://api.qpher.ai/api/v1/kem/encapsulate \
-H "X-API-Key: qph_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"key_version": 1
}'Errorsâ
| HTTP Status | Error Code | Description |
|---|---|---|
| 400 | ERR_KEM_001 | Invalid encapsulate request â invalid key_version. |
| 401 | ERR_AUTH_001 | Missing or invalid API key. |
| 404 | ERR_NOT_FOUND_001 | Key version not found or key is not in active status. |
| 429 | ERR_RATE_LIMIT_001 | Rate limit exceeded for your plan tier. |
Decapsulateâ
Returns the same 32-byte shared secret that was produced during encapsulation. Use it to decrypt data that was encrypted client-side. The referenced key must be in active or retired status.
Request bodyâ
| Field | Type | Required | Description |
|---|---|---|---|
kem_ciphertext | string (base64) | Yes | The KEM ciphertext from the encapsulate response. |
key_version | integer | Yes | The key version used during encapsulation. |
algorithm | string | No | "Kyber768" (default) or "X-Wing" (hybrid, Starter+). |
Response (200 OK)â
| Field | Type | Description |
|---|---|---|
data.shared_secret | string (base64) | The same 32-byte shared secret from the original encapsulate call. |
data.key_version | integer | The key version used. |
data.algorithm | string | "Kyber768" or "X-Wing". |
Exampleâ
curl -X POST https://api.qpher.ai/api/v1/kem/decapsulate \
-H "X-API-Key: qph_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"kem_ciphertext": "a2VtX2NpcGhlcnRleHRfZnJvbV9lbmNhcHN1bGF0ZS4uLg==",
"key_version": 1
}'Errorsâ
| HTTP Status | Error Code | Description |
|---|---|---|
| 400 | ERR_KEM_002 | Invalid decapsulate request â invalid kem_ciphertext size or key_version. |
| 401 | ERR_AUTH_001 | Missing or invalid API key. |
| 404 | ERR_NOT_FOUND_001 | Key version not found or key is in archived status. |
| 429 | ERR_RATE_LIMIT_001 | Rate limit exceeded for your plan tier. |
Key Wrapâ
Wraps (encrypts) a symmetric key using the KEM-DEM scheme with the specified key version. Use this to protect existing AES keys, HMAC secrets, or other symmetric material with quantum-safe encryption. The key must be in active status.
Request bodyâ
| Field | Type | Required | Description |
|---|---|---|---|
symmetric_key | string (base64) | Yes | Base64-encoded symmetric key to wrap. Supported sizes: 16, 24, 32, 48, or 64 bytes. |
key_version | integer | Yes | The version of the ML-KEM-768 key to use. Must reference an active key. |
algorithm | string | No | "Kyber768" (default) or "X-Wing" (hybrid, Starter+). If omitted, defaults to ML-KEM-768. |
Response (200 OK)â
| Field | Type | Description |
|---|---|---|
data.wrapped_key | string (base64) | The wrapped (encrypted) symmetric key, base64-encoded. |
data.key_version | integer | The key version used for wrapping. |
data.algorithm | string | "Kyber768" or "X-Wing", matching the algorithm used for the operation. |
data.wrapping_method | string | The wrapping method used, e.g. "KEM-DEM". |
Exampleâ
curl -X POST https://api.qpher.ai/api/v1/kem/key/wrap \
-H "X-API-Key: qph_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"symmetric_key": "c3ltbWV0cmljX2tleV8zMl9ieXRlcw==",
"key_version": 1
}'Errorsâ
| HTTP Status | Error Code | Description |
|---|---|---|
| 400 | ERR_KEM_010 | Invalid wrap request â missing symmetric_key, invalid key_version, bad encoding, or unsupported key size. Supported sizes: 16, 24, 32, 48, or 64 bytes. |
| 401 | ERR_AUTH_001 | Missing or invalid API key. |
| 404 | ERR_NOT_FOUND_001 | Key version not found or key is not in active status. |
| 429 | ERR_RATE_LIMIT_001 | Rate limit exceeded for your plan tier. |
Key Unwrapâ
Unwraps (decrypts) a symmetric key that was previously wrapped with the Key Wrap endpoint. The referenced key must be in active or retired status.
Request bodyâ
| Field | Type | Required | Description |
|---|---|---|---|
wrapped_key | string (base64) | Yes | Base64-encoded wrapped key returned by the wrap endpoint. |
key_version | integer | Yes | The key version that was used to wrap this key. |
algorithm | string | No | "Kyber768" (default) or "X-Wing" (hybrid, Starter+). If omitted, defaults to ML-KEM-768. |
Response (200 OK)â
| Field | Type | Description |
|---|---|---|
data.symmetric_key | string (base64) | The unwrapped symmetric key, base64-encoded. |
data.key_version | integer | The key version used for unwrapping. |
data.algorithm | string | "Kyber768" or "X-Wing", matching the algorithm used for the operation. |
Exampleâ
curl -X POST https://api.qpher.ai/api/v1/kem/key/unwrap \
-H "X-API-Key: qph_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"wrapped_key": "d3JhcHBlZF9rZXlfZGF0YV9oZXJl...",
"key_version": 1
}'Errorsâ
| HTTP Status | Error Code | Description |
|---|---|---|
| 400 | ERR_KEM_011 | Invalid unwrap request â missing wrapped_key, bad base64 encoding, or data too short. |
| 400 | ERR_KEM_012 | Wrapped key integrity check failed â corrupt data or wrong key_version. |
| 401 | ERR_AUTH_001 | Missing or invalid API key. |
| 404 | ERR_NOT_FOUND_001 | Key version not found or key is in archived status. |
| 429 | ERR_RATE_LIMIT_001 | Rate limit exceeded for your plan tier. |
Key Version Requirementsâ
On encrypt, key_version is optional â omit it to use your tenant's active key (the response returns the version used). On decrypt, encapsulate, decapsulate, and key wrap/unwrap, key_version is required.
| Operation | Allowed Key Statuses |
|---|---|
| Encrypt | active only |
| Decrypt | active or retired |
| Encapsulate | active only |
| Decapsulate | active or retired |
| Key Wrap | active only |
| Key Unwrap | active or retired |
Use the Key Management API to list your keys and find the current active version.