Embed Protocol

Встраивание опросов: покажите опрос внутри своего сайта или приложения и управляйте им из кода

Embed Protocol v1

Embed Protocol v1 — обмен сообщениями между встроенным опросом и тем, во что он встроен. Опрос показывается прямо внутри вашей страницы или прямо внутри вашего приложения, при этом сообщает продукту, что происходит: открылся, человек начал, ответил, отправил, завершил. А продукт может им управлять — закрыть, перезагрузить, перехватить концовку и показать свой экран.

Один протокол обслуживает три транспорта: iframe на сайте, WKWebView на iOS и WebView на Android. Сам опрос при этом один и тот же — та же ссылка, те же вопросы, та же логика, те же ответы в кабинете.

Сначала главное: возможно, писать хост не нужно

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

Для приложения готового пакета нет — ни под iOS, ни под Android, ни под React Native. Нативный хост пишется по этому контракту: рецепты транспортов и минимальные примеры ниже.

1. Что даёт протокол и кому он нужен

Опрос и без протокола работает в iframe: вставили — показывается, человек отвечает, ответы приходят в кабинет. Протокол добавляет к этому две вещи, которых у плоского фрейма нет вовсе.

Появляется Зачем это нужно на практике
Продукт слышит опрос
15 событий
Отметить в своей аналитике, что опрос открылся и завершился. Показать свой индикатор заполнения. Узнать, что человека отсеяли, что ему нужна авторизация, что он платит внутри опроса.
Продукт управляет опросом
4 команды
Закрыть шторку, не потеряв недопечатанный ответ. Перехватить концовку: вместо ухода опроса на «Спасибо» показать свой экран. Проверить, что опрос ещё отвечает.

Главная проблема, которую это снимает, — концовка в приложении. Без протокола после отправки ответов WebView уезжает с опроса на страницу благодарности прямо внутри вашего интерфейса. С протоколом опрос отдаёт этот переход вам (survey.navigate-request), и приложение решает само, что показать дальше.

Старые встраивания не ломаются, но новый трафик видят

Для респондента не меняется ничего: опрос показывается, ответы уходят, концовку выполняет он сам, пока хост её не забрал. Перевставлять код не нужно, права никому не выдаются сами.

Но сказать надо прямо: значение по умолчанию перевёрнуто, и существующие встраивания теперь получают сообщения, которых не просили — маячок survey.handshake, а за ним факты про сам опрос (survey.ready, survey.error, survey.blocked с причиной про опрос). Уходят они прямому родителю сразу, ещё до приветствия. Ничего про респондента в них нет — это и делает рассылку безопасной, — но если на вашей странице уже висит чужой слушатель message, он эти сообщения увидит и должен уметь их отличать.

Одно легаси-сообщение уходит вне протокола

При каждой успешной отправке ответа опрос по-прежнему шлёт старое сообщение { webask: true, action: 'answer' } — на него настроены существующие встраивания, и снимать его не будут. Если вы обрабатываете и его, и survey.submitted, отправка посчитается дважды. Отличать по ключу: у протокола он __webask, у легаси — webask.

Если вам хватает готового скрипта

Код встраивания берётся в кабинете: там же выбирается вид (врезка в страницу, окно поверх, круглая кнопка в углу, вкладка у края), условие показа и частота повторов. Рукопожатие делает сам скрипт — вам остаётся объявить подписку, и права он выведет из того, на что вы подписались.

<script>
  WebAsk.q = WebAsk.q || [];
  WebAsk.q.push(['ID_ОПРОСА', 'completed', function (payload, meta) {
    /* payload.reason — 'finish' или 'screenout' */
  }]);
</script>

Порядок относительно кода встраивания не важен: очередь работает и до загрузки скрипта, и после. Имя подписки — это имя события протокола без префикса survey.: ready, started, page-changed, submitted, completed и так далее — а payload в колбэке тот же, что в таблице событий ниже. Права скрипт выводит из набора ваших подписок, поэтому отдельно объявлять их не нужно: подписались на question-answered — получите answers.

Не ставьте оба пути на один фрейм. Свой хост рядом с кабинетным скриптом означает два приветствия на один документ, а второе опрос игнорирует целиком — часть подписок молча не сработает. Либо готовый скрипт, либо свой хост; всё остальное в этом разделе — про свой.

Поддержка браузеров и WebView

Уровень Версии
Полная поддержка iOS / Safari 14.5+, Chrome 84+, Firefox 78+, Edge 79+
Прохождение гарантировано, отличия косметические iOS 12.2+ (Safari 12.1+), Chrome и Android WebView 79+
Ниже респондент видит статический экран «Браузер устарел»

На устаревшем движке опрос не монтируется, поэтому маячок survey.handshake и ответ на host.ping вы получить можете, а событий прохождения не будет вовсе. На совсем древнем не будет и маячка — там не загружается бандл. На Android границу задаёт версия системного WebView (он обновляется отдельно от ОС), на iOS — версия системы.

2. Где протокол включается

Включать ничего не надо — ни у нас, ни в адресе

  • Никакого query-параметра активация не требует. Встраивается любая существующая ссылка опроса, как есть. ?embed=1 остаётся допустимым (его ставят старые embed-коды) и ничего не значит.
  • Регистрация домена не требуется. Ни белого списка на нашей стороне, ни ключа, ни заявки. Веб-часть интеграции делается без нашего участия и без ожидания настроек.
  • Фрейминг не запрещён. Домены, с которых отдаются опросы, не выставляют X-Frame-Options и не ограничивают frame-ancestors.

Оговорка на случай проверки руками: на маркетинговом домене продукта X-Frame-Options стоит — но опросы оттуда не отдаются вовсе, во фрейм он и не попадает. Проверять надо тот адрес, который вы реально встраиваете, то есть адрес опроса.

Условия активации

Контекст разрешается один раз за жизнь документа, при старте. Нужны два условия одновременно: подходящий роут и опознанный транспорт.

Транспорт опознаётся по ctx.embed
window.self !== window.top — мы во фрейме iframe
не во фрейме и есть window.webkit.messageHandlers.webask ios
не во фрейме и есть window.WebAskAndroid android
иначе протокол выключен

Правила 2 и 3 проверяют не операционную систему, а наличие моста с известным именем. WebView, который ни одного из этих имён не выставляет, для протокола выглядит обычной страницей: хендшейка нет, события не уходят, команды игнорируются. Практически важный случай — WebView в React Native: у него собственный канал и оба имени отсутствуют (см. раздел про приложение).

Порядок правил тоже осмысленный: во фрейме нативный мост не используется, даже если он доступен. window.webkit.messageHandlers виден во всех фреймах WKWebView, включая кросс-доменные, — мост во фрейме указывал бы не на того, кто встроил опрос, а на приложение, через голову клиента.

Роуты: что можно встроить и где живёт протокол

Роут Встраивается Протокол
/:uuid — прохождение да, ради него всё и делается да
/:uuid/preview — предпросмотр да: это штатный тестовый режим да, с пометкой ctx.preview
/:uuid/edit/:editUuid — правка своего ответа да: опрос уезжает туда сам по кнопке «Редактировать ответ», в том же фрейме нет
/:uuid/preview/question/:id — предпросмотр одного вопроса только из редактора нет
/:uuid/summary|report|ai-report|answers/… теоретически — страницы результатов на сайте клиента нет
/:uuid/response/…, /print, /booking/cancel/…, / нет нет

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

Единственный практический разрыв — edit: встроенный респондент уезжает туда внутри того же фрейма, и с этого момента хост не может ни закрыть опрос командой, ни узнать, что происходит. В версии 1 правка ответа участником протокола не является.

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

3. Адрес опроса: главное правило

Всё, что стоит в query, — это скрытая переменная автора

Такие пары уходят бэкенду вместе с запросом опроса, лежат в данных ответа, подставляются в тексты и ссылки автора — и участвуют в подписи прохождения. Если опрос настроен опознавать респондента по адресу, полный набор переменных адреса и есть ответ на вопрос «тот же это респондент или другой». Подпись сменилась — опрос стирает ответы, таймеры, позицию и терминальный экран.

Прямое следствие: кэш-бастер в src фрейма стирает респонденту прогресс на каждой загрузке. ?t=Date.now(), ?v=<random>, «уникальный» идентификатор сессии хоста — каждое новое значение это новая личность. А загрузок у документа много, и опрос перезагружает себя сам.

Правила для хоста, все четыре:

  • Адрес стабилен на всё прохождение и на все перезагрузки: тот же путь, тот же набор и те же значения параметров.
  • Кэш-бастинг не дописывать. Обход кеша HTML у рендерера уже есть и живёт под служебным именем _r, которое из подписи вычитается; чужое имя вычесть нечем.
  • Скрытые переменные ставить осознанно. ?user_id=… — штатный и единственный способ связать ответ со своим пользователем, но он же и идентичность: значение обязано быть постоянным для этого респондента.
  • Служебных имён не занимать. Их четыре, список закрыт и не расширяется — каждое имя здесь навсегда отнимается у авторов опросов: loader_bg, loader_spinner, oidc, _r. Сравнение точное, без приведения регистра.
так     https://webask.io/6f2b1c8e-0000-4000-8000-000000000000?user_id=12345
не так  https://webask.io/6f2b1c8e-0000-4000-8000-000000000000?user_id=12345&t=1786824064103

Так же ведут себя utm-метки, если хост пересобирает их между заходами.

4. Сайт: iframe и postMessage

На сайте опрос живёт в <iframe>, а разговор идёт через postMessage между вашей страницей и этим фреймом. Адрес опроса — обычный, дописывать к нему ничего не нужно.

Атрибуты фрейма

Обязательного нет ничего, но фрейм у вас, как правило, кросс-доменный, а часть браузерных фич разрешена по умолчанию только документу верхнего уровня. Рабочее значение:

<iframe src="https://webask.io/ID_ОПРОСА"
        allow="fullscreen; autoplay; encrypted-media; clipboard-write; picture-in-picture; payment"
        style="display:block;width:100%;height:100%;border:0"></iframe>

Отказ всегда молчаливый, и вложенные плееры своими allow-списками его не исправят: больше, чем есть у нашего фрейма, им не выдать.

Токен Что без него не работает
fullscreenкнопка «на весь экран» у видео
autoplayавтовоспроизведение, включая вложенные YouTube / Rutube / VK
encrypted-mediaзащищённые видеопотоки
clipboard-writeкнопка «скопировать код ошибки»
paymentApple Pay / Google Pay в оплате
geolocationкнопка «моё местоположение», если в опросе есть карта. Добавляется отдельно

Если вы ставите sandbox

