Walker Formulation Academy for developers API reference Error catalog OpenAPI
Sections

#JWE Format for Personal Data Fields

Status: Available on cells EN and ID. The RU cell has no such format: the PUT /me/encryption-key route responds with 404, the pii_encryption field is absent from GET /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

  1. How it works
  2. Which keys are accepted
  3. Creating a key
  4. Registering the public key
  5. Modes
  6. The Encryption-Key-Id header
  7. What the response looks like
  8. Key rotation
  9. Decryption: Node.js
  10. Decryption: Python
  11. Errors
  12. Limits of the format

#How it works

  1. 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.
  2. On read, the platform replaces the values of personal data fields with compact JWE strings built with your public key.
  3. 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).

CellalgCurves / lengthNote
ENECDH-ES+A256KWP-256, P-384, X25519EC is kty: "EC", X25519 is kty: "OKP"
ENRSA-OAEP-256RSA modulus of 2048 bits or more
IDECDH-ES+A256KWP-384, X25519P-256 is not accepted
IDRSA-OAEP-256, RSA-OAEP-384RSA modulus of 3072 bits or moreRSA-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
}

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

ModeWhat arrives in personal data fields
offvalues as is, over HTTPS, and only if named in fields=
optionalif a key is registered, values arrive as JWE strings; if there is no key, as is
requiredonly 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:

#Key rotation

  1. Create a new pair with a new kid.
  2. Register the new public key with the same PUT /me/encryption-key. It becomes the primary key immediately.
  3. 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 in GET /me: pii_encryption.previous_key.expires_at.
  4. 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

CodeStatusWhen
encryption_key_required409required mode, no key registered, the request contains personal data fields
validation_failed (unknown_key_id)422Encryption-Key-Id names an unregistered kid or a previous key outside the rotation window
validation_failed (/jwk)422the key was not accepted at registration (see above)
invalid_state409the kid already belongs to a different key of the integration
not_found404PUT /me/encryption-key on the RU cell
service_unavailable503the encryption setting could not be read: data is not returned in plain form

Error code catalog: Conventions.

#Limits of the format