Как делать11 октября 2026 г.10 мин чтения
API и интеграцииШаг 7 из 10

Интеграция с CRM: адаптер, маппинг полей, синхронизация

Коротко

Интеграцию сайта с CRM строят как адаптер за портом: ядро приложения передаёт заявку в своём формате, а адаптер переводит её в запросы конкретной CRM — в этом руководстве это amoCRM REST API v4. Надёжность дают четыре решения: маппинг полей по id из настроек аккаунта, поиск дублей по нормализованному телефону и email, журнал обмена с идемпотентным повтором и два канала синхронизации статусов — вебхуки и периодическая сверка.

РуководствоУровень: СреднийИнтеграции с CRMПрактика: ~1–2 ч

После материала вы сможете

  • спроектировать порт CrmPort и адаптер amoCRM, который создаёт сделку с контактом через POST /api/v4/leads/complex
  • описать маппинг полей формы на custom_fields_values и этапов воронки на статусы своего приложения
  • находить дубли контактов по нормализованному телефону и email и не создавать вторую сделку при повторе запроса
  • синхронизировать статусы вебхуками и периодической сверкой с учётом лимита 7 запросов в секунду и одноразового refresh token

Проверено на

amoCRM REST API v4 (docs as of 2026-10-11)Node.js 24 LTS (24.18), built-in fetchTypeScript 7.0.2 (tsc --strict)Vitest 5.0.3, @types/node 24.19

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Что строим: заявка с сайта превращается в сделку
  2. 02Изучаем API до кода: авторизация, лимиты, ответы
  3. 03Маппинг полей и справочников
  4. 04Дубли: поиск по телефону и email
  5. 05Ошибки, повторы и журнал обмена
  6. 06Синхронизация статусов: вебхуки и сверка
  7. 07Проверка на мок-сервере, частые ошибки и упражнения

Что строим: заявка с сайта превращается в сделку

Типовых сценариев три. Заявка с сайта становится сделкой с контактом. Повторное обращение того же человека привязывается к уже существующему контакту, а не плодит новый. Смена этапа сделки в CRM возвращается в приложение — например, чтобы показать статус в личном кабинете или остановить автоматические напоминания.

Примеры написаны для amoCRM и его REST API v4 по официальной документации разработчика (amocrm.ru/developers). У Kommo — международной версии продукта — отдельная документация на developers.kommo.com: при работе с ней сверяйте пути и ограничения по ней. С Битрикс24 подход тот же, меняется только адаптер.

Новая заявка → сделка + контакт

Направление
сайт → CRM
Метод amoCRM API v4
POST /api/v4/leads/complex

Поиск существующего контакта

Направление
сайт → CRM
Метод amoCRM API v4
GET /api/v4/contacts?query=…

Смена этапа сделки

Направление
CRM → сайт
Метод amoCRM API v4
вебхук «Смена статуса» (сделка)

Сверка пропущенных изменений

Направление
CRM → сайт
Метод amoCRM API v4
GET /api/v4/leads?order[updated_at]=desc
  1. 01Форма на сайте
  2. 02Use case + журнал
  3. 03Адаптер amoCRM
  4. 04amoCRM API v4
Заявки идут в CRM через порт и адаптер; статусы возвращаются вебхуками и сверкой.

Ядро приложения не должно знать, что CRM — это amoCRM. Оно работает со своей заявкой и портом CrmPort; всё, что связано с custom_fields_values, токенами и лимитами, живёт в адаптере. Это прямое применение портов и адаптеров: схема подробно разобрана в материале «Чистая архитектура», а заготовка для повторного использования — в рецепте «Порт и адаптер для внешнего сервиса». Смена CRM или второй адаптер для тестов не затрагивает бизнес-логику.

src/core/lead.ts
// Ядро ничего не знает про amoCRM: своя модель заявки и порт, который реализует адаптер
export type LeadRequest = {
  externalId: string; // id заявки на сайте — ключ идемпотентности
  name: string;
  phone: string;
  email?: string;
  comment?: string;
  source: "site-form" | "callback" | "landing";
};

export type LeadStatus = "new" | "in_progress" | "won" | "lost";

export type CrmLeadRef = {
  crmLeadId: number;
  crmContactId: number;
  contactReused: boolean;
};

export interface CrmPort {
  /* найти уже созданную сделку по id заявки — защита от дубля при повторе */
  findLeadByExternalId(externalId: string): Promise<number | null>;
  createLead(lead: LeadRequest): Promise<CrmLeadRef>;
}
Модель заявки и порт CRM — ядро не импортирует ничего из адаптера

Изучаем API до кода: авторизация, лимиты, ответы

Перед первой строкой адаптера выпишите ограничения API: архитектуру они определяют сильнее, чем набор методов. Сводка из раздела «Ограничения и рекомендации» и описаний методов amoCRM:

Частота запросов

По документации
не более 7 в секунду на интеграцию и до 50 в секунду на аккаунт
Что делаем в коде
очередь в клиенте: старт запросов не чаще раза в 150 мс

Превышение лимита

По документации
HTTP 429; при многократных нарушениях аккаунт блокируется и на любой запрос приходит 403
Что делаем в коде
пауза и повтор только на 429; 403 — тревога, без повторов

Размер пакета

По документации
добавление и изменение — до 250 сущностей, рекомендовано до 50; leads/complex — до 50 сделок и до 40 значений полей на сущность
Что делаем в коде
заявки — по одной; массовый импорт — порциями по 50

Истёк access token

По документации
HTTP 401 — обновить токен по refresh token и повторить запрос
Что делаем в коде
один автоматический обмен и повтор

Подписка закончилась

По документации
HTTP 402
Что делаем в коде
ошибка в журнал, без повторов

Ошибка валидации

По документации
HTTP 400 и validation-errors с путём до поля
Что делаем в коде
тело ответа сохраняем в журнал

Авторизация — OAuth 2.0. Администратор аккаунта устанавливает интеграцию, вы обмениваете код авторизации (он действует 20 минут) на пару access token и refresh token через POST /oauth2/access_token. В примере ответа из документации expires_in равен 86400 секундам, то есть суткам. Refresh token действует 3 месяца и обменивается только один раз: после обмена старый становится недействительным, а новую пару нужно сохранить до первого использования — иначе доступ придётся запрашивать у пользователя заново. Общая механика authorization code flow описана в материале «OAuth 2.0 и OpenID Connect».