Ставить его не обязательно: фрейм без sandbox работает полностью. Но каждый недоданный токен ломает что-то молча.

Не дали токен Что происходит
allow-scriptsопрос не загрузится вовсе — это SPA
allow-same-originтеряется Web Storage: прогресс не переживает перезагрузку, а бэкенд видит нового респондента на каждой загрузке. Плюс документ опроса теряет адресуемое имя — все сообщения приходят с event.origin === 'null', и отправлять ему надо иначе, см. врезку ниже
allow-popupsмертвы «Посмотреть ответ», шаринг, ссылки «в новом окне» и попап SSO
allow-popups-to-escape-sandboxSSO не работает даже с выданным allow-popups
allow-formsвозможны отказы в подтверждении банка при оплате

Если вы сами поставили sandbox без allow-same-origin: адресат только '*'

Это единственное исключение из правила «никогда '*'», и обойти его нельзя. 'null' — это имя непрозрачного origin, а не адрес: postMessage(msg, 'null') бросает SyntaxError: Invalid target origin, а отправку на конкретный адрес браузер у такого документа молча отбрасывает. Не предусмотрели — приветствие не доедет никогда, и встраивание останется половинчатым: факты про сам опрос идут, а события про респондента и все команды мертвы, включая host.close, то есть недопечатанный ответ теряется.

Отправлять на '*' здесь безопасно, и это не поблажка: в сторону опроса данных о респонденте не уходит вовсеhost.hello везёт ваше самоописание, у close и reset полей нет вообще, а ping несёт ваш же nonce.

Ещё два следствия, которые легко пропустить. На приёме 'null' не дописывается в список адресов инсталляции, а заменяет его: сверка со списком иначе отбросит все сообщения подряд. И trusted-тир при этом не теряется — он считается от origin вашей страницы, а не документа опроса, поэтому события про респондента идут как обычно.

Чем за это платите, кроме потери хранилища: origin перестаёт различать документы. 'null' придёт и от опроса, и от внешней страницы «Спасибо», и от ссылки автора — то есть приветствие и команды доедут до того документа, который окажется во фрейме в этот момент, включая чужой. Поэтому признак песочницы берите со своего же элемента, а не из пришедшего сообщения — атрибут ставили вы: frame.hasAttribute('sandbox') без allow-same-origin.

w.postMessage(msg, origin === 'null' ? '*' : origin);

Тильда, Wix и другие конструкторы

Конструкторы обычно держат ваш HTML в своём sandbox-фрейме, и тогда к опросу ваш origin приезжает как 'null'. Опрос там показывается и работает, соединение устанавливается, команды исполняются, приходят факты про сам опрос. Но событий про конкретного посетителя не будет никогда — ни начала, ни ответов, ни завершения: адресата зафиксировать физически нечем, а рассылка «кому угодно» сообщала бы любому, что этот человек проходит этот опрос.

Следствие, которое надо учесть в сценарии: терминальный переход опрос выполнит сам, фрейм уедет на «Спасибо». Если сценарий строится на событиях, проверьте это на своей платформе заранее. Как отличить этот случай от «моё приветствие не доехало» — в разделе про отладку, шаг 5.

Минимальный веб-хост целиком

На что смотреть в этом коде:

  • слушатель вешается до вставки фрейма — маячок опрос присылает сам, и пропустить его значит остаться без адреса опроса;
  • адрес опроса не задан заранее: он берётся из event.origin проверенного сообщения, поэтому переезд опроса на другой домен ничего не ломает;
  • приветствие уходит на каждый новый ctx.instanceId — это и есть «на каждую загрузку документа», только без догадок о том, когда она была;
  • проверка входящего: сначала источник, потом разбор, и только потом доверие;
  • закрытие двухшаговое, с предохранителем на случай молчания, и откладывается на время оплаты.
<div id="survey-slot" style="width:100%;height:600px"></div>
<script>
(function () {
  /* Адрес как он опубликован: свой параметр в query станет скрытой переменной автора. */
  var SURVEY_URL = 'https://webask.io/6f2b1c8e-0000-4000-8000-000000000000';

  var slot = document.getElementById('survey-slot');
  var surveyOrigin = null;    /* адрес ТЕКУЩЕГО документа опроса, адресат наших команд */
  var greetedInstance = null; /* ctx.instanceId, с которым мы уже поздоровались        */
  var closeTimer = null;      /* ждём survey.closed на свой host.close                 */
  var payWait = null;         /* отложенное закрытие: ждём терминальный статус оплаты   */
  var paymentOpen = false;    /* между survey.payment 'started' и терминальным статусом */

  var frame = document.createElement('iframe');
  frame.title = 'WebAsk survey';
  frame.style.cssText = 'display:block;width:100%;height:100%;border:0';
  /* Без этих токенов молча отваливаются оплата, копирование кода ошибки, полноэкранный
     режим и автовоспроизведение видео автора. geolocation здесь намеренно НЕТ: он нужен
     ровно одному виджету (карта), а лишнее разрешение кросс-доменному фрейму выдавать
     незачем — добавьте его, только если в опросе есть карта. */
  frame.setAttribute('allow', 'fullscreen; autoplay; encrypted-media; clipboard-write; picture-in-picture; payment');

  /* Слушатель ДО вставки фрейма: опрос присылает маячок сам, как только загрузится,
     и пропустить его значит остаться без адреса опроса. */
  window.addEventListener('message', onMessage);
  frame.src = SURVEY_URL;
  slot.appendChild(frame);

  /* Единственное место, где трогаем окно фрейма: у снятого фрейма contentWindow это
     null, и postMessage на нём бросает TypeError прямо в обработчик клика хоста. */
  function post(msg, origin) {
    var w = frame.contentWindow;
    if (!w) return;
    /* Перевод адресата обязателен: 'null' это ИМЯ непрозрачного origin, а не адрес.
       postMessage(msg, 'null') бросает SyntaxError, а отправку на конкретный адрес
       браузер у такого документа молча отбрасывает. Единственный адресат фрейма в
       песочнице — '*', и это единственное исключение из запрета '*': в сторону опроса
       данных о респонденте не уходит вовсе. */
    w.postMessage(msg, origin === 'null' ? '*' : origin);
  }

  /* Приветствие открывает события про респондента, объявляет права и фиксирует наш
     адрес. Адрес опроса угадывать не нужно: он приезжает с его сообщениями. Это
     важно, потому что опрос вправе уехать сам — на другой домен инсталляции или с
     www на не-www, — и заранее составленный список там молча не сработал бы. */
  function greet(origin, instanceId) {
    surveyOrigin = origin;
    greetedInstance = instanceId;
    post({
      __webask: 1,               /* маркер конверта и версия протокола, обязательно ЧИСЛО */
      type: 'host.hello',
      payload: {
        min: 1,
        max: 1,
        host: { sdk: 'my-site', version: '1.0.0', platform: 'web' },
        /* Только то, что реально обрабатываем: 'navigate' — переход, 'results' — балл.
           Второй hello в пределах документа рендерер игнорирует целиком: расширить
           список потом нельзя. */
        capabilities: ['navigate', 'results']
      }
    }, origin);
  }

  function onMessage(event) {
    /* 1. Источник — единственное, что отличает наш опрос от соседнего фрейма или
          чужого скрипта. Пустой contentWindow отдельной проверкой: null !== null это
          false, и после снятия фрейма условие пропускало бы синтетическое сообщение. */
    if (!frame.contentWindow || event.source !== frame.contentWindow) return;

    /* 2. Разбор: на проводе JSON-СТРОКА, и только в try/catch (на странице чужой трафик). */
    var msg = event.data;
    if (typeof msg === 'string') {
      try { msg = JSON.parse(msg); } catch (e) { return; }
    }
    if (!msg || typeof msg !== 'object') return;
    /* Маркер — фильтр «наш трафик», а не подпись: внутри опроса исполняется JS автора. */
    if (typeof msg.__webask !== 'number' || typeof msg.type !== 'string') return;

    /* 3. Новый документ — новое приветствие. Сверяем instanceId, а не факт хендшейка:
          опрос перезагружает себя сам (принят пароль, вернулись из SSO, сменилась
          версия), и каждая загрузка обнуляет права. origin берём из event.origin —
          в теле сообщения ему верить нельзя. */
    var ctx = msg.ctx || {};
    if (ctx.instanceId && ctx.instanceId !== greetedInstance) {
      greet(event.origin, ctx.instanceId);
    }

    /* 4. Тестовый прогон отсекаем ДО всякой логики: события предпросмотра по форме
          неотличимы от настоящих — они и обязаны быть неотличимы. */
    if (ctx.preview) { console.log('[webask preview]', msg); return; }

    /* replay берём из КОНВЕРТА: рукопожатие приходит дважды всегда, исход загрузки —
       если случился до приветствия. Различить их больше нечем. */
    handle(msg.type, msg.payload || {}, msg.replay === true);
  }

  function send(type, data) {
    if (!surveyOrigin) return; /* адреса ещё нет или фрейм снят — слать некуда */
    post({ __webask: 1, type: type, payload: data || {} }, surveyOrigin);
  }

  function clearTimers() {
    if (closeTimer !== null) { clearTimeout(closeTimer); closeTimer = null; }
    if (payWait !== null) { clearTimeout(payWait); payWait = null; }
  }

  function destroyFrame() {
    clearTimers();
    window.removeEventListener('message', onMessage); /* фрейма нет — источник не сверить */
    surveyOrigin = null;                              /* соединение кончилось             */
    greetedInstance = null;
    if (frame.parentNode) frame.parentNode.removeChild(frame);
  }

  /* Два шага: host.close фиксирует ввод ЛОКАЛЬНО (на бэкенд ничего не уходит) и
     отвечает survey.closed; отказаться от команды рендерер не может. */
  function beginClose() {
    clearTimers();
    send('host.close', {});
    closeTimer = setTimeout(destroyFrame, 500); /* молчание тоже штатно */
  }

  window.closeSurvey = function () {
    if (closeTimer !== null || payWait !== null) return; /* закрытие уже идёт */
    /* Открыта оплата: снятый фрейм оборвал бы 3DS банка. Ждём терминальный статус, но
       со своим сроком — протокол его не гарантирует. */
    if (paymentOpen) { payWait = setTimeout(beginClose, 300000); return; }
    beginClose();
  };

  function handle(type, p, isReplay) {
    switch (type) {
      case 'survey.handshake':
        /* Протокол на месте, и это известно ДО приветствия. Не пришёл вовсе — дело не
           в приветствии: не тот адрес, роут предпросмотра или наша же проверка выше. */
        break;

      case 'survey.ready':
        /* Цвет — КОНТЕЙНЕРУ, не фрейму: документ опроса закрашивает свой прямоугольник
           целиком, а полоса чужого цвета видна на стыке (в модалке — шторка). */
        if (p.backgroundColor) slot.style.backgroundColor = p.backgroundColor;
        /* Повтор безвреден для покраски, но не для неидемпотентного — его по !isReplay. */
        if (!isReplay) trackOpen();
        break;

      case 'survey.completed':
        /* p.reason: 'finish' | 'screenout'; p.result — только при 'results'.
           Шторку здесь НЕ закрываем: терминальный экран автора только что появился. */
        break;

      case 'survey.payment':
        /* 'started' | 'succeeded' | 'canceled' | 'interrupted'; права не требует. */
        paymentOpen = p.status === 'started';
        if (!paymentOpen && payWait !== null) beginClose(); /* отложенное закрытие — можно */
        break;

      case 'survey.navigate-request':
        /* Следствие 'navigate': адреса в событии НЕТ, только p.reason ('thanks' |
           'finish-link'). Уводим сами и за 1200 мс, иначе уведёт рендерер внутри фрейма;
           display:none не годится — фрейм нужно снять или уйти со страницы. */
        destroyFrame();
        showOwnThanks();
        break;

      case 'survey.closed':
        /* p.hadUnsavedProgress === true — ответы на бэкенд НЕ ушли (null = не сообщается). */
        if (p.hadUnsavedProgress) warnUnsaved();
        if (closeTimer !== null) destroyFrame(); /* только подтверждение на наш host.close */
        break;

      case 'survey.error':
        /* p.code, p.status, p.exception, p.fatal; текста ошибки в протоколе нет.
           Приходит и до приветствия: пустая дырка вместо фрейма хуже экрана ошибки
           самого опроса, поэтому сначала свой экран с повтором. */
        if (p.fatal) { destroyFrame(); showOwnError(); }
        break;

      /* Незнакомый type игнорируем молча: протокол растёт аддитивно. */
    }
  }

  function showOwnThanks() { /* ваш экран «спасибо» */ }
  function showOwnError()  { /* ваш экран ошибки с кнопкой «Повторить» */ }
  function warnUnsaved()   { /* «прохождение не отправлено» — предложите продолжить */ }
  function trackOpen()     { /* ваш пиксель: строго один раз на загрузку */ }
})();
</script>

