Пока впечатление о полученной услуге свежее, клиент лучше помнит детали и охотнее делится обратной связью. Бизнесу это помогает быстрее находить слабые места в сервисе и исправлять их.
Когда клиентов мало, администратор может быстро их обзвонить: спросить, всё ли понравилось, и зафиксировать ответы. При масштабировании бизнеса этот вариант уже не подходит: звонки отнимают много времени. В итоге часть визитов остаётся без проверки, а бизнес узнаёт о проблеме, когда недовольный клиент опубликовал негативный отзыв в интернете, ухудшив рейтинг компании, и перестал возвращаться.
В этой статье разберём, как автоматизировать исходящие звонки. Клиенту, получившему услугу, звонит голосовой робот и проводит короткое анкетирование. Результаты опроса сразу попадают в рабочую таблицу, а если клиент остался недоволен, управляющий дополнительно получает СМС и может быстрее разобраться в ситуации.
Стек решения: Python 3.10+, Flask, requests, python-dotenv, SQLite, YCLIENTS API, голосовой робот и SMS API МТС Exolve, MWS Tables.
Общая схема работы
Сценарий начинается, когда YCLIENTS считает визит завершённым. Сервис получает вебхук, забирает полную карточку посещения и проверяет, что по этому визиту ещё не было звонка.
После этого он создаёт локальную запись состояния и запускает голосового робота, который звонит клиенту и задаёт три вопроса: как он оценивает услугу, цену и готов ли вернуться. Такой набор вопросов сохраняет звонок коротким и даёт бизнесу оценку качества работы, восприятие стоимости и риск потери клиента.
После каждого ответа сервис получает колбэк, сохраняет шаг опроса и обновляет статус визита в локальной базе. Если клиент не ответил или звонок завершился с ошибкой, это тоже фиксируется отдельным статусом.
Если оценка услуги низкая, владельцу отправляется СМС, а результат записывается в таблицу. После этого по каждому визиту видно, дошёл ли клиент до конца опроса и какая была обратная связь.
Архитектура решения
Всё работает на одном Flask-сервисе, трёх внешних системах и локальной базе.
YCLIENTS хранит основные данные визита и присылает событие о его завершении. SQLite хранит текущее состояние опроса: статус, идентификатор звонка, ответы по шагам и отметку об отправке алерта. MWS Tables получают уже итог по визиту, когда опрос завершён.
App.py управляет статусами визита: принимает вебхуки, связывает их с нужной записью и запускает внешние вызовы. Основной ключ сценария — visit_id. Идентификатор звонка нужен, чтобы потом привязать колбэк голосового сценария к уже созданной записи.
Внешние вызовы вынесены в отдельные файлы: yclients_api.py читает визит из YCLIENTS, exolve_voice.py запускает звонок, sms_alerts.py отправляет СМС, tables_api.py пишет итог в таблицу.
Пререквизит
Нужен Python 3.10+: в коде используется синтаксис str | None. Токены, идентификатор таблицы и порог низкой оценки лежат в .env.
python -m venv .venv source .venv/bin/activate pip install -r requirements.txt
Пример переменных окружения:
WEBHOOK_SECRET=change-me YCLIENTS_BASE_URL=https://api.yclients.com/api/v1 YCLIENTS_PARTNER_TOKEN=*** YCLIENTS_USER_TOKEN=*** MWS_TABLES_BASE_URL=https://tables.mws.ru MWS_TABLES_API_KEY=*** MWS_TABLE_ID=*** MWS_VIEW_ID=*** EXOLVE_API_KEY=*** EXOLVE_CAMPAIGN_ID=*** EXOLVE_CAMPAIGN_URL=https://api.exolve.ru/campaign/v1/Call EXOLVE_SENDER=SalonBot OWNER_ALERT_PHONE=79990001122 LOW_SCORE_THRESHOLD=2 DB_NAME=salon_quality.db
Для финальной записи результатов в MWS Tables заранее создайте таблицу quality_results_table со следующими колонками:
visit_id — строка
client_name — строка
master_name — строка
branch_name — строка
visit_at — дата/время
service_score — число
price_score — число
return_intent — число
survey_status — строка
alert_sent — логическое поле
В текущем коде секрет вебхука передаётся как параметр запроса token, поэтому сам URL вебхука тоже нельзя светить. Ключи API приложение читает через python-dotenv.
Шаг 1. Поднимаем конфиг и локальное состояние
Опрос не заканчивается одним событием, поэтому сервису нужна отдельная запись по каждому визиту. Состояние опроса сервис держит в Config и таблице surveys. В ней лежат контекст визита и технические поля опроса: status, call_id, три оценки и alert_sent. Первичный ключ на visit_id защищает от дублей.
# config.py from dotenv import load_dotenv import os load_dotenv() class Config: WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "super-secret-token") YCLIENTS_BASE_URL = os.environ.get("YCLIENTS_BASE_URL", "https://api.yclients.com/api/v1") YCLIENTS_PARTNER_TOKEN = os.environ.get("YCLIENTS_PARTNER_TOKEN", "***") YCLIENTS_USER_TOKEN = os.environ.get("YCLIENTS_USER_TOKEN", "***") MWS_TABLES_BASE_URL = os.environ.get("MWS_TABLES_BASE_URL", "https://tables.mws.ru") MWS_TABLES_API_KEY = os.environ.get("MWS_TABLES_API_KEY", "***") MWS_TABLE_ID = os.environ.get("MWS_TABLE_ID", "***") MWS_VIEW_ID = os.environ.get("MWS_VIEW_ID", "***") EXOLVE_API_KEY = os.environ.get("EXOLVE_API_KEY", "your_exolve_key") EXOLVE_CAMPAIGN_ID = os.environ.get("EXOLVE_CAMPAIGN_ID", "***") EXOLVE_CAMPAIGN_URL = os.environ.get("EXOLVE_CAMPAIGN_URL", "https://api.exolve.ru/campaign/v1/Call") EXOLVE_SENDER = os.environ.get("EXOLVE_SENDER", "SalonBot") OWNER_ALERT_PHONE = os.environ.get("OWNER_ALERT_PHONE", "79990001122") LOW_SCORE_THRESHOLD = int(os.environ.get("LOW_SCORE_THRESHOLD", 2)) DB_NAME = os.environ.get("DB_NAME", "salon_quality.db")
Ниже минимальная схема таблицы, в которой сервис хранит состояние опроса.
# database.py def init_db(): with get_conn() as conn: conn.execute(""" CREATE TABLE IF NOT EXISTS surveys ( visit_id TEXT PRIMARY KEY, client_name TEXT, client_phone TEXT, master_name TEXT, branch_name TEXT, visit_at TEXT, status TEXT NOT NULL, call_id TEXT, q1_service_score INTEGER, q2_price_score INTEGER, q3_return_intent INTEGER, alert_sent INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ) """) conn.commit()
Эта таблица хранит текущее состояние опроса. Данные визита и технический статус сервис держит в одной записи, без отдельного журнала событий.
Сервис использует такие статусы:
NEW — визит создан локально, опрос еще не запущен
CALL_STARTED — звонок запущен
Q1_ANSWERED — получен ответ на первый вопрос
Q2_ANSWERED — на второй
Q3_ANSWERED — и на третий
COMPLETED — опрос завершен
NO_ANSWER — клиент не ответил
FAILED — опрос завершился технической ошибкой
Последовательность статусов при успешном звонке выглядит так:
NEW -> CALL_STARTED -> Q1_ANSWERED -> Q2_ANSWERED -> Q3_ANSWERED -> COMPLETED
Если клиент не ответил:
NEW -> CALL_STARTED -> NO_ANSWER
Если звонок завершился ошибкой:
NEW -> CALL_STARTED -> FAILED
Шаг 2. Принимаем закрытый визит из YCLIENTS
Вебхук YCLIENTS сообщает, что визит закрыт. Дальше сервис проверяет token, смотрит на attendance == 1 и отдельно забирает карточку визита через get_visit(). В локальную БД он пишет уже нормализованные данные.
Пример входящего вебхука:
{ "id": "98765", "company_id": "12345", "attendance": 1 }
Ниже сам обработчик этого вебхука.
# app.py @app.route("/webhook/yclients/visit", methods=["POST"]) def yclients_webhook(): if request.args.get("token") != Config.WEBHOOK_SECRET: return "Forbidden", 403 data = request.get_json(silent=True) or {} visit_id = str(data.get("id") or "") company_id = str(data.get("company_id") or "") if not visit_id or not company_id: return "Bad Request: visit_id or company_id missing", 400 if data.get("attendance") != 1: return jsonify({"status": "ignored_status"}), 200 visit_raw = get_visit(company_id, visit_id) visit = normalize_visit(visit_raw) if not visit["client_phone"]: return jsonify({"status": "invalid_phone"}), 200 created = create_survey_if_new(visit) if not created: return jsonify({"status": "duplicate"}), 200
После первичной проверки сервис отдельно нормализует ответ YCLIENTS и дальше работает уже с одной и той же структурой визита.
# yclients_api.py def normalize_phone(phone: str | None) -> str | None: if not phone: return None digits = "".join(ch for ch in str(phone) if ch.isdigit()) if len(digits) == 11 and digits.startswith("8"): digits = "7" + digits[1:] return digits if len(digits) == 11 and digits.startswith("7") else None def normalize_visit(visit: dict) -> dict: return { "visit_id": str(visit.get("id")), "client_name": visit.get("client", {}).get("name"), "client_phone": normalize_phone(visit.get("client", {}).get("phone")), "master_name": visit.get("staff", {}).get("name"), "branch_name": visit.get("company", {}).get("title"), "visit_at": visit.get("datetime") }
После нормализации в локальную базу попадает карточка визита с клиентом, мастером, филиалом и временем посещения. Повторный вебхук не создаёт дубль, потому что запись привязана к visit_id. Если телефон не проходит нормализацию, сервис возвращает invalid_phone. Завершённым визит считает только по attendance == 1.
Шаг 3. Запускаем голосовой опрос через МТС Exolve
Когда визит уже записан локально, сервис запускает по этому клиенту голосовую кампанию в МТС Exolve.

