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, or fills in a form), 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);
  • short questionnaires: several fields of different types in one question (forms) or a chain of questions where the next one depends on the answer (branching).

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. Each question charges one ask event from the daily quota.

In the examples below NOTIFLY_URL=https://api.notifly.ru.

The type of question is determined by the answer_options and fields fields:

TypeWhenHow answered
openneither is setarbitrary text
closedanswer_options is an array of stringschoosing one (or several) option-buttons
formfields describes the form fieldsa JSON object {field_name: value}

answer_options and fields are mutually exclusive.

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)
fieldsobject[]noform fields → makes the question a form (see Forms)
nextobjectnobranching: option → next question (see Branching)

A few important details:

  • allow_multi works only for closed questions (not for forms or branching). 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}

When you need to collect several values at once, pass a fields array instead of answer_options — the question becomes a form (askType: "form"). The admin and the Android app render input fields, and the answer comes back as a JSON object.

Окно терминала
curl -X POST "$NOTIFLY_URL/ask" \
-H "X-Notifly-Key: A<appToken>" \
-H "Content-Type: application/json" \
-d '{
"title": "Release parameters",
"question": "Where and how do we roll out?",
"fields": [
{"name": "env", "label": "Environment", "type": "select", "required": true,
"options": [{"id": "stage", "label": "Stage"}, {"id": "prod", "label": "Prod"}]},
{"name": "replicas", "label": "Replicas", "type": "number", "required": true},
{"name": "migrate", "label": "Run migrations", "type": "bool"},
{"name": "comment", "label": "Comment", "placeholder": "optional"}
],
"timeout_sec": 900
}'

A form field:

FieldDescription
namemachine name: a-z, 0-9, _, up to 32 characters; unique within the form
labelfield label (defaults to name)
typetext (default), number, bool or select
optionsselect only: up to 20 options {id, label} (either one is enough)
requiredthe field is required
placeholderhint shown in an empty field

A form has at most 10 fields. The answer to a form is a string containing a JSON object:

Окно терминала
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}" }

The server validates the answer against the schema and stores it normalized: numbers become numbers, bool becomes true/false, and for select the option id is stored (both id and label are accepted). Unknown fields, missing required fields and values of the wrong type are rejected with 400, and the response body carries a per-field error map:

{
"error": "Bad Request",
"errorCode": 400,
"errorDescription": "form validation failed",
"fields": {"replicas": "must be a number"}
}

A form’s default_answer must pass the same validation — otherwise the question is not created (400).

A closed question can continue with a follow-up question depending on the selected option. The next field is an object “option → nested question” with the same fields as POST /ask:

Окно терминала
curl -X POST "$NOTIFLY_URL/ask" \
-H "X-Notifly-Key: A<appToken>" \
-H "Content-Type: application/json" \
-d '{
"question": "Roll back the release?",
"answer_options": ["Yes", "No"],
"next": {
"Yes": {
"question": "To which version?",
"answer_options": ["v1.4.2", "v1.4.1"]
}
}
}'

When a person picks an option that has a continuation, the server immediately creates the next question in the same channel and reports its id in the nextQuestionId field — in the POST …/answer response, in GET /ask-question/:id, in the WebSocket signal and in the webhook callback. From then on, wait for the answer to that question.

  • Only for a closed question without allow_multi; the keys of next must be existing options from answer_options.
  • The chain is at most 5 questions deep, counting the first one.
  • callback_url and callback_secret are inherited by the nested question unless it sets its own.
  • Each spawned question charges its own ask event. If it could not be created (for example, the quota ran out), the answer to the original question is still recorded — just without nextQuestionId.

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 answered — GET /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://api.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"} (+ nextQuestionId with branching)
    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
}

With branching, an answered callback also carries nextQuestionId.

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, 429 and 5xx are retried up to three times with pauses of about 1, 4 and 10 seconds, but there is no durable queue: if your receiver was down for longer, 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.
  • The whole callback delivery, retries included, gets about 25 seconds — after that it is cut off. Respond quickly (2xx) and do heavy work asynchronously.

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. From a terminal the same is done by notifly ask --wait (CLI), from code — by Ask + WaitAskAnswer in the Go and Python SDKs.

Окно терминала
# 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": "Deploy to prod?",
"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": "Enter the deployment parameters",
"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 in the admin or in the Android app — in the feed or right from the notification: a closed question with 1–3 options has option buttons in the notification, an open question can be answered with text from the notification shade, and forms, multi-select and questions with more options open via the Reply button (see Android app). Programmatically the answer is sent with a device token (C…) or an API token. 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, for a form (askType: "form") — the fields array. A question spawned by branching carries parentQuestionId. As the question lives on, the server updates the block: status (pending → answered → delivered, or expired/cancelled), answer and nextQuestionId. 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
POST /ask-question/:id/extendapp- or client-tokenextend the answer deadline ({"timeout_sec": N})

If a person is running out of time and the question is about to expire, the deadline is shifted with extend:

Окно терминала
curl -X POST "$NOTIFLY_URL/ask-question/12345678/extend" \
-H "Content-Type: application/json" \
-H "X-Notifly-Key: <app token, API token or device token>" \
-d '{"timeout_sec": 1800}'
{"id": 12345678, "status": "pending", "expiresAt": 1750686600000}
  • timeout_sec — from 1 to 86400 (a day), counted from the current moment, not from the previous deadline.
  • A question in the pending status simply gets a new deadline.
  • An already expired question can be revived: it returns to pending if it expired no more than 24 hours ago. An older one returns 409.
  • A question in the answered or cancelled status cannot be extended — 409.

Extending broadcasts updated extras and a WS signal, so the buttons in the UI and in the email become active again.

Section titled “Answering from an email, Telegram or chat link”

If the channel has delivery to email, Telegram or a team chat enabled, a closed question arrives there with option buttons (or links). A button leads to the public signed address GET /a/answer?token=…: no authentication needed, the answer is recorded right away. The token is HMAC-signed and lives no longer than the answer deadline, so the link cannot be forged or reused. Next to it there is a ✓ Read button (GET /m/read?token=…) that marks the message as read and stops further escalation.