Walker Formulation Academy для разработчиков Справочник API Каталог ошибок OpenAPI
Разделы

#Формат JWE для полей ПДн

Статус: Доступно на ячейках EN и ID. На ячейке RU формата нет: маршрут PUT /me/encryption-key отвечает 404, поле pii_encryption в GET /me отсутствует, а эта страница не показывается.

Назначение: как получать значения полей с персональными данными (ПДн) в виде зашифрованных строк JWE (RFC 7516), которые может прочитать только вы. Ключевую пару вы создаёте у себя; платформе передаётся только открытый ключ.

#Содержание

  1. Как это устроено
  2. Какие ключи принимаются
  3. Создание ключа
  4. Регистрация открытого ключа
  5. Режимы
  6. Заголовок Encryption-Key-Id
  7. Как выглядит ответ
  8. Ротация ключа
  9. Расшифровка: Node.js
  10. Расшифровка: Python
  11. Ошибки
  12. Границы формата

#Как это устроено

  1. Вы создаёте у себя пару ключей и регистрируете открытый ключ в интеграции. Платформа ключей не создаёт, закрытый ключ не получает и не хранит.
  2. При чтении платформа заменяет значения ПДн-полей строками компактной JWE, сформированными вашим открытым ключом.
  3. Расшифровать их может только держатель закрытого ключа, в том числе если ответ проходит через ваши промежуточные серверы, очереди и журналы.

Формат дополняет HTTPS, а не заменяет его, и сочетается с выбором полей: шифруются только те ПДн-поля, которые вы назвали в fields= (Минимизация и выбор полей). Если ПДн-полей в запросе нет, шифровать нечего и ответ приходит как обычно.

Формат действует на ответах чтения диалогов, сообщений и лидов (GET /conversations, GET /conversations/{id}, GET /conversations/{id}/messages, GET /leads, GET /leads/{id}). События вебхуков сегодня не несут ПДн; если событие будет их нести, значения заменяются тем же образом и теми же правилами.

#Какие ключи принимаются

Контент всегда шифруется алгоритмом A256GCM. Алгоритм обёртки ключа выбирает ваш ключ (поле alg в JWK обязательно).

ЯчейкаalgКривые / длинаПримечание
ENECDH-ES+A256KWP-256, P-384, X25519EC — kty: "EC", X25519 — kty: "OKP"
ENRSA-OAEP-256модуль RSA от 2048 бит
IDECDH-ES+A256KWP-384, X25519P-256 не принимается
IDRSA-OAEP-256, RSA-OAEP-384модуль RSA от 3072 битпредпочтительнее RSA-OAEP-384

Не принимаются: RSA1_5, RSA-OAEP (SHA-1), dir, ECDH-ES без обёртки, короткие RSA, показатель степени RSA меньше 65537 или чётный, симметричные ключи (kty: "oct"), ключи с use, отличным от enc. Действующий список ячейки возвращает GET /me в поле pii_encryption.accepted.

#Создание ключа

Закрытый ключ остаётся у вас: не отправляйте его платформе и не кладите в репозиторий.

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'; // свой идентификатор: буквы, цифры, . _ : -
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)); // это регистрируем
// prv сохраните в своём хранилище секретов

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())   # это регистрируем
secret = key.export_private()  # сохраните в своём хранилище секретов

Без kid платформа использует отпечаток ключа (RFC 7638), но свой понятный kid удобнее при ротации. kid: 1–128 знаков из букв, цифр и . _ : -.

#Регистрация открытого ключа

PUT /api/public/v1/me/encryption-key, область webhooks:manage, только EN и ID.

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:

{
  "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
}

Основные отказы: 422 validation_failed с полем /jwk и кодом причины (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, если этот kid уже принадлежит другому ключу интеграции (дайте новому ключу новый kid).

#Режимы

РежимЧто приходит в ПДн-полях
offзначения как есть, по HTTPS, и только если названы в fields=
optionalесли ключ зарегистрирован, значения приходят строками JWE; если ключа нет, как есть
requiredтолько строками JWE; без зарегистрированного ключа запрос ПДн-полей получает 409 encryption_key_required, открытых данных нет

Начальный режим off. Регистрация ключа переводит off в optional. Режим required включается и снимается только в кабинете, через API он не понижается: так интеграция не может случайно вернуться к открытым значениям.

Запрос без ПДн-полей 409 encryption_key_required не вызывает: он вообще не содержит ПДн.

#Заголовок Encryption-Key-Id

Заголовок запроса Encryption-Key-Id: <kid> выбирает, каким из зарегистрированных ключей шифровать этот ответ: действующим или прежним (в окне ротации). Заголовок также включает JWE для запроса даже в режиме off: клиент явно ждёт шифр.

Неизвестный kid, или прежний ключ после окончания окна, даёт 422 validation_failed с полем /headers/Encryption-Key-Id и кодом unknown_key_id.

#Как выглядит ответ

Шифруется значение поля, а не весь объект: форма ответа не меняется, поле остаётся на своём месте, но вместо значения лежит строка JWE. Рядом с объектом появляется _enc — список путей полей этого объекта, которые заменены шифром.

{
  "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"]
}

Правила:

#Ротация ключа

  1. Создайте новую пару с новым kid.
  2. Зарегистрируйте новый открытый ключ тем же PUT /me/encryption-key. Он становится основным сразу.
  3. Прежний ключ 7 суток остаётся доступным по Encryption-Key-Id: сервер, который ещё не получил новый закрытый ключ, может дочитать данные. Срок виден в GET /me: pii_encryption.previous_key.expires_at.
  4. После срока удалите прежний закрытый ключ там, где он больше не нужен.

Тот же kid у другого ключа не принимается (409), иначе получатель не отличил бы старый шифр от нового.

#Расшифровка: 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));
}

// Заменяет в объекте строки JWE исходными значениями по списку _enc.
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));

Для ключа RSA замените ECDH-ES+A256KW на свой alg (RSA-OAEP-256 или RSA-OAEP-384).

#Расшифровка: 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

Для ключа RSA добавьте в algs свой alg (RSA-OAEP-256 или RSA-OAEP-384).

#Ошибки

КодСтатусКогда
encryption_key_required409режим required, ключ не зарегистрирован, в запросе есть ПДн-поля
validation_failed (unknown_key_id)422в Encryption-Key-Id указан незарегистрированный kid или прежний ключ вне окна ротации
validation_failed (/jwk)422ключ не принят при регистрации (см. выше)
invalid_state409kid уже принадлежит другому ключу интеграции
not_found404PUT /me/encryption-key на ячейке RU
service_unavailable503не удалось прочитать настройку шифрования: данные открытыми не отдаются

Каталог кодов: Соглашения.

#Границы формата