В запрос уходит не только телефон, но и initialData.visit_id. Этот ключ нужен, чтобы потом связать колбэк с нужным визитом. В ответе от МТС Exolve сервис дополнительно сохраняет call_id как идентификатор звонка для обратного маршрута вебхука и переводит запись в статус CALL_STARTED.
# exolve_voice.py def start_quality_campaign(phone: str, visit_id: str) -> dict: headers = {"Authorization": f"Bearer {Config.EXOLVE_API_KEY}", "Content-Type": "application/json"} payload = { "campaign_id": Config.EXOLVE_CAMPAIGN_ID, "params": { "destination": phone, "initialData": {"visit_id": visit_id} } } resp = requests.post(Config.EXOLVE_CAMPAIGN_URL, headers=headers, json=payload, timeout=20) resp.raise_for_status() return resp.json()
Дальше сервис сохраняет идентификатор звонка в SQLite и переводит визит в статус CALL_STARTED.
# app.py response = start_quality_campaign(visit["client_phone"], visit["visit_id"]) call_id = response.get("call_id") or response.get("id") update_survey_status(visit["visit_id"], "CALL_STARTED", call_id=call_id) return jsonify({"status": "accepted"}), 200
На этом шаге сервис создаёт звонок и сохраняет два ключа: идентификатор визита и идентификатор звонка. Если вызов к МТС Exolve не проходит из-за сетевой ошибки или не пройденной авторизации, вебхук YCLIENTS не завершается штатно. Ограничение здесь прямое: вызов к МТС Exolve идёт синхронно внутри вебхука.
Шаг 4. Принимаем ответы DTMF и ведём состояние опроса
Во втором вебхуке сервис получает не итог опроса целиком, а отдельные шаги: вопрос, цифру ответа и статус звонка. q1_service_score хранит оценку услуги, q2_price_score — оценку цены, q3_return_intent — готовность вернуться. Отдельные ветки NO_ANSWER и FAILED нужны, чтобы отличать отсутствие ответа от технического сбоя.
Маршрут /webhook/exolve/survey снова проверяет token, приводит тело запроса к внутреннему контракту через normalize_callback, ищет опрос по call_id или visit_id, преобразует dtmf_digit в число и сохраняет ответ в одно из трёх полей состояния.
Пример колбэка, который отправляет голосовой сценарий:
{ "visit_id": "98765", "call_id": "call_001", "current_step": "q2", "dtmf_digit": "4", "result_status": "COMPLETED" }
Дальше сервис приводит такой колбэк к короткому внутреннему формату.
# exolve_voice.py def normalize_callback(payload: dict) -> dict: return { "call_id": payload.get("call_id"), "visit_id": payload.get("visit_id"), "step": payload.get("current_step"), "digit": payload.get("dtmf_digit"), "result_status": payload.get("result_status") }
После нормализации у обработчика остаются пять полей: шаг, цифра, статус, visit_id и call_id.
# app.py if cb.get("result_status") == "NO_ANSWER": update_survey_status(visit_id, "NO_ANSWER") return jsonify({"status": "processed"}), 200 if cb.get("result_status") == "FAILED": update_survey_status(visit_id, "FAILED") return jsonify({"status": "processed"}), 200 if step == "q1": update_survey_status(visit_id, "Q1_ANSWERED", q1_service_score=digit) return jsonify({"status": "step_saved"}), 200 if step == "q2": update_survey_status(visit_id, "Q2_ANSWERED", q2_price_score=digit) return jsonify({"status": "step_saved"}), 200 if step == "q3": update_survey_status(visit_id, "Q3_ANSWERED", q3_return_intent=digit) finalize_survey(visit_id) return jsonify({"status": "step_saved"}), 200
На этом шаге сервис либо сохраняет ответ, либо переводит опрос в терминальный статус. Прикладные ошибки здесь простые: 404, если визит не найден, и 400, если пришёл неверный шаг или цифра. Код проверяет только int, но не диапазон оценки.
Шаг 5. Финализируем опрос, отправляем СМС и пишем результат в таблицу
Когда опрос заканчивается, сервис читает запись из SQLite, при низкой оценке отправляет СМС владельцу и затем пишет итог по визиту в таблицу.