Чего в этом коде намеренно нет

Списка адресов, где опрос может оказаться, и обработчика load. Ни то, ни другое больше не нужно: адрес приезжает с маячком, а признаком новой загрузки служит ctx.instanceId. Двум веб-хостам список всё же обязателен — см. проверки на стороне хоста.

Обратите внимание на асимметрию: приветствие отправляется объектом, а события приходят строкой JSON — её нужно разобрать. Подробнее в разделе про конверт.

5. Приложение: iOS, Android, React Native

Готовой библиотеки для установки нет — ни под iOS, ни под Android, ни под React Native

Хост пишется по этому контракту, и это порядка 200–400 строк на площадку: транспорт, приветствие на каждую загрузку документа, разбор конверта, ответ на команды, двухшаговое закрытие. Ниже — минимальные хосты целиком: их можно скопировать в проект и править как свой код.

Отдельно обратите внимание на то, что ломается молча — без ошибки ни у вас, ни у нас: мост зарегистрирован после загрузки адреса, приветствие отправлено один раз вместо каждой загрузки, ответ команде ушёл с потока JavaBridge, WebView снесён сразу после host.close, шторка «спрятана» вместо ухода на терминальном переходе, в адресе кэш-бастер, список origin неполный, ctx.preview не отсечён. Все они с симптомами и лечением собраны в таблице «Что ломается молча» — прочитайте её до того, как начнёте, а не после.

Отвечаем на частый вопрос сразу: адрес опроса в приложении обычный, тот же, что вы дали бы человеку в браузере. Дописывать к нему ничего не надо. Но если просто открыть эту ссылку в WebView, вы получите работающий опрос и ноль сообщений: приложение не узнает ни что человек начал, ни что он завершил, а после отправки WebView уедет с опроса на страницу благодарности — внутри вашего интерфейса.

Чтобы разговор состоялся, приложение регистрирует мост — канал, по которому опрос сможет отвечать. Как только мост на месте, опрос начинает присылать сообщения сам: здесь регистрация моста и есть разрешение говорить, потому что позвать его может только код вашего приложения. Приветствие в приложении нужно не для того, чтобы сообщения пошли, а чтобы объявить права.

Платформа Имя канала Чем регистрируется
iOS, WKWebView webask обработчик сообщений в конфигурации WebView
Android, WebView WebAskAndroid объект-интерфейс с методом receive
React Native WebAskAndroid переходник, положенный до исполнения кода страницы

Мост регистрируется ДО загрузки адреса

Опрос определяет обстановку один раз при старте. Если добавить обработчик после того, как адрес уже поехал, протокол останется выключенным навсегда — и никаких сообщений не будет. Причём молча.

Обратный канал: как звать команды из нативного кода

Из приложения в опрос команды уходят вызовом на странице опроса:

window.__webask.receive('<JSON-строка конверта>')
  • Проверка на существование глобала обязательна в каждом вызове: на роуте без протокола его нет, и голое обращение даёт TypeError, который уходит в обработчик завершения и теряет команду молча.
  • Литерал строит JSON-сериализатор, а не конкатенация с кавычками. Ручное «заменим слэш и апостроф» не ловит реальный случай по построению: JSON.stringify оставляет U+2028 и U+2029 сырыми, а в JS-литерале старого движка это разрыв строки, то есть SyntaxError вместо доставки команды. На Android — JSONObject.quote плюс отдельная замена этих двух символов на \u2028 и \u2029. На iOS 14+ аргумент передаётся значением — экранирования нет вовсе; на iOS 12–13 литерал строит тот же JSONSerialization, но два разделителя строк заменять всё равно нужно: сериализатор оставляет их сырыми на любой платформе.
  • На Android метод моста исполняется не на главном потоке, а обратный вызов в WebView требует главного. Ответ нужно переложить на UI-поток, иначе приложение упадёт у себя, а опрос об этом не узнает.

Что ещё лежит в этом глобале — пригодится, если вы здороваетесь раньше, чем опрос успел подняться:

Поле Что это
protocolверсия протокола этого билда
receiveприём команд, единственная точка входа для нативного кода
_qочередь нативных команд, пришедших раньше протокола, максимум 32
_mqочередь ранних postMessage, максимум 8
readyна него нельзя опираться: в iframe он по построению остаётся false, и решения на нём строить нечего

Практический вывод: ранний host.hello допустим и даже надёжнее — он попадает в очередь и исполнится, когда протокол поднимется. Опоздать хуже, чем поспешить: в приложении окно придерживания событий под права — 5 секунд от инициализации протокола.

iOS: WKWebView

Три настройки WebView, которые стоит сделать, и одна, которую нельзя ломать:

Настройка Что без неё
allowsInlineMediaPlayback = true видео в вариантах ответа уезжает в нативный полноэкранный плеер поверх опроса
mediaTypesRequiringUserActionForPlayback = [] не работает автовоспроизведение видео автора
websiteDataStore — персистентный это значение по умолчанию, но .nonPersistent() отнимает хранилище, а с ним прогресс и идентификацию сессии
WKUIDelegate — обязателен window.open в WKWebView без него не делает ничего, а через него идут все переходы, которые протокол не делегирует: шаринг, «Посмотреть ответ», авторские ссылки «в новом окне». Молча мёртвые кнопки

JavaScript и выбор файла на iOS работают без настройки.

На что смотреть в коде ниже:

  • обработчик регистрируется в конфигурации до создания WebView и до загрузки адреса;
  • приветствие отправляется в ответ на survey.handshake, а не по завершению загрузки — так оно попадает в пятисекундное окно и покрывает все перезагрузки, которые опрос делает сам; и только на первый хендшейк, потому что второй — это уже подтверждение вашего приветствия;
  • тело сообщения приводится к строке: словарь здесь не приходит никогда;
  • принимается только главный фрейм и только с разрешённого адреса — мост виден всем фреймам внутри WebView, включая виджет оплаты и видео;
  • обработчик держится слабой ссылкой, иначе получается цикл удержания.
import UIKit
import WebKit

final class SurveyHostViewController: UIViewController, WKScriptMessageHandler, WKUIDelegate {

    /// Обычный публичный адрес опроса: дописывать ничего не нужно и не надо — служебный
    /// кэш-бастер стирал бы респонденту прогресс на каждой загрузке.
    private let surveyURL = URL(string: "https://webask.io/ID_ОПРОСА")!

    /// Origin'ы ЦЕЛИКОМ, а не хосты: опрос вправе переехать (бэкенд отдаёт другой адрес
    /// того же опроса, в том числе на другом домене). Схема и порт входят в сравнение.
    private let allowedOrigins: Set<String> = ["https://webask.io"]

    private var webView: WKWebView!
    /// Взведён — закрытие запросили МЫ: только тогда `survey.closed` адресован нам.
    private var closeTimer: Timer?

    override func viewDidLoad() {
        super.viewDidLoad()

        let config = WKWebViewConfiguration()
        // Имя обработчика задано протоколом. Регистрируем ДО load(): транспорт рендерер
        // определяет один раз, при старте, — мост, добавленный позже, не заметят.
        config.userContentController.add(WeakScriptHandler(self), name: "webask")
        config.allowsInlineMediaPlayback = true
        config.mediaTypesRequiringUserActionForPlayback = []

        webView = WKWebView(frame: view.bounds, configuration: config)
        // Обязателен: без WKUIDelegate `window.open` не делает НИЧЕГО.
        webView.uiDelegate = self
        webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        view.addSubview(webView)

        // Свайп-закрытие шторки выключаем намеренно: после него досохранять уже нечего —
        // WebView разрушен вместе с недопечатанным вводом.
        isModalInPresentation = true
        webView.load(URLRequest(url: surveyURL))
    }

    // MARK: - Приём событий (главный поток — в отличие от Android)

    func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
        // Мост доступен ЛЮБОМУ фрейму WKWebView — внутри опроса живут чужие (оплата, видео).
        guard message.name == "webask", message.frameInfo.isMainFrame else { return }
        let origin = originOf(message.frameInfo.securityOrigin)
        guard allowedOrigins.contains(origin) else {
            // Не молча: забытый домен = хост, слепой навсегда, а рендерер об этом
            // не узнает — отправка в мост у него успешна.
            print("[webask] отброшен главный фрейм с чужого origin: \(origin)")
            return
        }

        // На проводе ВСЕГДА JSON-строка, а не словарь.
        guard let body = message.body as? String,
              let data = body.data(using: .utf8),
              let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              envelope["__webask"] is NSNumber,
              let type = envelope["type"] as? String else { return }

