#JWE Format for Personal Data Fields
Status: Available on cells EN and ID. The RU cell has no such format: the
PUT /me/encryption-keyroute responds with404, thepii_encryptionfield is absent fromGET /me, and this page is not shown.
Purpose: how to receive the values of fields that contain personal data as encrypted JWE strings (RFC 7516) that only you can read. You create the key pair on your side; only the public key is passed to the platform.
#Contents
- How it works
- Which keys are accepted
- Creating a key
- Registering the public key
- Modes
- The Encryption-Key-Id header
- What the response looks like
- Key rotation
- Decryption: Node.js
- Decryption: Python
- Errors
- Limits of the format
#How it works
- You create a key pair on your side and register the public key in the integration. The platform does not create keys and does not receive or store the private key.
- On read, the platform replaces the values of personal data fields with compact JWE strings built with your public key.
- Only the holder of the private key can decrypt them, including when the response passes through your intermediate servers, queues, and logs.
The format complements HTTPS rather than replacing it, and works together with field selection: only the personal data fields you named in fields= are encrypted (Minimization and field selection). If the request contains no personal data fields, there is nothing to encrypt and the response arrives as usual.
The format applies to read responses for conversations, messages, and leads (GET /conversations, GET /conversations/{id}, GET /conversations/{id}/messages, GET /leads, GET /leads/{id}). Webhook events carry no personal data today; if an event does carry it, the values are replaced in the same way and by the same rules.
#Which keys are accepted
The content is always encrypted with the A256GCM algorithm. The key wrapping algorithm is chosen by your key (the alg field in the JWK is required).
| Cell | alg | Curves / length | Note |
|---|---|---|---|
| EN | ECDH-ES+A256KW | P-256, P-384, X25519 | EC is kty: "EC", X25519 is kty: "OKP" |
| EN | RSA-OAEP-256 | RSA modulus of 2048 bits or more | |
| ID | ECDH-ES+A256KW | P-384, X25519 | P-256 is not accepted |
| ID | RSA-OAEP-256, RSA-OAEP-384 | RSA modulus of 3072 bits or more | RSA-OAEP-384 is preferable |
Not accepted: RSA1_5, RSA-OAEP (SHA-1), dir, ECDH-ES without key wrapping, short RSA keys, an RSA public exponent below 65537 or even, symmetric keys (kty: "oct"), and keys whose use is anything other than enc. The cell's current list is returned by GET /me in the pii_encryption.accepted field.
#Creating a key
The private key stays with you: do not send it to the platform and do not put it in a repository.
Node.js (jose):
import { generateKeyPair, exportJWK } from 'jose';
const { publicKey, privateKey } = await generateKeyPair('ECDH-ES+A256KW', {
crv: 'P-384',
extractable: true,
});
const kid = 'key-2026-10'; // your own identifier: letters, digits, . _ : -
const pub = { ...(await exportJWK(publicKey)), kid, alg: 'ECDH-ES+A256KW', use: 'enc' };
const prv = { ...(await exportJWK(privateKey)), kid, alg: 'ECDH-ES+A256KW', use: 'enc' };
console.log(JSON.stringify(pub)); // this is what we register
// store prv in your secrets storage
Python (jwcrypto):
import json
from jwcrypto import jwk
key = jwk.JWK.generate(kty="EC", crv="P-384", kid="key-2026-10", alg="ECDH-ES+A256KW", use="enc")
print(key.export_public()) # this is what we register
secret = key.export_private() # store in your secrets storage
Without a kid, the platform uses the key thumbprint (RFC 7638), but your own readable kid is more convenient during rotation. kid: 1-128 characters from letters, digits, and . _ : -.
#Registering the public key
PUT /api/public/v1/me/encryption-key, scope webhooks:manage, EN and ID only.
curl -sS -X PUT "$API_BASE_URL/me/encryption-key" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"jwk": {"kty":"EC","crv":"P-384","x":"…","y":"…","kid":"key-2026-10","alg":"ECDH-ES+A256KW","use":"enc"}}'
200 response:
{
"mode": "optional",
"key": {
"kid": "key-2026-10",
"alg": "ECDH-ES+A256KW",
"kty": "EC",
"crv": "P-384",
"rsa_bits": null,
"thumbprint": "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs",
"created_at": "2026-10-12T10:00:00.000Z"
},
"previous_key": null,
"accepted": { "alg": ["ECDH-ES+A256KW", "RSA-OAEP-256", "RSA-OAEP-384"], "crv": ["P-384", "X25519"], "min_rsa_bits": 3072, "enc": "A256GCM" },
"changed": true
}
- The same key again:
changed: false, nothing changes. - Private parts of a JWK (
d,p,q,dp,dq,qi,oth,k) are rejected immediately: the private key must not end up with the platform even by mistake. - A key is accepted only after a trial encryption: a corrupted curve point or RSA modulus causes a rejection at registration, not on the first response with data.
- The state is visible in
GET /me: thepii_encryptionfield (mode,key,previous_key,accepted). On RU the field is absent. - The key can also be entered in the dashboard: Settings → API → integration → the "JWE format for personal data" block (the mode is also changed and the key deleted there).
Main rejections: 422 validation_failed with the /jwk field and a reason code (private_key_not_allowed, symmetric_key_not_allowed, alg_required, alg_not_allowed, curve_not_allowed, key_too_short, weak_exponent, use_not_enc, kid_invalid, invalid_jwk); 409 invalid_state if this kid already belongs to a different key of the integration (give the new key a new kid).
#Modes
| Mode | What arrives in personal data fields |
|---|---|
off | values as is, over HTTPS, and only if named in fields= |
optional | if a key is registered, values arrive as JWE strings; if there is no key, as is |
required | only as JWE strings; without a registered key a request for personal data fields gets 409 encryption_key_required, with no plain data returned |
The initial mode is off. Registering a key switches off to optional. The required mode is turned on and off only in the dashboard and cannot be lowered through the API: this way an integration cannot accidentally fall back to plain values.
A request without personal data fields does not cause 409 encryption_key_required: it contains no personal data at all.
#The Encryption-Key-Id header
The request header Encryption-Key-Id: <kid> selects which of the registered keys encrypts this response: the current one or the previous one (during the rotation window). The header also turns on JWE for the request even in off mode: the client explicitly expects ciphertext.
An unknown kid, or a previous key after its window has ended, results in 422 validation_failed with the /headers/Encryption-Key-Id field and the code unknown_key_id.
#What the response looks like
The field value is encrypted, not the whole object: the response shape does not change, the field stays in its place, but a JWE string lies there instead of the value. Next to the object an _enc field appears: the list of paths of this object's fields that have been replaced with ciphertext.
{
"id": "9a7d4e10-0000-4000-8000-000000000002",
"status": "new",
"title": "eyJhbGciOiJFQ0RILUVTK0EyNTZLVyIsImN0eSI6Impzb24i…",
"contact": {
"id": "3b9e1f22-0000-4000-8000-000000000003",
"email": "eyJhbGciOiJFQ0RILUVTK0EyNTZLVyIsImN0eSI6Impzb24i…",
"phone": null
},
"_enc": ["contact.email", "title"]
}
Rules:
- The plaintext of the ciphertext: the JSON of the original value in UTF-8 (
"[email protected]", and an array for nested items). After decryption applyJSON.parseand you get a value of the original type. - The JWE protected header:
{"alg": …, "enc": "A256GCM", "kid": …, "cty": "json"}. Usekidto choose the key during rotation. nulland a missing field are not encrypted: there is nothing to disclose.- An
_encwith an empty list means that encryption was in effect but the object had no personal data. No_encmeans encryption was not applied to this response (offmode, no personal data fields, or no key inoptionalmode). - In lists,
_encis present on every object indata[].
#Key rotation
- Create a new pair with a new
kid. - Register the new public key with the same
PUT /me/encryption-key. It becomes the primary key immediately. - For 7 days the previous key remains available through
Encryption-Key-Id: a server that has not yet received the new private key can still read the data. The deadline is visible inGET /me:pii_encryption.previous_key.expires_at. - After the deadline, delete the previous private key wherever it is no longer needed.
The same kid for a different key is not accepted (409); otherwise the recipient could not tell an old ciphertext from a new one.
#Decryption: Node.js
import { compactDecrypt, importJWK } from 'jose';
const privateKey = await importJWK(JSON.parse(process.env.PRIVATE_JWK), 'ECDH-ES+A256KW');
async function decryptField(token) {
const { plaintext } = await compactDecrypt(token, privateKey, {
keyManagementAlgorithms: ['ECDH-ES+A256KW'],
contentEncryptionAlgorithms: ['A256GCM'],
});
return JSON.parse(new TextDecoder().decode(plaintext));
}
// Replaces the JWE strings in the object with the original values, following the _enc list.
async function decryptObject(obj) {
const out = structuredClone(obj);
for (const path of obj._enc ?? []) {
const [top, nested] = path.split('.');
if (nested === undefined) out[top] = await decryptField(obj[top]);
else out[top][nested] = await decryptField(obj[top][nested]);
}
delete out._enc;
return out;
}
const lead = await (await fetch(`${API_BASE_URL}/leads/${leadId}?fields=status,contact.email,title`, {
headers: { Authorization: `Bearer ${process.env.API_KEY}` },
})).json();
console.log(await decryptObject(lead));
For an RSA key, replace ECDH-ES+A256KW with your own alg (RSA-OAEP-256 or RSA-OAEP-384).
#Decryption: Python
import copy
import json
import os
from jwcrypto import jwe, jwk
private_key = jwk.JWK.from_json(os.environ["PRIVATE_JWK"])
def decrypt_field(token):
t = jwe.JWE(algs=["ECDH-ES+A256KW", "A256GCM"])
t.deserialize(token, key=private_key)
return json.loads(t.payload)
def decrypt_object(obj):
out = copy.deepcopy(obj)
for path in obj.get("_enc", []):
top, _, nested = path.partition(".")
if nested:
out[top][nested] = decrypt_field(obj[top][nested])
else:
out[top] = decrypt_field(obj[top])
out.pop("_enc", None)
return out
For an RSA key, add your own alg (RSA-OAEP-256 or RSA-OAEP-384) to algs.
#Errors
| Code | Status | When |
|---|---|---|
encryption_key_required | 409 | required mode, no key registered, the request contains personal data fields |
validation_failed (unknown_key_id) | 422 | Encryption-Key-Id names an unregistered kid or a previous key outside the rotation window |
validation_failed (/jwk) | 422 | the key was not accepted at registration (see above) |
invalid_state | 409 | the kid already belongs to a different key of the integration |
not_found | 404 | PUT /me/encryption-key on the RU cell |
service_unavailable | 503 | the encryption setting could not be read: data is not returned in plain form |
Error code catalog: Conventions.
#Limits of the format
- This is one protective measure, not a replacement for the others: you are responsible for storing the private key, rotating it, and handling incidents on your side.
- The format does not change the roles of the parties (who is the controller, who is the processor) and does not cancel the rules on transferring data abroad.
- The implementation uses publicly available standards (RFC 7516, 7517, 7518) and is not a separate cryptographic module with its own conformity assessment.
- Legal bases by cell: Data Protection by Cell.