Appearance
HTTP API reference
Every example assumes a server at http://localhost:8080 and the master API_KEY (shown as your-secret-key). Scoped hk_ tokens work wherever their topic pattern and read/publish permissions allow. The machine-readable contract is openapi.yaml; the task-oriented guide is INTEGRATION.md.
Conventions
- Topic names allowed:
[A-Za-z0-9_-], max 64 chars - Topic endpoints require
Authorization: Bearer <token>(the masterAPI_KEYor a scopedhk_token) unless the topic's ACL allows anonymous access - Outgoing Telegram sends are rate-limited to 30/sec
What is persisted
Hooka stores the pieces worth keeping long-term in Postgres:
topics: logical channels (similar tontfytopics)topic_routes: where a topic fans out (telegramchat IDs orwebhookURLs + config)messages: published payloads with title/text/priority/tagsdeliveries: per-chat delivery outcomes and latencyidempotency_keys: dedupe keys for safe retriesingest_hooks: anonymous publish-only capability URLs (/in/{token}), token hashes onlytelegram_poll_hooks/telegram_poll_messages: per chat/thread feeds with their/pollURL and/poll mcpcredential hashes, the MCP server-side cursor, and captured rowsios_push_devices: APNs device registrations for iOS clientsforms: owner-managed native form definitions with stable public IDs and optimistic versionsform_responses: immutable response snapshots, answers, and idempotency receipts
Reading messages: prefer SSE over polling
Subscribers should consume GET /topics/{topic}/stream (Server-Sent Events) rather than polling GET /topics/{topic}/messages in a loop. The stream emits event: ready on connect, event: message per new message with the full StoredMessage JSON, and : keepalive every 20 seconds. Publishes and Telegram inbound captures handled by the same server process wake streams immediately; a 2-second database fallback still catches missed or cross-instance writes. Reconnect by re-opening with ?since=<last_message.created_at> — see INTEGRATION.md → Live stream (SSE) for query parameters and a Node example.
Telegram message features
For Telegram text notifications, Hooka passes these Bot API fields through in publish/send payloads:
rich_messagefor Telegram Bot API 10.2 Rich Messages. Supply exactly one non-emptymarkdown,html, orblocksfield; optionalmedia,is_rtl, andskip_entity_detectionare passed through.Hooka does not upload files for this JSON path; media entries must use references Telegram already accepts (for example an existing file ID or URL).
reply_markupfor inline keyboards, reply keyboards,ForceReply, and keyboard removalreply_parameterslink_preview_optionsdisable_web_page_previewas a legacy alias forlink_preview_options: {"is_disabled": true}message_thread_iddisable_notificationprotect_contentallow_paid_broadcastmessage_effect_id
Rich messages use Telegram's separate sendRichMessage endpoint and are not split into 4,096-character text chunks. The ordinary text field is still required: Hooka stores and searches it and forwards it to webhook/iOS routes. For Telegram routes, rich_message alone controls rendering, so title, text, parse_mode, format, and raw_text are ignored for presentation. link_preview_options and disable_web_page_preview are not supported by sendRichMessage and are rejected when combined with rich_message.
bash
hooka publish deployments \
--text "Deployment report (plain fallback)" \
--rich-message-json '{"markdown":"# Deployment ✅\n\n| Service | Status |\n|---|---|\n| API | Healthy |"}'Agents that need Telegram's "typing..." indicator can call POST /send-chat-action with {"chat_id":"...","action":"typing","topic":"..."}. The optional topic lets scoped publish tokens authorize the chat action against the same topic they will publish the final reply to.
Endpoints
Health
bash
curl http://localhost:8080/health
# {"status":"ok","db":"ok"}Ready
bash
curl http://localhost:8080/ready
# {"status":"ready","db":"ok"}Metrics (Prometheus)
bash
curl http://localhost:8080/metricsPublic media (/media/{path})
Docs pages can embed files kept in a private S3-compatible bucket, such as tutorial videos:
html
<video src="https://hooka.example.com/media/tutorials/01-sign-in.mp4" controls></video>GET or HEAD /media/{path} needs no auth and never streams the file. Both return the same redirect, signed for GET, so follow it with GET. It returns 302 Found with a Location that is a freshly presigned GET URL for {MEDIA_S3_PREFIX}/{path}, valid for MEDIA_PRESIGN_TTL. The bytes then come straight from the bucket, which also answers Range requests. The response carries Cache-Control: public, max-age=300 and Access-Control-Allow-Origin: *.
{path} is 1–4 segments. Each segment matches ^[a-z0-9][a-z0-9._-]{0,127}$ and contains no .., and the path ends in .mp4, .webm, .jpg, .jpeg, .png, or .webp. Hooka answers 404 in three cases:
- the path does not meet these rules;
- the path uses any percent-encoding;
MEDIA_S3_*is not configured.
Any other method gets 405. Hooka does not check that the object exists: the bucket answers the redirected request. Upload files under the prefix, for example public/tutorials/01-sign-in.mp4.
bash
curl -sI http://localhost:8080/media/tutorials/01-sign-in.mp4
# HTTP/1.1 302 Found
# Location: https://<bucket>.<s3-endpoint-host>/public/tutorials/01-sign-in.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256&...Native Forms
Hooka Forms is a first-party form builder; it does not call Google APIs or store data in Google products. The dashboard owner creates a form at /forms, shares the generated /f/{public_id} link, and receives responses in the form detail view with CSV export. Responders do not need a Hooka account.
Owner API routes require the master API key:
bash
curl -X POST "$HOOKA_URL/forms" \
-H "Authorization: Bearer $HOOKA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Event RSVP",
"description": "Let us know whether you can make it.",
"status": "open",
"confirmation_message": "Thanks — your RSVP was received.",
"questions": [
{"id":"name","type":"short_text","title":"Your name","required":true},
{
"id":"attendance",
"type":"single_choice",
"title":"Can you attend?",
"required":true,
"options":[{"id":"yes","label":"Yes"},{"id":"no","label":"No"}]
}
]
}'
curl -H "Authorization: Bearer $HOOKA_API_KEY" \
"$HOOKA_URL/forms/1/responses?limit=50"The public definition and submit endpoints are intentionally anonymous and return no owner metadata or response count. A submission must include the current form version and a retry-safe Idempotency-Key:
bash
curl "$HOOKA_URL/public/forms/hform_..."
curl -X POST "$HOOKA_URL/public/forms/hform_.../responses" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: rsvp-alice-2026" \
-d '{"version":1,"answers":{"name":"Alice","attendance":"yes"}}'The CLI exposes the same owner workflow:
bash
hooka form-create --input ./form.json
hooka forms
hooka form 1
hooka form-responses 1 --limit 50
hooka form-update 1 --input ./updated-form.jsonEdits require expected_version; stale edits and stale public submissions return HTTP 409. Closing a form preserves its history and makes new public submissions return HTTP 410. See the OpenAPI contract and the agent form skill for exact schemas.
Telegram group workflow (chat ID + control)
- Invite your
hookabot to a Telegram group. - In that group, send
/chatid(or/chatid@YourBotUsername). - Bot replies with the exact chat ID, active/stopped status, forum thread ID when present, bound hooka topics for that chat/thread, and inbound capture status.
- Admin receives an invite notification (to
TELEGRAM_ADMIN_CHAT_ID) with inline buttons:Stop Acting: disable hooka deliveries to that chatResume: re-enable deliveries to that chat
Optional admin chat commands:
/hooka_stop/hooka_resume/webhook— mint an anonymous POST URL that delivers into this chat (or this forum thread)/webhook auth— mint a publish-only Bearer credential;/webhook auth rotateupgrades an existing URL/webhook rotate//webhook revoke//webhook status/poll— enable incoming chat capture and mint a read-only polling URL/poll auth— mint a read-only Bearer credential;/poll auth rotateupgrades an existing URL/poll rotate//poll revoke//poll status/poll mcp— mint a chat-scoped MCP credential (read this feed, post here) for Grok Bot or another hosted agent/poll mcp rotate//poll mcp revoke//poll mcp status/recent <topic>— last 5 messages of a topic (admin only whenTELEGRAM_ADMIN_USER_IDis set)
/webhook creates a dedicated telegram-only topic, routes it at the current chat, and replies with a capability URL (/in/hin_...). POST plain text or {"text":"..."} to that URL with no auth header. The token is shown only at mint/rotate time. If TELEGRAM_ADMIN_USER_ID is set, only that user can mint.
Try mode (/start in a private chat)
With HOOKA_TRY_ENABLED=true, anyone who sends /start to the bot in a private chat (with any deep-link payload, so a QR code for https://t.me/<bot>?start=try works) gets a personal ingest URL and a ready-to-paste curl -d "Hello from my laptop 👋" <url>, plus a curl --json variant, a copy button, and a docs link. The server routes a per-chat topic try-<chat id> to that DM only and mints a publish-only hook bound to it that expires after HOOKA_TRY_TTL (default 168h). The URL has the usual ingest guards, including the per-hook INGEST_BURST / INGEST_RATE_PER_MINUTE limit.
- Each
/startrevokes the chat's previous try URL, then mints a new one (a token can only be shown at mint time). One chat can mint at most 5 per 10 minutes; past that the bot says the newest URL still works. - Once
HOOKA_TRY_MAX_ACTIVE(default5000) try URLs are live across all chats,/startreplies that try mode is full. A chat that already holds a URL can still rotate it. - If the reply cannot be delivered, the new URL is revoked.
- Mints and revocations are recorded in the
audittopic as store-only messages, so a busy event does not page the operator. HOOKA_PUBLIC_URLis required (startup fails without it), because the printed command must work as pasted.
Groups, channels, and servers with try mode off handle /start exactly as before: like any other unrecognised command.
For clients that support headers, prefer /poll auth and /webhook auth. Read GET /poll (or /poll/stream) with Authorization: Bearer hpol_...; post to POST /in with Authorization: Bearer hin_.... These credentials are rejected in URL paths. Plain rotate preserves the mode. Existing URLs keep working; auth rotate invalidates them. API creation also accepts "bearer_only": true, or use hooka ingest-create inbox --bearer-only. See header authentication, examples, and rollout notes.
These commands and inline controls work via Telegram long polling (getUpdates).
Telegram chat registry
Hooka keeps an admin-only registry of every Telegram chat it has observed. It records chat metadata, current bot membership status, the first durable observation, the latest observed join, the latest leave/kick, and the most recent event. Membership changes come from Telegram's my_chat_member updates; ordinary messages refresh last_seen_at. Existing installations are backfilled from routes, controls, inbound capture, deliveries, message mappings, and replies.
bash
hooka telegram-chats
hooka telegram-chats --membership active
curl -H "Authorization: Bearer your-secret-key" \
"http://localhost:8080/telegram/chats?membership=all"Telegram Bot API has no “list every chat this bot belongs to” method and does not expose a chat creation timestamp. Therefore this endpoint is an event-sourced registry, not a retroactive Telegram directory:
first_seen_atis Hooka's first durable evidence, not chat creation time.joined_atis the latest join event Hooka observed and isnullwhen that event predates the registry.- backfilled chats use
membership_status: "unknown"until Hooka observes an authoritative membership update.
Results are ordered by last_seen_at DESC, chat_id ASC. Use membership=active, inactive, unknown, or an exact Telegram status to filter them.
Telegram inbound capture
To use hooka like Memo-Boost's quick memo capture, configure each Telegram chat or forum thread from inside Telegram:
text
/capture <topic>
/capture_status
/capture_stopInbound means Telegram-to-hooka: ordinary Telegram text and Telegram photos become stored hooka topic messages. /capture todo enables capture for the current chat or forum thread, ensures the todo topic exists, and stores future captured messages there with telegram and inbound tags plus chat/sender metadata in the raw payload. Photo captures add a photo tag and store Telegram file metadata in the payload; hooka does not download or retain the image bytes. Captioned photos use the caption as message text, and captionless photos use [Photo] as a stable placeholder. Replies to hooka-delivered notifications keep using the reply-handler path and are also captured when inbound capture is enabled for that scope. Consumers subscribed to both paths should deduplicate by Telegram chat and message ID. Consumers that execute commands should use GET /topics/{topic}/inbound?cursor=0&limit=100 with a token allowed to read that topic. It returns {messages, next_cursor, has_more} in ascending message ID order (limit 1–200). Persist the cursor after processing, and continue while has_more is true. Each row includes the Telegram chat/thread/message IDs, original sent_at, captured_at, sender, text/caption and reply ID. Enforce your own sender allowlist, exact chat/thread scope and message freshness before acting.
This endpoint includes only new server-verified Telegram captures. Topic message lists and streams also expose their server-owned source: "telegram_inbound". Publish payloads, including application_payload, type, tags or a caller's source field, cannot grant that provenance. Existing records are not backfilled as trusted. Generic topic JSON payloads alone must never authorize commands.
Captured messages do not require a topic route, but topic watchers still run, so you can forward captured notes to a webhook or another topic. If TELEGRAM_ADMIN_USER_ID is set, only that Telegram user can change capture settings; otherwise any user who can message the bot can configure the current chat. The topic appears in the Topics list as soon as capture is enabled; captured inbound messages appear in that topic's message log after Telegram delivers them to hooka. Consumers that need Telegram chat, thread, message, sender, or photo metadata can read messages or SSE streams with include_payload=true.
Telegram group privacy mode still applies. For full group/thread capture, turn off privacy mode for the bot in BotFather; otherwise Telegram may only deliver commands, DMs, replies, and mentions to hooka. In some existing groups, Telegram may keep delivering only command updates even after privacy is turned off; promote the bot to admin, or remove and re-add it after changing privacy, if /capture works but normal text never appears in the topic log. Sending /capture without a topic returns usage text; use /capture_status to inspect the current setting.
Capture settings are also manageable from the API, CLI, and web UI. The topic detail Routes tab shows enabled inbound capture rows, and admin API tokens can configure the same rows directly:
bash
hooka inbound-captures --topic todo --include-disabled
hooka inbound-capture-set --chat-id -1001234567890 --topic todo --priority 3
hooka inbound-capture-stop --chat-id -1001234567890 --topic todo
curl -H "Authorization: Bearer your-secret-key" \
http://localhost:8080/inbound-captures?topic=todo
curl -N -H "Authorization: Bearer your-secret-key" \
"http://localhost:8080/topics/todo/stream?tags=telegram,inbound&include_payload=true"
curl -X POST http://localhost:8080/inbound-captures \
-H "Authorization: Bearer your-secret-key" \
-H "Content-Type: application/json" \
-d '{"chat_id":"-1001234567890","topic_name":"todo","enabled":true,"priority":3}'Let another bot read the chat
Run /poll in the group or forum thread and share the returned GET URL with the bot's operator. The URL grants read access only to that Telegram scope; the existing /webhook URL remains the way to post back. The configured Telegram admin must enable sharing. Polling captures new human text, photo metadata, and replies, with cursor pagination for catching up after downtime. It does not backfill Telegram history or change /capture topic bindings.
| Endpoint | Credential | Use |
|---|---|---|
GET /poll/{hpol_…} | the URL | JSON pages; add ?wait=1..25 to long-poll |
GET /poll/{hpol_…}/stream | the URL | SSE: ready, message (id: = feed ID), : keepalive; resume with Last-Event-ID |
POST /mcp/chat | Authorization: Bearer hchat_… from /poll mcp | Remote MCP server: read_messages, wait_for_messages, send_message, chat_info for that one chat/thread |
For a hosted agent such as Grok Bot, run /poll mcp and send the bot the message Hooka replies with: Add a custom MCP server called hooka at https://YOUR-HOOKA/mcp/chat with header Authorization: Bearer hchat_….
See Chat polling for setup, the consumer contract, the MCP tools, coverage limits, revocation, and Connect Grok Bot.
Private iPhone alerts
For explicit APNs-only task alerts and reminders without topic/default-chat delivery, see Store-only iOS alerts. The contract includes capability detection, private digest/classifier exclusion and deployment/rollback requirements.
Send direct
bash
curl -X POST http://localhost:8080/send \
-H "Authorization: Bearer your-secret-key" \
-H "Content-Type: application/json" \
-d '{"text":"Hello"}'Send a document (PDF, etc.) — direct
Uses Telegram's sendDocument so files are delivered uncompressed. Max 50MB.
bash
curl -X POST http://localhost:8080/send-document \
-H "Authorization: Bearer your-secret-key" \
-F "[email protected]" \
-F "caption=Q1 report" \
-F "chat_id=-1001234567890"Omit chat_id to use TELEGRAM_DEFAULT_CHAT_ID.
Publish a document to a topic
Fans out the file to every Telegram route attached to the topic. Webhook routes on the same topic are skipped (reported as skipped_non_telegram in the response). When ATTACHMENT_S3_* is configured, hooka also uploads the file to the configured S3-compatible bucket before sending it to Telegram. Set ATTACHMENT_S3_ACL=public-read when the provider supports canned ACLs and uploaded object URLs should be public. PostgreSQL stores only metadata under messages.payload.storage (provider, bucket, key, url, content_type, sha256, size, acl); raw file bytes are not stored in the database.
bash
curl -X POST http://localhost:8080/publish/reports/document \
-H "Authorization: Bearer your-secret-key" \
-H "Idempotency-Key: q1-report-2026" \
-F "[email protected]" \
-F "caption=Q1 report" \
-F "title=Quarterly Report" \
-F "tags=finance,q1" \
-F "priority=3"A photo variant exists at /send-photo and /publish/{topic}/photo (compressed, max 10MB).
Apps that need private image input/output without sending to notification routes use POST /topics/{topic}/images and authenticated downloads. These store JPEG/PNG bytes in Postgres with message retention; the existing /publish/{topic}/photo delivery endpoint does not support store_only.
A photos album variant exists at /publish/{topic}/photos: repeat the photo field 1-10 times (10MB each) and the batch delivers as one Telegram media-group message per route, with caption rendered as the album caption. A single photo falls back to a plain sendPhoto; more than ten photos is rejected. An optional reply_parameters field (Telegram's ReplyParameters JSON, for example {"message_id":42,"allow_sending_without_reply":true}) sends the album as a reply, the way an app threads images under the command it answers.
bash
curl -X POST http://localhost:8080/publish/reports/photos \
-H "Authorization: Bearer your-secret-key" \
-F "[email protected]" \
-F "[email protected]" \
-F "caption=Nightly charts" \
-F "priority=3"URL-token variant, for callers that cannot set headers. Use a scoped token here — the master API_KEY is refused as a URL token (URLs leak via shell history and proxy logs, and a leaked master key grants everything; the attempt is logged and lands on the audit topic):
bash
# mint once: hooka token-create --name url-sender --can-publish=true --can-read=false --topic-pattern "*"
curl -X POST http://localhost:8080/send/hk_your-scoped-token \
-H "Content-Type: application/json" \
-d '{"text":"Hello"}'Publish to a topic
bash
curl -X POST http://localhost:8080/publish/deployments \
-H "Authorization: Bearer your-secret-key" \
-H "Idempotency-Key: deploy-2026-03-05-001" \
-H "Content-Type: application/json" \
-d '{
"title": "Deploy Succeeded",
"text": "api@a1b2c3 shipped",
"priority": 4,
"tags": ["prod", "release"]
}'Publish a batch to a topic
POST /publish/{topic}/batch accepts up to 100 messages in one request. The server fans out internally with a bounded worker pool (cap = 8 concurrent goroutines) so a 50-message batch doesn't take 50× a single message but also doesn't blow past Telegram's per-chat rate limit. Per-message failures land in results[i].error — the batch never aborts on a single failure.
bash
curl -X POST http://localhost:8080/publish/deployments/batch \
-H "Authorization: Bearer your-secret-key" \
-H "Idempotency-Key: hourly-2026-04-27-15" \
-H "Content-Type: application/json" \
-d '{
"messages": [
{"title": "Build #1", "text": "ok", "priority": 3, "tags": ["ci"]},
{"title": "Build #2", "text": "ok", "priority": 3, "tags": ["ci"]}
]
}'Response:
json
{
"ok": true,
"topic": "deployments",
"delivered": 2,
"failed": 0,
"results": [
{"index": 0, "ok": true, "message_id": 12345, "delivered": 1, "failed": 0},
{"index": 1, "ok": true, "message_id": 12346, "delivered": 1, "failed": 0}
]
}The Idempotency-Key header applies to the whole batch — retries with the same key reuse the existing per-message idempotency table (the server synthesizes per-message keys as <batch-key>:<index>) and return the original message_ids without storing duplicates.
Send a batch direct
POST /send-batch is the chat-targeted equivalent. Each message must carry chat_id (or fall back to TELEGRAM_DEFAULT_CHAT_ID if configured). Messages targeting the same chat_id are serialized internally so a many-messages-to-one-chat batch doesn't trip Telegram's per-chat 1msg/sec limit; messages to different chats stay parallel.
bash
curl -X POST http://localhost:8080/send-batch \
-H "Authorization: Bearer your-secret-key" \
-H "Content-Type: application/json" \
-d '{
"messages": [
{"chat_id": "-1001111111111", "text": "alpha"},
{"chat_id": "-1002222222222", "text": "beta"}
]
}'A message missing chat_id (with no default configured) lands as {"index": K, "ok": false, "error": "missing chat_id", "code": "missing_chat_id"} while the rest of the batch continues.
Add/update a topic route
Telegram route:
bash
curl -X POST http://localhost:8080/topics/deployments/routes \
-H "Authorization: Bearer your-secret-key" \
-H "Content-Type: application/json" \
-d '{"provider":"telegram","chat_id":"-1001234567890","parse_mode":"HTML"}'Webhook route:
bash
curl -X POST http://localhost:8080/topics/deployments/routes \
-H "Authorization: Bearer your-secret-key" \
-H "Content-Type: application/json" \
-d '{
"provider":"webhook",
"target":"https://example.com/hook",
"headers":{"Authorization":"Bearer your-upstream-secret"}
}'SMS route (master key only, and the number needs a configured backend, see SMS; the target is an E.164 number stored normalized, so +1 (212) 555-0123 becomes +12125550123):
bash
curl -X POST http://localhost:8080/topics/deployments/routes \
-H "Authorization: Bearer your-secret-key" \
-H "Content-Type: application/json" \
-d '{"provider":"sms","target":"+12125550123"}'Delete a topic route
Matched on the same key the upsert conflicts on — provider + chat/target + message_thread_id — so removing one thread's route leaves a sibling route for a different thread in the same chat untouched.
bash
curl -X DELETE http://localhost:8080/topics/deployments/routes \
-H "Authorization: Bearer your-secret-key" \
-H "Content-Type: application/json" \
-d '{"provider":"telegram","chat_id":"-1001234567890","message_thread_id":"4231"}'Returns 404 when nothing matched, so a typo'd thread id fails loudly instead of reporting a delete that never happened.
List routes
bash
curl -X GET http://localhost:8080/topics/deployments/routes \
-H "Authorization: Bearer your-secret-key"Forum thread routing (message_thread_id)
Getting alerts in the wrong forum topic almost always comes down to one of these four rules:
- Precedence. A request's
message_thread_idbeats the route's. It is applied to every matched route, so on a multi-route topic one request-level thread overrides the fan-out. Prefer per-route threads; pass a request thread only when you really mean to override all of them. - Unparseable ids misroute silently. Telegram reads the leading integer out of the value and treats an unparseable one as absent, delivering to the chat's General topic with
ok:true. Hooka therefore rejects any non-integermessage_thread_idwith400. (A well-formed but nonexistent id is fine to send — Telegram answers400 message thread not found, which surfaces as a failed delivery.) - A route is identified by its thread.
route-addwith a new--thread-idadds a route; it does not re-point the topic. Delete the old one or the topic delivers to both threads. - No route means the default chat. A topic with no route falls back to
TELEGRAM_DEFAULT_CHAT_ID— usually a DM, not the group. The server logs a warning (topic has no route; falling back to TELEGRAM_DEFAULT_CHAT_ID).
Every delivery records the thread it actually used, so you can audit where a message really went instead of inferring it:
sql
SELECT m.topic_name, d.chat_id, d.message_thread_id, d.status, d.delivered_at
FROM deliveries d JOIN messages m ON m.id = d.message_id
WHERE d.delivered_at > NOW() - INTERVAL '1 day'
ORDER BY d.delivered_at DESC;Read topic history
bash
curl -X GET "http://localhost:8080/topics/deployments/messages?limit=20" \
-H "Authorization: Bearer your-secret-key"With filters:
bash
curl -X GET "http://localhost:8080/topics/deployments/messages?limit=20&min_priority=4&tags=prod,release&since=2026-03-05T00:00:00Z" \
-H "Authorization: Bearer your-secret-key"Add include_payload=true when a subscriber needs stored raw payload metadata, such as Telegram inbound chat/thread/sender fields.
Read dead letters
bash
curl -X GET "http://localhost:8080/topics/deployments/deadletters?limit=20" \
-H "Authorization: Bearer your-secret-key"Replay dead letters
Re-attempt delivery of messages that previously exhausted their retries. On success the dead-letter row is removed; on failure it is retained with an updated attempt count. Requires publish scope on the topic.
bash
# replay one (id from the deadletters listing)
curl -X POST "http://localhost:8080/topics/deployments/deadletters/42/replay" \
-H "Authorization: Bearer your-secret-key"
# replay all (up to ?limit=, default 200)
curl -X POST "http://localhost:8080/topics/deployments/deadletters/replay" \
-H "Authorization: Bearer your-secret-key"
# CLI
hooka deadletters-replay deployments 42
hooka deadletters-replay deployments --allTopic ACL (admin/master key)
Read ACL:
bash
curl -X GET "http://localhost:8080/topics/deployments/acl" \
-H "Authorization: Bearer your-secret-key"Update ACL:
bash
curl -X POST "http://localhost:8080/topics/deployments/acl" \
-H "Authorization: Bearer your-secret-key" \
-H "Content-Type: application/json" \
-d '{"anonymous_publish":false,"anonymous_read":true}'Anonymous ingest hooks (publish-only capability URLs)
For pushing messages to yourself from agents and services you don't control: mint an unguessable URL bound to one topic, hand out the URL, and the holder can POST text to it with no auth header — nothing else. Unlike the topic ACL (which opens a topic to anyone who guesses its name), the 192-bit token in the URL is the credential: per-counterparty, revocable, rate-limited (INGEST_BURST / INGEST_RATE_PER_MINUTE, 429s carry Retry-After), optional ttl + max_uses (idempotent replays and route-less failures don't consume uses), priority capped at 4, body rendered as Label: value bullets with HTML escaped and no markdown/autolink (poster markup is never interpreted), refused on topics with webhook routes, and every delivered message opens with an unremovable 📥 via ingest "<name>" line. Unknown/revoked/expired tokens all answer a uniform 404, and /in/ paths are redacted from request logs.
bash
# mint (admin) — token_value is shown exactly once
curl -X POST http://localhost:8080/ingest-hooks \
-H "Authorization: Bearer your-secret-key" \
-H "Content-Type: application/json" \
-d '{"name":"bobs-agent","topic":"summaries","ttl":"168h","max_uses":50}'
# the counterparty posts anonymously — the URL is the whole credential
curl -X POST http://localhost:8080/in/hin_...long-token... \
-H "Content-Type: application/json" \
-d '{"text":"Session summary: shipped the fix","priority":3,"tags":["summary"]}'
# plain text works too, and GET on the URL returns the contract
curl -X POST http://localhost:8080/in/hin_...long-token... -d "done: tests green"
# manage (admin)
curl -H "Authorization: Bearer your-secret-key" http://localhost:8080/ingest-hooks
curl -X DELETE -H "Authorization: Bearer your-secret-key" http://localhost:8080/ingest-hooks/3The intended flow: hooka ingest-create summaries --name "bobs-agent" → paste the printed URL into any agent conversation ("send your summary as an HTTP POST to this URL") → the message lands in your Telegram via the topic's routes. To bind a URL to the current Telegram chat without picking a topic, run /webhook in that chat (see Telegram Group Workflow). See INTEGRATION.md §5.2.
Stream topic events (SSE)
bash
curl -N "http://localhost:8080/topics/deployments/stream?since=2026-03-05T00:00:00Z&min_priority=4&tags=prod,release" \
-H "Authorization: Bearer your-secret-key" \
-H "Accept: text/event-stream"Use include_payload=true on the stream for consumers that need stored payload metadata in each event: message frame. Same-process publishes wake connected streams immediately, with a periodic database fallback for missed or cross-instance writes.
Check topic stream status
bash
curl "http://localhost:8080/topics/deployments/status" \
-H "Authorization: Bearer your-secret-key"The response reports active SSE subscribers for that topic:
json
{
"ok": true,
"topic": "deployments",
"connected": true,
"stream_subscriber_count": 1,
"subscribers": [
{
"client_name": "build-box",
"client_role": "agent_bridge",
"connected_at": "2026-06-14T03:00:00Z"
}
]
}Manage scoped API tokens (admin/master key)
Create token:
bash
curl -X POST http://localhost:8080/tokens \
-H "Authorization: Bearer your-secret-key" \
-H "Content-Type: application/json" \
-d '{"name":"ci-publisher","can_publish":true,"can_read":false,"topic_pattern":"deployments*"}'topic_pattern is required (the server no longer silently defaults it to *). Use "*" to grant all topics, or a prefix like "deployments*" to scope the token. A token with an empty/missing pattern is rejected at creation, and an empty pattern grants no topics at authorization time (fail closed).
List tokens:
bash
curl -X GET http://localhost:8080/tokens \
-H "Authorization: Bearer your-secret-key"Revoke token:
bash
curl -X DELETE http://localhost:8080/tokens/1 \
-H "Authorization: Bearer your-secret-key"Log in with Hooka
Small sites and prototypes can request a scoped Hooka token without handling the master API key. The site creates a public login request with a concrete topic pattern and keeps the returned device_code secret:
bash
curl -X POST http://localhost:8080/login/requests \
-H "Content-Type: application/json" \
-d '{"client_name":"demo-site","can_read":true,"can_publish":true,"topic_pattern":"demo-*"}'An admin reviews pending requests with the master key:
bash
curl http://localhost:8080/login/requests?status=pending \
-H "Authorization: Bearer your-secret-key"
curl -X POST http://localhost:8080/login/requests/lr_abc123/approve \
-H "Authorization: Bearer your-secret-key" \
-H "Content-Type: application/json" \
-d '{"can_read":true,"can_publish":false,"topic_pattern":"demo-*"}'The client polls/exchanges with the secret device code. Before approval this returns 202 authorization_pending; after approval it returns token_value once and marks the request exchanged.
bash
curl -X POST http://localhost:8080/login/requests/lr_abc123/exchange \
-H "Content-Type: application/json" \
-d '{"device_code":"hdc_secret"}'Reminders (scheduled notifications)
Schedule one-shot or recurring notifications. Delivery reuses the normal publish pipeline, so topic routing, retries, and dead-letters all apply. Failed occurrences retry durably with exponential backoff. A precise in-memory timer fires at the exact fire_at, and a 30-second poller acts as a durability safety net — reminders that miss their timer (e.g. during a restart) fire on the next poll. Multi-instance safe: claim uses FOR UPDATE SKIP LOCKED.
Schedule spec fields are two independent, optional pairs (at least one field overall is required):
- first fire: choose at most one of
fire_at(RFC3339) orin(Go duration) - recurrence: choose at most one of
every(Go duration) orcron(5-field cron or descriptor)
A first-fire field may be combined with a recurrence field. If only every/cron is supplied, the first fire is derived from that schedule.
Create:
bash
# one-shot relative
curl -X POST http://localhost:8080/reminders \
-H "Authorization: Bearer your-secret-key" \
-H "Idempotency-Key: deployments-standup-2026-04-20" \
-H "Content-Type: application/json" \
-d '{
"topic": "deployments",
"in": "30m",
"notification": {"text":"stand up in 30m","priority":3}
}'
# recurring (every hour)
curl -X POST http://localhost:8080/reminders \
-H "Authorization: Bearer your-secret-key" \
-H "Content-Type: application/json" \
-d '{
"topic": "heartbeat",
"every": "1h",
"notification": {"text":"💓 alive","priority":2}
}'
# recurring (weekdays at 9am UTC)
curl -X POST http://localhost:8080/reminders \
-H "Authorization: Bearer your-secret-key" \
-H "Content-Type: application/json" \
-d '{
"topic": "standup",
"cron": "0 9 * * 1-5",
"notification": {"title":"Daily standup","text":"Join the call","priority":4}
}'
# direct Telegram forum topic (fixed destination)
curl -X POST http://localhost:8080/reminders \
-H "Authorization: Bearer your-secret-key" \
-H "Content-Type: application/json" \
-d '{
"in": "10m",
"notification": {
"chat_id": "-1001234567890",
"message_thread_id": "42",
"text": "Check the deployment"
}
}'Reuse the request Idempotency-Key after an ambiguous timeout to get the same reminder ID instead of scheduling twice. Hooka provides at-least-once reminder delivery. Because Telegram has no provider-side idempotency transaction, a crash after Telegram accepts a message but before Hooka records success can rarely result in a duplicate; pending reminders are never silently discarded. Both the creation Idempotency-Key and optional idempotency_prefix are limited to 200 UTF-8 bytes.
Create, list, and get responses include the full durable record plus a delivery receipt. Direct Telegram destinations are fixed at creation time; topic routes are intentionally resolved at fire time. Use ?include_terminal=true to include fired, failed, and cancelled history.
List (master key required when topic omitted):
bash
curl -X GET "http://localhost:8080/reminders?topic=deployments&status=pending&limit=50" \
-H "Authorization: Bearer your-secret-key"Get one / cancel:
bash
curl -X GET http://localhost:8080/reminders/42 \
-H "Authorization: Bearer your-secret-key"
curl -X DELETE http://localhost:8080/reminders/42 \
-H "Authorization: Bearer your-secret-key"Statuses: pending → firing (briefly during dispatch) → fired (one-shot success) or failed (one-shot error). Recurring reminders return to pending with a new fire_at after each fire. Cancelled reminders are terminal (cancelled).
Webhook forward
bash
curl -X POST http://localhost:8080/webhook/github \
-H "Authorization: Bearer your-secret-key" \
-H "Content-Type: application/json" \
-d '{"event":"push","repo":"my-app"}'Telegram forum thread names
Route responses (GET /topics/{topic}/routes and the route entries in GET /topics) include optional read-only thread_name and chat_title fields for Telegram destinations. Names are learned from Telegram's forum_topic_created and forum_topic_edited service messages; replies containing the topic creation message can recover older names. A delayed creation message never overwrites a newer rename. Names belong to (chat_id, message_thread_id), independently of Hooka's own topic names.
Telegram Bot API has no method to list or retrieve existing forum topic names. For an older thread whose name is unknown, reply to its topic creation service message or rename it in Telegram while Hooka is receiving updates. Until Hooka observes a name, the fields are omitted and clients should display the IDs.
Reserved topics
audit
audit is the canonical topic for hooka's own control-plane events: dashboard logins, scoped token lifecycle, destructive route/reminder actions, etc. Admin tools (the dashboard, ops scripts) emit structured events to /publish/audit, and the server itself publishes:
token.created/token.revoked— scoped API token lifecycle (priority 4)ingest_hook.created/ingest_hook.revoked— anonymous ingest URL lifecycle (priority 4)auth.master_key_in_url.rejected— the master key was presented as a URL path token and refused (priority 5, throttled to ~1/min)ingest.scan_suspected— ≥INGEST_SCAN_ALERT_THRESHOLDunknown/revoked ingest tokens hit/in/within a 5-minute window (priority 5, ≤1/hour)
Server events never include raw token values. If the audit topic has no route (and no default chat), events are still persisted, so GET /topics/audit/messages remains a durable audit log.
Event schema:
| Field | Value |
|---|---|
title | event type, e.g. login.success, login.failed, token.created, route.added, reminder.cancelled |
text | structured detail (Actor:, IP:, UA:, Resource:) |
priority | 2 info, 4 auth/destructive, 5 security-sensitive (failed auth, suspicious pattern) |
tags | [event-type, actor-slug] |
Route it somewhere. Add a telegram route pointing at your admin chat:
bash
curl -X POST "$HOOKA_URL/topics/audit/routes" \
-H "Authorization: Bearer $HOOKA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"provider":"telegram","chat_id":"<admin-chat-id>","parse_mode":"HTML"}'With no route configured, events fall back to TELEGRAM_DEFAULT_CHAT_ID per the usual rules — setting an explicit route lets you keep audit traffic separate from default notifications.