        let payload = envelope["payload"] as? [String: Any] ?? [:]
        let ctx = envelope["ctx"] as? [String: Any] ?? [:]

        // Тестовый прогон отсекаем до всякой логики.
        if (ctx["preview"] as? Bool) == true { print("[webask preview] \(type)"); return }

        switch type {
        case "survey.handshake":
            // Здороваемся ровно здесь: окно придерживания событий под capability (5 с,
            // отсчёт от инициализации протокола, а НЕ от didFinish) открыто именно сейчас.
            // Хендшейк приходит на КАЖДЫЙ документ, поэтому все перезагрузки, которые
            // рендерер делает сам, покрыты этой веткой. Но за документ он приходит ДВАЖДЫ:
            // маячком и подтверждением. Здороваемся только на первый.
            if (envelope["replay"] as? Bool) != true { sendHostHello() }

        case "survey.ready":
            // Разбор приведением типа, а не через `??`: объявленные поля приезжают явным
            // `null`, то есть NSNull, и `??` на нём не срабатывает.
            let title = payload["title"] as? String ?? ""
            let background = payload["backgroundColor"] as? String
            print("готов: \(title), фон: \(background ?? "тема без цвета")")

        case "survey.completed":
            // Шторку здесь НЕ закрываем: терминальный экран автора только что появился.
            // Ключа `result` НЕТ ВОВСЕ без capability `results`; `score: null` внутри него
            // означает «скоринг у опроса выключен». Путать эти два случая нельзя.
            let reason = payload["reason"] as? String ?? "?"
            var score = "не считался"
            if let result = payload["result"] as? [String: Any],
               let value = result["score"] as? Double { score = "\(value)" }
            print("завершено: \(reason), балл: \(score)")

        case "survey.navigate-request":
            // Объявили `navigate` — обязаны увести респондента сами и уложиться в 1200 мс.
            // СПРЯТАТЬ НЕДОСТАТОЧНО: скрытый WebView откат только откладывает.
            teardown()
            showOwnThanks()

        case "survey.closed":
            // Подтверждение на наш `host.close`. Только если закрытие запрашивали мы.
            if closeTimer != nil { teardown() }

        default:
            break // протокол растёт аддитивно: незнакомый type игнорируем молча
        }
    }

    /// Origin в том же виде, в каком он лежит в списке: без порта по умолчанию.
    private func originOf(_ o: WKSecurityOrigin) -> String {
        let isDefaultPort = o.port == 0
            || (o.`protocol` == "https" && o.port == 443)
            || (o.`protocol` == "http" && o.port == 80)
        return isDefaultPort ? "\(o.`protocol`)://\(o.host)" : "\(o.`protocol`)://\(o.host):\(o.port)"
    }

    // MARK: - Команды хосту → рендереру

    private func send(_ command: [String: Any]) {
        guard let webView = webView,
              let data = try? JSONSerialization.data(withJSONObject: command),
              let json = String(data: data, encoding: .utf8) else { return }
        // На iOS 14+ аргумент передаётся ЗНАЧЕНИЕМ, ручного экранирования нет вовсе.
        // Проверка на глобал обязательна в КАЖДОМ вызове, и мир .page — тоже.
        if #available(iOS 14.0, *) {
            webView.callAsyncJavaScript("if (window.__webask) window.__webask.receive(msg)",
                                        arguments: ["msg": json], in: nil, in: .page)
            return
        }
        // iOS 12–13 (нижняя граница рендерера — 12.2): callAsyncJavaScript не существует,
        // и без этой ветки ни host.hello, ни host.close не уходят вовсе — молча.
        // Литерал строит ТОТ ЖЕ сериализатор, а не конкатенация: JSON оставляет U+2028 и
        // U+2029 сырыми, а в старом движке это разрыв строки, то есть SyntaxError.
        // Обёртка массивом, а не .fragmentsAllowed: сама опция появилась только в iOS 13.
        guard let wrapped = try? JSONSerialization.data(withJSONObject: [json]),
              let text = String(data: wrapped, encoding: .utf8) else { return }
        let literal = String(text.dropFirst().dropLast())
            .replacingOccurrences(of: "\u{2028}", with: "\\u2028")
            .replacingOccurrences(of: "\u{2029}", with: "\\u2029")
        webView.evaluateJavaScript("if (window.__webask) window.__webask.receive(\(literal))")
    }

    private func sendHostHello() {
        send([
            "__webask": 1,          // ЧИСЛО: строку "1" рендерер молча отбросит
            "type": "host.hello",
            "payload": [
                "min": 1, "max": 1,
                "host": ["sdk": "my-app-ios", "version": "1.0.0", "platform": "ios"],
                // Объявляем только то, что обрабатываем: `navigate` обязывает увести
                // респондента самим.
                "capabilities": ["navigate", "results"],
            ],
        ])
    }

    // MARK: - Закрытие: WebView разрушаем только после подтверждения

    /// Зовёт кнопка закрытия шторки. `host.close` досохраняет недопечатанный ввод и
    /// отложенную запись прогресса. Предохранитель 500 мс — на случай молчания.
    func closeSurvey() {
        guard closeTimer == nil else { return }
        send(["__webask": 1, "type": "host.close", "payload": [:]])
        closeTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in
            self?.teardown()
        }
    }

    private func teardown() {
        closeTimer?.invalidate(); closeTimer = nil
        webView?.removeFromSuperview(); webView = nil
        dismiss(animated: true)
    }

    private func showOwnThanks() { /* ваш экран «спасибо» */ }

    /// `window.open` в WKWebView без этого метода возвращает null и не делает ничего.
    func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
                 for navigationAction: WKNavigationAction,
                 windowFeatures: WKWindowFeatures) -> WKWebView? {
        if let url = navigationAction.request.url { UIApplication.shared.open(url) }
        return nil
    }
}

/// `WKUserContentController` держит обработчик СИЛЬНО, а сам живёт в конфигурации
/// WebView: регистрация `self` замкнула бы цикл «VC → WebView → config → controller → VC».
private final class WeakScriptHandler: NSObject, WKScriptMessageHandler {
    private weak var target: WKScriptMessageHandler?
    init(_ target: WKScriptMessageHandler) { self.target = target }
    func userContentController(_ c: WKUserContentController, didReceive m: WKScriptMessage) {
        target?.userContentController(c, didReceive: m)
    }
}

Здороваться в didFinish нельзя

didFinish ждёт все ресурсы и опаздывает к окну capability, а срабатывает он на любой документ — включая чужие страницы, куда рендерер уводит этот же WebView сам. Годится он только для диагностики.

Android: WebView

Мост регистрируется как addJavascriptInterface(bridge, "WebAskAndroid"), метод помечен @JavascriptInterface и принимает String — единственный тип, который безопасно проходит мост.

Настройка Что без неё
javaScriptEnabled = true ничего: опрос — SPA, без скриптов не загрузится вовсе. По умолчанию выключено
domStorageEnabled = true прогресс не переживает перезагрузку, а бэкенд видит нового респондента на каждой загрузке. По умолчанию выключено
WebChromeClient.onShowFileChooser на вопрос с загрузкой файла ответить нельзя: тап по полю не открывает ничего, и респондент не получает никакого сообщения
mediaPlaybackRequiresUserGesture = false не работает автовоспроизведение видео автора

Геолокация в виджете карты требует разрешения самого приложения и обработки запроса из WebView. Ссылки «в новом окне» протокол не делегирует — их хост перехватывает сам, на уровне WebView.

Метод моста исполняется не на UI-потоке — и это роняет хост, написанный в лоб

WebView зовёт @JavascriptInterface на своём служебном потоке JavaBridge, а любой вызов самого WebView и любое касание вьюх разрешены только с главного. Поэтому «принял событие — тут же ответил командой» не работает: evaluateJavascript внутри receive даёт RuntimeException: A WebView method was called on thread 'JavaBridge', а обновление интерфейса — CalledFromWrongThreadException. Падение происходит у вас, рендерер о нём не узнает и в survey.error оно не попадёт: со стороны опроса всё в порядке. Правило: тело метода моста только разбирает строку, всё остальное уходит на главный поток.

class WebAskBridge {
    private final Handler ui = new Handler(Looper.getMainLooper());
    private final WebView webView;

    WebAskBridge(WebView webView) { this.webView = webView; }

    @JavascriptInterface
    public void receive(String json) {
        // Поток JavaBridge: ни webView, ни вьюхи здесь трогать нельзя.
        final JSONObject message;
        try {
            message = new JSONObject(json);
        } catch (JSONException e) {
            return; // чужая строка не должна ронять мост
        }
        ui.post(() -> onMessage(message)); // всё дальнейшее — на главном потоке
    }

    private void onMessage(JSONObject message) {
        // Главный поток: здесь можно и evaluateJavascript, и UI.
    }
}

Мост на Android закрывается только снаружи

Внутри @JavascriptInterface отправителя проверить нечем: сведений о фрейме у метода нет вовсе — ни origin, ни признака главного фрейма, в отличие от frameInfo на iOS. А позвать мост вправе любой фрейм внутри документа: виджет оплаты, встроенное видео, HTML-виджет автора. Значит проверка переносится на уровень навигации WebView:

  • в WebViewClient.shouldOverrideUrlLoading пускать только адреса из своего списка origin, остальное уводить во внешний браузер;
  • removeJavascriptInterface("WebAskAndroid") перед загрузкой чужого адреса — иначе страница, на которую опрос уехал в конце (авторская ссылка, «Спасибо»), получает прямой канал к вашему приложению;
  • при разрушении экрана мост снимать первым, до destroy().

React Native: протокол сам не включится

Отдельного транспорта под React Native в версии 1 нет, и это не деталь реализации, а следствие правил активации: рендерер ищет фрейм либо мост с известным именем. WebView в RN не даёт ни одного из них ни на iOS, ни на Android — у него собственный канал, а документ в нём верхнего уровня, не фрейм. Поэтому «из коробки» протокол выключен: маячка нет, событий нет, команды игнорируются. Включить можно двумя способами.

Способ 1 — переходник над штатным каналом RN, без нативного кода.

/* injectedJavaScriptBeforeContentLoaded — именно он, см. условия ниже. */
window.WebAskAndroid = {
  receive: function (json) { window.ReactNativeWebView.postMessage(json); }
};
true;

