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.
Open and closed question
Section titled “Open and closed question”The type of question is determined by the presence of the answer_options field:
| Type | When | How answered |
|---|---|---|
open | answer_options is not set | arbitrary text |
closed | answer_options is an array of strings | choosing 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.
Question parameters
Section titled “Question parameters”All parameters are passed in the body of POST /ask (there are no per-channel settings).
| Field | Type | Required | Description |
|---|---|---|---|
question | string | yes | question text |
title | string | no | message title (default ❓) |
answer_options | string[] | no | answer options → makes the question closed |
default_option | string | no | which of answer_options to mark as the default |
allow_multi | boolean | no | allow selecting multiple options (only for closed question) |
timeout_sec | number | no | after how many seconds the question auto-expires (0/absent — never) |
default_answer | string | no | answer filled in when timeout occurs |
json_format | boolean | no | answer must be valid JSON (for open question) |
client_message_id | string | no | idempotency key |
priority | number | no | message priority (default — channel priority or 5) |
callback_url | string | no | http(s) URL: webhook on answered/expired/cancelled (see below) |
callback_secret | string | no | secret for the HMAC signature of the callback body (X-Notifly-Signature) |
A few important details:
allow_multiworks only for closed questions. If a person sends more than one answer for an open question or for a closed question withoutallow_multi— the server will return400. Multiple answers are joined into a string with,.json_formatis validated server-side: an answer that is not valid JSON is rejected with400.client_message_idprovides idempotency: repeatingPOST /askwith the sameclient_message_idwithin 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 }Question statuses
Section titled “Question statuses”A question lives in one of four statuses:
| Status | Meaning |
|---|---|
pending | question sent, waiting for an answer |
answered | a person answered; the answer is in the answer field |
expired | timeout_sec expired before an answer |
cancelled | question 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.
-
Open a WebSocket with the same token and parameter
?id=<question id>:wss://notifly.ru/ws?token=A<appToken>&id=12345678 -
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} -
On the signal, fetch the answer via
GET /ask-question/:id:Code Meaning 200answer ready: {"id":…,"answer":"…","status":"answered"}204still waiting (question is still pending)410question expiredorcancelledThis 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.
Webhook callback
Section titled “Webhook callback”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
5xxare 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 alwaysGET /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.
Approval gate in CI
Section titled “Approval gate in CI”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 — setdefault_answerto the approving option.client_message_idis 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 Actionsrepository_dispatchendpoint — 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.
Examples (curl)
Section titled “Examples (curl)”Closed question with buttons
Section titled “Closed question with buttons”# 1. Send the questioncurl -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/cancelledcurl "$NOTIFLY_URL/ask-question/12345678" \ -H "X-Notifly-Key: A<appToken>"# → { "id": 12345678, "answer": "Yes", "status": "answered" }Open question with JSON answer
Section titled “Open question with JSON answer”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 instead of WebSocket
Section titled “Polling instead of WebSocket”# Which channel questions already have answers but are not yet fetchedcurl "$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 }Answering the question (client side)
Section titled “Answering the question (client side)”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 idcurl -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" }'Extras notifly::ask
Section titled “Extras notifly::ask”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.
REST API
Section titled “REST API”| Method and path | Auth | Purpose |
|---|---|---|
POST /ask | app-token | send a question to a channel |
GET /ask/pending-answers | app-token | list of questions with an answer ready but not fetched |
POST /ask/ack | app-token | acknowledge delivery of answers ({"ids":[…]}) |
GET /ask-question/:id | app-token | fetch the answer (200 / 204 / 410) |
POST /ask-question/:id/answer | client-token | answer the question |
DELETE /ask-question/:id | client-token | cancel a pending question |
POST /message/:id/answer | client-token | answer a question by message id |