Ext Ops Panel
Открыть панель

Главная/Документация/Карточка клиента

Контракт карточки клиента

Версия контракта 1.0.0 · входит в CRM обратной связи на Indie, Studio и Fleet

Когда вы открываете человека в CRM обратной связи, панель может показать, кто этот клиент в вашей системе: тариф, платежи, работа с продуктом, ссылка в вашу админку. Вы держите один HTTPS-эндпоинт, наш сервер подписанным запросом спрашивает у него карточку, а панель рисует ответ рядом с перепиской. Карточку мы не храним, а её действия никогда не исполняем.

↓ Скачать OpenAPI 3.1 (YAML) /customer-card-openapi.yaml · info.version 1.0.0

1. Как это работает

Направление то же, что у кастом-метрик: вызывает наш сервер, отвечает ваш. Вы открываете подтверждённого человека в разделе «Люди» на вкладке «Обратная связь» проекта (см. контракт обратной связи, разделы 1 и 9). Наш сервер отправляет один подписанный POST на ваш эндпоинт, сверяет ответ со схемой ниже и отдаёт панели только то, что понимает. Сырое тело ответа в браузер не попадает.

Четыре правила, которые не меняет ни одна настройка:

2. Настройка в панели

В настройке проекта, секция «Виджет обратной связи», подраздел «Карточка клиента» (только владелец, тариф с CRM):

  1. Адрес эндпоинта. https://…, только порт 443, публичный адрес (раздел 9).
  2. Секрет карточки. ccs_ и 32 случайных байта в base64url. Его выпускаем мы и показываем один раз; дальше видны только последние 4 символа. Храним его зашифрованным.
  3. «Заменить секрет». Новый секрет показывается один раз, а прежний действует ещё 24 часа. В это окно каждый запрос несёт две подписи (раздел 4), и у вас есть время выкатить новый секрет на свой сервер.
  4. «Проверить». Отправляет запрос с test: true для личности, которую вы вводите (по умолчанию test и test@example.com). Панель показывает «Карточка получена, полей N» или HTTP-статус и до 300 байт тела ответа.
  5. «Включить». Переключатель не включается, пока не заданы адрес и секрет.

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_iduuidнаш id проекта: один эндпоинт может обслуживать несколько проектов
person.user_idстрока до 128 символов или nullid пользователя, который подписал ваш сервер (identify() и подпись v1)
person.emailстрока до 254 символов или nulle-mail, который подписал ваш сервер
person.verifiedtrueвсегда true: карточка запрашивается только для подтверждённых; поле оставлено для совместимости
localeen ru de es fr ptязык панели читателя, чтобы подписи пришли на нём
testbooleantrue – запрос кнопки «Проверить»; можно ответить демонстрационной карточкой

Имя, 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

Эндпоинт проверяет:

  1. оба заголовка есть;
  2. |now − timestamp| ≤ 300 секунд;
  3. хотя бы одна подпись из заголовка совпадает (сравнение за постоянное время).

Иначе отвечайте 401: владелец увидит «Карточка недоступна», как при любом ответе, кроме 200 и 404. Повтор перехваченного запроса в окне 5 минут вернёт ту же карточку того же человека: тело подписано, подменить e-mail нельзя. Для строгой защиты от повтора храните X-ExtOps-Request-Id 5 минут и отклоняйте повтор. Это по вашему желанию.

Почему HMAC, а не bearer-токен: эндпоинт карточки отдаёт персональные данные по e-mail. Bearer-токен, утёкший из логов прокси, CDN или APM, позволил бы кому угодно перебирать адреса. Секрет HMAC по сети не передаётся никогда, а перехваченный запрос действителен 5 минут и только для одного человека.

Замена секрета

В течение 24 часов после замены секрета заголовок несёт две подписи через запятую: v1=<новым секретом>,v1=<прежним секретом>. Принимайте запрос, если совпала хотя бы одна. Сниппеты ниже это уже делают.

Тест-векторы

Сверьте с ними свою реализацию. Те же значения закреплены тестами нашего сервера.

Значение
card_secretccs_test_vector_secret_not_for_production
прежний секретccs_previous_test_vector_secret
timestamp1760371200
METHOD / path_with_queryPOST / /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

Совместимость и отбрасывание

Карточку рисует общий рендерер, поэтому сервер разбирает ваш ответ по правилам ниже и отдаёт панели только известное. Сырое тело в браузер не попадает.

Пример: 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. Безопасность на нашей стороне

10. Тарифы

Карточка клиента входит в CRM обратной связи на Indie, Studio и Fleet. Считается тариф вашего аккаунта, триал её не открывает. После понижения тарифа настройки сохраняются, а карточка не запрашивается, пока тариф не вернётся.

11. Машиночитаемый контракт

Формальное описание эндпоинта на вашей стороне, запроса, схемы карточки и её перечислений – файл OpenAPI 3.1; эндпоинт описан в webhooks, потому что вызываем мы. В нём та же версия контракта (1.0.0), это первая версия контракта.

↓ Скачать OpenAPI 3.1 (YAML) /customer-card-openapi.yaml