How to buildOctober 11, 202612 min read
APIs & IntegrationsStep 7 of 10

CRM integration: adapter, field mapping, synchronisation

In short

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.

How-toLevel: IntermediateCRM IntegrationsPractice: ~1–2 h

After this you will be able to

  • design a CrmPort port and an amoCRM adapter that creates a lead with a contact via POST /api/v4/leads/complex
  • describe the mapping of form fields to custom_fields_values and 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

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
  1. 01What we build: a website lead becomes a CRM deal
  2. 02Study the API before coding: auth, limits, responses
  3. 03Mapping fields and reference values
  4. 04Duplicates: matching by phone and email
  5. 05Errors, retries and the exchange log
  6. 06Status sync: webhooks and reconciliation
  7. 07Testing 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
  1. 01Website form
  2. 02Use case + log
  3. 03amoCRM adapter
  4. 04amoCRM API v4
Leads go to the CRM through a port and an adapter; statuses come back via webhooks and reconciliation.

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.

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

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

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

export interface CrmPort {
  /* найти уже созданную сделку по id заявки — защита от дубля при повторе */
  findLeadByExternalId(externalId: string): Promise<number | null>;
  createLead(lead: LeadRequest): Promise<CrmLeadRef>;
}
Lead model and CRM port — the core imports nothing from the adapter

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-errors and 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.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  private async throttle() {
    const now = Date.now();
    const slot = Math.max(now, this.nextSlot);
    this.nextSlot = slot + this.opts.minIntervalMs;
    if (slot > now) await wait(slot - now);
  }
}
amoCRM HTTP client: rate limit, Bearer token, retries on 401 and 429, body-less 204

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

Email

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
src/amocrm/types.ts
// Формы запросов и ответов amoCRM API v4 — по разделам «Сделки», «Контакты», «Поля»
export type AmoFieldValue = { value: string; enum_code?: string };
export type AmoCustomField = { field_id?: number; field_code?: string; values: AmoFieldValue[] };

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

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

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

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

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

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

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

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

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

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

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

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

export function readText(fields: AmoCustomField[] | null, fieldId: number): string | null {
  return fields?.find((f) => f.field_id === fieldId)?.values[0]?.value ?? null;
}
Mapping a lead to the leads/complex model and a pipeline stage to an application status

Duplicates: 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.

  1. Normalise

    normalizePhone and normalizeEmail live in the core: the same rules for lookups and writes.

  2. Find candidates

    GET /api/v4/contacts?query=… is a full-text search over filled fields: first by phone, then by email.

  3. Re-check

    Keep only contacts whose normalised phone or email matches exactly. A similar number is not a duplicate.

  4. Choose

    With several matches take the oldest contact; merging cards is left to a manager.

  5. Create the deal

    leads/complex with { id } of the found contact or with a new contact model.

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

export function normalizeEmail(raw: string | undefined): string | null {
  const email = raw?.trim().toLowerCase();
  return email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? email : null;
}
Normalising deduplication keys — one function for lookups and writes
src/amocrm/dedup.ts
import { normalizeEmail, normalizePhone } from "../core/normalize.ts";
import type { AmoContact } from "./types.ts";

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

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

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

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

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

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

  private async findContact(phone: string | null, email: string | null): Promise<number | null> {
    for (const query of [phone, email]) {
      if (!query) continue;
      const page = await this.client.request<AmoPage<"contacts", AmoContact>>(
        "GET",
        `/api/v4/contacts?query=${encodeURIComponent(query)}&limit=50`,
      );
      const id = pickDuplicate(page?._embedded.contacts ?? [], phone, email);
      if (id !== null) return id;
    }
    return null;
  }
}
amoCRM adapter: the CrmPort implementation

Errors, 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-After or 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 by externalId

400

What the code does
failed with the response body, no auto-retry: the mapping needs fixing

402, 403

What the code does
failed and 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.

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

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

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

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

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

  async failed() {
    return [...this.entries.values()].filter((e) => e.status === "failed");
  }
}
Exchange log port and an in-memory implementation for tests
src/core/submit-lead.ts
import type { CrmPort, LeadRequest } from "./lead.ts";
import type { ExchangeEntry, ExchangeLog } from "./exchange-log.ts";

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

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

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

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

Status 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
src/amocrm/webhook.ts
import { timingSafeEqual } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";

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

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

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

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

A 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.

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

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

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

Testing 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.

bash
npm init -y && npm pkg set type=module
npm install -D typescript@7.0.2 @types/node@24 vitest@5.0.3
npx tsc -p tsconfig.json   # 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
Example project: dependencies and check commands
tsconfig.json
{
  "compilerOptions": {
    "target": "es2023",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "types": ["node"],
    "skipLibCheck": true
  },
  "include": ["src", "mock", "test"]
}
tsconfig: Node 24 runs .ts without a build step, hence .ts import extensions and erasable syntax only
mock/amo-mock.ts
// Локальный мок amoCRM API v4: пути, коды и формы ответов — по примерам из документации разработчика.
// Не эмулирует права, контроль дублей, фильтры и прочее, что не нужно тестам адаптера.
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";

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

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

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

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

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

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

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

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

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

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

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

    return problem(res, 404);
  });

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

if (import.meta.main) {
  const mock = await startAmoMock();
  console.log(`amoCRM mock: ${mock.url}  (Bearer ${mock.state.access})`);
}
amoCRM API v4 mock for tests — not a standalone production server
test/adapter.test.ts
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { startAmoMock } from "../mock/amo-mock.ts";
import { AmoCrmAdapter } from "../src/amocrm/adapter.ts";
import { AmoClient } from "../src/amocrm/client.ts";
import { AmoTokenProvider, type Tokens, type TokenStore } from "../src/amocrm/tokens.ts";
import type { AmoMapping } from "../src/amocrm/types.ts";
import { MemoryExchangeLog } from "../src/core/exchange-log.ts";
import type { LeadRequest } from "../src/core/lead.ts";
import { submitLead } from "../src/core/submit-lead.ts";

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

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

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

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

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

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

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

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

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

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

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

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_id values 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
  • POST is 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_secret live 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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.