Для приватной интеграции под один аккаунт с февраля 2024 года можно выпустить долгосрочный токен сроком от 1 дня до 5 лет. Refresh token у него нет, обменивать нечего. Это проще, но токен хранят и отзывают так же аккуратно, а дату окончания стоит поставить в календарь.

src/amocrm/tokens.ts
export type Tokens = { accessToken: string; refreshToken: string; expiresAt: number };

// Хранилище токенов — своя БД или менеджер секретов, не файл в репозитории
export interface TokenStore {
  load(): Promise<Tokens>;
  save(tokens: Tokens): Promise<void>;
}

type OAuthApp = { clientId: string; clientSecret: string; redirectUri: string };

export class AmoTokenProvider {
  private refreshing: Promise<Tokens> | null = null;
  private baseUrl: string;
  private app: OAuthApp;
  private store: TokenStore;

  constructor(baseUrl: string, app: OAuthApp, store: TokenStore) {
    this.baseUrl = baseUrl;
    this.app = app;
    this.store = store;
  }

  async accessToken(): Promise<string> {
    const tokens = await this.store.load();
    if (tokens.expiresAt - 60_000 > Date.now()) return tokens.accessToken;
    return (await this.refresh()).accessToken;
  }

  // один обмен на процесс: refresh token одноразовый, параллельный второй обмен его «сожжёт»
  refresh(): Promise<Tokens> {
    this.refreshing ??= this.exchange().finally(() => {
      this.refreshing = null;
    });
    return this.refreshing;
  }

  private async exchange(): Promise<Tokens> {
    const { refreshToken } = await this.store.load();
    const res = await fetch(`${this.baseUrl}/oauth2/access_token`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        client_id: this.app.clientId,
        client_secret: this.app.clientSecret,
        grant_type: "refresh_token",
        refresh_token: refreshToken,
        redirect_uri: this.app.redirectUri,
      }),
      signal: AbortSignal.timeout(10_000),
    });
    if (!res.ok) throw new Error(`token refresh failed: HTTP ${res.status}`);
    const body = (await res.json()) as { access_token: string; refresh_token: string; expires_in: number };
    const tokens = {
      accessToken: body.access_token,
      refreshToken: body.refresh_token,
      expiresAt: Date.now() + body.expires_in * 1000,
    };
    await this.store.save(tokens); // сохранить до первого использования: старый refresh уже недействителен
    return tokens;
  }
}
Обмен refresh token: одна операция на процесс, новая пара сохраняется до использования. Ключи — плейсхолдеры, реальные значения берите из переменных окружения
src/amocrm/client.ts
export class AmoHttpError extends Error {
  status: number;
  body: string;
  constructor(status: number, body: string) {
    super(`amoCRM HTTP ${status}: ${body.slice(0, 300)}`);
    this.status = status;
    this.body = body;
  }
}

type TokenSource = { accessToken(): Promise<string>; refresh(): Promise<unknown> };

type Options = {
  baseUrl: string; // https://<subdomain>.amocrm.ru — адрес аккаунта, не общий домен
  tokens: TokenSource;
  minIntervalMs?: number; // лимит — 7 запросов в секунду на интеграцию: 150 мс между стартами
  maxRetries?: number;
  sleep?: (ms: number) => Promise<void>;
};

const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));

export class AmoClient {
  private nextSlot = 0;
  private opts: Required<Options>;

  constructor(opts: Options) {
    this.opts = { minIntervalMs: 150, maxRetries: 3, sleep: wait, ...opts };
  }

  async request<T>(method: "GET" | "POST" | "PATCH", path: string, body?: unknown): Promise<T | null> {
    let refreshed = false;
    for (let attempt = 0; ; attempt++) {
      await this.throttle();
      const res = await fetch(this.opts.baseUrl + path, {
        method,
        headers: {
          Authorization: `Bearer ${await this.opts.tokens.accessToken()}`,
          "Content-Type": "application/json",
        },
        body: body === undefined ? undefined : JSON.stringify(body),
        signal: AbortSignal.timeout(15_000),
      });
      if (res.status === 204) return null; // нет данных: тело пустое
      if (res.ok) return (await res.json()) as T;

      const text = await res.text();
      if (res.status === 401 && !refreshed) {
        refreshed = true; // access token истёк — обменять refresh и повторить один раз
        await this.opts.tokens.refresh();
        continue;
      }
      // 429 — запрос не выполнен, повтор безопасен; 5xx повторяем только для чтения
      const retryable = res.status === 429 || (res.status >= 500 && method === "GET");
      if (retryable && attempt < this.opts.maxRetries) {
        const retryAfter = Number(res.headers.get("retry-after"));
        await this.opts.sleep(retryAfter > 0 ? retryAfter * 1000 : 1000 * 2 ** attempt);
        continue;
      }
      throw new AmoHttpError(res.status, text);
    }
  }

  private async throttle() {
    const now = Date.now();
    const slot = Math.max(now, this.nextSlot);
    this.nextSlot = slot + this.opts.minIntervalMs;
    if (slot > now) await wait(slot - now);
  }
}
HTTP-клиент amoCRM: лимит частоты, Bearer-токен, повтор при 401 и 429, ответ 204 без тела

Клиент повторяет GET при ответах 5xx, но не повторяет POST. Создание сделки не идемпотентно, и слепой повтор после таймаута — самый частый источник дублей. Повтор записи выполняет use case через журнал обмена — об этом ниже.

Маппинг полей и справочников

Маппинг — это таблица соответствия «поле формы → поле CRM», которую фиксируют до кода. В amoCRM стандартные поля контакта — телефон и email — адресуются символьным кодом (field_code: "PHONE" и "EMAIL") и типом значения enum_code: для телефона WORK, WORKDD, MOB, FAX, HOME, OTHER, для email — WORK, PRIV, OTHER. Дополнительные поля сделки создаёт администратор, и в каждом аккаунте у них свои числовые field_id. Их получают через GET /api/v4/leads/custom_fields и хранят в конфигурации, а не в коде.

