Skip to content

Questions in channels (Ask)

Ask is a question your application sends to a channel and waits for a human reply. A regular message goes one way; a question goes both ways: the app publishes “Deploy to prod?”, the person in the admin or Android app presses a button (or types text), and the app receives the reply back using the same token.

Useful for:

  • manual confirmation of dangerous operations (deploy, delete, migrate) from CI/scripts;
  • HITL scenarios (human-in-the-loop): an agent/bot asks a human and continues based on the answer;
  • simple polls and collecting short input (choose an environment, enter a reason).

A question is a normal channel message with an extra field notifly::ask in extras. Therefore it is sent with the same channel’s App token (A…) used for any push notifications, and is visible in the channel feed alongside them.

The type of question is determined by the presence of the answer_options field:

TypeWhenHow answered
openanswer_options is not setarbitrary text
closedanswer_options is an array of stringschoosing one (or several) option-buttons

For a closed question each element of answer_options becomes an answer option {id, label} (id = label = the option string). The server, when receiving an answer, checks that it matches one of the options (by id or label), otherwise returns 400.

All parameters are passed in the body of POST /ask (there are no per-channel settings).

FieldTypeRequiredDescription
questionstringyesquestion text
titlestringnomessage title (default )
answer_optionsstring[]noanswer options → makes the question closed
default_optionstringnowhich of answer_options to mark as the default
allow_multibooleannoallow selecting multiple options (only for closed question)
timeout_secnumbernoafter how many seconds the question auto-expires (0/absent — never)
default_answerstringnoanswer filled in when timeout occurs
json_formatbooleannoanswer must be valid JSON (for open question)
client_message_idstringnoidempotency key
prioritynumbernomessage priority (default — channel priority or 5)
callback_urlstringnohttp(s) URL: webhook on answered/expired/cancelled (see below)
callback_secretstringnosecret for the HMAC signature of the callback body (X-Notifly-Signature)

A few important details:

  • allow_multi works only for closed questions. If a person sends more than one answer for an open question or for a closed question without allow_multi — the server will return 400. Multiple answers are joined into a string with , .
  • json_format is validated server-side: an answer that is not valid JSON is rejected with 400.
  • client_message_id provides idempotency: repeating POST /ask with the same client_message_id within a channel does not create a new question, but returns {"id": …} of the existing one. Useful for retries from CI.

The response to POST /ask is the question id:

{ "id": 12345678 }

A question lives in one of four statuses:

StatusMeaning
pendingquestion sent, waiting for an answer
answereda person answered; the answer is in the answer field
expiredtimeout_sec expired before an answer
cancelledquestion cancelled by an administrator (DELETE /ask-question/:id) before an answer

Answering (POST …/answer) and cancelling (DELETE …) are allowed only while the question is in pending; otherwise — 409 Conflict.

Delivery is tracked separately: when the application actually fetches the answer, a delivery mark is set on the question, and in the message card (in the admin UI) the status changes to delivered. The question status itself remains answeredGET /ask-question/:id still returns "status":"answered" after delivery.

Delivering the answer: WebSocket signal + GET

Section titled “Delivering the answer: WebSocket signal + GET”

The answer is delivered with a notify-then-fetch model: the WebSocket only sends a “answer ready” signal, and the actual answer is fetched with a separate GET request. This is done for serverless environments and large answers.

  1. Open a WebSocket with the same token and parameter ?id=<question id>:

    wss://notifly.ru/ws?token=A<appToken>&id=12345678
  2. When an answer appears, a signal will arrive on that connection (the answer body is NOT sent over the socket):

    {"id":12345678,"status":"answered","fetch":true}
  3. On the signal, fetch the answer via GET /ask-question/:id:

    CodeMeaning
    200answer ready: {"id":…,"answer":"…","status":"answered"}
    204still waiting (question is still pending)
    410question expired or cancelled

    This GET also marks the delivery (the message card in the admin moves to delivered).

Details of the protocol, ?id= subscription and the get-pending action are on the WebSocket protocol.

The third way to learn about the answer is to have Notifly call you: pass a callback_url field to POST /ask. When the question reaches a terminal status (answered, expired or cancelled), a POST with JSON is sent to that URL:

{
"id": 12345678,
"question": "Deploy to prod?",
"status": "answered",
"answer": "Yes",
"answeredAt": 1750684800,
"ts": 1750684801
}

If callback_secret is set, the body is signed with HMAC-SHA256 and the signature is sent in the X-Notifly-Signature: sha256=<hex> header (same format as generic-webhook channels). Verify it on your side.

