Интеграцию сайта с CRM строят как адаптер за портом: ядро приложения передаёт заявку в своём формате, а адаптер переводит её в запросы конкретной CRM — в этом руководстве это amoCRM REST API v4. Надёжность дают четыре решения: маппинг полей по id из настроек аккаунта, поиск дублей по нормализованному телефону и email, журнал обмена с идемпотентным повтором и два канала синхронизации статусов — вебхуки и периодическая сверка.
После материала вы сможете
- спроектировать порт
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 г.
Содержание
Что строим: заявка с сайта превращается в сделку
Типовых сценариев три. Заявка с сайта становится сделкой с контактом. Повторное обращение того же человека привязывается к уже существующему контакту, а не плодит новый. Смена этапа сделки в 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
- 01Форма на сайте
- 02Use case + журнал
- 03Адаптер amoCRM
- 04amoCRM API v4
Ядро приложения не должно знать, что CRM — это amoCRM. Оно работает со своей заявкой и портом CrmPort; всё, что связано с custom_fields_values, токенами и лимитами, живёт в адаптере. Это прямое применение портов и адаптеров: схема подробно разобрана в материале «Чистая архитектура», а заготовка для повторного использования — в рецепте «Порт и адаптер для внешнего сервиса». Смена CRM или второй адаптер для тестов не затрагивает бизнес-логику.
// Ядро ничего не знает про 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>;
}Изучаем 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 у него нет, обменивать нечего. Это проще, но токен хранят и отзывают так же аккуратно, а дату окончания стоит поставить в календарь.
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;
}
}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);
}
}Клиент повторяет 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; невалидный — ошибка маппинга
- amoCRM
EMAIL,enum_code: WORK- Правило
- нижний регистр; пустой не передаём
id заявки
- amoCRM
- текстовое поле сделки
- Правило
- ключ идемпотентности и поиска
Комментарий
- amoCRM
- текстовое поле сделки
- Правило
- пустые значения не отправляем
Источник
- amoCRM
- текстовое поле сделки и тег
- Правило
- справочник значений в коде
Этап воронки
- amoCRM
status_id- Правило
- 142 — «Успешно реализовано», 143 — «Закрыто и не реализовано»: системные статусы любой воронки
// Формы запросов и ответов 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;
};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 найденного контакта, либо полную модель нового. Штатный контроль остаётся второй линией защиты.
Нормализовать
normalizePhoneиnormalizeEmailживут в ядре: одни и те же правила для поиска и для записи.Найти кандидатов
GET /api/v4/contacts?query=…— полнотекстовый поиск по заполненным полям: сначала по телефону, затем по email.Перепроверить
Из ответа берём только контакты, у которых нормализованный телефон или email совпадает точно. Похожий номер — не дубль.
Выбрать
При нескольких совпадениях — самый старый контакт; склейку карточек оставляем менеджеру.
Создать сделку
leads/complexс{ id }найденного контакта или с моделью нового контакта.
// Нормализация ключей дедупликации. Правило для плана нумерации +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;
}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;
}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;
}
}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 заявки записывается в поле сделки, а повторная попытка сначала ищет сделку по нему.
// Журнал обмена: по каждой заявке видно, дошла ли она до 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");
}
}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;
}
}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
- Обрабатывать идемпотентно: одно и то же событие может прийти повторно
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);
});
};
}node:httpВебхук может потеряться: ваш сервер лежал дольше часа, хук отключили, кто-то поменял настройки. Сверка раз в N минут закрывает эти дыры: читает сделки, изменённые после прошлого прогона, и применяет статусы тем же идемпотентным обработчиком. Фильтр filter[updated_at] в amoCRM относится к API-фильтрации (Alpha), которая подключается отдельным пакетом. Поэтому сверка опирается на базовую сортировку order[updated_at]=desc и постраничное чтение с остановкой на границе.
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 минут: страницы сдвигаются во время чтения
}since, перекрытие 5 минутПроверка на мок-сервере, частые ошибки и упражнения
Реальный аккаунт CRM в автотестах не нужен и даже вреден: тесты создают сделки, упираются в лимит и зависят от сети. Вместо него — локальный мок на node:http, который отвечает теми же путями, кодами и формами JSON, что приведены в документации: hal+json для успешных ответов, problem+json с validation-errors для 400, 204 без тела, одноразовый refresh token. Права доступа, штатный контроль дублей и фильтры мок не эмулирует.
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{
"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"]
}.ts без сборки, поэтому импорты с расширением .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})`);
}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");
});
});Результат: 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.