Интеграция простой формы с AmoCRM на «бумаге» выглядит просто. Кажется, что можно просто отправить контакт, создать лид, прикрепить товары к сделке — и готово. На практике всё наоборот.
Честно говоря, документация AmoCRM сначала меня запутала. Я полез гуглить по моей ситуации (связка формы с CRM), но не нашел почти ничего. Посмотрел ролик на YouTube про библиотеку. Понял основы, но всё равно оставалось куча вопросов.
Дело в том, что AmoCRM в упор не видит дубликаты контактов и товаров. При очистке дублей из админки ничего не удаляется. Все из-за уникальных ID, которые назначаются при отправке данных.
После множества экспериментов, я все таки смог подружить небольшой бэкенд и API AmoCRM.
В данной статье я разберу два модуля. Для соединения с API использовал библиотеку amocrm-js. Ссылка на библиотеку
amoCRM.ts — интеграционный слой. Связываем сущности, проверяем дубли, отправляем данные.
orderProcessor.ts — воркер на BullMQ/Redis для очереди сообщений и кеша.
Все данные по сделкам подтягиваются из небольшого интернет магазина.
Разберемся для начала как устроена механика.
Каркас интеграции
Создаем внешнюю интеграцию в AmoCRM
Указываем ссылку на сайт (это нужно для аутентификации)
Предоставляем доступ: Все
Пишем название и описание
Получаем секретный ключ и долгосрочный токен
// Логирует сообщения с единым префиксом AmoCRM для удобной фильтрации.
const amoLog = (...args: unknown[]) => console.log("[AmoCRM]", ...args);
// Ваши данные из интеграции
const client = new Client({
domain: "rolloffstore",
auth: {
client_id: process.env.AMO_CLIENT_ID, // ID интеграции
client_secret: process.env.AMO_CLIENT_SECRET, // Секретный ключ
redirect_uri: process.env.AMO_REDIRECT_URI, // Ссылка на перенаправление
bearer: process.env.AMO_TOKEN, // Долгосрочный токен
},
});
const connectionPromise = client.connection
.connect()
.then(() => {
amoLog("connection:established");
})
.catch((error) => {
console.error("Failed to connect to AmoCRM", error);
throw error;
});
// Гарантирует установку соединения с AmoCRM перед выполнением запросов.
async function ensureConnected() {
await connectionPromise;
}
Для чего это:
Один ленивый промис на всё приложение убирает гонки «кто первый подключится» и дублирование кода.
Все функции
upsert* / createLead*будут начинаться сawait ensureConnected()для проверки соединения с API.
«Корзины ссылок» (link buckets): копим привязки и контролируем поведение
Проблема
Нам нужно было брать данные заказов и отправлять их в CRM. Звучит просто, но на деле всплывает множество других проблем.
При отправке данных контакта, AmoCRM создает новый контакт каждый раз, назначая уникальный ID. В итоге мы получаем дубли. Amo в упор не видит дубли и не удаляет.
Такая же история с товарами. Если связывать эти уникальные контакты и товары со сделкой, то мы не увидим историю заказов клиента.
Можно конечно использовать интеграцию по контролю дублей из маркетплейса, но я не горел желанием покупать 3-4 интеграции по 10 000 рублей.
Задача
Чтобы каждый раз не обращаться к API при связке контактов и товаров к сделке, создать временное хранилище для этих ссылок.
Пока мы не создали сделку, накапливаем привязки (контакты/товары) в этом временном хранилище и группируем по ключу заказа.
Потом одним запросом /leads/{id}/link связываем их со сделкой.
// amoCrm.ts
import { Client } from "amocrm-js";
import { Order } from "../types/order"; // Импорт типа (проверка получаемых данных).
import { toString } from "lodash";
type LinkPayload = {
to_entity_id: number;
to_entity_type: "contacts" | "catalog_elements";
metadata?: {
quantity: number;
catalog_id: number;
};
};
/**
* Временное хранилище связей (контакты/товары), сгруппированных по ключу заказа.
Ключ заказа — ID задачи в BullMQ. Если его нет, то назначается randomUUID.
* Пока сделка не создана, ссылки собираются здесь, после чего одной пачкой
* отправляются в AmoCRM через "createLeadInAmo".
*/
const linkBuckets = new Map<string, LinkPayload[]>();
/**
* Возвращает корзину ссылок для заданного ключа, создавая её при необходимости.
*/
const getBucket = (linkKey: string) => {
if (!linkBuckets.has(linkKey)) {
linkBuckets.set(linkKey, []);
}
return linkBuckets.get(linkKey);
};
/**
* Откладываем привязку элемента каталога к сделке, сохраняя количество товаров и ID каталога.
*/
export const pushProduct = (
linkKey: string,
prodId: number,
quantity: number,
catalogId: number,
) => {
if (!prodId) {
amoLog("pushProduct:skip", { linkKey, prodId, reason: "missing id" });
return;
}
// Получаем корзину ссылок по ключу заказа
const bucket = getBucket(linkKey);
amoLog("pushProduct", { linkKey, prodId, quantity, catalogId });
// Пушим товары в корзину
bucket.push({
to_entity_id: prodId,
to_entity_type: "catalog_elements",
metadata: {
quantity,
catalog_id: catalogId,
},
});
};
/**
* Откладывает привязку контакта к сделке, избегая повторных связей.
* Сделка создается после того, как контакт будет добавлен/обновлен в CRM.
*/
export const pushContact = (linkKey: string, contactId: number) => {
if (!contactId) {
amoLog("pushContact:skip", { linkKey, contactId, reason: "missing id" });
return;
}
// Получаем корзину ссылок по ключу заказа
const bucket = getBucket(linkKey);
amoLog("pushContact", { linkKey, contactId });
const alreadyLinked = bucket.some(
(link) => link.to_entity_type === "contacts" && link.to_entity_id === contactId,
);
if (!alreadyLinked) {
bucket.push({
to_entity_id: contactId,
to_entity_type: "contacts",
});
} else {
amoLog("pushContact:skipped", { linkKey, contactId, reason: "duplicate" });
}
};
pushProduct и pushContact складывают будущие связи (товары и контакт) в «корзину» по ключу заказа (linkKey). Позже, когда лид создан, весь накопленный список уходит одним запросом на линковку сущностей. Это ускоряет поток, упрощает контроль дублей и уменьшает количество API-вызовов.
// amoCrm.ts
/**
* Извлекает и очищает накопленные связи для передачи в AmoCRM одной пачкой.
*/
export const consumeLinks = (linkKey: string) => {
const bucket = linkBuckets.get(linkKey) ?? [];
amoLog("consumeLinks", { linkKey, count: bucket.length });
// Удаление ключа из Map не очищает уже возвращённый массив — у нас остаётся валидная ссылка (linkPayload ниже), которую и возвращаем.
linkBuckets.delete(linkKey);
return bucket;
};
/**
* Сбрасывает корзину связей по ключу после завершения операции или ошибки. Принудительная очистка корзины
*/
export const resetLinks = (linkKey: string) => {
const removed = linkBuckets.delete(linkKey);
amoLog("resetLinks", { linkKey, removed });
};
consumeLinks(linkKey) — забирает накопленные связи и для подготовки к отправке
Берёт «корзину» связей (контакты и товары), накопленную по linkKey.
Удаляет запись из Map, чтобы корзина больше не висела в памяти.
В случае если HTTP запрос упадет, то данные потеряются, так как корзина уже удалена. Поэтому лучше создать функционал с заменой, а не удалением.
resetLinks(linkKey) — принудительно удаляет корзину.
Ни в коем случае не вызывать его до отправки данных, иначе всё удалите.
Как выглядит процесс с корзиной:
Приходят данные заказа — вы копите связи через
pushContact/pushProduct.Создаёте лид.
Запускаете
consumeLinks→ получаете массив ссылок → отправляете одним запросом.В
finally— запускаетеresetLinks, чтобы гарантированно не осталось мусора.
Если важно не терять ссылки при сбоях запросов:
Вместо удаления корзины по ключу, подмените на пустую и возвращайте старую со ссылками.
Если линковка упала, сложите неотправленные ссылки обратно в пустую корзину.
// Пример
type LinkPayload = {
to_entity_id: number;
to_entity_type: "contacts" | "catalog_elements";
metadata?: { quantity: number; catalog_id: number };
};
export const consumeSwap = (linkKey: string) => {
const bucket = linkBuckets.get(linkKey) ?? [];
amoLog("consumeLinks", { linkKey, count: bucket.length });
// На место кладём пустой массив
linkBuckets.set(linkKey, []);
return bucket;
};
export const rollBack = (linkKey: string, notSent: LinkPayload[]) => {
if (notSent.length === 0) return;
// Берём то, что уже накопилось в корзине. В случае undefined возвращаем пустой массив
const pending = linkBuckets.get(linkKey) ?? [];
// Склеиваем массивы и кладём обратно в Map. Сначала помещаем те, которые не отправились, а потом то, что накопилось позже
linkBuckets.set(linkKey, notSent.concat(pending));
}
rollBack нужно будет поместить в блок try catch. При ошибке, вызываем rollBack, передавая payload.
Поиск, создание, обновление контактов
При поиске контактов номер телефона является ключевой переменной, а имя — вторичная проверка.
// amoCrm.ts
export async function upsertContactInAmo(orders: Order, linkKey: string): Promise<number> {
// Расставляем вывод логов, чтобы видеть где крэшится
amoLog("upsertContact:ensuringConnection");
await ensureConnected();
amoLog("upsertContact:start", { linkKey, phone: orders.phone, name: orders.name });
// Пробуем найти контакт
try {
amoLog("upsertContact:searching", { linkKey, phone: orders.phone });
const searchRes = await client.request.get(
`/api/v4/contacts?query=${encodeURIComponent(orders.phone)}`
);
const searchData = searchRes.data as { _embedded?: { contacts?: any[] } };
const rawHits = searchData._embedded?.contacts ?? [];
amoLog("upsertContact:searchResults", { linkKey, count: rawHits.length });
let contact: InstanceType<typeof client.Contact> | null = null;
// Ищем контакт по номеру телефона
for (const raw of rawHits) {
const c = raw;
const cf: Array<{ field_code?: string; values?: any[] }> = c.custom_fields_values;
const phoneField = cf.find(
(f: { field_code?: string; values?: any[] }) => f.field_code === "PHONE",
);
const values: string[] = phoneField && Array.isArray(phoneField.values)
? phoneField.values.map((v: any) => v.value)
: [];
// Если есть мэтч, то присваиваем к переменной contact
if (c.name === orders.name && values.includes(orders.phone)) {
contact = c;
amoLog("upsertContact:existingMatch", { linkKey, contactId: contact.id });
break;
}
}
// Если контакта не существует, то создаем новый и связываем с номером телефона из заказа
if (!contact) {
const nc = new client.Contact();
nc.name = orders.name;
nc.custom_fields_values = [
{
field_id: amoContactIds.phoneId, // Phone field ID
field_code: "PHONE",
values: [{ value: orders.phone }],
},
{
field_id: amoContactIds.addressId, // Address field ID
values: [{ value: orders.address ?? "" }],
},
];
// Сохраняем контакт
await nc.save();
contact = nc;
amoLog("upsertContact:created", { linkKey, contactId: contact.id });
} else {
amoLog("upsertContact:updateExisting", { linkKey, contactId: contact.id });
// Обновляем данные существующего контакта
contact.custom_fields_values = [
{
field_id: amoContactIds.phoneId,
field_code: "PHONE",
values: [{ value: orders.phone }],
},
{
field_id: amoContactIds.addressId,
values: [{ value: orders.address ?? "" }],
},
];
// Отправляем данные в AmoCRM
await client.contacts.update([contact]);
amoLog("upsertContact:updated", { linkKey, contactId: contact.id });
}
// Добавляем контакт в корзину ссылок
pushContact(linkKey, contact.id);
amoLog("upsertContact:success", { linkKey, contactId: contact.id });
// Возвращаем ID сохраненного контакта. Это нужно для кеширования и создания сделки (разберем ниже).
return contact.id;
} catch (error) {
console.error("Failed to upsert contact in AmoCRM", error);
amoLog("upsertContact:error", { linkKey, message: (error as Error)?.message });
throw error;
}
}
Поиск, создание, обновление товаров
Логика такая же, но уже взаимодействуем с каталогом.
Для поиска товаров нам нужно найти ID каталога.
// amoCrm.ts
export async function upsertProductInAmo(orders: Order, linkKey: string): Promise<number> {
await ensureConnected();
amoLog("upsertProduct:start", { linkKey, itemCount: orders.items.length });
try {
// Смотрим какие каталоги у нас есть
const catalogsRes = await client.request.get("/api/v4/catalogs");
const catalogsData = catalogsRes.data as { _embedded?: { catalogs?: any[] } };
const catalogs = catalogsData._embedded?.catalogs || [];
// Ищем каталог с названием "Товары"
// У меня был только один каталог с товарами. Если у вас несколько, то нужно проверять и по имени каталога
const productsCatalog = catalogs.find((c: any) => c.type === "products");
if (!productsCatalog) {
throw new Error("Products catalog not found in AmoCRM");
}
// После поиска присваиваем ID к переменной
const catalogId = productsCatalog.id;
// amoProductIds.priceId — поле из группы полей в сделке.
// Можете создать объект и внести туда ID своих полей
const PRICE_FIELD = amoProductIds.priceId;
// Переменная для последнего созданного/обновленного контакта
let lastElementId: number;
// Я не стал делать сложную логику по массовой проверке товаров из ответа.
// Так как в заказе товаров обычно 5-6, то можно проверить каждый товар отдельно.
for (const p of orders.items) {
amoLog("upsertProduct:item", { linkKey, name: p.name, quantity: p.quantity });
const filterUrl = `/api/v4/catalogs/${catalogId}/elements?filter[name]=${encodeURIComponent(
p.name,
)}`;
amoLog("upsertProduct:searching", { linkKey, name: p.name, url: filterUrl });
// Нужно найти товар и определить id полей
const response = await client.request.get(filterUrl);
const data = response.data as { _embedded?: { elements?: any[] } };
const hits = data._embedded?.elements || [];
let elementId: number;
// Мэтч по имени
const match = hits.find((el: any) => el.name === p.name);
// При мэтче, просто обновляем данные товара
if (match) {
elementId = match.id;
amoLog("upsertProduct:match", { linkKey, elementId, name: p.name });
const payload = [
{
id: match.id,
name: p.name,
custom_fields_values: [
{
field_id: amoProductIds.priceId,
values: [{ value: p.price }],
},
],
},
];
// Обновляем данные товара
await client.request.patch(`/api/v4/catalogs/${catalogId}/elements`, payload);
amoLog("upsertProduct:updated", { linkKey, elementId, name: p.name });
} else {
amoLog("upsertProduct:create", { linkKey, name: p.name });
const payload = [
{
name: p.name,
custom_fields_values: [
{
field_id: amoProductIds.priceId,
values: [{ value: p.price }],
},
],
},
];
// Создаем новый товар в каталоге
const created = await client.request.post(`/api/v4/catalogs/${catalogId}/elements`, payload);
const createdData = created.data as { _embedded: { elements: Array<{ id: number }> } };
// Вытаскиваем ID созданного товара
const createdProductId = createdData._embedded.elements?.[0]?.id;
if (!createdProductId) {
throw new Error("Failed to create product in AmoCRM");
}
elementId = createdProductId;
amoLog("upsertProduct:created", { linkKey, elementId, name: p.name });
}
// Добавляем в корзину ссылок
pushProduct(linkKey, elementId, p.quantity, catalogId);
amoLog("upsertProduct:linked", {
linkKey,
elementId,
quantity: p.quantity,
catalogId,
});
lastElementId = elementId;
}
amoLog("upsertProduct:complete", { linkKey, lastElementId });
// Возвращаем ID. Нужно для создания сделки (разберем ниже).
return lastElementId;
} catch (error) {
console.error("Failed to upsert product in AmoCRM", error);
amoLog("upsertProduct:error", { linkKey, message: (error as Error)?.message });
throw error;
}
}
Создание сделки, привязка контактов и товаров
// amoCrm.ts
export async function createLeadInAmo(orders: Order, linkKey: string): Promise<number> {
await ensureConnected();
amoLog("createLead:start", { linkKey, total: orders.totalPrice, name: orders.name });
// amoIds — это объект с полями из сделки. Можете создать кастомную группу полей и скопировать ID.
try {
const createdLeads = await client.leads.create([
{
name: orders.name,
custom_fields_values: [
{
field_id: amoIds.phoneId,
values: [{ value: toString(orders.phone) }],
},
{
field_id: amoIds.numberOfPeopleId,
values: [{ value: Number(orders.numberOfPeople) }],
},
{
field_id: amoIds.deliveryMethodId,
values: [{ value: toString(orders.deliveryMethod) }],
},
{
field_id: amoIds.locationId,
values: [{ value: toString(orders.location) }],
},
{
field_id: amoIds.rayonId,
values: [{ value: toString(orders.rayon) }],
},
{
field_id: amoIds.addressId,
values: [{ value: toString(orders.address) }],
},
{
field_id: amoIds.paymentMethodId,
values: [{ value: toString(orders.paymentMethod) }],
},
{
field_id: amoIds.changeNeededId,
values: [{ value: toString(orders.changeNeeded) }],
},
{
field_id: amoIds.sumId,
values: [{ value: toString(orders.totalPrice) }],
},
{
field_id: amoIds.changeNeededId,
values: [{ value: toString(orders.changeNeeded) }],
},
{
field_id: amoIds.promocode,
values: [{ value: toString(orders.promocode) }],
},
{
field_id: amoIds.commentId,
values: [{ value: toString(orders.comment) }],
},
],
},
]);
// Создаем сделку и вытаскиваем ID
const leadId = createdLeads[0]?.id;
if (!leadId) {
throw new Error("Lead was not created in AmoCRM");
}
// Получаем корзину ссылок (контакты и товары)
const linkPayload = consumeLinks(linkKey);
amoLog("createLead:links", { linkKey, count: linkPayload.length });
if (linkPayload.length > 0) {
// Пачкой привязываем контакты и товары к сделке
await client.request.post(`/api/v4/leads/${leadId}/link`, linkPayload);
amoLog("createLead:linked", { linkKey, leadId, linkCount: linkPayload.length });
}
amoLog("createLead:success", { linkKey, leadId });
return leadId;
} catch (error) {
console.error("Failed to create lead in AmoCRM", error);
// Здесь можно вставить rollBack, но linkPayload нужно объявить снаружи try блока
// rollbackLinks(linkKey, linkPayload);
amoLog("createLead:error", { linkKey, message: (error as Error)?.message });
throw error;
} finally {
// Очищаем корзину ссылок
resetLinks(linkKey);
}
}
Очередь сообщений, кеширование, лимитирование, ретраи
Кеш контакта по телефону в Redis → меньше запросов к API Rate limiter (limiter.schedule) — значит укладываемся в лимиты API.
Импортируем Bottleneck для ограничения количества параллельных запросов. Подключаем Redis для хранения состояний.
// rateLimiter.ts
import Bottleneck from "bottleneck";
export const limiter = new Bottleneck({
maxConcurrent: 5,
minTime: 50,
datastore: "ioredis",
clientOptions: {
host: process.env.REDIS_HOST,
port: Number(process.env.REDIS_PORT),
password: process.env.REDIS_PASSWORD,
},
});
// orderProcessor.ts
import { randomUUID } from "crypto";
import { Worker, Job } from "bullmq";
import pRetry from "p-retry";
import { redis } from "../lib/redis";
import "dotenv/config"
import { limiter } from "../lib/rateLimiter";
import {
upsertContactInAmo,
upsertProductInAmo,
createLeadInAmo,
pushContact,
resetLinks,
} from "amoCrm";
import type { Order } from "../types/order";
import { Client } from "amocrm-js";
const workerLog = (...args: unknown[]) => console.log("[OrderProcessor]", ...args);
const client = new Client({
domain: "rolloffstore",
auth: {
client_id: process.env.AMO_CLIENT_ID!,
client_secret: process.env.AMO_CLIENT_SECRET!,
redirect_uri: process.env.AMO_REDIRECT_URI!,
bearer: process.env.AMO_TOKEN!,
},
});
// Проверяем подключение
async function connectAmo() {
workerLog("connect:start");
try {
const res = await client.connection.connect();
if (!res) {
throw new Error("Failed to connect to AmoCRM");
}
workerLog("connect:success");
} catch (error) {
console.error("Error connecting to AmoCRM");
workerLog("connect:error", { message: (error as Error)?.message });
throw error;
}
}
// orderProcessor.ts
// Ищем контакт в кеше или помещаем новый. Помним, что функции возвращают ID.
const getOrCreateContact = async (order: Order, linkKey: string): Promise<number> => {
workerLog("contact:lookup", { linkKey, phone: order.phone });
const key = `contact:${order.phone}`;
const cached = await redis.get(key);
if (cached) {
const contactId = Number(cached);
workerLog("contact:cacheHit", { linkKey, contactId });
pushContact(linkKey, contactId);
return contactId;
}
workerLog("contact:notFound, upserting", { linkKey, phone: order.phone });
// Получаем ID контакта из AmoCRM
const contactId = await limiter.schedule(() => upsertContactInAmo(order, linkKey));
workerLog("contact:created", { linkKey, contactId });
await redis.set(key, String(contactId));
// Возвращаем ID
return contactId;
};
// orderProcessor.ts
const processOrder = async (order: Order, linkKey: string) => {
workerLog("job:start", { linkKey, total: order.totalPrice, itemCount: order.items.length });
resetLinks(linkKey);
try {
// Получаем ID контакта
const contactId = await getOrCreateContact(order, linkKey);
workerLog("job:contactReady", { linkKey, contactId });
// Получаем ID товара
const productId = await limiter.schedule(() => upsertProductInAmo(order, linkKey));
workerLog("job:productsReady", { linkKey, productId });
// Получаем ID созданной сделки
const leadId = await limiter.schedule(() => createLeadInAmo(order, linkKey));
workerLog("job:leadReady", { linkKey, leadId });
const result = {
contactId,
productId,
leadId,
status: "AmoCRM lead processed successfully",
};
workerLog("job:complete", { linkKey, leadId });
return result;
} catch (error) {
console.error("Failed to process AmoCRM order workflow", error);
workerLog("job:error", { linkKey, message: (error as Error)?.message });
resetLinks(linkKey);
throw error;
}
};
// orderProcessor.ts
(async () => {
try {
console.log("Starting AmoCRM order processor worker...");
await connectAmo();
} catch (error) {
console.error("Initial AmoCRM connection failed", error);
workerLog("startup:error", { message: (error as Error)?.message });
process.exit(1);
}
// Создаем новый воркер
const worker = new Worker<Order>(
"amoQueue",
async (job: Job<Order>) => {
workerLog("worker:jobReceived", { jobId: job.id });
const linkKey = job.id?.toString() ?? randomUUID();
workerLog("worker:linkKey", { jobId: job.id, linkKey });
// Запускаем процесс с 2 ретраями
return await pRetry(() => processOrder(job.data, linkKey), { retries: 2 });
},
{
connection: {
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT),
password: process.env.REDIS_PASSWORD,
},
concurrency: 5,
}
);
worker.on("active", (job) => {
workerLog("worker:active", { jobId: job.id });
});
worker.on("completed", (job) => {
workerLog("worker:completed", { jobId: job.id, result: job.returnvalue });
});
worker.on("failed", (job, err) => {
console.error(`Job ${job?.id} failed:`, err);
workerLog("worker:failed", { jobId: job?.id, message: err?.message });
});
worker.on("error", (err) => {
console.error("Worker error:", err);
workerLog("worker:error", { message: err?.message });
});
})();
Заключение
Конечно, можно было бы улучшить много чего: перейти на массовые проверки наличия товаров вместо серии одиночных запросов к API, активнее кешировать данные и состояния, добавить вспомогательный функционал. Но это уже следующий шаг, если вы хотите больше скорости, меньше нагрузки на amoCRM и стабильную работу при масштабировании.
Что конкретно можно улучшить или добавить:
Массовые операции. Ищем/обновляем контакты, сделки и товары пачками, линковку отправляем батчами.
Кешируем товаров по SKU. Чтобы не отправлять одиночные запросы.
Замена и rollBack вместо удаления корзины.
Пагинацию, если в базе много товаров или контактов.
Разделить Redis: db0 — BullMQ, db1 — кеш.
Токены и секреты: авто-обновление
bearer.Градация приоритетов в очереди (важные заказы вперёд).
Я лишь написал рабочую схему. Все дальнейшие доработки снизят число запросов и улучшат устойчивость под нагрузкой.
Если возникли вопросы, напишите в комментариях.