Функция finalize_survey читает запись из БД и сначала проверяет первую оценку. Если q1_service_score меньше или равен LOW_SCORE_THRESHOLD, сервис вызывает send_low_score_alert через SMS API МТС Exolve. Затем из локальной записи сервис собирает итог по визиту и отправляет его в таблицу.
# app.py def finalize_survey(visit_id: str): survey = get_survey(visit_id) if not survey: return is_alert_sent = False if survey["q1_service_score"] is not None and survey["q1_service_score"] <= Config.LOW_SCORE_THRESHOLD: try: send_low_score_alert( survey["master_name"], survey["client_name"], survey["q1_service_score"] ) is_alert_sent = True except Exception: is_alert_sent = False record = { "visit_id": survey["visit_id"], "client_name": survey["client_name"], "master_name": survey["master_name"], "branch_name": survey["branch_name"], "visit_at": survey["visit_at"], "service_score": survey["q1_service_score"], "price_score": survey["q2_price_score"], "return_intent": survey["q3_return_intent"], "survey_status": "COMPLETED", "alert_sent": is_alert_sent }
На этом месте app.py уже собрал финальный record. Дальше один клиент отправляет СМС владельцу, а второй пишет итог по визиту в таблицу.
# sms_alerts.py headers = {"Authorization": f"Bearer {Config.EXOLVE_API_KEY}", "Content-Type": "application/json"} payload = {"number": Config.EXOLVE_SENDER, "destination": Config.OWNER_ALERT_PHONE, "text": text} resp = requests.post(url, headers=headers, json=payload, timeout=20) resp.raise_for_status()
В таблицу уходит уже итог по визиту: оценки, статус опроса и отметка об алерте.
# tables_api.py headers = {"Authorization": f"Bearer {Config.MWS_TABLES_API_KEY}", "Content-Type": "application/json"} payload = { "records": [{"fields": record}], "fieldKey": "name", } resp = requests.post(url, params=params, headers=headers, json=payload, timeout=20) resp.raise_for_status()
На этом шаге сервис отправляет СМС владельцу и пишет строку в таблицу. SQLite остаётся хранилищем состояния, а команда смотрит итог уже в Таблицах. Если любой вызов в этом блоке падает, визит переходит в FAILED. Общей транзакции между СМС, записью в таблицу и локальным статусом здесь нет.
Запуск и проверка
Чтобы прогнать сценарий целиком, понадобятся токены YCLIENTS, МТС Exolve и идентификаторы таблицы. После заполнения .env достаточно поднять Flask-приложение и либо закрыть тестовый визит в YCLIENTS, либо отправить вебхук вручную.
python -m venv .venv source .venv/bin/activate pip install -r requirements.txt flask --app app run --port 5000
Если проверяем первый маршрут вручную, отправляем запрос на вебхук YCLIENTS. При валидном секрете и корректных внешних токенах ожидаем {"status":"accepted"}.
curl -X POST "http://127.0.0.1:5000/webhook/yclients/visit?token=super-secret-token" \ -H "Content-Type: application/json" \ -d '{"id":"98765","company_id":"12345","attendance":1}'
Когда запись уже создана, колбэк от голосового сценария можно эмулировать отдельно. Для локальной проверки удобно отправлять visit_id без call_id, тогда маршрут ищет опрос напрямую по визиту. После первого вебхука в SQLite уже должна лежать строка со статусом CALL_STARTED и сохранённым call_id, если внешний вызов кампании отработал штатно.
curl -X POST "http://127.0.0.1:5000/webhook/exolve/survey?token=super-secret-token" \ -H "Content-Type: application/json" \ -d '{"visit_id":"98765","current_step":"q1","dtmf_digit":"2","result_status":"COMPLETED"}'
После колбэка с q1 строка должна перейти в Q1_ANSWERED и получить q1_service_score. После колбэка с q3 сервис либо завершит опрос статусом COMPLETED и создаст запись в таблице, либо пометит визит как FAILED, если финализация не прошла. Проверять в итоге нужно три точки: переходы status в SQLite, СМС на OWNER_ALERT_PHONE при низкой оценке и итоговую запись в таблице после третьего шага.
Если приходят 4xx, смотрим на token, company_id, visit_id и формат цифры. Если получаем 5xx, причина обычно в сетевой ошибке при вызове YCLIENTS, МТС Exolve или Таблиц.
В итоге
Такой сценарий помогает бизнесу быстро получать обратную связь о качестве сервиса и компания узнаёт о качестве обслуживания клиента практически сразу после оказания услуги. Это даёт возможность быстро решить проблему и не допустить ее повторения, а еще увеличивает шанс на повторный визит клиента, мнение которого услышали и учли.
Решение можно запустить за пару вечеров. Если эффект есть, сценарий можно расширять: встраивать в CRM, добавлять повторные касания, автоматизировать разбор негатива. Такой подход превращает работу с клиентами в постоянный процесс мониторинга и управления качеством сервисного обслуживания.
Возможности для развития
Добавить повторный контакт по клиентам, которые не ответили на первый звонок
Развести реакцию по уровню негатива: критические случаи отправлять сразу, остальные разбирать в течение дня
После низкой оценки не только слать СМС владельцу, но и ставить задачу на обратный звонок или разбор кейса
Смотреть результаты отдельно по филиалам, мастерам, услугам и времени визита
Сделать разные сценарии для новых и постоянных клиентов
Собрать регулярный отчёт по покрытию опроса, доле ответов, низким оценкам и скорости реакции
Запускать после низкой оценки отдельный сценарий удержания: быстрый звонок, извинение или повторный визит
Интегрироваться с CRM и другими системами, если команда уже работает в ней
Код проекта на гитхабе.