События после этого приходят в onMessage как event.nativeEvent.data — та же JSON-строка конверта, что и на любом другом транспорте. Обратное направление — injectJavaScript с защищённым вызовом и экранированием. Условий два, и оба жёсткие:

  • переходник должен существовать раньше, чем стартует код документа. Обычный injectedJavaScript исполняется после загрузки страницы — для этого поздно;
  • ctx.embed приедет 'android' на обеих платформах. Поле называет мост, а не ОС: имя моста выбрали вы. Ветвиться по нему на iOS нельзя — платформу хост знает сам.

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

Способ 2 — настоящий нативный мост: зарегистрировать его на платформенном WebView так, как описано выше, то есть написать нативный модуль. Дальше рецепты iOS и Android применяются как есть, включая правило про поток JavaBridge, а ctx.embed называет реальную платформу.

6. Рукопожатие и жизненный цикл

Опрос заговаривает первым. Как только он загрузился, наружу уходит survey.handshake — сам, ничего не спрашивая. Значит слушатель сообщений всегда что-то получает: вы сразу знаете, что протокол здесь есть и какой он версии. Молчание в ответ на плоский фрейм — не ваш случай.

Но приходит не всё. Граница проходит по одному вопросу: зависит ли факт от того, кто именно открыл опрос.

Что за факт Примеры Когда приходит
Про сам опрос и про загрузку рукопожатие, опрос готов, ошибка загрузки, опрос закрыт автором Сразу, без приветствия
Про этого человека начал проходить, перешёл к экрану, ответил, отправил, завершился, нужна авторизация, оплата После вашего host.hello

Причина не в вежливости. Пока вы не поздоровались, опрос не знает, кому отправляет. Факты про сам опрос от этого не страдают: их узнаёт кто угодно, просто открыв публичную ссылку. А факты про конкретного человека адресату без имени отдавать нельзя. Это не рассылка: получателем может быть только фрейм-родитель — тот, кто опрос и встроил. Страница, обернувшая вашу страницу, не получит ничего.

Порядок

Кто Что
Опрос survey.handshake — сам, как только загрузился: версии, которые понимает, и номер сборки
Опрос факты про опрос — готов, ошибка, закрыт: по мере того как случаются
Хост host.hello — представляется и перечисляет права, которые ему нужны
Опрос survey.handshake ещё раз, с replay: true — приветствие принято, права зафиксированы
Опрос накопленное — события про человека, случившиеся до приветствия, приходят пачкой
Опрос дальше по ходу — всё остальное по мере прохождения

Накопленное — важная часть. Человек успевает начать проходить до того, как вы поздоровались, и эти события не теряются: они лежат в буфере (до 32, старые вытесняются) и уходят сразу после приветствия. Исход загрузки при этом переигрывается с пометкой replay — даже если он уже приходил вам раньше.

Одно и то же событие приходит дважды — штатно

Рукопожатие — всегда: маячком и подтверждением. Исход загрузки — если он случился до вашего приветствия. Всё неидемпотентное — счётчики, начисления — вешайте только на события без replay.

Но повтор нельзя просто отбрасывать: он несёт больше, чем первый экземпляр. До приветствия адресат не подтверждён, поэтому поля про конкретного посетителя приезжают обнулёнными — ready.resumed в первом survey.ready в iframe всегда null, а настоящее значение приходит только с повтором. На нативном мосту этого не бывает: соединение доверено с первой миллисекунды, и первый же экземпляр полный — ждать там повтора за настоящим resumed нельзя, его может не быть вовсе. Хост, который оставил первый экземпляр и выбросил повтор, навсегда потерял факт «человек вернулся к незавершённому прохождению».

Отсюда правило: дедуплицируйте исход загрузки по паре «ctx.instanceId плюс тип события», а не по seq — у повтора он свой, новый. И не заменяйте, а сливайте: поля про посетителя берите из того экземпляра, где они не null. seq остаётся тем, чем и был — детектором потерь (дырка в нумерации) в пределах одного документа.

Здороваться нужно на каждую загрузку документа

Хост, поздоровавшийся один раз, после первой же перезагрузки навсегда перестаёт получать события. Перезагрузился документ — исчезли и экземпляр (ctx.instanceId), и счётчик (seq снова с 1), и зафиксированный origin, и объявленные права, и буфер. Второе приветствие в пределах одного документа опрос игнорирует целиком.

А перезагружает себя опрос сам, не спрашивая хоста, минимум в восьми продуктовых сценариях:

  • принят верный пароль у опроса под паролем;
  • подтверждён email (гейт верификации);
  • вернулись из попапа SSO;
  • хост прислал host.reset;
  • респондент нажал «пройти ещё раз» на финальном экране;
  • автор опубликовал новую версию, респондент принял её из снекбара;
  • выкачен новый билд рендерера — применяется молча, когда документ скрыт;
  • плюс переезд опроса на другой адрес, «Обновить» на экране ошибки и уход на правку ответа.

Триггер один и он общий для всех трёх транспортов: первый survey.handshake этого документа — тот, у которого нет replay. Вместе с ним приезжает всё, чего для приветствия не хватало: origin документа (в вебе) и ctx.instanceId, по которому видно, что документ новый.

Что при этом хост обязан сделать у себя:

  • сбросить состояние экземпляра: последний seq, признак «приветствие отправлено», зафиксированный origin, накопленный прогресс, дедупликацию исходов;
  • веб-хосту — заново определить origin документа из заголовка пришедшего маячка (event.origin), никогда из тела сообщения: опрос мог переехать, и старый origin отбросит доставку команд;
  • не считать молчание после приветствия ошибкой: документ мог уехать на роут без протокола;
  • не разрушать фрейм или WebView в ожидании ответа — ответа может не быть вовсе.

В приложении на приветствие есть около 5 секунд

На нативном мосту соединение доверено по построению, поэтому события про человека идут и без приветствия — кроме тех, что зависят от прав. Эти опрос придерживает в ожидании и по истечении срока выбрасывает. Срок — 5 секунд, и отсчёт идёт от инициализации протокола, а не от готовности страницы. Поэтому здороваться надо в ответ на маячок, а не в didFinish / onPageFinished: на медленной сети между этими моментами укладываются все пять секунд. Первой пропадает цель start — она срабатывает при загрузке опроса.

Одно исключение из придерживания, и оно дорогое: право results в очередь не попадает — оно подавляет не событие, а поле внутри него. Значит survey.completed, отправленный до принятого приветствия, приезжает без ключа result — и второй раз не приедет: переигрывается только исход загрузки, а завершение исходом не является. Опоздали с приветствием — балла не будет никогда. Ещё одна причина здороваться на первый хендшейк, а не на готовность страницы.

Чем полезен маячок

Он решает две задачи, которые раньше решались вручную и плохо.

  • Проверка живости. Пришёл маячок — протокол на месте. Не пришёл — дело не в приветствии: либо адрес не тот, либо это роут без протокола, либо мост в приложении не зарегистрирован.
  • Адрес опроса, который вы не угадывали. Опрос вправе уехать сам — на другой домен инсталляции или с www на не-www. Раньше это молча убивало протокол: приветствие уходило на прежний адрес. Теперь новый документ присылает маячок со своего адреса, и вы здороваетесь с тем, который увидели.

Опоздали со слушателем? Это штатный случай — скрипт из менеджера тегов, инициализация после cookie-баннера, фрейм, уже отрендеренный в HTML. Маячка вы не увидите, и второго не будет: второй хендшейк уходит только на принятое приветствие. Тупика нет — host.hello принимается в любой момент, а адресатом берите event.origin первого дошедшего сообщения любого типа, прошедшего ваши проверки. Цену платит один пункт: без маячка «сообщение пришло от документа опроса» подтверждать нечем, кроме списка адресов инсталляции — такому хосту он обязателен.

Дешёвый признак того, что вы опоздали: первое увиденное сообщение пришло с seq > 1 при неизменном ctx.instanceId.

7. Права (capabilities)

Права перечисляются в приветствии, и их четыре. Запрашивайте только то, что обрабатываете: незаявленное просто не придёт. Оговорка важная: «ничего не просил» означает «не получит фактов про посетителя» — маячок и факты про сам опрос уходят прямому родителю и без приветствия (см. выше про совместимость).

Право Что открывает Когда нужно
navigate Событие о терминальном переходе, и опрос перестаёт уходить сам Вы показываете свой экран концовки
results Балл и категорию в событии о завершении Реакция на итог: извинение недовольному, предложение довольному
answers Событие о том, что вопрос получил ответ. Значения ответа в нём нет Свой индикатор заполнения
analytics Цели аналитики, настроенные автором опроса Переслать цели в свою систему аналитики

Право navigate меняет поведение опроса для человека

Заявив его, вы берёте концовку на себя: опрос ждёт вас 1200 мс и только потом уходит сам. Если вы ничего не сделаете, человек эту паузу увидит. Либо обрабатывайте survey.navigate-request, либо не запрашивайте это право.

Зачем права, если хост объявляет их себе сам?

Это не граница безопасности и не защита от враждебного хоста: авторизации хоста в системе не существует, любая страница может встроить опрос и попросить что угодно. Права решают две другие задачи — совместимость (существующее встраивание не начинает получать факты про посетителя, хотя маячок и факты про сам опрос теперь видит) и сокращение поверхности по умолчанию (данные не уходят туда, где их некому обработать). Расширить список потом нельзя: второе приветствие в пределах документа игнорируется целиком.

8. Конверт сообщения

Одна форма для всех событий, на всех трёх транспортах:

{
  "__webask": 1,                 // маркер и версия протокола
  "type": "survey.completed",
  "seq": 7,                      // счётчик с 1, дырка = потерянное сообщение
  "ts": 1786824064103,
  "replay": true,                // только у переигранных, иначе поля нет
  "ctx": {
    "instanceId": "m3f8x-a91",   // уникален на загрузку документа
    "virtualId": "ID_ОПРОСА",
    "versionId": "12",
    "embed": "iframe",           // iframe | ios | android
    "preview": false             // true = тестовый прогон, за событиями нет записи
  },
  "payload": { "reason": "finish", "widgetId": "w_18" }
}

На проводе это строка JSON, а не объект

И в postMessage на сайте, и в мосту приложения. Разбирайте её первым делом и в try/catch — на странице бывают сообщения от других библиотек, и они не должны ронять ваш обработчик. Обратное направление асимметрично: команды хоста в вебе отправляются объектом.

  • __webask обязан быть числом. Строку "1" рендерер молча отбросит — это касается и ваших команд.
  • Смена ctx.instanceId означает новую загрузку документа: счётчик начинается заново, и состояние на вашей стороне надо сбросить.
  • seq нужен только для детекта потерь в пределах одного instanceId. Порядок событий по нему строить нельзя — см. врезку ниже.
  • Незнакомые type и незнакомые поля хост обязан игнорировать молча: протокол растёт аддитивно, и новые события совместимость не ломают.
  • Два опроса на одной странице — штатный случай, но раскладывать события по ctx нельзя: его проставляет сам опрос, и virtualId у двух врезок одного и того же опроса совпадёт. Атрибуция — строго по окну-отправителю (event.source), то есть держите состояние рядом со своим объектом фрейма. На Android то же самое острее: мост там один на все фреймы WebView.

