A website-to-CRM integration is built as an adapter behind a port: the application core hands over a lead in its own format, and the adapter translates it into calls to a specific CRM — in this guide, the amoCRM REST API v4. Reliability comes from four decisions: field mapping by ids from the account settings, duplicate search by normalised phone and email, an exchange log with idempotent retries, and two status sync channels — webhooks plus periodic reconciliation.
After this you will be able to
- design a
CrmPortport and an amoCRM adapter that creates a lead with a contact viaPOST /api/v4/leads/complex - describe the mapping of form fields to
custom_fields_valuesand of pipeline stages to your application's statuses - find duplicate contacts by normalised phone and email and avoid creating a second lead when a request is retried
- sync statuses with webhooks and periodic reconciliation within the 7 requests per second limit and a single-use refresh token
Know beforehand
Verified with
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
Checked against docs: October 11, 2026
Contents
- What we build: a website lead becomes a CRM deal
- Study the API before coding: auth, limits, responses
- Mapping fields and reference values
- Duplicates: matching by phone and email
- Errors, retries and the exchange log
- Status sync: webhooks and reconciliation
- Testing against a mock server, common mistakes, exercises
What we build: a website lead becomes a CRM deal
There are three typical scenarios. A website lead becomes a deal with a contact. A repeat enquiry from the same person is attached to the existing contact instead of creating a new one. A stage change in the CRM flows back into the application — for example, to show the status in a customer portal or to stop automated reminders.
The examples target amoCRM and its REST API v4, following the official developer documentation (amocrm.ru/developers). Kommo, the international edition of the product, has its own documentation at developers.kommo.com — check paths and limits there if you work with it. With Bitrix24 the approach is the same; only the adapter changes.
New lead → deal + contact
- Direction
- site → CRM
- amoCRM API v4 method
POST /api/v4/leads/complex
Look up an existing contact
- Direction
- site → CRM
- amoCRM API v4 method
GET /api/v4/contacts?query=…
Deal stage change
- Direction
- CRM → site
- amoCRM API v4 method
- "status change" webhook (leads)
Reconcile missed changes
- Direction
- CRM → site
- amoCRM API v4 method
GET /api/v4/leads?order[updated_at]=desc
- 01Website form
- 02Use case + log
- 03amoCRM adapter
- 04amoCRM API v4
The application core should not know that the CRM is amoCRM. It works with its own lead and the CrmPort port; everything about custom_fields_values, tokens and rate limits lives in the adapter. This is ports and adapters applied directly: the pattern is covered in depth in "Clean Architecture", and a reusable template is in the recipe "Port and adapter for an external service". Switching CRMs or adding a second adapter for tests leaves the business logic untouched.
// Ядро ничего не знает про 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>;
}Study the API before coding: auth, limits, responses
Before writing the adapter, list the API constraints: they shape the architecture more than the set of methods. Summary from amoCRM's "Limits and recommendations" page and method descriptions:
Request rate
- Per the docs
- at most 7 per second per integration and up to 50 per second per account
- What the code does
- a client-side queue: request starts at least 150 ms apart
Limit exceeded
- Per the docs
- HTTP 429; after repeated violations the account is blocked and every request returns 403
- What the code does
- pause and retry only on 429; 403 is an alert, no retries
Batch size
- Per the docs
- create/update — up to 250 entities, 50 recommended;
leads/complex— up to 50 deals and 40 field values per entity - What the code does
- leads one at a time; bulk imports in chunks of 50
Access token expired
- Per the docs
- HTTP 401 — refresh the token with the refresh token and repeat the request
- What the code does
- one automatic refresh and retry
Subscription ended
- Per the docs
- HTTP 402
- What the code does
- error into the log, no retries
Validation error
- Per the docs
- HTTP 400 with
validation-errorsand a path to the field - What the code does
- store the response body in the log
Authorisation is OAuth 2.0. An account administrator installs the integration, and you exchange the authorisation code (valid for 20 minutes) for an access token and refresh token pair via POST /oauth2/access_token. In the documented sample response expires_in is 86400 seconds, i.e. one day. The refresh token lives 3 months and can be exchanged only once: after an exchange the old one is invalid, and the new pair must be stored before first use — otherwise you have to ask the user for access again. The general authorization code flow is covered in "OAuth 2.0 and OpenID Connect".
For a private integration with a single account, since February 2024 you can issue a long-lived token valid from 1 day to 5 years. It has no refresh token, so there is nothing to exchange. That is simpler, but the token must be stored and revoked just as carefully, and its expiry date belongs in a calendar.
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);
}
}The client retries GET on 5xx responses but never retries POST. Creating a deal is not idempotent, and a blind retry after a timeout is the most common source of duplicates. Write retries are handled by the use case through the exchange log — see below.
Mapping fields and reference values
A mapping is a "form field → CRM field" table agreed before any code. In amoCRM the standard contact fields — phone and email — are addressed by a symbolic code (field_code: "PHONE" and "EMAIL") plus a value type enum_code: WORK, WORKDD, MOB, FAX, HOME, OTHER for phones, and WORK, PRIV, OTHER for email. Custom deal fields are created by an administrator and have their own numeric field_id in each account. Fetch them via GET /api/v4/leads/custom_fields and keep them in configuration, not in code.
Name
- amoCRM
- contact
first_name - Rule
- trim whitespace
Phone
- amoCRM
PHONE,enum_code: MOB- Rule
- normalise to +7XXXXXXXXXX; invalid input is a mapping error
- amoCRM
EMAIL,enum_code: WORK- Rule
- lower-case; omit when empty
Lead id
- amoCRM
- deal text field
- Rule
- idempotency and lookup key
Comment
- amoCRM
- deal text field
- Rule
- do not send empty values
Source
- amoCRM
- deal text field and tag
- Rule
- value dictionary in code
Pipeline stage
- amoCRM
status_id- Rule
- 142 — "Closed – won", 143 — "Closed – lost": system statuses of every pipeline
// Формы запросов и ответов 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 model and a pipeline stage to an application statusDuplicates: matching by phone and email
One person submits a form with the phone "8 (701) 123-45-67" and a week later with "+7 701 123 4567". To the CRM these are two different values; to the business it is one customer. So deduplication keys are normalised first: the phone to E.164 (+77011234567), the email to lower case.
amoCRM has a built-in "duplicate control": it is configured per pipeline, looks for duplicates by phone, email or a field value, and converts phones to international format. But according to the docs, for integrations it works only through POST /api/v4/leads/complex and only when duplicate-control support is ticked in the integration settings. It does not deduplicate data within a single request and does not work with legacy API keys. That is why the adapter looks up the contact itself and passes leads/complex either the id of the found contact or the full model of a new one. The built-in control remains a second line of defence.
Normalise
normalizePhoneandnormalizeEmaillive in the core: the same rules for lookups and writes.Find candidates
GET /api/v4/contacts?query=…is a full-text search over filled fields: first by phone, then by email.Re-check
Keep only contacts whose normalised phone or email matches exactly. A similar number is not a duplicate.
Choose
With several matches take the oldest contact; merging cards is left to a manager.
Create the deal
leads/complexwith{ id }of the found contact or with a new contact model.
// Нормализация ключей дедупликации. Правило для плана нумерации +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 implementationErrors, retries and the exchange log
The exchange log is a table in your database showing, for each lead, the delivery status, number of attempts, CRM deal id and the last error text. It answers "why is this lead missing from the CRM" without digging through logs and gives a background job the list of what to retry.
429
- What the code does
- the client waits (
Retry-Afteror 1, 2, 4 seconds) and retries
401
- What the code does
- one refresh token exchange and a retry
5xx on reads
- What the code does
- retry with a pause
5xx or timeout on create
- What the code does
- entry marked
failed; the retry first looks the deal up byexternalId
400
- What the code does
failedwith the response body, no auto-retry: the mapping needs fixing
402, 403
- What the code does
failedand a notification to the owner
The nastiest case is a lost response: the CRM created the deal, but you received a 502 or a timeout. A blind retry creates a duplicate: per the docs, leads/complex only looks for duplicates among already stored data and only when duplicate control is enabled. That is why the lead id is written into a deal field and a retry looks the deal up by it first.
// Журнал обмена: по каждой заявке видно, дошла ли она до 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 and protection against lost responsesStatus sync: webhooks and reconciliation
The sync direction follows the source of truth for each field. The website creates the lead and its original data, while managers own the deal stage, the responsible user and the budget in the CRM. So statuses flow from the CRM to the application, and the application does not overwrite them — otherwise the two sides keep undoing each other's changes.
Contact and lead text
- Source of truth
- website, at creation
- Direction
- site → CRM, once
Deal stage
- Source of truth
- CRM
- Direction
- CRM → site
Responsible user, budget
- Source of truth
- CRM
- Direction
- read-only in the application, if needed at all
Lead id
- Source of truth
- website
- Direction
- written into a deal field and never changed
Webhooks are configured in the account's integrations section or via POST /api/v4/webhooks (admin rights required, at most 100 hooks per account). amoCRM sends them as x-www-form-urlencoded with keys like leads[status][0][id]. It waits no more than 2 seconds for a response; a status outside 100–299 or a timeout counts as a failed delivery. Retries follow after 5 minutes, 15 minutes, 15 minutes and 1 hour, and if more than 100 invalid responses pile up within 2 hours, the hook is disabled.
- Respond 200 immediately and put processing on a queue
- Do not trust the body: the docs describe no webhook signature, so the URL carries a secret, the account subdomain is checked, and the deal is re-read via the API when it matters
- Process idempotently: the same event can arrive more than once
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:httpA webhook can get lost: your server was down for more than an hour, the hook was disabled, someone changed the settings. Reconciliation every N minutes closes those gaps: it reads deals changed since the previous run and applies statuses through the same idempotent handler. In amoCRM filter[updated_at] belongs to API filtering (Alpha), which is enabled as a separate package. So reconciliation relies on the basic order[updated_at]=desc sort and page-by-page reading that stops at the boundary.
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-minute overlapTesting against a mock server, common mistakes, exercises
A real CRM account has no place in automated tests: the tests create deals, hit the rate limit and depend on the network. Instead, use a local mock on node:http that answers with the same paths, status codes and JSON shapes as the documentation: hal+json for success, problem+json with validation-errors for 400, body-less 204, a single-use refresh token. The mock does not emulate permissions, the built-in duplicate control or filters.
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 # types: strict, no emit
npx vitest run # 23 tests against the local mock
node mock/amo-mock.ts # mock on its own, for manual curl requests{
"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 without a build step, hence .ts import extensions and erasable syntax only// Локальный мок 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");
});
});Result: tsc passes with no errors and all 23 Vitest tests are green — mapping, normalisation and deduplication, deal creation reusing a contact, idempotent retry, a lost 502 response, 429, token refresh on 401, a validation error, webhook parsing, paginated reconciliation and request spacing. The code has not been run against a real amoCRM account: verify full-text phone search behaviour and exact error texts on a test account before going live.
- Numeric
field_idvalues hard-coded — after a field is recreated the integration fails with 400 - Requests go to the shared domain instead of the account address
https://<subdomain>.amocrm.ru - The new refresh token is not saved right after the exchange — a day later the integration loses access
POSTis retried after a timeout without a check — duplicate deals appear in the CRM- The webhook handler calls the API and writes to the database before responding — the hook counts as undelivered and is eventually disabled
- Tokens and
client_secretlive in the repository — they belong in a secrets manager or environment variables
How this was verified2 · passed1 · not runchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.