# notifyd — API Reference for AI Agents > Self-hosted notification service. Email, SMS, Push, In-App — one REST API. > Base URL: http://localhost:3400 (or your deployed domain) > Auth: X-Api-Key header on every request --- ## AUTHENTICATION All requests need: X-Api-Key: sk__xxx Inbox/SSE endpoints also accept: Authorization: Bearer Admin endpoints need the admin API key. Get a subscriber JWT: POST /v1/auth/subscriber-token Body: {"subscriber_id": "user-1"} Response: {"token": "eyJ..."} --- ## SEND NOTIFICATION POST /v1/send Send via one or more channels. Async queue, processed <1s. Body: { "channels": ["email", "in_app"], // or "channel": "email" for single "subscriber_id": "user-uuid", // optional, links to subscriber record "to": "user@example.com", // optional; without it each channel takes its address from the subscriber: // email, phone (sms/whatsapp), data.telegram_chat_id, data.slack (webhook or channel id), // data.discord_webhook; in_app/push use the subscriber id. No address = channel skipped. "subject": "Email subject", // email only "body": "Hello {{first_name}}!", // supports {{var}} substitution "template": "template_id", // optional, use stored template instead "vars": {"first_name": "Alice"}, // template variables "scheduled_at": "2026-03-25T14:00:00Z", // optional future delivery "idempotency_key": "unique-key", // optional dedup key "priority": "normal", // critical|high|normal|low|bulk or 0-100; lower first "tags": [{"name": "category", "value": "transactional"}], // email; category=campaign|marketing|newsletter defaults priority to bulk "email_headers": {"List-Unsubscribe": ""}, // email, custom MIME headers "attachments": [{"filename": "f.pdf", "content": "", "content_type": "application/pdf"}], "cc": ["copy@example.com"], "reply_to": "support@example.com", "send_window": {"start":"09:00","end":"20:00","tz":"Europe/Paris","days":[1,2,3,4,5]}, // bulk waits for recipient daytime; false bypasses the project window "topic": "tips", // optional stream name; defaults to the template's topic. See TOPICS. "track": false, // or {"opens": false, "clicks": true}; see TRACKING. Default: project setting (on). "sms": {"from": "MyShop"} // optional per-message SMS sender: E.164, or 1-11 letters/digits/spaces with a letter (one-way) } Channels: "email", "sms", "whatsapp", "in_app", "push", "telegram", "slack", "discord" Response: {"success": true, "job_ids": ["uuid", ...], "scheduled_at": "...", "channels": ["email", "in_app"], "topic": "tips"|null, "skipped": [{"channel": "email", "reason": "subscriber opted out of topic 'tips' on email"}]} (one job per channel actually queued, same order; channels skipped for an opt-out, a missing address or no connector are listed in `skipped`, no job is created for them. A 2xx with empty job_ids means nothing was queued.) A channel this instance cannot deliver (no connector; alphanumeric SMS sender on Telnyx without TELNYX_MESSAGING_PROFILE_ID) is refused at enqueue. If no job was created because of that: 422 {"error": "channel_not_configured", "message", "skipped"}. /v1/batch refuses the whole batch with that 422. GET /v1/channels -> {"channels": {"sms": {"allowed": bool, "configured": bool}, ...}} tells which channels the project lists and this instance can deliver. Job lifecycle (GET /v1/jobs/:id): pending -> processing -> sent | retry | failed. - provider accepted: sent, provider + provider_message_id stored - 429 / 5xx / network with EMAIL_FALLBACK_PROVIDER: sent through the fallback immediately, primary rests EMAIL_FAILOVER_COOLDOWN_SECS (60) - 429: retry WITHOUT consuming an attempt; the whole channel pauses for Retry-After (priorities order the resume, they do not bypass the pause) - 5xx / timeout / network: retry with backoff 30s, 2min, 10min, 30min, 2h (+-20% jitter), failed after max_attempts (5) - other 4xx, invalid recipient, suppression list: failed immediately - after sent, provider feedback: Resend webhooks (email) and Twilio status callbacks (SMS, need PUBLIC_URL; POST /webhooks/twilio/status, X-Twilio-Signature, no API key). Twilio delivered -> delivered_at + job.delivered; undelivered|failed on a sent (or still processing) job -> bounced, bounced_at, error "twilio " + job.bounced; smsmode (SMS, need PUBLIC_URL and SMSMODE_WEBHOOK_SECRET; POST /webhooks/smsmode/status, X-Smsmode-256, no API key): DELIVERED -> delivered_at + job.delivered; UNDELIVERABLE|UNDELIVERED -> bounced, bounced_at, error "smsmode " + job.bounced; first terminal status wins; a job's events can arrive in any order, GET /v1/jobs/:id is the truth Worker claims jobs ORDER BY priority, scheduled_at: bulk never delays transactional. Jobs stuck in processing > 10 min (worker crash) are re-queued by a reaper (attempt consumed). POST /v1/batch takes "subscribers": [ids] OR "segment": {filter} (see SEGMENTS), accepts idempotency_key (declined per subscriber and channel), `topic`, `track`, `push`; each job carries the subscriber's address for its channel; reports jobs_created, jobs_deduplicated, jobs_skipped (opt-outs), jobs_without_address (unknown id or no address), subscribers. Send windows: project settings.send_window (PATCH /v1/admin/projects/:id {send_window}) or per request; applies to marketing email (priority>=80 / campaign tag) unless applies_to=all; recipient timezone from subscribers.timezone (POST /v1/subscribers {timezone}). Bulk email (priority>=80 or tag category=campaign|marketing|newsletter) gets List-Unsubscribe + one-click headers pointing at PUBLIC_URL/u/; POST /u/:token records a marketing-scoped suppression (transactional email still goes). Suppressions have scope all|marketing. --- ## BATCH SEND POST /v1/batch Send same notification to multiple subscribers. Body: { "channels": ["email", "in_app"], "subscribers": ["user-1", "user-2", "user-3"], "template": "weekly_digest", "vars": {"week": "March 24-30"}, "icon": "calendar", // optional for in-app batch notifications "url": "/digest/2026-w13" // optional deep link for in-app batch notifications } --- ## SUBSCRIBERS POST /v1/subscribers — Create or update subscriber Body: {"id": "user-1", "email": "user@example.com", "phone": "+33612345678", "first_name": "Alice", "last_name": "Dupont", "locale": "fr", "data": {}} GET /v1/subscribers — List subscribers (query: ?limit=20&offset=0) GET /v1/subscribers/:id — Get one subscriber DELETE /v1/subscribers/:id — Delete subscriber and all their data --- ## IN-APP INBOX GET /v1/inbox/:subscriber_id — List notifications Query: ?limit=20&offset=0&filter=all|unread|todo&q= Response: {"items": [...], "total": n, "limit": 20, "offset": 0} Each notification: { "id": "uuid", "body": "You have a new message", "icon": "message", "url": "/messages/42", "data": {}, "read_at": null, "is_todo": false, "created_at": "2026-03-25T10:30:00Z" } PATCH /v1/inbox/:subscriber_id/:msg_id — Update notification Body: {"read": true} or {"archived": true} or {"is_todo": true} POST /v1/inbox/:subscriber_id/read-all — Mark all as read GET /v1/inbox/:subscriber_id/unread-count — Badge count Response: {"count": 5} --- ## SSE REALTIME STREAM GET /v1/inbox/:subscriber_id/stream?ticket= Content-Type: text/event-stream The URL accepts only a one-time ticket (EventSource cannot send headers). A subscriber JWT or an API key goes in "Authorization: Bearer", never in the URL: in the query it is refused (401). Events: data: {"type":"new_notification","notification":{...}} data: {"type":"count_update","unread_count":5} data: {"type":"read","notification_id":"uuid"} data: {"type":"archived","notification_id":"uuid"} One-time ticket: POST /v1/inbox/:subscriber_id/stream-ticket (subscriber JWT or API key, in a header) Response: {"ticket": "abc-123", "expires_in_seconds": 30} Then: GET /v1/inbox/:subscriber_id/stream?ticket=abc-123 The ticket is consumed by the first connection and bound to the project and subscriber: used, expired or unknown = 401, another subscriber = 403. Reconnect with a fresh ticket. `?token=` is the former name of `ticket`, read as a ticket only. --- ## JOBS GET /v1/jobs/:id — Get job status Response: {"id", "status", "channel", "subscriber_id", "recipient", "template_id", "topic", "priority", "attempts", "max_attempts", "provider", "provider_message_id", "scheduled_at", "created_at", "sent_at", "delivered_at", "bounced_at", "opened_at", "clicked_at", "error", "provider_events": [{"provider", "type", "occurred_at", ...}]} Status values: pending, processing, sent, failed, cancelled, retry DELETE /v1/jobs/:id — Cancel pending or scheduled job --- ## TEMPLATES POST /v1/templates — Create or update template Body: { "id": "welcome_email", "channel": "email", "subject": "Welcome {{first_name}}!", "body": "Hello {{first_name}}, welcome to {{app_name}}.", "body_html": "