Как согласуется версия — и что будет, если не сойдётся

В приветствии вы объявляете диапазон minmax, опрос в маячке тоже. Рабочей становится меньшая из двух верхних границ. Поэтому хост, умеющий только версию 1, обязан объявлять min: 1, max: 1 — это не формальность.

Если диапазоны не пересеклись вовсе, приветствие канал не открывает, а сужает: наружу уходит только survey.error с code: 'protocol-unsupported', второго хендшейка не будет вовсе — подтверждать нечего, — а survey.closed и survey.pong перестают приходить, хотя сами команды исполняются. Хост, который ждёт подтверждения приветствия, здесь зависнет.

Порядок событий гарантирован только один

survey.handshake — первое. Остальное хост обязан обрабатывать независимо от порядка. В частности, survey.started может прийти раньше survey.ready: при возврате к незавершённому прохождению сессия уже существует, а ready ждёт, пока данные опроса не разобраны.

9. События опроса — все 15

Колонка «до приветствия» отвечает сразу на два вопроса, потому что граница у них одна и та же. Первый — что придёт, пока вы не поздоровались. Второй — что доступно во встраивании, где браузер не даёт опросу опознать вашу страницу (sandbox-фрейм конструктора сайтов): там события про человека не приходят вообще никогда, даже после приветствия.

Событие Когда приходит Поля Право До приветствия
survey.handshake Маячок при загрузке и второй раз — ответом на ваше приветствие min, max, buildId, appVersion да
survey.ready Опрос отрисуется и готов к прохождению title, locale, screenCount, hasFinishScreen, backgroundColor, resumed да
survey.started Человек начал проходить startedAt нет
survey.page-changed Показан другой экран index, total, progress, direction, widgetId, widgetType нет
survey.question-answered Вопрос впервые получил ответ. Значения ответа нет answeredCount, widgetId, widgetType answers нет
survey.submitted Ответы ушли на сервер и приняты isScreenout нет
survey.completed Показывается терминальный экран. Не значит, что человек его досмотрел reason, widgetId, result?: { score, category } results для result нет
survey.navigate-request Опрос просит увести человека со страницы reason: thanks | finish-link navigate нет
survey.blocked Опрос недоступен этому человеку или закрыт вообще reason, retryable только про опрос
survey.auth-required Нужна авторизация до показа вопросов method: password | sso | email нет
survey.payment Изменилось состояние оплаты внутри опроса status: started | succeeded | canceled | interrupted нет
survey.analytics Сработала цель, настроенная автором опроса goal, type, elementId analytics нет
survey.closed Ответ на команду закрытия: ввод сохранён hadUnsavedProgress да
survey.error Опрос не смог загрузиться или отправить ответ code, status, exception, fatal да
survey.pong Ответ на проверку связи nonce да

Три ошибки, которые делают почти все

1. Не закрывайте опрос по survey.completed

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

2. Терминальную ветку различают по паре полей

Уходит со страницы только reason: 'finish' с пустым widgetId — своего финального экрана у опроса нет, и об этом приходит отдельный survey.navigate-request. Во всех остальных случаях закрытие — действие человека, а не ваше. Отлаживать эту ветку нужно на опросе без своего финального экрана: у опроса с финальным экраном её нет вовсе.

Но обработчик перехода вешайте безусловно, а не внутри разбора completed. Обратное не работает: survey.navigate-request из полей survey.completed не выводится и приходит там, где completed не отправляется вовсе — например респонденту, который этот опрос уже проходил (blocked: already-answered) и которого опрос уводит со страницы.

3. result отсутствует и result.score === null — это разные вещи

Ключа result нет вовсе — значит хост не просил право results. result.score равен null — значит скоринг у опроса выключен, считать нечего. Первое — факт про вашу же интеграцию, второе — факт про опрос. Отлаживать надо на опросе, где скоринг включён, иначе одно неотличимо от другого.

Что важно знать про отдельные поля

Поле Чего по нему нельзя предполагать
ready.screenCount
page-changed.total
Это снимок, а не итог. Опрос дописывает в порядок обхода виртуальные экраны, которых в структуре нет (экран отсева по умолчанию, финальный экран, собранный из настроек кнопки отправки), поэтому total может расти по ходу. Кэшировать screenCount как знаменатель нельзя — полосу прогресса пересчитывайте по total каждого события.
page-changed.direction Три значения: 'forward' и 'back' — одиночный шаг, 'jump' — прыжок условной логики. То есть jump прямо сообщает, что у опроса сработало ветвление.
analytics.goal Готовый идентификатор цели, тот же, что уходит в веб-пиксели автора: пересылайте как есть, урезать нельзя — получится цель, которой в его аналитике не существует. typestart, submit, btn_finish и подобные; идентификаторы счётчиков автора не передаются.
page-changed.progress Ветвление делает путь короче объявленного: пропущенные логикой экраны из порядка обхода не исчезают, поэтому прогресс вправе прыгать и не дойти до 1 линейно.
ready.hasFinishScreen Описывает структуру на момент готовности и терминальную ветку не предсказывает: экран может быть собран позже из настроек кнопки отправки, а отсев приводит к своему экрану независимо от этого поля. Планировать концовку по нему нельзя — только по survey.completed.
ready.resumed
closed.hadUnsavedProgress
null означает «не сообщается», а не «нет». Это единственные поля про конкретного посетителя внутри событий, которые уходят и без приветствия, поэтому неподтверждённому адресату они не отдаются. В обычном веб-порядке первый ready всегда несёт здесь null, а настоящее значение приезжает с повтором (replay: true).
ready.backgroundColor Красить надо контейнер, а не фрейм: документ опроса закрашивает свой прямоугольник целиком, и полоса чужого цвета видна на стыке — в модалке это шторка, в приложении статус-бар. null — тема без цвета.
ready.locale Хост его читает, а не задаёт: языком встроенного опроса он не управляет — ни командой, ни параметром адреса.
completed.widgetId Может оказаться идентификатором виртуального экрана, которого в структуре опроса нет. Сопоставлять его с публичным списком не нужно — значимо только «какой экран показан».
completed.result.category Идентификатор из настроек автора, а не название. null — категорийный режим выключен либо ни одна категория не набрала баллов.
error.code Пять значений: network (сеть или не догрузившийся код опроса), server, render (краш рендерера), protocol-unsupported (версии не сошлись), unknown. status — HTTP-статус ответа, null у ошибок не от HTTP.
error.exception Только имя класса (TypeError, ChunkLoadError), но не его текст. Свободного текста протокол не носит вовсе — ни ответа бэкенда, ни текста исключения.
error.fatal Не отвечает на вопрос «опрос ещё отвечает на команды», и неверно в обе стороны. Признак мёртвого опроса один и он наблюдаемый: host.close ушёл, а survey.closed не пришло за 500 мс. При этом host.ping продолжает отвечать: протокол живёт отдельно от экрана прохождения.

Причины блокировки

Делятся на две группы, и это важно: одни говорят о самом опросе, другие о конкретном посетителе. Вторые не приходят в sandbox-фрейме конструктора и не приходят до приветствия.

Группа Значения
Про опрос — одинаково для любого, кто откроет ссылку quiz-closed, schedule-closed, archived, invalid-uuid, answer-limit, time-limit
Про посетителя — другому откроется already-answered, one-ip, extra-params, device-blocked, ip-denied, password-attempts

retryable отвечает на вопрос «есть ли смысл повторить позже»: у закрытого по расписанию — да, у неверного адреса — нет. Незнакомую причину обрабатывайте как общий отказ: список может пополниться без смены версии протокола. Две причины из списка — one-ip и extra-params — в версии 1 на провод не выходят: в этой ветке исход загрузки survey.ready, а о том, что респондент уже проходил опрос, хост узнаёт из already-answered.

Оплата: единственное событие, из-за которого нельзя закрывать шторку

Между started и терминальным статусом у респондента может быть открыто 3DS-подтверждение банка — то есть деньги в движении. Хост, закрывший в этот момент шторку, обрывает платёж, и узнать об этом иначе нельзя: виджет оплаты живёт в своём фрейме. Отдельного права у события нет намеренно — не знать про идущий платёж опаснее, чем получить лишнее событие. Данных карты здесь нет и быть не может.

Но ждать терминальный статус бессрочно нельзя. succeeded, canceled и interrupted рождаются из событий платёжного виджета, поэтому гарантии, что после started придёт хоть один из них, у протокола нет: респондент мог уйти со страницы, WebView мог быть уничтожен, виджет мог не сообщить ничего. Рекомендуемый предел ожидания — порядка 5 минут от started: подтверждение банка приходит SMS и минуты занимает штатно, а держать шторку дольше уже хуже, чем закрыть. Вышел срок — закрывайте; прошёл платёж или нет, решается не по событию, а по серверному факту.

Что опрос НЕ делегирует хосту

Делегируется только терминальный переход внутри своего фрейма. Всё остальное опрос выполняет сам, и события для этого нет: новое окно, шаринг, «Посмотреть ответ», «Редактировать ответ», ссылки автора, попап SSO. Практическое следствие для приложения: эти переходы идут через window.open, а он в WebView без делегата не делает ничего — кнопки будут молча мёртвыми, пока вы не перехватите их на уровне WebView.

И отдельно про высоту: события про высоту контента в протоколе нет. Пока опрос занимает весь вьюпорт, потребителя у такого события не существует.

10. Команды опросу — все 4

Белый список входящих — ровно четыре типа. Всё остальное, что придёт в документ опроса сообщением, протокол молча отбрасывает, ничего не отвечая.

Команда Поля Что делает Ответ
host.hello min, max, host: { sdk, version, platform }, capabilities Устанавливает связь и фиксирует права. Принимается один раз за загрузку survey.handshake с replay
host.close Просит сохранить недопечатанный ввод перед закрытием. На бэкенд при этом ничего не уходит survey.closed
host.reset Стирает прогресс текущего опроса и перезагружает его перезагрузка, события нет
host.ping nonce? Проверка, что опрос ещё отвечает survey.pong с эхом nonce

Не убирайте фрейм или WebView сразу после команды закрытия

Ради подтверждения host.close и существует: оно означает «ввод досохранён». Не дождавшись, вы потеряете то, что человек набрал в последнем поле и что дебаунс не успел записать. Ждать долго не нужно — опрос отвечает в том же тике, поэтому предохранитель 500 мс: не пришло за это время — не придёт, разрушать безопасно.