Important limitations:

  • Delivery is best-effort. Network errors and 5xx are retried a few times immediately, but there is no durable queue: if your receiver was down, there will be no later retry. The source of truth is always GET /ask-question/:id; the callback is an accelerator, not a guarantee. Deduplicate by (id, status).
  • The URL must be public http(s): private-network addresses (RFC1918, loopback, link-local) are blocked by the SSRF guard at delivery time.
  • A slow receiver won’t delay the user’s answer by more than ~8 seconds — after that the callback delivery is cut off.

The classic scenario: a pipeline reaches a dangerous step and waits for a human “yes” from a push/Telegram message. Everything needed is already there: a closed question + timeout_sec + default_answer (what to assume if nobody answers) + short polling.

Окно терминала
# notifly_gate "Deploy to prod?" Yes No
# → prints the answer; exit code 0 — answer received, 1 — expired/error.
# default_answer = the second option ($2 after shift), i.e. 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 "deploy rejected: $answer"; exit 1; }

Design choices in this recipe:

  • timeout_sec + default_answer: "No" — if nobody answers within 10 minutes, the gate safely closes itself (fail-closed). Want fail-open — set default_answer to the approving option.
  • client_message_id is tied to the pipeline id: a retried CI step won’t create a second question but keeps waiting for the same one.
  • Polling every 5 seconds — the server responds instantly (204 — empty), no connection has to be kept open. A human “yes” takes minutes, so this is more than enough.
  • Instead of polling you can pass a callback_url (see above) — e.g. a GitHub Actions repository_dispatch endpoint — and continue the pipeline by event.

The same pattern is available to AI agents via the MCP server: the ask_question / get_ask_answer(wait_sec) tools give human-in-the-loop without a single line of HTTP code.

Окно терминала
# 1. Send the question
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. When you get {"id":12345678,"fetch":true} via WS — fetch the answer:
# 200 — answer ready, 204 — still waiting, 410 — expired/cancelled
curl "$NOTIFLY_URL/ask-question/12345678" \
-H "X-Notifly-Key: A<appToken>"
# → { "id": 12345678, "answer": "Yes", "status": "answered" }
Окно терминала
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
}'
Окно терминала
# Which channel questions already have answers but are not yet fetched
curl "$NOTIFLY_URL/ask/pending-answers" \
-H "X-Notifly-Key: A<appToken>"
# → [ { "id": 12345678, "fetch": true } ]
# Acknowledge delivery in bulk (will set delivered)
curl -X POST "$NOTIFLY_URL/ask/ack" \
-H "X-Notifly-Key: A<appToken>" \
-H "Content-Type: application/json" \
-d '{ "ids": [12345678] }'
# → { "acknowledged": 1 }

Usually a question is answered by a button in the admin or in the Android app. Programmatically the answer is sent with a Client token (C…) or Basic Auth. Single answer (answer) and multi-select (answers) are supported:

Окно терминала
# Single answer by question 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" }
# Multi-select (only for closed question with 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"] }'
# Answer by message id (if you know the message id instead of question id)
curl -X POST "$NOTIFLY_URL/message/9876543/answer" \
-H "X-Notifly-Key: C<clientToken>" \
-H "Content-Type: application/json" \
-d '{ "answer": "Yes" }'

A question is a message that has an object notifly::ask in extras. Clients (admin, Android) use it to understand that the message is interactive and render buttons/input:

{
"notifly::ask": {
"askQuestionId": 12345678,
"askType": "closed",
"status": "pending",
"options": [
{"id": "Yes", "label": "Yes"},
{"id": "No", "label": "No", "default": true}
],
"allowMulti": false,
"expiresAt": 1750684800000
}
}

The fields allowMulti, options and expiresAt are present only when applicable (options/allowMulti — for closed question, expiresAt — when timeout_sec is set); for an open question with json_format jsonFormat: true is added. About working with extras in general — see Message extras.

Method and pathAuthPurpose
POST /askapp-tokensend a question to a channel
GET /ask/pending-answersapp-tokenlist of questions with an answer ready but not fetched
POST /ask/ackapp-tokenacknowledge delivery of answers ({"ids":[…]})
GET /ask-question/:idapp-tokenfetch the answer (200 / 204 / 410)
POST /ask-question/:id/answerclient-tokenanswer the question
DELETE /ask-question/:idclient-tokencancel a pending question
POST /message/:id/answerclient-tokenanswer a question by message id