Пока впечатление о полученной услуге свежее, клиент лучше помнит детали и охотнее делится обратной связью. Бизнесу это помогает быстрее находить слабые места в сервисе и исправлять их.

Когда клиентов мало, администратор может быстро их обзвонить: спросить, всё ли понравилось, и зафиксировать ответы. При масштабировании бизнеса этот вариант уже не подходит: звонки отнимают много времени. В итоге часть визитов остаётся без проверки, а бизнес узнаёт о проблеме, когда недовольный клиент опубликовал негативный отзыв в интернете, ухудшив рейтинг компании, и перестал возвращаться.

В этой статье разберём, как автоматизировать исходящие звонки. Клиенту, получившему услугу,  звонит голосовой робот и проводит короткое анкетирование. Результаты опроса сразу попадают в рабочую таблицу, а если клиент остался недоволен, управляющий дополнительно получает СМС и может быстрее разобраться в ситуации.

Стек решения: 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.

Скриншот схемы голосового робота МТС 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, при низкой оценке отправляет СМС владельцу и затем пишет итог по визиту в таблицу.

Скриншот из MWS Tables
Скриншот из MWS Tables

Функция 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 и другими системами, если команда уже работает в ней

Код проекта на гитхабе.