Ask API

Human-in-the-loop as a single HTTP call. Your script or CI job POSTs a question with a few possible actions. Zenhook pages a human (push, email, desktop) and the HTTP call returns whatever they tap. No SDK, no session, no polling framework required.

Ask a question

Same token as your channel's webhook URL: an ask is just a question-shaped event on the channel. By default the call blocks until a human answers (or ~80 seconds pass, see waiting modes).

POSThttps://zenhook.dev/api/ask/{token}
Example Request
curl -X POST https://zenhook.dev/api/ask/abc123def456 \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Deploy api-server to production?",
    "message": "3 commits since the last deploy. Tests green.",
    "actions": ["Deploy", "Abort"],
    "default": "Abort",
    "timeout": 600
  }'
Response once answered
{
  "id": "ask_cmr5o5l6",
  "status": "answered",
  "answer": "Deploy",
  "answeredVia": "link",
  "answeredAt": "2026-07-04T09:41:12.000Z",
  "actions": ["Deploy", "Abort"],
  "defaultAction": "Abort",
  "askUrl": "https://zenhook.dev/ask/…",
  "pollUrl": "https://zenhook.dev/api/ask/…/ask_cmr5o5l6"
}

Everyone on the channel gets the question like a normal alert, with an Answer link. The link opens a public answer page. One tap on the phone and the blocked call above returns. Whoever holds the link can answer; no Zenhook account needed.

Request fields

titlestringRequired
The question. Keep it answerable with one tap.
actionsstring[]
1-6 short choices. Defaults to ["Approve", "Deny"]. First action renders as the primary button.
messagestring
Context under the title (what changed, why you're asking).
fieldsarray
Same label/value pairs as the webhook API, shown on the answer page.
timeoutnumber
Seconds until the ask expires. 30 to 86400. Default 600.
defaultstring
Suggested fallback if nobody answers. Echoed back as defaultAction; never applied for you, and never returned in answer.
waitbool | number
true (default) holds this call until answered or ~80s. A number holds that many seconds. false returns 201 immediately with pollUrl.
callbackUrlstring
POSTed the outcome on answer/expiry: { type: "ask.answered" | "ask.expired", ask: {…} }.
emoji / level / sourcestring
Same semantics as the webhook API. Level defaults to warning, emoji to ❓.

Waiting modes

A single held HTTP request is capped around 80 seconds (proxy limits). For longer decisions, loop on pollUrl: each poll can itself hold up to 80s with ?wait=60, so the loop is cheap:

Approval gate (bash)
RES=$(curl -s -X POST "https://zenhook.dev/api/ask/$TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"title":"Deploy to prod?","actions":["Deploy","Abort"],"timeout":600}')

while [ "$(echo "$RES" | jq -r .status)" = "pending" ]; do
  RES=$(curl -s "$(echo "$RES" | jq -r .pollUrl)?wait=60")
done

if [ "$(echo "$RES" | jq -r .answer)" = "Deploy" ]; then
  ./deploy.sh
else
  echo "Held back by a human." && exit 1
fi

Or skip waiting entirely and get the outcome pushed to you:

Callback mode
{
  "title": "Refund $420 to customer #1847?",
  "actions": ["Refund", "Escalate", "Ignore"],
  "timeout": 3600,
  "wait": false,
  "callbackUrl": "https://your-app.com/hooks/zenhook-answer"
}

Answer semantics

  • First answer wins. Two people tapping at once can't both win; the loser sees who answered first.
  • Expiry never invents an answer.An expired ask has status: "expired" and answer: null. Your default comes back as defaultAction; applying it is your call.
  • Answers are audited. Who answered (when signed in), via which surface, and when.

Plan limits

Free includes 25 asks per month per workspace. Pro raises this to 1,000 asks per month. Asks also count toward your monthly event quota.