Имя

amoCRM
first_name контакта
Правило
обрезаем пробелы

Телефон

amoCRM
PHONE, enum_code: MOB
Правило
нормализуем в +7XXXXXXXXXX; невалидный — ошибка маппинга

Email

amoCRM
EMAIL, enum_code: WORK
Правило
нижний регистр; пустой не передаём

id заявки

amoCRM
текстовое поле сделки
Правило
ключ идемпотентности и поиска

Комментарий

amoCRM
текстовое поле сделки
Правило
пустые значения не отправляем

Источник

amoCRM
текстовое поле сделки и тег
Правило
справочник значений в коде

Этап воронки

amoCRM
status_id
Правило
142 — «Успешно реализовано», 143 — «Закрыто и не реализовано»: системные статусы любой воронки
src/amocrm/types.ts
// Формы запросов и ответов amoCRM API v4 — по разделам «Сделки», «Контакты», «Поля»
export type AmoFieldValue = { value: string; enum_code?: string };
export type AmoCustomField = { field_id?: number; field_code?: string; values: AmoFieldValue[] };

export type AmoContactInput = { first_name: string; custom_fields_values: AmoCustomField[] };

export type AmoComplexLead = {
  name: string;
  pipeline_id: number;
  request_id: string;
  custom_fields_values: AmoCustomField[];
  tags_to_add: { name: string }[];
  _embedded: { contacts: [{ id: number } | AmoContactInput] };
};

export type AmoComplexResult = {
  id: number;
  contact_id: number | null;
  company_id: number | null;
  request_id: string[];
  merged: boolean;
};

export type AmoContact = { id: number; name: string; custom_fields_values: AmoCustomField[] | null };
export type AmoLead = {
  id: number;
  status_id: number;
  pipeline_id: number;
  updated_at: number;
  custom_fields_values: AmoCustomField[] | null;
};

export type AmoPage<K extends string, T> = {
  _page?: number;
  _links: { next?: { href: string } };
  _embedded: Record<K, T[]>;
};

// id полей и воронки отличаются в каждом аккаунте: берите их из GET /api/v4/leads/custom_fields
export type AmoMapping = {
  pipelineId: number;
  firstStatusId: number;
  fields: { externalId: number; comment: number; source: number };
  tag: string;
};
Формы запросов и ответов API v4, нужные адаптеру, и конфигурация маппинга
src/amocrm/mapping.ts
import type { LeadRequest, LeadStatus } from "../core/lead.ts";
import { normalizeEmail, normalizePhone } from "../core/normalize.ts";
import type { AmoComplexLead, AmoCustomField, AmoMapping } from "./types.ts";

const SOURCE_LABEL: Record<LeadRequest["source"], string> = {
  "site-form": "Форма на сайте",
  callback: "Обратный звонок",
  landing: "Лендинг",
};

const text = (field_id: number, value: string | undefined): AmoCustomField[] =>
  value?.trim() ? [{ field_id, values: [{ value: value.trim() }] }] : [];

export function toAmoComplexLead(
  lead: LeadRequest,
  map: AmoMapping,
  existingContactId: number | null,
): AmoComplexLead {
  const phone = normalizePhone(lead.phone);
  if (!phone) throw new Error(`invalid phone for ${lead.externalId}`);
  const email = normalizeEmail(lead.email);

  const contact =
    existingContactId !== null
      ? { id: existingContactId }
      : {
          first_name: lead.name.trim(),
          custom_fields_values: [
            { field_code: "PHONE", values: [{ value: phone, enum_code: "MOB" }] },
            ...(email ? [{ field_code: "EMAIL", values: [{ value: email, enum_code: "WORK" }] }] : []),
          ],
        };

  return {
    name: `Заявка ${lead.externalId}`,
    pipeline_id: map.pipelineId,
    request_id: lead.externalId,
    custom_fields_values: [
      ...text(map.fields.externalId, lead.externalId),
      ...text(map.fields.comment, lead.comment),
      ...text(map.fields.source, SOURCE_LABEL[lead.source]),
    ],
    tags_to_add: [{ name: map.tag }],
    _embedded: { contacts: [contact] },
  };
}

// 142 и 143 — системные статусы любой воронки amoCRM: «успешно реализовано» и «закрыто и не реализовано»
export function toLeadStatus(statusId: number, map: AmoMapping): LeadStatus {
  if (statusId === 142) return "won";
  if (statusId === 143) return "lost";
  return statusId === map.firstStatusId ? "new" : "in_progress";
}

export function readText(fields: AmoCustomField[] | null, fieldId: number): string | null {
  return fields?.find((f) => f.field_id === fieldId)?.values[0]?.value ?? null;
}
Маппинг заявки в модель leads/complex и этапа воронки — в статус приложения

Дубли: поиск по телефону и email

Один человек оставляет заявку с телефоном «8 (701) 123-45-67», через неделю — «+7 701 123 4567». Для CRM это два разных значения, для бизнеса — один клиент. Поэтому ключи дедупликации сначала нормализуют: телефон приводят к формату E.164 (+77011234567), email — к нижнему регистру.

В amoCRM есть штатный «Контроль дублей»: он настраивается для каждой воронки, ищет дубли по телефону, email или значению поля и приводит телефоны к международному формату. Но по документации для интеграций он работает только через POST /api/v4/leads/complex и только если в настройках интеграции отмечена поддержка контроля дублей. Он не дедуплицирует данные внутри одного запроса и не срабатывает при работе через старые API-ключи. Поэтому адаптер ищет контакт сам и передаёт в leads/complex либо id найденного контакта, либо полную модель нового. Штатный контроль остаётся второй линией защиты.

  1. Нормализовать

    normalizePhone и normalizeEmail живут в ядре: одни и те же правила для поиска и для записи.

  2. Найти кандидатов

    GET /api/v4/contacts?query=… — полнотекстовый поиск по заполненным полям: сначала по телефону, затем по email.

  3. Перепроверить

    Из ответа берём только контакты, у которых нормализованный телефон или email совпадает точно. Похожий номер — не дубль.

  4. Выбрать

    При нескольких совпадениях — самый старый контакт; склейку карточек оставляем менеджеру.

  5. Создать сделку

    leads/complex с { id } найденного контакта или с моделью нового контакта.

