
Привет, Хабр! В B2C-продажах — недвижимость, страхование, медицина — каждые 30 секунд задержки с первым звонком стоят процентов конверсии. Покупатель оставляет контакты в момент максимального интереса. Но импульс гаснет быстрее, чем менеджер успевает открыть CRM и набрать номер.
Почта и СМС не спасают: письмо пропустят, сообщение не заменит живого разговора. А ручной перенос данных из квиза в базу и набор номера — это десятки потерянных секунд на каждой заявке.
Вот здесь и кроется главная сложность: можно ли сделать так, чтобы лид сам создавался в CRM, а звонок запускался автоматически, без участия человека?
Оказывается, да. Мы сделали сервис, который соединяет квиз, CRM и телефонию в один сценарий. Он принимает вебхук от Марквиз, создает лид в amoCRM и запускает автоматический звонок через МТС Exolve. После разговора сервис получает событие от Exolve и сохраняет результат в CRM.
Менеджеру остается только ответить на вызов. Все остальное делает система.
Стек: FastAPI, SQLite, requests, API Марквиз, amoCRM и МТС Exolve.
Общая схема работы
Пользователь прошел квиз — Марквиз отправляет в наш сервис вебхук с контактами и ответами. Сервис проверяет данные, отсеивает дубликаты и сохраняет заявку в журнале.
Если заявка новая, сервис создает карточку в CRM и запускает звонок между менеджером и клиентом. После разговора результат улетает в amoCRM, а статус заявки в SQLite обновляется.
Забегая вперед: даже в случае сбоя сервис восстановится сам, без ручного вмешательства.
Архитектура: как мы разделили обязанности
Сервис состоит из двух частей: прием вебхуков (сохраняет входящее событие в базу) и фоновая обработка (доводит задачу до конца — создает лид в CRM, ставит звонок и записывает итог).
Разберем, кто с кем общается. Марквиз выступает источником данных и присылает JSON с результатами опроса. FastAPI — центральный узел, он принимает вебхуки и распределяет задачи в фоне. SQLite хранит состояния заявок и токены. amoCRM — система для ведения продаж, где сервис фиксирует сделки и заметки. А МТС Exolve обеспечивает обратный вызов и присылает отчеты о звонках.
Система работает как конечный автомат. Каждая заявка проходит цепочку статусов: от NEW до финальных ANSWERED или NO_ANSWER. Если внешнее API вернуло ошибку, заявка уходит в RETRY_WAIT.
Такой подход делает систему отказоустойчивой. Вместо сложных транзакций — простая стейт-машина в SQLite. После сбоя сервис восстанавливается с того шага, на котором остановился.
Что понадобится для запуска
Вам понадобится Python версии 3.10 или выше. Ключи доступа к API МТС Exolve и amoCRM нужно вынести в переменные окружения. Для Exolve получите API-ключ и идентификатор ресурса колбэка в личном кабинете API Платформы. Для amoCRM создайте приватное приложение и получите начальные токены.
Настройка окружения выглядит так:
bash
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txtФайл .env собираем по примеру из .env.example:
text
AMO_SUBDOMAIN=...
AMO_CLIENT_ID=...
AMO_CLIENT_SECRET=...
AMO_REDIRECT_URI=...
AMO_INITIAL_ACCESS_TOKEN=...
AMO_INITIAL_REFRESH_TOKEN=...
EXOLVE_API_KEY=...
EXOLVE_NUMBER=7800XXXXXXX
EXOLVE_CALLBACK_RESOURCE_ID=12345
MANAGER_PHONE=7999XXXXXXXПоясним параметры. EXOLVE_NUMBER — ваш системный номер в МТС Exolve, он будет виден менеджеру и клиенту. MANAGER_PHONE определяет, на чей телефон поступит первый вызов при колбэке.
Шаг 1. Принимаем вебхук от Марквиз
Здесь мы получаем запрос от Марквиз, проверяем номер телефона клиента и решаем — запускать обработку или завершиться.
Чтобы исключить повторы, мы создаем уникальный ключ (это называется идемпотентностью — повторный вызов с тем же ключом не создает дубликата). Если в вебхуке есть result.id — берем его. Если нет — собираем из идентификатора квиза, телефона и времени создания. При обнаружении дубликата в базе система прекращает обработку. Новые заявки уходят в фоновую задачу.
Код
python
@app.post("/webhook/marquiz")
async def marquiz_webhook(request: Request, background_tasks: BackgroundTasks):
payload = await request.json()
if payload.get("test") == "marquiz_route_check":
return {"status": "ok", "message": "Marquiz test passed"}
raw_phone = payload.get("contacts", {}).get("phone")
if not raw_phone:
return {"status": "skipped", "reason": "No phone"}
phone = normalize_phone(raw_phone)
idempotency_key = build_idempotency_key(payload, phone)
db_id = save_new_lead(idempotency_key, phone, payload)
if db_id is None:
return {"status": "ok", "message": "Already processed (Duplicate)"}
background_tasks.add_task(process_quiz_lead, db_id)
return {"status": "ok", "message": "Lead accepted"}Функция сборки ключа идемпотентности:
Код
python
def build_idempotency_key(payload: dict, phone: str) -> str:
res_id = payload.get("result", {}).get("id")
if res_id:
return f"marquiz_result:{res_id}"
quiz_id = (
payload.get("quiz", {}).get("id")
or payload.get("quiz_id")
or payload.get("form_id")
or "unknown_quiz"
)
created = payload.get("created") or payload.get("created_at") or "unknown_time"
raw_str = f"{quiz_id}_{phone}_{created}"
return "marquiz_hash:" + hashlib.sha256(raw_str.encode()).hexdigest()Результат шага: новая запись в журнале заявок или ответ о дубликате.
Ограничение сейчас: вебхук не проверяется по подписи. Скорее всего, для боевого сценария это стоит добавить.
Шаг 2. Сохраняем состояние заявки
Сервис пишет данные в локальную SQLite. Это помогает повторять запросы, отлаживать и связывать заявки с внешними системами.
Первая таблица хранит статусы заявок и связи с внешними действиями, вторая — токены amoCRM. После перезапуска сервис продолжает работу из базы, а не начинает с нуля.
Код
python
cursor.execute("""
CREATE TABLE IF NOT EXISTS leads_journal (
id INTEGER PRIMARY KEY AUTOINCREMENT,
idempotency_key TEXT UNIQUE,
phone TEXT,
amocrm_lead_id INTEGER,
exolve_call_id TEXT,
status TEXT DEFAULT 'NEW',
attempts INTEGER DEFAULT 0,
last_error TEXT,
raw_payload TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS amocrm_tokens (
id INTEGER PRIMARY KEY CHECK (id = 1),
access_token TEXT,
refresh_token TEXT
)
""") Фактически таблица с лидами хранит конечный автомат: NEW → PROCESSING → CRM_CREATED → CALL_STARTED → финал. Отдельной истории событий сейчас нет, но для небольшого проекта это не критично.
Шаг 3. Создаем лид в amoCRM
После сохранения лида в SQLite добавляем его в воронку продаж. Система создает контакт со сделкой и прикрепляет заметку с ответами квиза.
Защита от повторной обработки работает так: если запись уже в статусе PROCESSING, обработчик пропускает ее. Заявки с финальным статусом (ANSWERED, NO_ANSWER, FAILED) повторно в API не отправляются.
python
if not amo_lead_id:
amo_lead_id = amocrm_client.create_lead_with_contact(phone)
update_lead(db_id, amocrm_lead_id=amo_lead_id, status="CRM_CREATED", last_error="")
answers = "\n".join([f"{q.get('q', '')}: {q.get('a', '')}" for q in raw_payload.get("answers", [])])
amocrm_client.add_note(amo_lead_id, f"Ответы квиза:\n{answers}")python
resp = requests.request(method, url, headers=headers, json=json_data, timeout=15)
if resp.status_code == 401:
new_token = self.refresh_access_token()
headers["Authorization"] = f"Bearer {new_token}"
resp = requests.request(method, url, headers=headers, json=json_data, timeout=15)Результат: в базе появляется номер сделки и заметка с ответами. Если что-то пошло не так — заявка уходит в RETRY_WAIT. На этом этапе внешние вызовы синхронные.
Шаг 4. Запускаем колбэк
Теперь сам звонок. Через Callback API МТС Exolve сервис запускает соединение менеджера и клиента, сохраняет идентификатор звонка и связывает его с заявкой.
python
exolve_call_id = lead_record["exolve_call_id"]
if not exolve_call_id:
exolve_call_id = initiate_callback(phone)
update_lead(db_id, exolve_call_id=exolve_call_id, status="CALL_STARTED", attempts=attempts, last_error="")Для запуска обратного вызова сервис берет из окружения номер Exolve, идентификатор ресурса и телефоны менеджера с клиентом. API Платформы возвращает идентификатор звонка в ответе на MakeCallback. Если его нет — считаем ошибкой.
python
payload = {
"request_description": "quiz_callback",
"number_code": int(exolve_number),
"callback_resource_id": callback_resource_id,
"line_1": {"destinations": [{"number": manager_phone, "timeout": 30}], "ring_logic": 1, "display_number": exolve_number},
"line_2": {"destinations": [{"number": client_phone, "timeout": 30}], "ring_logic": 1, "display_number": exolve_number}
}
resp = requests.post(url, headers=headers, json=payload, timeout=20)После этого заявка переходит в CALL_STARTED. Если идентификатор не получен — запись уходит в RETRY_WAIT.
На этом этапе звонок только передан в Exolve. Ждем вебхук от API Платформы — по нему поймем, состоялся разговор или нет.
Шаг 5. Подчищаем сбойные и зависшие заявки
Раз в минуту отдельный цикл ищет записи, которые еще не дошли до звонка или финального статуса. Это страховка: если фоновая задача не стартовала или сервис перезапустился, заявка все равно попадет в обработку.
Для статусов RETRY_WAIT, CRM_CREATED и зависших PROCESSING (не обновлялись дольше пяти минут) цикл работает как восстановление после сбоя. Количество повторов — не больше пяти.
python
pending_leads = conn.execute("""
SELECT id FROM leads_journal
WHERE (
status IN ('NEW', 'RETRY_WAIT', 'CRM_CREATED')
OR (
status = 'PROCESSING'
AND datetime(updated_at) < datetime('now', '-5 minutes')
)
)
AND attempts < 5
""").fetchall()Сервис отдельно проверяет заявки, которые зависли в обработке, и возвращает их на повторную обработку, если они слишком долго не обновлялись.
python
def is_stale_processing(updated_at: str) -> bool:
try:
updated_dt = datetime.fromisoformat(updated_at)
return updated_dt < datetime.utcnow() - timedelta(minutes=5)
except Exception:
return TrueCALL_STARTED в эту выборку не попадает — после старта звонка сервис ждет только вебхук от Exolve.
Шаг 6. Завершаем работу с заявкой
Когда звонок закончился, Exolve присылает вебхук с его идентификатором. Сервис находит запись в базе и обновляет статус в зависимости от результата.
Если разговор состоялся — ставим ANSWERED. Если стороны не соединились — статус может быть NO_ANSWER, CANCELLED, COMPLETED или FAILED в зависимости от причины.
Код
python
if event_type != "d":
return {"status": "ok", "message": f"Event {event_type} ignored"}
if talk_time_ms > 0:
final_status = "ANSWERED"
elif hangup_cause in {"NO_ANSWER", "NO_USER_RESPONSE"}:
final_status = "NO_ANSWER"
elif "CANCEL" in hangup_cause:
final_status = "CANCELLED"
elif hangup_cause == "NORMAL_CLEARING":
final_status = "COMPLETED"
else:
final_status = "FAILED"
update_lead(lead["id"], status=final_status)После этого сервис добавляет заметку в карточку лида — менеджер видит итог звонка, не заходя в телефонию.
python
note_text = (
f"Результат Callback-звонка: {final_status}\n"
f"call_id: {call_id}\n"
f"Причина завершения: {hangup_cause}\n"
f"Время разговора: {talk_time_sec} сек.\n"
f"Общая длительность: {duration_sec} сек."
)
amocrm_client.add_note(lead["amocrm_lead_id"], note_text)Если заметку добавить не удалось, заявка получает статус NOTE_FAILED. Автоматического повтора для этого статуса нет.
Ограничения решения
У решения есть несколько ограничений, о которых важно знать.
Вебхуки от Марквиз и Exolve принимаются без проверки подписи. Это упрощает запуск, но повышает риск получить левые данные.
Сервис обновляет базу, CRM и телефонию последовательно, а не одной транзакцией. Иногда это приводит к рассинхрону: лид в CRM уже есть, а звонок еще не начался.
После старта звонка сервис ждет только обратный вебхук от Exolve. Если событие не приходит, заявка навсегда остается в промежуточном статусе.
SQLite хранит только текущее состояние, а не полную цепочку обработки. Вы видите статус, но восстановить, как заявка дошла до этого этапа, сложно.
SQLite нормально работает при небольшом потоке заявок. Но при сотнях одновременных вебхуков база начнет блокировать запись. Для промышленной эксплуатации стоит брать PostgreSQL и очередь задач.
Запуск и проверка
Настроили .env — запускаем:
bash
uvicorn main:app --host 0.0.0.0 --port 8000Сначала отправляем тестовый пинг от Марквиз, чтобы убедиться, что маршрут доступен:
bash
curl -X POST http://localhost:8000/webhook/marquiz \
-H "Content-Type: application/json" \
-d '{"test":"marquiz_route_check"}'Затем отправляем тестовый лид. В ответ ждем Lead accepted, а в journal.db — новую запись с ключом защиты от дублей.
bash
curl -X POST http://localhost:8000/webhook/marquiz \
-H "Content-Type: application/json" \
-d '{
"contacts": {"phone": "+7 (999) 123-45-67"},
"result": {"id": "r_001"},
"answers": [{"q":"Услуга","a":"Логистика"}]
}'Финальная проверка — событие от МТС Exolve с type: d и валидным call_id из вашей записи. После него заявка должна получить финальный статус.
bash
curl -X POST http://localhost:8000/webhook/exolve \
-H "Content-Type: application/json" \
-d '{
"type":"d",
"call_id":"123456789",
"hangup_cause":"NO_ANSWER",
"talk_time":0,
"duration":23000
}'Потенциал для развития
У нашего решения хороший потенциал для роста. Например, можно добавить повторные касания: если клиент не ответил, система сама запланирует следующий звонок. Лиды не выпадают, а нагрузка на команду не растет.
Полезна будет и приоритизация лидов — настроить очередь так, чтобы менеджер сначала получал заявки с высоким шансом на сделку. Тогда выручка ускоряется без увеличения потока клиентов.
Стоит внедрить контроль зависших заявок: система отслеживает лиды, которые застряли между этапами, и возвращает их в обработку. Это снижает скрытые потери в воронке.
И наконец, речевая аналитика. Анализ первого разговора помогает находить причины отказов и улучшать скрипты. Как результат — растут конверсия и качество продаж.
Что из этого внедрить в первую очередь — решать вам, исходя из текущих болевых точек.
Что в итоге?
Без автоматизации менеджер вручную перебивает данные и набирает номер. Результат предсказуем: потерянные лиды или звонок, когда клиент уже забыл, зачем оставлял заявку.
Наш сценарий закрывает разрыв между квизом и первым разговором. Заявка проходит путь: Марквиз → amoCRM → вызов в Exolve → статус в CRM. Бизнес получает меньше потерянных лидов и полную прозрачность по каждому контакту.
Главные метрики, которые стоит считать регулярно, — время до первого звонка и итоговый статус: дозвонились, нет или ошибка. На их основе легко принимать решения по улучшению продаж.
Код, как обычно, в репозитории.


