host.reset — деструктивная команда

Она стирает ответы человека по текущему опросу. Не используйте её, чтобы «вернуть опрос в рабочее состояние». Ответного события нет — вместо него идёт обычный старт нового экземпляра: новый ctx.instanceId, seq с единицы, и здороваться нужно заново. Чистится только текущий опрос: глобальный сброс снёс бы прогресс остальных опросов респондента.

И главное для отладки: это чистый лист для опроса, а не новый респондент. Серверная запись ответа перезагрузку переживает, поэтому после сброса остаются already-answered, answer-limit, time-limit и блокировки по IP и устройству; идентификация сессии и cookie командой тоже не чистятся. Прилетевший survey.blocked здесь — не поломка вашей интеграции. Чтобы прогонять опрос повторно, автор временно разрешает повторные ответы (allow_multiple_answer), либо проверяйте с нового профиля браузера; способ сверки задаёт настройка автора session_check_methodip, extra_params или cookie. Частая ложная поломка: при сверке по IP опрос сначала отрисуется (survey.ready), а already-answered придёт отдельным событием после.

Когда команда не срабатывает

«Всегда» тут не бывает, и все четыре случая наблюдаемы как молчание:

  • Роут. Команды принимаются там, где протокол вообще включён. На остальных роутах нет даже маячка — поэтому «команда ушла в никуда» отличимо от «команда не сработала»: молчание начинается с самого первого сообщения, которого нет.
  • Приветствие. В iframe host.close, host.reset и host.ping принимаются только после принятого приветствия и только с его origin. На нативном мосту — сразу, приветствие не требуется.
  • Версии не сошлись. Случай отдельный, потому что молчание здесь наступает: такой хост подключён ровно настолько, чтобы получить survey.error с protocol-unsupported, а его команды исполняются — origin приветствия зафиксирован. То есть host.close ввод досохранит, а host.reset документ перезагрузит, только подтверждений не будет: survey.closed и survey.pong из этого состояния уже не уходят — вместе с ними ломается и рекомендованная проверка живости.
  • Подписчик. host.close и host.reset исполняет код экрана прохождения, а host.hello и host.ping — сам протокол. Отсюда разное поведение: пинг отвечает всегда, пока протокол жив, а закрытие и сброс зависят от того, смонтирован ли опрос.

Когда подписчика нет, команда не теряется: она кладётся в буфер (последняя каждого типа выигрывает) и исполняется, когда подписчик появится. Если опрос размонтирован из-за сбоя, подписчиков больше не будет — survey.closed не придёт никогда, а host.ping продолжает отвечать.

11. Что хост узнаёт, а что не узнаёт никогда

Встроенный опрос стоит на чужой странице, поэтому вопрос «что именно сайт узнаёт о человеке» не теоретический. Граница проведена жёстко и одной фразой: наружу уходит ход прохождения и никогда — содержание ответов. Протокол носит информацию и никогда — доступ.

Что Уходит хосту
Опрос открылся, человек началда
Перешёл к следующему вопросу, номер шагада
Ответил на вопрос — сам фактда
Отправил ответы, завершил или был отсеянда
Нужна авторизация, опрос закрыт, ошибкада
Итог: балл и категория результатада, если хост запросил право results
Что именно человек выбрал и написалнет
Промокод с финального экрананет
Ссылка на редактирование или просмотр ответанет
Идентификатор ответа, хеш прохождения, время прохождения с бэкенданет

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

Честно про догадки — таблица этого не покрывает

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

И один крайний случай: если весь балл опроса держится на одном вопросе, то балл — это и есть ответ на него. Для NPS из одного вопроса так и получается, и в этом нет проблемы: балл затем и передаётся. Просто не обещайте в таком случае, что ответ останется неизвестным.

Что протокол не гарантирует: подлинность событий

События приходят с клиента, значит подделать их может тот, у кого есть браузер. Начисление бонусов, выдачу промокодов и отметку «опрос пройден» стройте на серверной стороне — событие survey.completed годится, чтобы показать нужный экран, и не годится как доказательство. То же и про права: раз хост объявляет их себе сам, балл, отданный под results, отдан фактически любому хосту, который его попросит.

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

12. Что обязан проверять хост

Маркер конверта подлинности не доказывает

На странице может быть несколько фреймов и чужие скрипты. Сообщение, в котором стоит наш маркер, могло прийти от любого из них. __webask — фильтр «наш трафик», а не подпись: внутри опроса исполняется JS автора опроса, и подделать конверт он может. Поэтому источник и origin проверяются раньше, а не вместо.

Порядок проверок на входящем — именно в этом порядке:

  1. Источник — тот самый фрейм. Сравните отправителя с окном вашего фрейма (event.source !== frame.contentWindow), а не просто примите сообщение. Пустой contentWindow проверяйте отдельно: null !== null это false, и после снятия фрейма условие пропускало бы синтетическое сообщение.
  2. Origin — из event.origin, не из тела. Тело сообщения подделывается, заголовок — нет. Если список у вас есть, сверяйте origin целиком, со схемой, а не подстрокой. Значение 'null' на приёме законно (вы сами поставили sandbox без allow-same-origin) — тогда оно заменяет собой список, а не дописывается в него, а отправлять такому документу можно только на '*' (почему — в разделе 4). И гейтить это надо признаком со своего фрейма, а не фактом прихода 'null': иначе вы начнёте безусловно принимать непрозрачный origin, а он приходит и от чужих документов.
  3. Только потом разбирайте JSON, и только в try/catch.
  4. И только потом смотрите тип и поля.

Нужен ли список адресов опроса

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

Но трём хостам он обязателен, и причина у первых двух одна: event.source доказывает «это наш фрейм» и не различает документы внутри него — contentWindow остаётся тем же объектом, куда бы фрейм ни уехал. А уезжает он в конце на адрес, который задал автор опроса, и та страница вправе прислать поддельный хендшейк.

Кому обязателен Почему
Хост, встраивающий опросы чужих авторов Адрес, на который фрейм уезжает в конце, задаёт автор опроса. Приветствие отправляйте только на origin из списка, а маячок с origin вне списка игнорируйте
Хост, чей слушатель может быть повешен позже инициализации протокола Маячка он не увидит и будет здороваться на первое дошедшее сообщение — а «от опроса ли оно», кроме списка, подтвердить нечем
Нативный хост — всегда Там нет ни event.source, ни адресата отправки: список — единственное, чем ограничивается доступ к мосту. iOS сверяет с ним frameInfo.securityOrigin, Android им же закрывает мост от чужих адресов

Как его собрать — три источника:

  1. origin встраиваемой ссылки — схема, хост и порт, без пути. Сравнение точное: https://webask.io и https://www.webask.io — разные origin, http и https — тоже;
  2. кастомные домены инсталляции — все, с которых опросы этого аккаунта могут отдаваться;
  3. адрес, на который опрос может переехать: в ответ на загрузку бэкенд вправе отдать другой адрес того же опроса, в том числе на другом домене.

Чего в список не входит: адрес страницы самого хоста (список — про адреса опроса), адреса из содержимого сообщений, маски и регулярные выражения. Строка 'null' — тоже не адрес.

Диагностика неполного списка — одна строка лога

Пропущенный домен не выглядит как полное молчание: маячок приходит, а сверка origin отбрасывает его молча. Логируйте event.origin сообщений, которые прошли проверку источника и маркера, но не прошли проверку origin. Это лог, а не приём: список от него не расширяется, зато пропавший домен становится виден одной строкой вместо расследования. Нативному хосту тот же лог обязателен — там сверка с origin единственный барьер.

13. Тестовый режим

Отлаживайтесь на адресе предпросмотра — /<ID_ОПРОСА>/preview. Это штатный тестовый режим: тот же протокол, тот же порядок сообщений и те же поля, что на настоящем прохождении, но отправка имитируется у клиента.

В тестовом прогоне Что это значит
На сервер не уходит ничего Лимит ответов автора не тратится, в выгрузке не появляется строки
Дедуп «уже отвечал» не считается Прогонять можно сколько угодно раз подряд
Счётчики автора не срабатывают Ваши заходы не попадают в его Метрику, GA и Facebook. При этом survey.analytics в протокол уходит — пересылку целей в свою аналитику можно отладить

Наружу отличие ровно одно: в каждом сообщении ctx.preview равно true. Одно оно намеренно — если бы в тестовом режиме менялся состав событий, отладка ничего не доказывала бы.

Отсюда обязанность, которую нельзя пропустить

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

/* первой строкой обработчика, до всякой логики */
if (msg.ctx.preview) { logForDebug(msg); return; }

Пометка живёт в конверте, а не в payload'ах, поэтому забыть её при добавлении нового события невозможно.

Предпросмотр одного виджета (/<ID>/preview/question/<ID_ЭКРАНА>) — не тестовый режим: там протокол выключен целиком. Порядка экранов и отправки там нет, то есть событие было бы вымыслом даже по форме, и помечать его нечем. И ещё две вещи про отладку: в предпросмотре серверного эха событий нет — наблюдать поведение можно ровно в одном месте, в собственном логе провода; и рендерер о вашей стороне не сообщает ничего — падение хоста в survey.error не попадает.

14. Отладка: порядок и молчаливые поломки

