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.
Question types
Section titled “Question types”The type of question is determined by the answer_options and fields fields:
| Type | When | How answered |
|---|---|---|
open | neither is set | arbitrary text |
closed | answer_options is an array of strings | choosing one (or several) option-buttons |
form | fields describes the form fields | a 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.
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) |
fields | object[] | no | form fields → makes the question a form (see Forms) |
next | object | no | branching: option → next question (see Branching) |
A few important details:
allow_multiworks 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 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}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:
| Field | Description |
|---|---|
name | machine name: a-z, 0-9, _, up to 32 characters; unique within the form |
label | field label (defaults to name) |
type | text (default), number, bool or select |
options | select only: up to 20 options {id, label} (either one is enough) |
required | the field is required |
placeholder | hint 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).
Branching (next)
Section titled “Branching (next)”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 ofnextmust be existing options fromanswer_options. - The chain is at most 5 questions deep, counting the first one.
callback_urlandcallback_secretare inherited by the nested question unless it sets its own.- Each spawned question charges its own
askevent. If it could not be created (for example, the quota ran out), the answer to the original question is still recorded — just withoutnextQuestionId.
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://api.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"}(+nextQuestionIdwith branching)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}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,
429and5xxare 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 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. - The whole callback delivery, retries included, gets about 25 seconds — after
that it is cut off. Respond quickly (
2xx) and do heavy work asynchronously.
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. From a terminal the same is done by notifly ask --wait
(CLI), from code — by Ask + WaitAskAnswer in the
Go and Python SDKs.
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": "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/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": "Enter the deployment parameters", "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 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 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, 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.
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 |
POST /ask-question/:id/extend | app- or client-token | extend the answer deadline ({"timeout_sec": N}) |
Extending the answer deadline
Section titled “Extending the answer deadline”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— from1to86400(a day), counted from the current moment, not from the previous deadline.- A question in the
pendingstatus simply gets a new deadline. - An already expired question can be revived: it returns to
pendingif it expired no more than 24 hours ago. An older one returns409. - A question in the
answeredorcancelledstatus 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.
Answering from an email, Telegram or chat link
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.