src/core/normalize.ts
// Нормализация ключей дедупликации. Правило для плана нумерации +7 (Казахстан, Россия);
// для международных номеров берите библиотеку уровня libphonenumber
export function normalizePhone(raw: string): string | null {
  const digits = raw.replace(/\D/g, "");
  if (digits.length === 11 && digits.startsWith("8")) return `+7${digits.slice(1)}`;
  if (digits.length === 11 && digits.startsWith("7")) return `+${digits}`;
  if (digits.length === 10) return `+7${digits}`;
  return null;
}

export function normalizeEmail(raw: string | undefined): string | null {
  const email = raw?.trim().toLowerCase();
  return email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? email : null;
}
Нормализация ключей дедупликации — одна функция для поиска и записи
src/amocrm/dedup.ts
import { normalizeEmail, normalizePhone } from "../core/normalize.ts";
import type { AmoContact } from "./types.ts";

const values = (c: AmoContact, code: string) =>
  (c.custom_fields_values ?? []).filter((f) => f.field_code === code).flatMap((f) => f.values.map((v) => v.value));

// query в amoCRM — полнотекстовый поиск: ответ перепроверяем точным сравнением нормализованных ключей
export function pickDuplicate(candidates: AmoContact[], phone: string | null, email: string | null): number | null {
  const matches = candidates.filter(
    (c) =>
      (phone !== null && values(c, "PHONE").some((v) => normalizePhone(v) === phone)) ||
      (email !== null && values(c, "EMAIL").some((v) => normalizeEmail(v) === email)),
  );
  // несколько совпадений — берём самый старый контакт (минимальный id), остальные склеивает менеджер
  return matches.length ? Math.min(...matches.map((c) => c.id)) : null;
}
Выбор дубля из выдачи полнотекстового поиска
src/amocrm/adapter.ts
import type { CrmLeadRef, CrmPort, LeadRequest } from "../core/lead.ts";
import { normalizeEmail, normalizePhone } from "../core/normalize.ts";
import type { AmoClient } from "./client.ts";
import { pickDuplicate } from "./dedup.ts";
import { readText, toAmoComplexLead } from "./mapping.ts";
import type { AmoComplexResult, AmoContact, AmoLead, AmoMapping, AmoPage } from "./types.ts";

export class AmoCrmAdapter implements CrmPort {
  private client: AmoClient;
  private map: AmoMapping;

  constructor(client: AmoClient, map: AmoMapping) {
    this.client = client;
    this.map = map;
  }

  async findLeadByExternalId(externalId: string): Promise<number | null> {
    const page = await this.client.request<AmoPage<"leads", AmoLead>>(
      "GET",
      `/api/v4/leads?query=${encodeURIComponent(externalId)}&limit=50`,
    );
    const hit = page?._embedded.leads.find(
      (l) => readText(l.custom_fields_values, this.map.fields.externalId) === externalId,
    );
    return hit?.id ?? null;
  }

  async createLead(lead: LeadRequest): Promise<CrmLeadRef> {
    const contactId = await this.findContact(normalizePhone(lead.phone), normalizeEmail(lead.email));
    const result = await this.client.request<AmoComplexResult[]>("POST", "/api/v4/leads/complex", [
      toAmoComplexLead(lead, this.map, contactId),
    ]);
    const created = result?.[0];
    if (!created) throw new Error(`amoCRM returned no lead for ${lead.externalId}`);
    return {
      crmLeadId: created.id,
      crmContactId: created.contact_id ?? contactId ?? 0,
      contactReused: contactId !== null || created.merged,
    };
  }

  private async findContact(phone: string | null, email: string | null): Promise<number | null> {
    for (const query of [phone, email]) {
      if (!query) continue;
      const page = await this.client.request<AmoPage<"contacts", AmoContact>>(
        "GET",
        `/api/v4/contacts?query=${encodeURIComponent(query)}&limit=50`,
      );
      const id = pickDuplicate(page?._embedded.contacts ?? [], phone, email);
      if (id !== null) return id;
    }
    return null;
  }
}
Адаптер amoCRM: реализация порта CrmPort

Ошибки, повторы и журнал обмена

Журнал обмена — таблица в вашей базе, где по каждой заявке видны статус отправки, число попыток, id сделки в CRM и текст последней ошибки. Он отвечает на вопрос «почему этой заявки нет в CRM» без чтения логов и даёт фоновой задаче список того, что нужно повторить.

429

Что делает код
клиент ждёт (Retry-After или 1, 2, 4 секунды) и повторяет

401

Что делает код
один обмен refresh token и повтор

5xx на чтении

Что делает код
повтор с паузой

5xx или таймаут на создании

Что делает код
запись failed; при повторе сначала ищем сделку по externalId

400

Что делает код
failed с телом ответа, без автоповтора: чинить нужно маппинг

402, 403

Что делает код
failed и уведомление ответственному

Самый коварный случай — потерянный ответ: CRM создала сделку, а до вас дошёл 502 или таймаут. Повтор вслепую даст дубль: leads/complex по документации ищет дубли только среди уже добавленных данных и только при включённом контроле дублей. Поэтому id заявки записывается в поле сделки, а повторная попытка сначала ищет сделку по нему.

src/core/exchange-log.ts
// Журнал обмена: по каждой заявке видно, дошла ли она до CRM, сколько было попыток и почему упала
export type ExchangeEntry = {
  externalId: string;
  status: "pending" | "synced" | "failed";
  attempts: number;
  crmLeadId?: number;
  lastError?: string;
  updatedAt: string;
};

export interface ExchangeLog {
  get(externalId: string): Promise<ExchangeEntry | null>;
  put(entry: ExchangeEntry): Promise<void>;
  failed(): Promise<ExchangeEntry[]>;
}

// Для тестов; в продакшене — таблица в своей БД с уникальным индексом по externalId
export class MemoryExchangeLog implements ExchangeLog {
  private entries = new Map<string, ExchangeEntry>();

  async get(externalId: string) {
    return this.entries.get(externalId) ?? null;
  }