Welcome {{first_name}}!

", "topic": "onboarding" // optional default topic for sends using this template } GET /v1/templates?limit=&offset= — List templates. Response: {"items": [...], "total", "limit", "offset"} GET /v1/templates/:id — Get template DELETE /v1/templates/:id — Delete template Variables use {{var_name}} syntax. Substituted at send time from vars object. --- ## PREFERENCES GET /v1/subscribers/:id/preferences — Get subscriber preferences PUT /v1/subscribers/:id/preferences — Set preferences Body: { "preferences": [ {"channel": "email", "topic": "tips", "enabled": false}, // stop one topic on one channel {"channel": "*", "topic": "tips", "enabled": false}, // stop one topic everywhere {"channel": "email", "workflow_id": "welcome-series", "enabled": false}, {"channel": "sms", "enabled": false} // whole channel (workflow_id "*") ] } Response rows: {"channel", "workflow_id", "topic", "enabled"} — `topic` and `workflow_id` are the same scope column. Everything is allowed by default. Decision order, most specific first: (channel, topic) > ("*", topic) > (channel, workflow or template id) > (channel, "*") > ("*", "*"). Enforced at enqueue (no job created, listed in `skipped`) and again by the worker. ## CHAT CHANNELS (telegram, slack, discord) Same request as any channel. Recipient: Telegram chat id · Slack incoming webhook URL or channel id (C…/D…) · Discord webhook URL. Server config: TELEGRAM_BOT_TOKEN (Telegram), SLACK_BOT_TOKEN (Slack channel ids only; webhooks need nothing), Discord: nothing. Text = subject + blank line + body; "url" becomes an inline "Open" button (Telegram), a link (Slack) or the embed link (Discord). "chat": {"text": "…"} replaces the built text; "chat": {"button": "See order"} names the Telegram button. Dead destinations (bot blocked, chat not found, channel archived, unknown webhook) fail the job for good; 429 pauses the lane. Pacing: TELEGRAM_RATE_PER_SEC (20), SLACK_RATE_PER_SEC (1), DISCORD_RATE_PER_SEC (0.5). TELEGRAM_API_BASE for a self-hosted Bot API. Digest to a chat: DIGEST_NOTIFY=telegram: | slack: | discord:, DIGEST_NOTIFY_EVERY (1d), DIGEST_NOTIFY_LEVEL (warning; critical|info|always). POST /v1/admin/digest/notify {"to"?, "window"?} and `notifyd digest --to telegram:` send one now. ## SEGMENTS POST /v1/segments/preview — Body: a segment. Response: {"count": n, "sample": ["id", ...]} (10 ids). POST /v1/batch {"segment": {...}, ...} — same filter, one job per matching subscriber and channel. Segment: { "locale": "fr" | ["fr", "be"], "timezone": "Europe/Paris", "has_email": true, "has_phone": false, "created_after": "2026-01-01T00:00:00Z", "created_before": "...", "data": {"plan": "pro", "country": ["FR", "BE"]}, // eq / in on subscriber data keys "where": [{"field": "data.seats", "op": "gte", "value": 5}, {"field": "email", "op": "contains", "value": "@acme.com"}] } Fields: locale, timezone, email, phone, first_name, last_name, created_at, data. (dots for nesting: data.company.size). Operators: eq, neq, in, not_in, exists (value true/false), contains, gt, gte, lt, lte (numbers; RFC 3339 for created_at). Everything is bound as a parameter; an empty segment means every subscriber of the project. ## TRACKING (opens and clicks, provider-independent) With PUBLIC_URL set, every **marketing** HTML email (bulk priority or campaign tag, the same rule as List-Unsubscribe) gets a 1×1 pixel (GET /t/o/.gif) and its http(s) links are wrapped (GET /t/c/ → 302 to the original URL). Tokens are HMAC-signed with JWT_SECRET: no open redirect, no forgery. Hits become provider_events rows (provider "notifyd", email.opened / email.clicked) and set jobs.opened_at / jobs.clicked_at (first time); GET /v1/jobs/:id returns both, /v1/admin/metrics/templates counts them with the provider webhooks. Unsubscribe links (/u/…), mailto: and template placeholders are never wrapped. A click event records the link's host only, never the full URL (sign-in and reset links carry tokens). Transactional mail (receipts, sign-in links) is never touched unless the project says {"tracking": {"applies_to": "all"}} or the request sets "track": true / {"opens", "clicks"}. Off: PATCH /v1/admin/projects/:id {"tracking": false} or {"tracking": {"opens": false}} (also the MCP tool update_project); per request "track": false. A request can narrow the project setting, or opt a transactional email in; it cannot re-enable tracking on a project that disabled it. Opens are approximate (image proxies and scanners load pixels); clicks are reliable. ## TOPICS A topic is the subscriber-facing name of a stream of messages ("tips", "release-notes", "billing"): 1–64 chars, lowercase letters, digits, "-", "_", ".", ":". Set it on /v1/send and /v1/batch, or once on a template (`POST /v1/templates {"topic": "tips"}`); jobs inherit the template's topic when the request has none. Jobs carry `topic` (GET /v1/jobs/:id, GET /v1/admin/jobs?topic=tips, MCP list_jobs). Bulk emails with a topic get an unsubscribe page with two choices: stop this topic only (stored as a preference) or stop all commercial email (marketing suppression). The RFC 8058 one-click POST from mail clients keeps the broad behaviour. Hierarchy: workflow-specific > channel-wide > global. Default: everything enabled. --- ## WORKFLOWS POST /v1/workflows — Create or update workflow Body: { "id": "welcome-series", "name": "Welcome Series", "description": "optional", "trigger_event": "user.signup", "enabled": true, "steps": [ {"type": "send", "channel": "email", "template": "welcome"}, {"type": "delay", "duration_secs": 86400}, {"type": "condition", "field": "payload.plan", "operator": "eq", "value": "pro", "on_true": 4}, {"type": "send", "channel": "email", "template": "nudge"}, {"type": "digest", "duration_secs": 3600, "channel": "email", "template": "hourly-digest"} ] } Step types (steps run in order, indexes start at 0): - "send": channel + template, or inline subject/body/body_html. Uses the trigger payload as template vars. - "delay": duration_secs. The run pauses (status "paused", resume_at set) and resumes on its own. - "condition": field is "inbox.is_read" (last in-app message read?) or "payload." (from the trigger payload); operator eq|neq (gt|lt for payload numbers); value; on_true / on_false = step index to jump to (default: next step). - "digest": collect further triggers for duration_secs, then send one message listing them ({{item_count}}, {{items}}). POST /v1/workflows/trigger — Trigger workflow Body: {"event": "user.signup", "subscriber_id": "user-42", "payload": {"plan": "pro"}} Response: {"success": true, "event": "user.signup", "workflow_runs": ["run-uuid"]} GET /v1/workflows — List workflows. Response: {"workflows": [...], "count": n} GET /v1/workflows/:id — Get workflow DELETE /v1/workflows/:id — Delete workflow GET /v1/workflows/runs?status=&workflow_id=&limit=&offset= — List runs. Response: {"items": [...], "total", "limit", "offset"} DELETE /v1/workflows/runs/:id — Cancel a run --- ## PUSH TOKENS POST /v1/push-tokens — Register push token Body: {"subscriber_id": "user-1", "token": "", "platform": "apns" | "fcm" | "web", "device_name": "iPhone"} Web Push subscriptions: {"subscriber_id", "endpoint", "p256dh", "auth"}. A subscriber may hold several tokens; a push fans out to all of them. APNs needs APNS_KEY_ID / APNS_TEAM_ID / APNS_PRIVATE_KEY / APNS_TOPIC on the server (docs/CONNECTORS.md). Push extras on /v1/send: "push": {"badge", "sound", "thread_id", "category", "collapse_id", "mutable_content", "background", "ttl_secs", "data"}; "subject" is the title, "body" the text, "url" a custom key. GET /v1/push-tokens/subscriber/:subscriber_id — List tokens DELETE /v1/push-tokens/:id — Delete token --- ## CLI (same binary) notifyd digest [--window 1d] [--json] · notifyd jobs [--status --project --channel --topic --recipient --since 24h --limit] [--json] notifyd job · notifyd retry · notifyd cancel · notifyd send-test --project P --channel C --to X [--subject --body] Target: --url / NOTIFYD_URL (default http://localhost:3400); key: --key / NOTIFYD_ADMIN_API_KEY (falls back to ADMIN_API_KEY). HTTP behind it: GET /v1/admin/jobs/:id (reader key) and POST /v1/admin/send-test {project_id, channel, to, subject?, body?} (admin key). ## ADMIN POST /v1/admin/projects — Create project Body: {"id": "myapp", "name": "My App", "channels": ["email", "in_app"]} GET /v1/admin/projects — List projects DELETE /v1/admin/projects/:id — Delete project POST /v1/admin/projects/:id/rotate-key — Rotate API key (zero-downtime, old key valid 24h) POST /v1/admin/projects/:id/revoke-secondary — Revoke old key after rotation GET /v1/admin/audit — Audit log (query: ?project_id=xxx&limit=50) --- ## WEBHOOKS POST /v1/admin/webhooks — Create webhook Body: {"project_id": "myapp", "url": "https://myapp.com/hooks", "events": ["job.sent", "job.failed", "job.delivered", "job.bounced"], "secret": "whsec_xxx"} Events (exact names; an unlisted event is not sent): job.sent (also skipped jobs, provider skipped), job.failed, job.delivered (Twilio or smsmode SMS), job.bounced (email bounce, SMS undelivered/failed), job.complained (email spam complaint). GET /v1/admin/webhooks — List webhooks DELETE /v1/admin/webhooks/:id — Delete webhook Payload {event, job_id, channel, subscriber_id, timestamp}, signed with HMAC-SHA256 (hex) in X-Notifyd-Signature header. Posted once, no retry: GET /v1/jobs/:id stays the source of truth. --- ## HEALTH & METRICS GET /v1/health (no auth) Response: {"status": "ok", "db": "ok", "version": "0.1.0"} GET /v1/metrics (admin auth) Response: { "jobs_pending": 12, "jobs_processing": 3, "jobs_sent_24h": 1547, "jobs_failed_24h": 2, "subscribers_total": 8420, "inbox_messages_total": 34210, "active_workflow_runs": 5, "uptime_seconds": 86400 } --- ## RATE LIMITS 100 requests/minute per project (configurable). 429 Too Many Requests when exceeded. Retry after 60s. --- ## ERROR FORMAT All errors: {"error": "description"} Status codes: 400 (bad request), 401 (no auth), 403 (forbidden), 404 (not found), 429 (rate limit), 500 (server error) --- ## CONFIGURATION notifyd.toml: [server] port = 3400 jwt_secret = "secret" [database] url = "postgres://user:pass@host:5432/notifyd" max_connections = 10 [worker] poll_interval_ms = 500 batch_size = 50 max_attempts = 5 [worker.pacing] # provider requests per second per replica email_per_sec = 8.0 sms_per_sec = 10.0 rate_limit_pause_secs = 2 [connectors.email] provider = "resend" # resend | cloudflare | smtp | agentmail | log api_key = "re_xxx" from = "notifications@domain.com" from_name = "My App" # cloudflare: api_key = Cloudflare token, account_id = "..." # smtp: [connectors.email.smtp] host/port/username/password/security(starttls|tls|none) # log: development only, nothing is sent Env-only deployments (no TOML): EMAIL_PROVIDER, RESEND_API_KEY | CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_EMAIL_API_TOKEN | SMTP_HOST/SMTP_PORT/SMTP_USERNAME/SMTP_PASSWORD/SMTP_SECURITY | AGENTMAIL_API_KEY, EMAIL_FROM, EMAIL_FROM_NAME, SMS_PROVIDER (telnyx|twilio|smsmode) + SMS_FROM + TELNYX_API_KEY | TWILIO_ACCOUNT_SID + TWILIO_AUTH_TOKEN | SMSMODE_API_KEY (+ SMSMODE_WEBHOOK_SECRET), WORKER_MAX_ATTEMPTS, EMAIL_RATE_PER_SEC, SMS_RATE_PER_SEC, WHATSAPP_RATE_PER_SEC, PUSH_RATE_PER_SEC, RATE_LIMIT_PAUSE_SECS. Metrics: GET /v1/metrics (JSON) and GET /v1/metrics/prometheus (text), admin key as x-api-key or Bearer. OPERATOR SURFACE (admin key): GET /v1/admin/digest?window=1h|6h|24h|7d|30d&format=json|markdown -> findings (critical/warning/info + action), queue, outcomes, failures, retries, latency, deliverability, projects GET /v1/admin/jobs?project_id&status&channel&recipient&since&limit ; POST /v1/admin/jobs/:id/retry ; POST /v1/admin/jobs/:id/cancel PATCH /v1/admin/projects/:id {name?, channels?, from_email?, from_name?, rate_limit_per_min?} GET|POST /v1/admin/suppressions ; DELETE /v1/admin/suppressions/:id ; project-scoped: POST /v1/jobs/:id/retry, POST /v1/suppressions {email} GET /v1/admin/metrics/templates?window&bucket=1h|1d&project_id -> funnel per template (sent, failed, delivered, bounced, complained, opened, clicked) Keys: ADMIN_API_KEY (all) ; READONLY_API_KEY (GET endpoints, metrics, read-only MCP tools). MCP: POST /mcp (Streamable HTTP, Authorization: Bearer ), tools digest, template_metrics, list_jobs, get_job, retry_job, cancel_job, list_projects, update_project, list_suppressions, add_suppression, release_suppression, send_test. Client config in docs/AGENT.md. Agent Skills: npx skills add rmzlb/notifyd (notifyd-operate, notifyd-integrate, notifyd-deploy). [connectors.sms] provider = "twilio" # or "telnyx", "smsmode" account_sid = "ACxxx" auth_token = "xxx" from = "+33600000000" [projects.myapp] api_key = "sk_myapp_xxx" channels = ["email", "sms", "in_app"] --- ## DOCKER docker compose up -d # Starts notifyd on :3400 + Postgres 16 Or standalone: docker run -p 3400:3400 -e DATABASE_URL=postgres://... -v ./notifyd.toml:/app/notifyd.toml:ro notifyd --- Built in Grenoble, France. MIT licensed. Source: https://github.com/rmzlb/notifyd --- ## TELEGRAM PER-USER CREDENTIALS Each project/owner registers its own bot token (AES-256-GCM encrypted). Requires TELEGRAM_ENCRYPTION_KEY env var (32-byte base64). PUT /v1/telegram/:project_id/:owner/credentials – upsert bot_token + bot_username → returns webhook_connection_id GET /v1/telegram/:project_id/:owner/credentials – read metadata (no token) DELETE /v1/telegram/:project_id/:owner/credentials – delete (cascades) PUT /v1/telegram/:project_id/:owner/destination – set chat address + optional thread_id (verified=false) DELETE /v1/telegram/:project_id/:owner/destination – remove destination POST /v1/telegram/:project_id/:owner/link – generate one-time link token (kind: private|group, expires 24h) POST /v1/telegram/webhooks/:webhook_connection_id – Telegram Bot API webhook (public, secret-validated) Link flow: POST /link → send link_url to user → user clicks → bot receives /start → destination verified automatically. Dispatch override: include {"chat": {"telegram_route_id": "project_id/owner"}} in job payload; worker resolves the encrypted token at runtime. ## Telegram per-user credentials (v0.4.0) Routes (project x-api-key auth; owner = trusted opaque id from caller): GET /v1/telegram/:owner Response: {data:{bot:BotInfo|null, destination:Destination|null}} BotInfo: {bot_username, owned:bool, source:"personal"|"instance", webhook_registered:bool, can_link:bool} Destination: {channel:"telegram", address_masked, verified:bool, created_at, telegram_thread_id:number|null, telegram_bot_username:string|null} PUT /v1/telegram/:owner Body: {bot_token:string, webhook_base:string} webhook_base = Baaton API_URL + /api/v1/public/telegram/webhook (notifyd appends /) Validates: HTTPS webhook_base, getMe token check, getWebhookInfo foreign-webhook check (409), never touches instance bot. 503 if TELEGRAM_ENCRYPTION_KEY absent. Response: same as GET. DELETE /v1/telegram/:owner Removes credential (best-effort deleteWebhook), destination, link tokens. 204. 404 if none. PUT /v1/telegram/:owner/destination Body: {address:string, telegram_thread_id?:number|null} Validates address via getChat (personal bot or instance bot). telegram_thread_id requires is_forum:true chat. verified:true = getChat succeeded (reachability, not identity). Response: {data:Destination} DELETE /v1/telegram/:owner/destination → 204 POST /v1/telegram/:owner/link Body: {kind:"private"|"group"} (default private) Personal bot only. Rate-limited: one active token per owner, 60s between generations. Token expires in 15 min. Consumed atomically on /start. Response: {data:{deep_link:string, expires_at:string, command:string}} deep_link private: https://t.me/?start= deep_link group: https://t.me/?startgroup= POST /v1/telegram/lookup Body: {owners:[string]} (max 100) Response: {data:[{owner, route_id:UUID, address:string, telegram_thread_id:number|null, bot_username:string, verified:bool}]} Only verified destinations. Project-isolated. One query. POST /v1/telegram/webhooks/:id (public, no project auth) Baaton proxy target for Telegram webhook callbacks. Authenticates via X-Telegram-Bot-Api-Secret-Token header (per-bot, decrypted). Handles /start for link pairing. Returns 2xx always; 503 on DB failure. Registered public path on Baaton: /api/v1/public/telegram/webhook/:id Worker integration: Job payload chat.telegram_route_id (UUID = telegram_credentials.id) → worker loads credentials by (route_id, project_id). Missing/cross-project → permanent failure, never falls back to instance bot. route_id from POST /v1/telegram/lookup response. Crypto: TELEGRAM_ENCRYPTION_KEY: base64-encoded 32 bytes → AES-256-GCM. Blob layout: [nonce(12) | ciphertext | tag(16)]. Key absent: registration fails 503; queued jobs with missing key fail permanently. Instance bot (TELEGRAM_BOT_TOKEN) unchanged; works without this key.