Вопросы в каналах (Ask)
Ask — это вопрос, который ваше приложение отправляет в канал и ждёт на него ответ от человека. Обычное сообщение летит в одну сторону; вопрос — в обе: приложение публикует «Деплоить в прод?», человек в админке или Android-приложении жмёт кнопку (или печатает текст, или заполняет форму), а приложение получает ответ обратно по тому же токену.
Удобно для:
- ручного подтверждения опасных операций (деплой, удаление, миграция) из CI/скрипта;
- HITL-сценариев (human-in-the-loop): агент/бот спрашивает человека и продолжает по ответу;
- простых опросов и сбора короткого ввода (выбрать окружение, ввести причину);
- коротких анкет: несколько полей разных типов в одном вопросе (формы) или цепочка вопросов, где следующий зависит от ответа (ветвление).
Вопрос — это обычное сообщение канала с доп-полем notifly::ask в
extras. Поэтому он отправляется App-токеном (A…)
того же канала, что и любые push-уведомления, и виден в ленте канала наравне
с ними. Каждый вопрос списывает одно событие ask из
дневной квоты.
В примерах ниже NOTIFLY_URL=https://api.notifly.ru.
Типы вопросов
Заголовок раздела «Типы вопросов»Тип вопроса определяется полями answer_options и fields:
| Тип | Когда | Как отвечают |
|---|---|---|
open | не задано ни то, ни другое | произвольный текст |
closed | answer_options — массив строк | выбор одного (или нескольких) вариантов-кнопок |
form | fields — описание полей формы | JSON-объект {имя_поля: значение} |
answer_options и fields взаимоисключающие.
Для закрытого вопроса каждый элемент answer_options превращается в вариант
ответа {id, label} (id = label = строка варианта). Сервер при получении ответа
проверяет, что он совпадает с одним из вариантов (по id или label), иначе —
400.
Параметры вопроса
Заголовок раздела «Параметры вопроса»Все параметры передаются в теле POST /ask (настроек на уровне канала нет).
| Поле | Тип | Обяз. | Описание |
|---|---|---|---|
question | string | да | текст вопроса |
title | string | нет | заголовок сообщения (по умолчанию ❓) |
answer_options | string[] | нет | варианты ответа → делает вопрос закрытым |
default_option | string | нет | какой из answer_options выделить как вариант по умолчанию |
allow_multi | boolean | нет | разрешить выбор нескольких вариантов (только для закрытого вопроса) |
timeout_sec | number | нет | через сколько секунд вопрос авто-истечёт (0/отсутствует — бессрочно) |
default_answer | string | нет | ответ, подставляемый по таймауту |
json_format | boolean | нет | ответ обязан быть валидным JSON (для открытого вопроса) |
client_message_id | string | нет | ключ идемпотентности |
priority | number | нет | приоритет сообщения (по умолчанию — приоритет канала или 5) |
callback_url | string | нет | http(s)-URL: webhook при answered/expired/cancelled (см. ниже) |
callback_secret | string | нет | секрет для HMAC-подписи тела callback’а (X-Notifly-Signature) |
fields | object[] | нет | поля формы → делает вопрос формой (см. Формы) |
next | object | нет | ветвление: вариант → следующий вопрос (см. Ветвление) |
Несколько важных деталей:
allow_multiработает только для закрытого вопроса (форме и ветвлению он недоступен). Если человек прислал более одного ответа на открытый вопрос или на закрытый безallow_multi— сервер вернёт400. Несколько ответов склеиваются в строку через,.json_formatвалидируется на стороне сервера: ответ, не являющийся корректным JSON, отклоняется с400.client_message_idдаёт идемпотентность: повторныйPOST /askс тем жеclient_message_idв рамках канала не создаёт новый вопрос, а возвращает{"id": …}уже существующего. Удобно для ретраев из CI.
Ответ на POST /ask — это id вопроса:
{"id": 12345678}Если нужно собрать сразу несколько значений, передайте вместо answer_options
массив fields — вопрос станет формой (askType: "form"). Админка и
Android-приложение рисуют поля ввода, а ответ приходит JSON-объектом.
curl -X POST "$NOTIFLY_URL/ask" \ -H "X-Notifly-Key: A<appToken>" \ -H "Content-Type: application/json" \ -d '{ "title": "Параметры релиза", "question": "Куда и как выкатываем?", "fields": [ {"name": "env", "label": "Окружение", "type": "select", "required": true, "options": [{"id": "stage", "label": "Stage"}, {"id": "prod", "label": "Prod"}]}, {"name": "replicas", "label": "Реплик", "type": "number", "required": true}, {"name": "migrate", "label": "Запускать миграции", "type": "bool"}, {"name": "comment", "label": "Комментарий", "placeholder": "необязательно"} ], "timeout_sec": 900 }'Поле формы:
| Поле | Описание |
|---|---|
name | машинное имя: a-z, 0-9, _, до 32 символов; уникально в форме |
label | подпись поля (по умолчанию — name) |
type | text (по умолчанию), number, bool или select |
options | только для select: до 20 вариантов {id, label} (достаточно одного из двух) |
required | поле обязательно |
placeholder | подсказка в пустом поле |
В форме не больше 10 полей. Ответ на форму — строка с JSON-объектом:
curl -X POST "$NOTIFLY_URL/ask-question/12345678/answer" \ -H "X-Notifly-Key: C<clientToken>" \ -H "Content-Type: application/json" \ -d '{"answer": "{\"env\": \"Prod\", \"replicas\": \"3\", \"migrate\": true}"}'# → { "status": "answered", "answer": "{\"env\":\"prod\",\"migrate\":true,\"replicas\":3}" }Сервер проверяет ответ по схеме и сохраняет его нормализованным: числа
становятся числами, bool — true/false, для select сохраняется id
варианта (подходит и id, и label). Неизвестные поля, пропущенные
обязательные и значения не того типа отклоняются с 400, а в теле ответа —
карта ошибок по полям:
{ "error": "Bad Request", "errorCode": 400, "errorDescription": "form validation failed", "fields": {"replicas": "must be a number"}}default_answer у формы тоже должен проходить эту проверку — иначе вопрос не
создастся (400).
Ветвление (next)
Заголовок раздела «Ветвление (next)»Закрытый вопрос может сам продолжиться следующим вопросом в зависимости от
выбранного варианта. Поле next — объект «вариант → вложенный вопрос» с теми
же полями, что и у POST /ask:
curl -X POST "$NOTIFLY_URL/ask" \ -H "X-Notifly-Key: A<appToken>" \ -H "Content-Type: application/json" \ -d '{ "question": "Откатываем релиз?", "answer_options": ["Да", "Нет"], "next": { "Да": { "question": "До какой версии?", "answer_options": ["v1.4.2", "v1.4.1"] } } }'Когда человек выбирает вариант, для которого задано продолжение, сервер сразу
создаёт следующий вопрос в том же канале и сообщает его id полем
nextQuestionId — в ответе на POST …/answer, в GET /ask-question/:id, в
WebSocket-сигнале и в webhook-callback’е. Дальше ждите ответ уже на него.
- Только для закрытого вопроса без
allow_multi; ключиnext— существующие варианты изanswer_options. - Глубина цепочки — не больше 5 вопросов, считая первый.
callback_urlиcallback_secretнаследуются вложенным вопросом, если в нём не заданы свои.- Каждый порождённый вопрос списывает своё событие
ask. Если создать его не удалось (например, кончилась квота), ответ на исходный вопрос всё равно засчитывается — просто безnextQuestionId.
Статусы вопроса
Заголовок раздела «Статусы вопроса»Сам вопрос живёт в одном из четырёх статусов:
| Статус | Что значит |
|---|---|
pending | вопрос отправлен, ждём ответа |
answered | человек ответил; ответ лежит в поле answer |
expired | истёк timeout_sec до ответа |
cancelled | вопрос отменён администратором (DELETE /ask-question/:id) до ответа |
Отвечать (POST …/answer) и отменять (DELETE …) можно только пока вопрос в
статусе pending; иначе — 409 Conflict.
Отдельно фиксируется доставка: когда приложение фактически забрало ответ,
у вопроса проставляется отметка доставки, а в карточке сообщения (в админке)
статус меняется на delivered. Сам статус вопроса при этом остаётся
answered — GET /ask-question/:id и после доставки возвращает
"status":"answered".
Доставка ответа: WebSocket-сигнал + GET
Заголовок раздела «Доставка ответа: WebSocket-сигнал + GET»Ответ доставляется по модели notify-then-fetch: по WebSocket уходит лишь сигнал «ответ готов», а сам ответ забирается отдельным GET-запросом. Так сделано ради serverless-среды и больших ответов.
-
Откройте WebSocket с тем же токеном и параметром
?id=<id вопроса>:wss://api.notifly.ru/ws?token=A<appToken>&id=12345678 -
Когда появится ответ, на это соединение придёт сигнал (тело ответа по сокету не едет):
{"id": 12345678, "status": "answered", "fetch": true} -
По сигналу заберите ответ через
GET /ask-question/:id:Код Значение 200ответ готов: {"id":…,"answer":"…","status":"answered"}(+nextQuestionIdпри ветвлении)204ещё ждём (вопрос всё ещё pending)410вопрос expiredилиcancelledЭтот GET одновременно фиксирует доставку (карточка сообщения в админке переходит в
delivered).
Все три шага за вас делает WaitAskAnswer из Go SDK: он держит
сокет, шлёт keepalive, переподключается при обрыве и сам падает в опрос там, где
сокет недоступен.
Подробности протокола, подписка ?id= и действие get-pending —
на странице WebSocket-протокол.
Webhook-callback
Заголовок раздела «Webhook-callback»Третий способ узнать об ответе — попросить Notifly самому постучаться к вам:
передайте в POST /ask поле callback_url. Когда вопрос перейдёт в терминальный
статус (answered, expired или cancelled), на этот URL уйдёт POST c JSON:
{ "id": 12345678, "question": "Деплоить в прод?", "status": "answered", "answer": "Yes", "answeredAt": 1750684800, "ts": 1750684801}При ветвлении в callback’е answered есть ещё
nextQuestionId.
Если задан callback_secret, тело подписывается HMAC-SHA256, подпись — в
заголовке X-Notifly-Signature: sha256=<hex> (тот же формат, что у
generic-webhook каналов). Проверяйте её на своей стороне.
Важные ограничения:
- Доставка best-effort. Сетевые ошибки,
429и5xxретраятся до трёх раз с паузами ~1, 4 и 10 секунд, но durable-очереди нет: если ваш приёмник был недоступен дольше — повторной попытки позже не будет. Источник истины — всегдаGET /ask-question/:id; callback это ускоритель, а не гарантия. Дедуплицируйте по паре (id,status). - URL должен быть публичным
http(s): адреса приватных сетей (RFC1918, loopback, link-local) блокируются SSRF-защитой на момент доставки. - На всю доставку callback’а вместе с повторами отводится около 25 секунд —
дальше она обрывается. Отвечайте быстро (
2xx), тяжёлую работу делайте асинхронно.
Approval gate в CI
Заголовок раздела «Approval gate в CI»Классический сценарий: пайплайн доходит до опасного шага и ждёт «да» от человека
в пуше/Telegram. Всё нужное уже есть: закрытый вопрос + timeout_sec +
default_answer (что считать, если никто не ответил) + короткий поллинг.
# notifly_gate "Deploy to prod?" Yes No# → печатает ответ; код возврата 0 — ответ получен, 1 — истёк/ошибка.# default_answer = второй вариант ($2 после shift), т.е. fail-closed.notifly_gate() { local question="$1"; shift local options; options=$(printf '"%s",' "$@"); options="[${options%,}]" local id id=$(curl -sf -X POST "$NOTIFLY_URL/ask" \ -H "X-Notifly-Key: $NOTIFLY_APP_TOKEN" \ -H "Content-Type: application/json" \ -d "{ \"title\": \"CI gate\", \"question\": \"$question\", \"answer_options\": $options, \"timeout_sec\": 600, \"default_answer\": \"$2\", \"client_message_id\": \"gate-$CI_PIPELINE_ID\" }" | jq -r .id) || return 1
while true; do local code code=$(curl -s -o /tmp/gate.json -w '%{http_code}' \ "$NOTIFLY_URL/ask-question/$id" \ -H "X-Notifly-Key: $NOTIFLY_APP_TOKEN") case "$code" in 200) jq -r .answer /tmp/gate.json; return 0 ;; 410) echo "expired/cancelled"; return 1 ;; 204) sleep 5 ;; *) echo "HTTP $code"; return 1 ;; esac done}
answer=$(notifly_gate "Deploy to prod?" Yes No) || exit 1[ "$answer" = "Yes" ] || { echo "деплой отклонён: $answer"; exit 1; }Разбор решений в этом рецепте:
timeout_sec+default_answer: "No"— если никто не ответил за 10 минут, гейт безопасно закрывается сам (fail-closed). Хотите fail-open — поставьтеdefault_answerравным подтверждающему варианту.client_message_idпривязан к id пайплайна: ретрай упавшего CI-шага не создаст второй вопрос, а продолжит ждать тот же.- Поллинг раз в 5 секунд — сервер отвечает мгновенно (
204— пусто), держать соединение не нужно. Человеческое «да» занимает минуты, так что этого более чем достаточно. - Вместо поллинга можно указать
callback_url(см. выше) — например, эндпоинтrepository_dispatchGitHub Actions — и продолжать пайплайн событием.
Тот же паттерн доступен AI-агентам через MCP-сервер: инструменты
ask_question / get_ask_answer(wait_sec) — human-in-the-loop без единой строчки
HTTP-кода. Из терминала то же делает notifly ask --wait (CLI),
из кода — Ask + WaitAskAnswer в Go и Python SDK.
Примеры (curl)
Заголовок раздела «Примеры (curl)»Закрытый вопрос с кнопками
Заголовок раздела «Закрытый вопрос с кнопками»# 1. Отправить вопросcurl -X POST "$NOTIFLY_URL/ask" \ -H "X-Notifly-Key: A<appToken>" \ -H "Content-Type: application/json" \ -d '{ "title": "Deploy decision", "question": "Деплоить в прод?", "answer_options": ["Yes", "No"], "default_option": "No", "allow_multi": false, "timeout_sec": 300, "default_answer": "No" }'# → { "id": 12345678 }
# 2. Когда по WS придёт {"id":12345678,"fetch":true} — забрать ответ:# 200 — ответ готов, 204 — ещё ждём, 410 — истёк/отменёнcurl "$NOTIFLY_URL/ask-question/12345678" \ -H "X-Notifly-Key: A<appToken>"# → { "id": 12345678, "answer": "Yes", "status": "answered" }Открытый вопрос с ответом в JSON
Заголовок раздела «Открытый вопрос с ответом в JSON»curl -X POST "$NOTIFLY_URL/ask" \ -H "X-Notifly-Key: A<appToken>" \ -H "Content-Type: application/json" \ -d '{ "title": "Config parameters", "question": "Введите параметры деплоя", "json_format": true }'Polling вместо WebSocket
Заголовок раздела «Polling вместо WebSocket»# Какие вопросы канала уже получили ответ, но ещё не забраныcurl "$NOTIFLY_URL/ask/pending-answers" \ -H "X-Notifly-Key: A<appToken>"# → [ { "id": 12345678, "fetch": true } ]
# Подтвердить доставку пачкой (проставит delivered)curl -X POST "$NOTIFLY_URL/ask/ack" \ -H "X-Notifly-Key: A<appToken>" \ -H "Content-Type: application/json" \ -d '{ "ids": [12345678] }'# → { "acknowledged": 1 }Ответ на вопрос (со стороны клиента)
Заголовок раздела «Ответ на вопрос (со стороны клиента)»Обычно на вопрос отвечают в админке или в Android-приложении — в ленте или
прямо из уведомления: у закрытого вопроса с 1–3 вариантами в уведомлении есть
кнопки вариантов, на открытый вопрос можно ответить текстом из шторки, а форма,
мультивыбор и вопросы с большим числом вариантов открываются кнопкой
«Ответить» (см. Android-приложение). Программно
ответ шлётся токеном устройства (C…) или API-токеном.
Поддерживается одиночный ответ (answer) и мультивыбор (answers):
# Одиночный ответ по id вопросаcurl -X POST "$NOTIFLY_URL/ask-question/12345678/answer" \ -H "X-Notifly-Key: C<clientToken>" \ -H "Content-Type: application/json" \ -d '{ "answer": "Yes" }'# → { "status": "answered", "answer": "Yes" }
# Мультивыбор (только для закрытого вопроса с allow_multi)curl -X POST "$NOTIFLY_URL/ask-question/12345678/answer" \ -H "X-Notifly-Key: C<clientToken>" \ -H "Content-Type: application/json" \ -d '{ "answers": ["Yes", "No"] }'
# Ответ по id сообщения (если знаете message id, а не id вопроса)curl -X POST "$NOTIFLY_URL/message/9876543/answer" \ -H "X-Notifly-Key: C<clientToken>" \ -H "Content-Type: application/json" \ -d '{ "answer": "Yes" }'Extras notifly::ask
Заголовок раздела «Extras notifly::ask»Вопрос — это сообщение, у которого в extras лежит объект notifly::ask.
Именно по нему клиенты (админка, Android) понимают, что сообщение интерактивное,
и рисуют кнопки/поле ввода:
{ "notifly::ask": { "askQuestionId": 12345678, "askType": "closed", "status": "pending", "options": [ {"id": "Yes", "label": "Yes"}, {"id": "No", "label": "No", "default": true} ], "allowMulti": false, "expiresAt": 1750684800000 }}Поля allowMulti, options и expiresAt присутствуют только когда применимы
(options/allowMulti — для закрытого вопроса, expiresAt — при заданном
timeout_sec); для открытого вопроса с json_format добавляется jsonFormat: true,
для формы (askType: "form") — массив fields. Вопрос, порождённый
ветвлением, несёт parentQuestionId. По мере жизни вопроса
сервер обновляет блок: status (pending → answered → delivered, либо
expired/cancelled), answer и nextQuestionId.
Про работу с extras в целом — Дополнения сообщений.
REST API
Заголовок раздела «REST API»| Метод и путь | Авторизация | Назначение |
|---|---|---|
POST /ask | app-token | отправить вопрос в канал |
GET /ask/pending-answers | app-token | список вопросов с готовым, но не забранным ответом |
POST /ask/ack | app-token | подтвердить доставку ответов ({"ids":[…]}) |
GET /ask-question/:id | app-token | забрать ответ (200 / 204 / 410) |
POST /ask-question/:id/answer | client-token | ответить на вопрос |
DELETE /ask-question/:id | client-token | отменить ожидающий вопрос |
POST /message/:id/answer | client-token | ответить на вопрос по id сообщения |
POST /ask-question/:id/extend | app-token или client-token | продлить срок ответа ({"timeout_sec": N}) |
Продление срока ответа
Заголовок раздела «Продление срока ответа»Если человек не успевает ответить, а вопрос вот-вот истечёт, срок сдвигается
вызовом extend:
curl -X POST "$NOTIFLY_URL/ask-question/12345678/extend" \ -H "Content-Type: application/json" \ -H "X-Notifly-Key: <app-токен, API-токен или токен устройства>" \ -d '{"timeout_sec": 1800}'{"id": 12345678, "status": "pending", "expiresAt": 1750686600000}timeout_sec— от1до86400(сутки), отсчитывается от текущего момента, а не от прежнего дедлайна.- Вопрос в статусе
pendingпросто получает новый срок. - Уже истёкший вопрос можно «оживить»: он вернётся в
pending, если истёк не более 24 часов назад. Более старый —409. - Вопрос в статусе
answeredилиcancelledпродлить нельзя —409.
Продление рассылает обновлённые extras и WS-сигнал, поэтому кнопки в интерфейсе и в письме снова становятся активными.
Ответ по ссылке из письма, Telegram и чатов
Заголовок раздела «Ответ по ссылке из письма, Telegram и чатов»Если у канала включена доставка на email, в Telegram или в
рабочий чат, закрытый вопрос приходит туда с кнопками (или ссылками) вариантов. Кнопка ведёт на публичный
подписанный адрес GET /a/answer?token=…: авторизация не нужна, ответ
засчитывается сразу. Токен подписан HMAC и живёт не дольше срока ответа на
вопрос, поэтому подделать или переиспользовать ссылку нельзя. Рядом добавляется
кнопка ✓ Прочитано (GET /m/read?token=…), которая отмечает сообщение
прочитанным и останавливает дальнейшую эскалацию.