  async put(entry: ExchangeEntry) {
    this.entries.set(entry.externalId, { ...entry });
  }

  async failed() {
    return [...this.entries.values()].filter((e) => e.status === "failed");
  }
}
Порт журнала обмена и реализация в памяти для тестов
src/core/submit-lead.ts
import type { CrmPort, LeadRequest } from "./lead.ts";
import type { ExchangeEntry, ExchangeLog } from "./exchange-log.ts";

type Deps = { crm: CrmPort; log: ExchangeLog; now?: () => Date };

export async function submitLead(lead: LeadRequest, deps: Deps): Promise<ExchangeEntry> {
  const now = () => (deps.now?.() ?? new Date()).toISOString();
  const prev = await deps.log.get(lead.externalId);
  if (prev?.status === "synced") return prev; // повтор той же заявки — второй сделки не будет

  const attempts = (prev?.attempts ?? 0) + 1;
  await deps.log.put({ externalId: lead.externalId, status: "pending", attempts, updatedAt: now() });

  try {
    // прошлая попытка могла создать сделку, а ответ — потеряться: сначала ищем её
    const existing = prev ? await deps.crm.findLeadByExternalId(lead.externalId) : null;
    const crmLeadId = existing ?? (await deps.crm.createLead(lead)).crmLeadId;
    const entry: ExchangeEntry = {
      externalId: lead.externalId,
      status: "synced",
      attempts,
      crmLeadId,
      updatedAt: now(),
    };
    await deps.log.put(entry);
    return entry;
  } catch (err) {
    const entry: ExchangeEntry = {
      externalId: lead.externalId,
      status: "failed",
      attempts,
      lastError: err instanceof Error ? err.message : String(err),
      updatedAt: now(),
    };
    await deps.log.put(entry); // повторит фоновая задача по log.failed()
    return entry;
  }
}
Use case отправки заявки: идемпотентность по externalId и защита от потерянного ответа

Синхронизация статусов: вебхуки и сверка

Направление синхронизации задаёт источник истины по каждому полю. Заявку и её исходные данные создаёт сайт, а этап сделки, ответственного и бюджет ведут менеджеры в CRM. Значит, статусы идут из CRM в приложение, и приложение их не перезаписывает — иначе две стороны будут бесконечно отменять изменения друг друга.

Контакт и текст заявки

Источник истины
сайт, в момент создания
Направление
сайт → CRM, один раз

Этап сделки

Источник истины
CRM
Направление
CRM → сайт

Ответственный, бюджет

Источник истины
CRM
Направление
в приложение — только для чтения, если нужны

id заявки

Источник истины
сайт
Направление
пишется в поле сделки и не меняется

Вебхуки настраиваются в разделе интеграций аккаунта или через POST /api/v4/webhooks (нужны права администратора, в аккаунте — не более 100 хуков). amoCRM отправляет их в формате x-www-form-urlencoded с полями вида leads[status][0][id]. Ответа ждёт не более 2 секунд; код вне диапазона 100–299 или таймаут считаются недоставкой. Повторы идут через 5 минут, 15 минут, 15 минут и 1 час, а если за 2 часа накопится больше 100 невалидных откликов, хук отключат.

  • Ответить 200 сразу, а обработку положить в очередь
  • Не доверять телу: подписи вебхука в документации нет, поэтому адрес содержит секрет, субдомен аккаунта сверяется, а сделку при необходимости перечитывают через API
  • Обрабатывать идемпотентно: одно и то же событие может прийти повторно
src/amocrm/webhook.ts
import { timingSafeEqual } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";

export type LeadStatusEvent = { leadId: number; statusId: number; oldStatusId: number | null };

// amoCRM шлёт x-www-form-urlencoded: leads[status][0][id]=…&leads[status][0][status_id]=…
export function parseLeadStatusWebhook(body: string): { subdomain: string | null; events: LeadStatusEvent[] } {
  const params = new URLSearchParams(body);
  const rows = new Map<string, Record<string, string>>();
  for (const [key, value] of params) {
    const m = /^leads\[status\]\[(\d+)\]\[(\w+)\]$/.exec(key);
    if (!m) continue;
    const row = rows.get(m[1]) ?? {};
    row[m[2]] = value;
    rows.set(m[1], row);
  }
  const events = [...rows.values()]
    .filter((r) => r.id && r.status_id)
    .map((r) => ({
      leadId: Number(r.id),
      statusId: Number(r.status_id),
      oldStatusId: r.old_status_id ? Number(r.old_status_id) : null,
    }));
  return { subdomain: params.get("account[subdomain]"), events };
}

const sameSecret = (a: string, b: string) =>
  a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));

// Подписи у вебхука нет: секрет в пути URL + сверка субдомена, а данные потом перечитываем через API
export function createWebhookHandler(opts: {
  secret: string;
  subdomain: string;
  onEvents: (events: LeadStatusEvent[]) => void; // только положить в очередь: на ответ есть 2 секунды
}) {
  return (req: IncomingMessage, res: ServerResponse) => {
    const secret = req.url?.match(/^\/hooks\/amocrm\/([\w-]+)$/)?.[1] ?? "";
    if (req.method !== "POST" || !sameSecret(secret, opts.secret)) {
      res.writeHead(404).end();
      return;
    }
    let body = "";
    req.setEncoding("utf8");
    req.on("data", (chunk: string) => {
      body += chunk;
      if (body.length > 1_000_000) req.destroy();
    });
    req.on("end", () => {
      const { subdomain, events } = parseLeadStatusWebhook(body);
      if (subdomain !== opts.subdomain) {
        res.writeHead(403).end();
        return;
      }
      res.writeHead(200).end(); // сначала ответ, обработка — асинхронно
      if (events.length) opts.onEvents(events);
    });
  };
}
Разбор вебхука «Смена статуса» и HTTP-обработчик на node:http

Вебхук может потеряться: ваш сервер лежал дольше часа, хук отключили, кто-то поменял настройки. Сверка раз в N минут закрывает эти дыры: читает сделки, изменённые после прошлого прогона, и применяет статусы тем же идемпотентным обработчиком. Фильтр filter[updated_at] в amoCRM относится к API-фильтрации (Alpha), которая подключается отдельным пакетом. Поэтому сверка опирается на базовую сортировку order[updated_at]=desc и постраничное чтение с остановкой на границе.