Порядок выбран так, что каждый шаг проверяет ровно одно звено и не требует предыдущих догадок.

  1. Логируйте провод целиком. Весь конверт плюс отдельно type, seq, ctx.instanceId и replay. Без этого лога отлаживать нечем: остальные шаги — это чтение именно его.
  2. Дождитесь маячка и поздоровайтесь в ответ. В вебе слушатель message вешается в том же синхронном блоке, что и создание фрейма; в приложении — подписка на мост до загрузки адреса. Убедитесь, что приветствие ушло на тот origin, который в маячке, и что на второй хендшейк (replay: true) вы не здороваетесь повторно: он и есть подтверждение.
  3. Проверьте обратный канал: host.pingsurvey.pong с тем же nonce. Это единственная команда версии 1 с ответом, то есть единственный способ доказать, что ваши сообщения доходят. Делайте это до того, как подключать остальное: у закрытия и сброса подтверждения либо нет, либо оно зависит от состояния опроса. На Android этот же шаг проверяет, что evaluateJavascript зовётся с главного потока.
  4. Дождитесь исхода загрузкиready, auth-required, blocked или error. Проверьте, что обработчик идемпотентен: то же событие может приехать второй раз с replay: true.
  5. Пройдите один экран — ждём survey.page-changed. Пришёл ready, а этого нет — причин две, и различает их наблюдаемый признак на вашей стороне: если location.origin вашей собственной страницы равен 'null' — в песочнице конструктора сидите вы сами, и события про человека не придут никогда. Обычный location.origin значит другое: приветствие не доехало, проверяйте шагом 3.
  6. Включите права и повторите. question-answered и analytics не приходят, пока не объявлены; на нативном мосту объявленные слишком поздно теряются — окно 5 секунд от инициализации протокола.
  7. Отправьте ответыsubmitted, затем completed. Разбирайте пару полей reason + widgetId, а не одно. Объявили results — проверьте здесь же, что внутри приехал объект result, и делайте это на опросе со включённым скорингом.
  8. Проверьте терминальную ветку на отдельном опросе БЕЗ своего финального экрана. Только там уходит navigate-request. Заодно убедитесь, что снимаете опрос за 1200 мс и что именно снимаете, а не прячете, — скрытый документ откат только откладывает.
  9. Проверьте закрытие в два шага: host.closesurvey.closed → и только теперь разрушение контейнера. Отдельно проверьте предохранитель на 500 мс: штатное молчание дешевле всего воспроизвести адресом, где протокола нет вовсе — наведите хост на /<ID>/preview/question/<ID_ЭКРАНА>.
  10. Проверьте перезагрузку. Пришлите host.reset (или возьмите опрос под паролем) и убедитесь, что здороваетесь заново, видите новый ctx.instanceId и seq с единицы, а своё состояние сбрасываете. Шаг обязателен: хост, поздоровавшийся один раз, после первой же перезагрузки навсегда перестаёт получать события.

Чем проверять живость НЕ надо

  • Флагом window.__webask.ready — он остаётся false в штатных случаях.
  • Полем fatal — признак мёртвого опроса другой: host.close без survey.closed за 500 мс.
  • Приходом survey.ready — он больше не доказывает, что ваше приветствие принято: факты про сам опрос уходят и без него. Доказывает survey.pong и приход событий про человека.
  • Отсутствием события — молчание штатный ответ, а не ошибка.

Что ломается молча — и что с этим делать

Симптом Почему Что делать
На сайте сообщений нет вообще Приветствие тут ни при чём: маячок приходит и без него. Значит либо адрес не тот, либо роут без протокола, либо источник и origin отсеяли сообщение вашей же проверкой Проверить, что слушатель вешается до создания фрейма, и залогировать отброшенные сообщения
В приложении сообщений нет вообще Мост не зарегистрирован или зарегистрирован после начала загрузки — приветствие тут ни при чём Поставить обработчик в конфигурацию WebView до загрузки адреса
Маячка нет, а другие события идут Вы опоздали со слушателем. Дешёвый признак: первое увиденное сообщение с seq > 1 Здороваться на первое дошедшее сообщение любого типа; список адресов инсталляции такому хосту обязателен
Приходит только загрузка Встраивание в песочнице: браузер не даёт опросу опознать вашу страницу Проверить location.origin у себя; на обычной странице приходит всё
Приходит только про сам опрос, про человека — нет Не поздоровались, или поздоровались один раз, а опрос успел перезагрузиться Здороваться на каждую загрузку документа, отслеживая ctx.instanceId
Часть событий не приходит Не запросили соответствующее право или запросили после рукопожатия Объявлять права в первом же host.hello — расширить список потом нельзя
Человек видит паузу перед уходом Заявлено право navigate, но обработчик ничего не делает Либо обработать, либо не запрашивать это право
Последний ответ пропал Фрейм убрали, не дождавшись ответа на команду закрытия Ждать survey.closed или таймаут 500 мс
Оплата обрывается Окно закрыли, пока человек подтверждал платёж в банке Не закрывать между started и терминальным статусом, но не дольше ~5 минут
Прогресс сбрасывается каждую загрузку К адресу добавлен меняющийся параметр — опрос считает его признаком другого человека Убрать из адреса всё, кроме осмысленных скрытых переменных
Через сутки опрос начинается сначала Срок жизни идентификации — 24 часа, абсолютных: отсчёт от первой выдачи, обращениями не продлевается Учесть в сценарии, если возврат ожидается позже
Хост считает отправку дважды Кроме события протокола опрос при каждой успешной отправке шлёт старое сообщение { webask: true, action: 'answer' } Отличать по маркеру: у протокола ключ __webask, у старого — webask
В приложении мертвы шаринг и ссылки автора Эти переходы идут через window.open, а он в WebView без делегата не делает ничего Реализовать делегат, открывающий адрес наружу
На сайте не поднимается оплата или не копируется код ошибки Фрейму не выданы разрешения браузера Поставить фрейму allow с оплатой, буфером обмена, полноэкранным режимом и автовоспроизведением
В приложении не работает загрузка файла WebView не разрешил выбор файла Реализовать обработчик выбора файла (onShowFileChooser на Android)
В приложении не сохраняется прогресс WebView запрещает хранение данных страницы Включить хранилище: domStorageEnabled на Android, персистентный websiteDataStore на iOS
Тестовый прогон обработали как настоящий Не проверили ctx.preview: события предпросмотра по форме неотличимы от настоящих — они и обязаны быть неотличимы Отсекать поток по этой пометке на входе обработчика

15. Чего в версии 1 нет

Список закрытый и без сроков: он отвечает на вопрос «на что нельзя рассчитывать сегодня», а не «что будет дальше». Планов публиковать пакет в документации нет. Но пункты внутри списка разной природы, и путать их дорого:

Не появится — это доступ, а не сведения. Значения ответов, промокод, ссылки на правку и просмотр ответа, хеш прохождения. Здесь дело не в объёме работ: отдать их значит отдать полномочия в чужом ответе, и никакое право этого не откроет.

Нет в версии 1 — появление было бы аддитивным. Мобильный пакет, транспорт под React Native, safe-area, auto-resize, выбор языка хостом, программный переход по экранам, офлайн-очередь. Совместимость такие добавления не ломают, поэтому закладываться на них заранее не нужно — и обещать их тоже нельзя.

  • Готового SDK для мобильных. Есть описание и рабочий пример, библиотеки для установки нет — ни под iOS, ни под Android, ни под React Native.
  • Идентификатора ответа. Аналога responseId нет ни в payload'ах, ни в ctx. Сцепку это не блокирует — она делается через скрытую переменную адреса, см. следующий раздел.
  • SSO во встроенном опросе. Попап открывает опрос, а в нативном WebView window.open по умолчанию не делает ничего: опрос под SSO в приложении не проходится.
  • Транспорта под React Native — включается переходником, см. выше.
  • Протокола на роуте правки ответа и возврата к правке кнопкой хоста: команды для этого нет, а адрес правки хосту не отдаётся.
  • Safe-area и клавиатуры (host.set-viewport). Ограничение чувствуется только у приложений, которые сознательно тянут WebView под вырез или сжимают его под клавиатуру.
  • Auto-resize по высоте контента — пока опрос занимает весь вьюпорт, потребителя нет.
  • Выбора языка встроенного опроса хостом, программного перехода по экранам (host.navigate), передачи скрытых полей командой (они передаются параметрами адреса), ссылок автора в нативном WebView (перехватываются на уровне WebView).
  • Офлайн-режима и очереди отправки ответов.
  • Event-таргетинга — это был бы отдельный контракт поверх этого протокола.

Android: проверяйте на своём билде

Поведение встраивания на Android определяет версия системного WebView, а она обновляется отдельно от операционной системы: одно и то же приложение на двух устройствах с одной версией Android может вести себя по-разному. Поэтому интеграцию стоит прогнать на своём билде и на том парке устройств, который у вас реально есть, а не только в эмуляторе.

16. Где брать результаты

Из результата протокол передаёт ровно две вещи, и обе по праву results: балл и категорию-победитель, объектом completed.result. Больше ничего, и это не пробел версии 1. Всё остальное забирается там, где авторизация настоящая, — в аккаунте, которому опрос принадлежит: через API ответов, экспорт или webhooks.

Как сопоставить ответ со своим пользователем

Приём один, и он целиком в вашей власти: положите свой идентификатор в адрес опроса скрытой переменной и найдите его рядом с ответом в выгрузке.

https://webask.io/6f2b1c8e-0000-4000-8000-000000000000?user_id=12345

Требования к значению — три, и все обязательны:

  • постоянное для этого респондента. Набор скрытых переменных — это ещё и идентичность прохождения: меняете значение между загрузками — стираете респонденту прогресс;
  • непрозрачное. Адрес виден респонденту и правится им же, поэтому кладите свой внутренний идентификатор, а не персональные данные;
  • не служебное имя. loader_bg, loader_spinner, oidc, _r заняты.

Сопоставление — удобство, а не доказательство

Значение переменной приходит из адреса, то есть подставить туда чужое может кто угодно. Начисление бонусов, выдачу промокодов и отметку «опрос пройден» стройте на серверной стороне — по тому же правилу, по которому события протокола не годятся в доказательства.

Где это значение всплывёт на сервере: рядом с ответом в выгрузке и в теле webhook-уведомления о новом ответе — по нему и сшивайте со своим пользователем на своей стороне.

Что из протокола для сопоставления всё же полезно: ctx.virtualId и ctx.versionId — какой опрос и какая его версия отдали событие. О конкретном ответе они не говорят ничего.

Коротко: чек-лист интеграции

  • Слушатель — до вставки фрейма; мост — до загрузки адреса.
  • Здороваться на каждый первый хендшейк документа, отслеживая ctx.instanceId.
  • Адресат команд — event.origin проверенного сообщения, никогда из тела. '*' запрещён с одним исключением: у документа в песочнице (event.origin === 'null') другого адресата не существует.
  • Права — только те, что обрабатываете. navigate обязывает увести человека за 1200 мс.
  • ctx.preview отсекать первой строкой обработчика.
  • Неидемпотентное — только на события без replay.
  • Закрытие в два шага, предохранитель 500 мс. Не закрывать во время оплаты.
  • Не закрывать по survey.completed.
  • Адрес опроса стабилен: никаких кэш-бастеров.
  • Незнакомые type и поля — игнорировать молча.

Как узнать, что протокол обновился. Новые события и поля анонсируются в разделе «Последние обновления» — сверяться стоит там, а не по факту прихода незнакомого type. На проводе версию видно сразу: маркер __webask в конверте, а buildId и appVersion в payload'е хендшейка называют релиз рендерера — их стоит логировать, по ним диагностируется рассинхрон.

Протокол растёт аддитивно: новые события и опциональные поля обратную совместимость не ломают. Версия протокола стоит маркером __webask в каждом конверте — сейчас это 1. Хост, который умеет только версию 1, обязан объявлять min: 1, max: 1: это не формальность, а способ не отключить себе то, что уже работает.