Главная/Документация/Карточка клиента
Контракт карточки клиента
Когда вы открываете человека в CRM обратной связи, панель может показать, кто этот клиент в вашей системе: тариф, платежи, работа с продуктом, ссылка в вашу админку. Вы держите один HTTPS-эндпоинт, наш сервер подписанным запросом спрашивает у него карточку, а панель рисует ответ рядом с перепиской. Карточку мы не храним, а её действия никогда не исполняем.
1. Как это работает
Направление то же, что у кастом-метрик: вызывает наш сервер, отвечает ваш. Вы открываете
подтверждённого человека в разделе «Люди» на вкладке «Обратная связь» проекта (см.
контракт обратной связи, разделы 1 и 9). Наш сервер отправляет
один подписанный POST на ваш эндпоинт, сверяет ответ со схемой ниже и отдаёт панели
только то, что понимает. Сырое тело ответа в браузер не попадает.
Четыре правила, которые не меняет ни одна настройка:
- Только подтверждённые. Карточку мы запрашиваем, только если личность человека подписал ваш сервер (подпись v1 контракта обратной связи). Для неподтверждённого запроса нет: e-mail может назвать кто угодно, и рядом с его сообщением оказались бы данные чужого клиента.
- Карточку мы не храним. Ни в базе, ни в кэше, ни в логах. Каждое открытие человека – новый запрос.
- Платежей и действий мы не исполняем. Действие карточки открывает ссылку, копирует значение или копирует текст для вашего ИИ-агента поддержки. Панель ничего не отправляет и ничего не вызывает.
- Карточку видит только владелец проекта на тарифе с CRM обратной связи. Те, с кем вы поделились проектом, карточку не видят никогда.
2. Настройка в панели
В настройке проекта, секция «Виджет обратной связи», подраздел «Карточка клиента» (только владелец, тариф с CRM):
- Адрес эндпоинта.
https://…, только порт 443, публичный адрес (раздел 9). - Секрет карточки.
ccs_и 32 случайных байта в base64url. Его выпускаем мы и показываем один раз; дальше видны только последние 4 символа. Храним его зашифрованным. - «Заменить секрет». Новый секрет показывается один раз, а прежний действует ещё 24 часа. В это окно каждый запрос несёт две подписи (раздел 4), и у вас есть время выкатить новый секрет на свой сервер.
- «Проверить». Отправляет запрос с
test: trueдля личности, которую вы вводите (по умолчаниюtestиtest@example.com). Панель показывает «Карточка получена, полей N» или HTTP-статус и до 300 байт тела ответа. - «Включить». Переключатель не включается, пока не заданы адрес и секрет.
3. Запрос
POST https://support.example.com/extops/customer-card
Content-Type: application/json
Accept: application/json
User-Agent: ExtOpsPanel-CustomerCard/1.0 (+https://extops.dev/docs/customer-card)
X-ExtOps-Card-Timestamp: 1760371200
X-ExtOps-Card-Signature: v1=035a47842ca90c1fd55619eccaa6aa87b4074f9157c810948d8fba656461c283
X-ExtOps-Request-Id: 6b0d1c9e-2f4a-4c55-9d0e-1f2a3b4c5d6e
{"schema_version":"1.0","project_id":"0c6a5c1e-6f1b-4b8e-9a52-3d1f0e7c2a10","person":{"user_id":"u_42","email":"anna@example.com","verified":true},"locale":"en","test":false}
| Поле | Тип | Смысл |
|---|---|---|
schema_version | "1.0" | версия схемы запроса и ответа |
project_id | uuid | наш id проекта: один эндпоинт может обслуживать несколько проектов |
person.user_id | строка до 128 символов или null | id пользователя, который подписал ваш сервер (identify() и подпись v1) |
person.email | строка до 254 символов или null | e-mail, который подписал ваш сервер |
person.verified | true | всегда true: карточка запрашивается только для подтверждённых; поле оставлено для совместимости |
locale | en ru de es fr pt | язык панели читателя, чтобы подписи пришли на нём |
test | boolean | true – запрос кнопки «Проверить»; можно ответить демонстрационной карточкой |
Имя, meta, текст сообщений и id человека в нашей базе не отправляются. Метод всегда
POST: e-mail и id не попадают в адресную строку, логи доступа и прокси, а тело
покрыто подписью.
Таймаут – 3 секунды на весь запрос (соединение до 2 секунд). Повторов нет. Редиректы не
выполняются: ответ 3xx считается сбоем.
4. Подпись v1
signed = "v1|" + timestamp + "|" + METHOD + "|" + path_with_query + "|" + hex(SHA256(raw_body))
signature = hex(HMAC-SHA256(card_secret, signed))
header = "v1=" + signature
timestamp– заголовокX-ExtOps-Card-Timestamp, Unix-время в секундах.METHOD–POST.path_with_query– путь вашего адреса вместе с query, как в строке запроса HTTP (/extops/customer-card,/hooks/card?project=a); пустой путь –/.hex– в нижнем регистре.card_secret– строка из панели целиком (ccs_…) как байты UTF-8.- Подпись считается над сырым телом, до разбора JSON.
Эндпоинт проверяет:
- оба заголовка есть;
|now − timestamp| ≤ 300секунд;- хотя бы одна подпись из заголовка совпадает (сравнение за постоянное время).
Иначе отвечайте 401: владелец увидит «Карточка недоступна», как при любом ответе,
кроме 200 и 404. Повтор перехваченного запроса в окне 5 минут вернёт ту
же карточку того же человека: тело подписано, подменить e-mail нельзя. Для строгой защиты от
повтора храните X-ExtOps-Request-Id 5 минут и отклоняйте повтор. Это по вашему
желанию.
Почему HMAC, а не bearer-токен: эндпоинт карточки отдаёт персональные данные по e-mail. Bearer-токен, утёкший из логов прокси, CDN или APM, позволил бы кому угодно перебирать адреса. Секрет HMAC по сети не передаётся никогда, а перехваченный запрос действителен 5 минут и только для одного человека.
Замена секрета
В течение 24 часов после замены секрета заголовок несёт две подписи через запятую:
v1=<новым секретом>,v1=<прежним секретом>. Принимайте запрос, если
совпала хотя бы одна. Сниппеты ниже это уже делают.
Тест-векторы
Сверьте с ними свою реализацию. Те же значения закреплены тестами нашего сервера.
| Значение | |
|---|---|
card_secret | ccs_test_vector_secret_not_for_production |
| прежний секрет | ccs_previous_test_vector_secret |
timestamp | 1760371200 |
METHOD / path_with_query | POST / /extops/customer-card |
| тело (одной строкой, без перевода строки) | {"schema_version":"1.0","project_id":"0c6a5c1e-6f1b-4b8e-9a52-3d1f0e7c2a10","person":{"user_id":"u_42","email":"anna@example.com","verified":true},"locale":"en","test":false} |
hex(SHA256(body)) | dcd0d48f2fb5d26f835b39e51076c79db02fc2bc4b98a364ab5c506959f45146 |
| подпись секретом | 035a47842ca90c1fd55619eccaa6aa87b4074f9157c810948d8fba656461c283 |
| подпись прежним секретом | 1f1ecbc40946bd56eefa381a7c65e01c780a28c37f5113b345d500ed3b0b94cd |
| заголовок в окне замены | v1=035a47842ca90c1fd55619eccaa6aa87b4074f9157c810948d8fba656461c283,v1=1f1ecbc40946bd56eefa381a7c65e01c780a28c37f5113b345d500ed3b0b94cd |
Проверка подписи
Node:
// Node (Express): нужен сырой body, например express.raw({ type: 'application/json' })
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
export function verifyCard(req, secret) {
const ts = req.get('X-ExtOps-Card-Timestamp');
const header = req.get('X-ExtOps-Card-Signature') || '';
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const bodyHash = createHash('sha256').update(req.body).digest('hex');
const signed = `v1|${ts}|${req.method}|${req.originalUrl}|${bodyHash}`;
const want = Buffer.from(createHmac('sha256', secret).update(signed).digest('hex'));
return header.split(',').some((part) => {
const got = Buffer.from(part.trim().replace(/^v1=/, ''));
return got.length === want.length && timingSafeEqual(got, want);
});
}
Go:
// Go: body – сырые байты тела, прочитанные до json.Unmarshal
func verifyCard(r *http.Request, body []byte, secret string) bool {
ts := r.Header.Get("X-ExtOps-Card-Timestamp")
sec, err := strconv.ParseInt(ts, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-sec)) > 300 {
return false
}
sum := sha256.Sum256(body)
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "v1|%s|%s|%s|%s", ts, r.Method, r.URL.RequestURI(), hex.EncodeToString(sum[:]))
want := []byte(hex.EncodeToString(mac.Sum(nil)))
for _, part := range strings.Split(r.Header.Get("X-ExtOps-Card-Signature"), ",") {
got := []byte(strings.TrimPrefix(strings.TrimSpace(part), "v1="))
if hmac.Equal(got, want) {
return true
}
}
return false
}
Python:
# Python (Flask): request.get_data() – сырые байты тела
import hashlib, hmac, time
def verify_card(request, secret: str) -> bool:
ts = request.headers.get("X-ExtOps-Card-Timestamp", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
return False
body_hash = hashlib.sha256(request.get_data()).hexdigest()
path = request.full_path.rstrip("?")
signed = f"v1|{ts}|{request.method}|{path}|{body_hash}"
want = hmac.new(secret.encode(), signed.encode(), hashlib.sha256).hexdigest()
parts = request.headers.get("X-ExtOps-Card-Signature", "").split(",")
return any(hmac.compare_digest(p.strip().removeprefix("v1="), want) for p in parts)
5. Ответ: статусы
| HTTP | Смысл | Что видит владелец |
|---|---|---|
200 и JSON карточки | карточка | карточку |
404 | такого клиента в вашей системе нет | «Клиент не найден в вашей системе» |
любой другой, 3xx, таймаут, не JSON, нарушение лимитов | сбой | «Карточка недоступна», переписка работает |
Тело ответа с ошибкой читается не дальше 300 байт и показывается владельцу только кнопкой «Проверить».
6. Ответ: карточка
Card {
schema_version: "1.0" обязательно, мажор 1
title?: string ≤ 80 заголовок карточки
subtitle?: string ≤ 160
status?: Status общий статус клиента
sections: Section[] 1..8 обязательно
actions?: Action[] ≤ 6 действия всей карточки
}
Section { id: slug ≤ 40, title?: string ≤ 60, fields: Field[] 1..20, actions?: Action[] ≤ 6 }
Field { id: slug ≤ 40, label: string ≤ 60, type: FieldType, value: <по типу>, ref?: Ref, hint?: string ≤ 160 }
FieldType:
text value: string ≤ 500
money value: { amount_minor: integer, currency: ISO 4217, три буквы }
date value: "YYYY-MM-DD" | RFC 3339
status value: Status
link value: { url: https-URL ≤ 2048, text?: string ≤ 80 }
count value: integer ≥ 0
Status { label: string ≤ 40, tone: "ok" | "warn" | "bad" | "neutral" }
Ref { kind: "payment" | "invoice" | "subscription" | "customer", id: string ≤ 128, provider?: string ≤ 32 }
Action:
link { type: "link", label ≤ 40, url: https-URL ≤ 2048 }
copy { type: "copy", label ≤ 40, value: string ≤ 500 }
prompt { type: "prompt", label ≤ 40, prompt: string ≤ 4000 }
| Тип поля | Значение |
|---|---|
text | строка до 500 символов |
money | { "amount_minor": 4900, "currency": "USD" }: сумма в минимальных единицах валюты (центах) и код ISO 4217 |
date | "YYYY-MM-DD" или время RFC 3339 |
status | { "label": "Active", "tone": "ok" } |
link | { "url": "https://…", "text": "Receipt" }; текст необязателен |
count | целое число от 0 |
- Тон статуса:
ok,warn,bad,neutral. - Вид
ref:payment,invoice,subscription,customer. - Тип действия:
link,copy,prompt(раздел 7). slug–[a-z0-9_-], от 1 до 40 символов. Длины строк считаются в символах Unicode.- Всего в карточке не больше 40 полей и 12 действий, тело не больше 64 KiB,
Content-Type: application/json, UTF-8. currencyможно прислать в любом регистре, сервер приводит его к верхнему.ref– машинная подсказка, о каком объекте поле: платёж, счёт, подписка или клиент. Панель её пока не показывает, она понадобится будущим пресетам.refс неизвестным видом отбрасывается, поле остаётся.
Совместимость и отбрасывание
Карточку рисует общий рендерер, поэтому сервер разбирает ваш ответ по правилам ниже и отдаёт панели только известное. Сырое тело в браузер не попадает.
- Неизвестный тип поля или действия, неизвестный тон: элемент отбрасывается, остальное рисуется.
- Элемент, нарушивший лимит (длина, пустая обязательная строка, значение не того типа),
отбрасывается целиком, а не обрезается. Необязательная строка сверх лимита (
title,subtitle, заголовок секции,hint) и неверныйrefотбрасываются сами, элемент остаётся. Секции, поля и действия сверх лимита количества отбрасываются с конца. Управляющие символы из строк удаляются, пробелы по краям обрезаются. - Секция без единого валидного поля отбрасывается.
- Нет
schema_versionс мажором 1, нет ни одной валидной секции, тело больше 64 KiB или не JSON: вся карточка недоступна. - Ссылки только
https:. Полеlinkс другим адресом показывается текстом (текст ссылки или адрес), действиеlinkс таким адресом отбрасывается.javascript:,data:и прочее ссылкой не становятся никогда. - Неизвестные ключи объектов игнорируются: их можно добавлять заранее.
Пример: SaaS-продукт
{
"schema_version": "1.0",
"title": "Anna Petrova",
"subtitle": "Customer since March 2025",
"status": { "label": "Active", "tone": "ok" },
"sections": [
{
"id": "plan",
"title": "Plan",
"fields": [
{ "id": "plan", "label": "Plan", "type": "text", "value": "Team, yearly" },
{ "id": "seats", "label": "Seats", "type": "count", "value": 12 },
{ "id": "mrr", "label": "MRR", "type": "money", "value": { "amount_minor": 4900, "currency": "USD" } },
{ "id": "renews", "label": "Renews", "type": "date", "value": "2026-03-14" }
]
},
{
"id": "usage",
"title": "Product",
"fields": [
{ "id": "last_seen", "label": "Last active", "type": "date", "value": "2026-10-12T18:04:00Z" },
{ "id": "errors", "label": "Failed exports, 7 days", "type": "count", "value": 3 },
{ "id": "sync", "label": "Sync", "type": "status", "value": { "label": "Paused", "tone": "warn" } }
],
"actions": [
{ "type": "link", "label": "Open in admin", "url": "https://admin.example.com/users/u_42" }
]
}
],
"actions": [
{ "type": "copy", "label": "Copy user id", "value": "u_42" },
{
"type": "prompt",
"label": "Ask the agent to check exports",
"prompt": "Customer u_42 reports failed exports. Check the last 7 days of export jobs for this user and summarise the errors. Do not change anything."
}
]
}
Пример: подписка и платежи
Владелец, который принимает оплату через EasyPay, может разметить подписку и платежи полями
ref. Готовые пресеты для карточек оплаты появятся позже, сейчас это обычная
карточка.
{
"schema_version": "1.0",
"title": "Studio, monthly",
"status": { "label": "Cancelled", "tone": "warn" },
"sections": [
{
"id": "subscription",
"title": "Subscription",
"fields": [
{ "id": "status", "label": "Status", "type": "status", "value": { "label": "Ends on 14 Nov", "tone": "warn" },
"ref": { "kind": "subscription", "id": "sub_1Q2w3E4r", "provider": "easypay" } },
{ "id": "price", "label": "Price", "type": "money", "value": { "amount_minor": 1900, "currency": "USD" } },
{ "id": "ends", "label": "Access until", "type": "date", "value": "2026-11-14" }
]
},
{
"id": "payments",
"title": "Recent payments",
"fields": [
{ "id": "p1", "label": "13 Oct, paid", "type": "money", "value": { "amount_minor": 1900, "currency": "USD" },
"ref": { "kind": "payment", "id": "pi_3Pq9Lm", "provider": "easypay" }, "hint": "Invoice 4F2A-0007" },
{ "id": "inv1", "label": "Invoice", "type": "text", "value": "4F2A-0007",
"ref": { "kind": "invoice", "id": "in_1Pq9Lm", "provider": "easypay" } },
{ "id": "r1", "label": "Receipt", "type": "link", "value": { "url": "https://pay.stripe.com/receipts/example", "text": "Receipt" } },
{ "id": "cust", "label": "Customer", "type": "text", "value": "cus_Q2w3E4r",
"ref": { "kind": "customer", "id": "cus_Q2w3E4r", "provider": "easypay" } }
]
}
],
"actions": [
{ "type": "copy", "label": "Copy subscription id", "value": "sub_1Q2w3E4r" }
]
}
Собрать такой эндпоинт проще всего из своей базы, которую наполняет вебхук EasyPay: id подписки,
её статус, даты отмены и окончания, платежи с payment_intent и счётом. Ключ партнёра
EasyPay с полными правами в эндпоинт карточки не кладите.
7. Действия
type | Что делает панель |
|---|---|
link | открывает https:-адрес в новой вкладке (rel="noopener noreferrer") |
copy | копирует value в буфер обмена |
prompt | показывает текст и копирует его для вашего ИИ-агента поддержки |
Prompt – это данные, а не команда: панель только копирует текст, никогда его не исполняет и никуда не отправляет. Не подставляйте в prompt текст сообщений посетителя: это prompt injection в агента, у которого могут быть права на деньги. Пишите в тексте, что любое действие с деньгами требует подтверждения человека.
8. Что положить в карточку
Карточку видит только владелец проекта, но всё, что вы отдаёте, уходит к нему в браузер. Достаточно того, что нужно поддержке: тариф, статус подписки, суммы и даты платежей, счётчики продукта, ссылки в свою админку. Не отдавайте данные карт, пароли, токены, почтовые адреса и данные других людей. E-mail и имя у владельца уже есть из обращения.
9. Безопасность на нашей стороне
- Адрес. Только
https, только порт 443. Хост не может бытьlocalhost, именем в.localили.internalили IP-литералом частной сети. Проверяем при сохранении настроек. - Соединение. Каждый адрес, к которому реально идёт сокет, проверяется при
подключении: это защита от DNS rebinding. Запрещены loopback, частные сети (RFC 1918 и ULA),
link-local, в том числе
169.254.169.254, unspecified и multicast, CGNAT100.64.0.0/10,0.0.0.0/8,192.0.0.0/24,198.18.0.0/15,240.0.0.0/4и NAT6464:ff9b::/96с частным адресом внутри. Прокси из окружения не используются, редиректы не выполняются. - Нагрузка. Не больше 8 одновременных запросов карточки на сервер (сверх этого карточка сразу недоступна), не больше 60 запросов в минуту на проект, кнопка «Проверить» – 10 раз в час на проект.
- Логи. Пишем id проекта, хост эндпоинта, HTTP-статус, длительность и класс ошибки. Никогда: тела запроса и ответа, e-mail, id пользователей, путь с query и подписи.
- Рендер. Только текст, никакого HTML и markdown; ссылки только
https:. - Выключатель. Мы можем выключить запросы карточки для всего сервиса, например во время инцидента. Тогда ни один запрос карточки не уходит с наших серверов, карточка и кнопка «Проверить» в панели недоступны, а ваши настройки сохраняются.
10. Тарифы
Карточка клиента входит в CRM обратной связи на Indie, Studio и Fleet. Считается тариф вашего аккаунта, триал её не открывает. После понижения тарифа настройки сохраняются, а карточка не запрашивается, пока тариф не вернётся.
11. Машиночитаемый контракт
Формальное описание эндпоинта на вашей стороне, запроса, схемы карточки и её перечислений –
файл OpenAPI 3.1; эндпоинт описан в webhooks, потому что вызываем мы. В нём та же
версия контракта (1.0.0), это первая версия контракта.