src/amocrm/reconcile.ts
import type { LeadStatus } from "../core/lead.ts";
import type { AmoClient } from "./client.ts";
import { toLeadStatus } from "./mapping.ts";
import type { AmoLead, AmoMapping, AmoPage } from "./types.ts";

// Сделки, изменённые после since (Unix, секунды): сортировка по updated_at и постраничное чтение.
// filter[updated_at] не используем: API-фильтрация в amoCRM — отдельный пакет (Alpha)
export async function* leadsUpdatedSince(client: AmoClient, since: number, pageSize = 250) {
  for (let page = 1; ; page++) {
    const res = await client.request<AmoPage<"leads", AmoLead>>(
      "GET",
      `/api/v4/leads?order[updated_at]=desc&limit=${pageSize}&page=${page}`,
    );
    for (const lead of res?._embedded.leads ?? []) {
      if (lead.updated_at < since) return;
      yield lead;
    }
    if (!res?._links.next) return;
  }
}

// Страховка к вебхукам: раз в N минут догоняем пропущенные смены статуса.
// apply обязан быть идемпотентным — одна сделка может прийти и вебхуком, и сверкой
export async function reconcile(
  client: AmoClient,
  map: AmoMapping,
  since: number,
  apply: (crmLeadId: number, status: LeadStatus) => Promise<void>,
  pageSize?: number,
): Promise<{ checked: number; nextSince: number }> {
  let checked = 0;
  let newest = since;
  for await (const lead of leadsUpdatedSince(client, since, pageSize)) {
    if (lead.pipeline_id !== map.pipelineId) continue;
    await apply(lead.id, toLeadStatus(lead.status_id, map));
    checked++;
    newest = Math.max(newest, lead.updated_at);
  }
  return { checked, nextSince: newest - 300 }; // перекрытие 5 минут: страницы сдвигаются во время чтения
}
Периодическая сверка статусов: страницы по 250 сделок, остановка на since, перекрытие 5 минут

Проверка на мок-сервере, частые ошибки и упражнения

Реальный аккаунт CRM в автотестах не нужен и даже вреден: тесты создают сделки, упираются в лимит и зависят от сети. Вместо него — локальный мок на node:http, который отвечает теми же путями, кодами и формами JSON, что приведены в документации: hal+json для успешных ответов, problem+json с validation-errors для 400, 204 без тела, одноразовый refresh token. Права доступа, штатный контроль дублей и фильтры мок не эмулирует.

bash
npm init -y && npm pkg set type=module
npm install -D typescript@7.0.2 @types/node@24 vitest@5.0.3
npx tsc -p tsconfig.json   # типы: strict, без сборки
npx vitest run             # 23 теста против локального мока
node mock/amo-mock.ts      # мок отдельно — для ручных запросов curl
Проект примера: зависимости и команды проверки
tsconfig.json
{
  "compilerOptions": {
    "target": "es2023",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "types": ["node"],
    "skipLibCheck": true
  },
  "include": ["src", "mock", "test"]
}
tsconfig: Node 24 запускает .ts без сборки, поэтому импорты с расширением .ts и только стираемый синтаксис
mock/amo-mock.ts
// Локальный мок amoCRM API v4: пути, коды и формы ответов — по примерам из документации разработчика.
// Не эмулирует права, контроль дублей, фильтры и прочее, что не нужно тестам адаптера.
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";

type Cf = { field_id?: number; field_code?: string; values: { value: string; enum_code?: string }[] };
type Contact = { id: number; name: string; custom_fields_values: Cf[] | null };
type Lead = {
  id: number;
  name: string;
  status_id: number;
  pipeline_id: number;
  updated_at: number;
  custom_fields_values: Cf[] | null;
  contactId: number;
};

export type MockState = {
  access: string;
  refresh: string;
  contacts: Contact[];
  leads: Lead[];
  knownLeadFields: number[];
  pipelineId: number;
  firstStatusId: number;
  fail429: number; // сколько следующих запросов к /api ответить 429
  loseNextComplexResponse: boolean; // создать сделку, но ответить 502 — «потерянный ответ»
  requests: string[];
};

const problem = (res: ServerResponse, status: number, extra: object = {}) => {
  res.writeHead(status, { "Content-Type": "application/problem+json" });
  res.end(JSON.stringify({ title: status === 401 ? "Unauthorized" : "Error", type: `https://httpstatus.es/${status}`, status, ...extra }));
};
const hal = (res: ServerResponse, body: unknown) => {
  res.writeHead(200, { "Content-Type": "application/hal+json" });
  res.end(JSON.stringify(body));
};
const readJson = async (req: IncomingMessage) => {
  let s = "";
  for await (const chunk of req) s += chunk;
  return JSON.parse(s || "null");
};
const digits = (s: string) => s.replace(/\D/g, "");
const cfText = (cfs: Cf[] | null) => (cfs ?? []).flatMap((f) => f.values.map((v) => v.value));
const matches = (q: string, haystack: string[]) =>
  haystack.some((h) =>
    digits(q).length >= 7 ? digits(h).includes(digits(q)) : h.toLowerCase().includes(q.toLowerCase()),
  );

export async function startAmoMock(init: Partial<MockState> = {}) {
  const state: MockState = {
    access: "access-1",
    refresh: "refresh-1",
    contacts: [],
    leads: [],
    knownLeadFields: [1001, 1002, 1003],
    pipelineId: 3383152,
    firstStatusId: 33929752,
    fail429: 0,
    loseNextComplexResponse: false,
    requests: [],
    ...init,
  };
  let seq = 19663100;
  const now = () => Math.floor(Date.now() / 1000);

  const server = createServer(async (req, res) => {
    const url = new URL(req.url ?? "/", "http://mock");
    state.requests.push(`${req.method} ${url.pathname}${url.search}`);

    if (req.method === "POST" && url.pathname === "/oauth2/access_token") {
      const body = await readJson(req);
      if (body?.grant_type !== "refresh_token" || body.refresh_token !== state.refresh) return problem(res, 400);
      const n = Number(state.refresh.split("-")[1]) + 1;
      state.access = `access-${n}`;
      state.refresh = `refresh-${n}`; // старый refresh больше не действует
      res.writeHead(200, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({ token_type: "Bearer", expires_in: 86400, access_token: state.access, refresh_token: state.refresh }));
    }

    if (!url.pathname.startsWith("/api/v4/")) return problem(res, 404);
    if (req.headers.authorization !== `Bearer ${state.access}`) return problem(res, 401, { detail: "Unauthorized" });
    if (state.fail429 > 0) {
      state.fail429--;
      return problem(res, 429);
    }

    if (req.method === "GET" && url.pathname === "/api/v4/contacts") {
      const q = url.searchParams.get("query") ?? "";
      const found = state.contacts.filter((c) => matches(q, [c.name, ...cfText(c.custom_fields_values)]));
      if (!found.length) {
        res.writeHead(204);
        return res.end();
      }
      return hal(res, { _page: 1, _links: {}, _embedded: { contacts: found } });
    }

    if (req.method === "GET" && url.pathname === "/api/v4/leads") {
      const q = url.searchParams.get("query");
      const limit = Math.min(Number(url.searchParams.get("limit") ?? 50), 250);
      const page = Number(url.searchParams.get("page") ?? 1);
      let list = q ? state.leads.filter((l) => matches(q, [l.name, ...cfText(l.custom_fields_values)])) : [...state.leads];
      if (url.searchParams.get("order[updated_at]") === "desc") list.sort((a, b) => b.updated_at - a.updated_at || b.id - a.id);
      const slice = list.slice((page - 1) * limit, page * limit);
      if (!slice.length) {
        res.writeHead(204);
        return res.end();
      }
      const next = page * limit < list.length ? { next: { href: `/api/v4/leads?page=${page + 1}` } } : {};
      return hal(res, {
        _page: page,
        _links: { self: { href: url.pathname + url.search }, ...next },
        _embedded: {
          leads: slice.map(({ contactId, ...l }) => ({ ...l, _embedded: { contacts: [{ id: contactId, is_main: true }] } })),
        },
      });
    }

    if (req.method === "POST" && url.pathname === "/api/v4/leads/complex") {
      const items = await readJson(req);
      if (!Array.isArray(items) || items.length > 50) return problem(res, 400, { detail: "Request validation failed" });
      const errors = items.flatMap((item, i) =>
        (item.custom_fields_values ?? [])
          .map((f: Cf, j: number) => ({ f, j }))
          .filter(({ f }: { f: Cf }) => f.field_id !== undefined && !state.knownLeadFields.includes(f.field_id))
          .map(({ j }: { j: number }) => ({
            request_id: item.request_id ?? String(i),
            errors: [{ code: "NotSupportedChoice", path: `custom_fields_values.${j}.field_id`, detail: "The value you selected is not a valid choice." }],
          })),
      );
      if (errors.length) return problem(res, 400, { "validation-errors": errors, title: "Bad Request", detail: "Request validation failed" });

      const result = items.map((item, i) => {
        const embedded = item._embedded?.contacts?.[0];
        let contactId: number;
        if (embedded?.id) contactId = embedded.id;
        else {
          contactId = ++seq;
          state.contacts.push({ id: contactId, name: embedded?.first_name ?? "", custom_fields_values: embedded?.custom_fields_values ?? null });
        }
        const lead: Lead = {
          id: ++seq,
          name: item.name ?? "",
          status_id: item.status_id ?? state.firstStatusId,
          pipeline_id: item.pipeline_id ?? state.pipelineId,
          updated_at: now(),
          custom_fields_values: item.custom_fields_values ?? null,
          contactId,
        };
        state.leads.push(lead);
        return { id: lead.id, contact_id: contactId, company_id: null, request_id: [item.request_id ?? String(i)], merged: false };
      });
      if (state.loseNextComplexResponse) {
        state.loseNextComplexResponse = false;
        return problem(res, 502);
      }
      res.writeHead(200, { "Content-Type": "application/hal+json" });
      return res.end(JSON.stringify(result));
    }

    return problem(res, 404);
  });

  await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
  const { port } = server.address() as AddressInfo;
  return {
    url: `http://127.0.0.1:${port}`,
    state,
    setStatus(leadId: number, statusId: number, updatedAt: number) {
      const lead = state.leads.find((l) => l.id === leadId);
      if (lead) Object.assign(lead, { status_id: statusId, updated_at: updatedAt });
    },
    close: () => new Promise<void>((r) => server.close(() => r())),
  };
}

if (import.meta.main) {
  const mock = await startAmoMock();
  console.log(`amoCRM mock: ${mock.url}  (Bearer ${mock.state.access})`);
}
Мок amoCRM API v4 для тестов — не самостоятельный сервер для продакшена
test/adapter.test.ts
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { startAmoMock } from "../mock/amo-mock.ts";
import { AmoCrmAdapter } from "../src/amocrm/adapter.ts";
import { AmoClient } from "../src/amocrm/client.ts";
import { AmoTokenProvider, type Tokens, type TokenStore } from "../src/amocrm/tokens.ts";
import type { AmoMapping } from "../src/amocrm/types.ts";
import { MemoryExchangeLog } from "../src/core/exchange-log.ts";
import type { LeadRequest } from "../src/core/lead.ts";
import { submitLead } from "../src/core/submit-lead.ts";

const map: AmoMapping = {
  pipelineId: 3383152,
  firstStatusId: 33929752,
  fields: { externalId: 1001, comment: 1002, source: 1003 },
  tag: "site",
};
const lead = (externalId: string, phone: string): LeadRequest => ({
  externalId,
  name: "Айгерим",
  phone,
  source: "site-form",
});

class MemoryTokenStore implements TokenStore {
  tokens: Tokens;
  constructor(tokens: Tokens) {
    this.tokens = tokens;
  }
  async load() {
    return this.tokens;
  }
  async save(t: Tokens) {
    this.tokens = t;
  }
}

let mock: Awaited<ReturnType<typeof startAmoMock>>;
let store: MemoryTokenStore;
let sleeps: number[];
let crm: AmoCrmAdapter;

beforeEach(async () => {
  mock = await startAmoMock();
  store = new MemoryTokenStore({ accessToken: "access-1", refreshToken: "refresh-1", expiresAt: Date.now() + 3_600_000 });
  const tokens = new AmoTokenProvider(mock.url, { clientId: "CLIENT_ID", clientSecret: "CLIENT_SECRET", redirectUri: "https://example.com/oauth" }, store);
  sleeps = [];
  const client = new AmoClient({
    baseUrl: mock.url,
    tokens,
    minIntervalMs: 0,
    sleep: async (ms) => {
      sleeps.push(ms);
    },
  });
  crm = new AmoCrmAdapter(client, map);
});
afterEach(() => mock.close());

describe("AmoCrmAdapter против мок-сервера", () => {
  it("второй лид с тем же телефоном в другом формате привязывается к существующему контакту", async () => {
    const first = await crm.createLead(lead("web-1", "+7 701 123 45 67"));
    const second = await crm.createLead(lead("web-2", "8 (701) 123-45-67"));
    expect(first.contactReused).toBe(false);
    expect(second).toMatchObject({ crmContactId: first.crmContactId, contactReused: true });
    expect(mock.state.contacts).toHaveLength(1);
    expect(mock.state.leads).toHaveLength(2);
  });

  it("повтор той же заявки не создаёт вторую сделку", async () => {
    const log = new MemoryExchangeLog();
    const a = await submitLead(lead("web-7", "87011234567"), { crm, log });
    const b = await submitLead(lead("web-7", "87011234567"), { crm, log });
    expect(a.status).toBe("synced");
    expect(b.crmLeadId).toBe(a.crmLeadId);
    expect(mock.state.leads).toHaveLength(1);
  });

  it("потерянный ответ: сделка создана, но пришёл 502 — повтор находит её по externalId", async () => {
    const log = new MemoryExchangeLog();
    mock.state.loseNextComplexResponse = true;
    const failed = await submitLead(lead("web-9", "87011234567"), { crm, log });
    expect(failed).toMatchObject({ status: "failed", attempts: 1 });
    expect(failed.lastError).toMatch(/HTTP 502/);
    expect(await log.failed()).toHaveLength(1);

    const retried = await submitLead(lead("web-9", "87011234567"), { crm, log });
    expect(retried).toMatchObject({ status: "synced", attempts: 2, crmLeadId: mock.state.leads[0].id });
    expect(mock.state.leads).toHaveLength(1);
  });

  it("429 — пауза и повтор", async () => {
    mock.state.fail429 = 2;
    await crm.createLead(lead("web-3", "87011234567"));
    expect(sleeps).toEqual([1000, 2000]);
    expect(mock.state.leads).toHaveLength(1);
  });

  it("401 — обмен refresh token, новая пара сохранена, запрос повторён", async () => {
    mock.state.access = "access-revoked"; // сервер больше не принимает выданный access token
    mock.state.refresh = "refresh-1";
    await crm.createLead(lead("web-4", "87011234567"));
    expect(store.tokens).toMatchObject({ accessToken: "access-2", refreshToken: "refresh-2" });
    expect(mock.state.requests.filter((r) => r.startsWith("POST /oauth2"))).toHaveLength(1);
    expect(mock.state.leads).toHaveLength(1);
  });

  it("ошибка валидации 400 доходит до журнала с телом ответа", async () => {
    const log = new MemoryExchangeLog();
    mock.state.knownLeadFields = [1001]; // поля 1003 в аккаунте нет — маппинг устарел
    const entry = await submitLead(lead("web-5", "87011234567"), { crm, log });
    expect(entry.status).toBe("failed");
    expect(entry.lastError).toContain("custom_fields_values.1.field_id");
  });
});
Тесты адаптера и use case против мока; тесты маппинга, дедупликации, вебхука и сверки устроены так же

Результат: tsc проходит без ошибок, 23 теста Vitest зелёные — маппинг, нормализация и дедупликация, создание сделки с повторным использованием контакта, идемпотентный повтор, потерянный ответ 502, 429, обмен токена на 401, ошибка валидации, разбор вебхука, сверка с пагинацией и интервал между запросами. Против реального аккаунта amoCRM код не запускался: поведение полнотекстового поиска по телефонам и точные тексты ошибок проверьте на тестовом аккаунте до запуска.

  • Числовые field_id зашиты в код — после пересоздания поля интеграция падает с 400
  • Запросы идут на общий домен, а не на адрес аккаунта https://<subdomain>.amocrm.ru
  • Новый refresh token не сохранён сразу после обмена — через сутки интеграция теряет доступ
  • POST повторяется после таймаута без проверки — в CRM появляются дубли сделок
  • Обработчик вебхука ходит в API и пишет в базу до ответа — хук считается недоставленным и со временем отключается
  • Токены и client_secret лежат в репозитории — место им в менеджере секретов или переменных окружения
Как проверено2 · пройдено1 · не запускалосьсверено 11 октября 2026 г.
  • запущено, работает

    src/core/*.ts, src/amocrm/*.ts (порт, нормализация, журнал, use case, клиент, токены, маппинг, дедупликация, адаптер, вебхук, сверка), mock/amo-mock.ts, tsconfig.json

    tsc -p tsconfig.json (TypeScript 7.0.2, strict, erasableSyntaxOnly) без ошибок во временном проекте scratchpad/kb/verify/integraciya-s-crm, Node.js 24.18

  • запущено, работает

    test/*.test.ts — 23 теста (маппинг, нормализация, дедупликация, адаптер против мок-сервера: дубль контакта, идемпотентный повтор, потерянный ответ 502, 429, 401+refresh, 400 validation-errors; вебхук; сверка с пагинацией; интервал запросов)

    vitest run (Vitest 5.0.3): 4 файла, 23 passed; мок запускался и отдельно (node mock/amo-mock.ts + curl: 200 hal+json на leads/complex, 204 на пустой поиск)

  • не запускалось

    работа против реального аккаунта amoCRM (OAuth, поиск контактов по query, контроль дублей, доставка вебхуков)

    реального аккаунта и ключей нет и по правилам не используются; формы запросов/ответов сверены с документацией, мок повторяет примеры из неё

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.