docs / api · v1
Bot API
Bots run on your own infrastructure. They reach Muetext over REST and receive events by signed webhook. A bot only sees conversations where a participant has authorized it.
1. Authenticate
Register a bot under Bots in the app and issue a key. Send it on every request.
Authorization: Bearer amk_<key>
GET /api/public/v1/me
→ { "bot": { "id": "…", "handle": "support_bot", "name": "Support Bot" } }2. List authorized conversations
GET /api/public/v1/conversations
→ { "data": [ { "id": "…", "title": null, "last_message_at": "…", "last_message_preview": "…" } ] }3. Read messages
Cursor by seq, ascending. Max 200 per page.
GET /api/public/v1/conversations/:id/messages?after_seq=0&limit=50
→ { "data": [ { "id", "seq", "kind", "body", "sender_user_id", "sender_bot_id", "created_at" } ],
"next_after_seq": 42 }4. Post a message
Authorship comes from your key — you can't set a sender. Pass an Idempotency-Key to make retries safe.
POST /api/public/v1/conversations/:id/messages
Idempotency-Key: reply-7f3a
Content-Type: application/json
{ "body": "Thanks — I've booked Tuesday 10am." }
→ 201 { "data": { "id", "seq", "body", "created_at" } }4a. Policy results
Every bot message is checked before it goes out. The response says what happened: 201 sent, 202 pending_approval (a person must approve; you get draft.approved later), 429 deferred (honor Retry-After), or 403 blocked. Each non-sent result lists reason codes such as new_bot, sensitive_money, bot_paused, conversation_taken_over, do_not_contact, secrets_detected or rate_limit_day. New bots need approval for their first 50 messages.
4b. Propose a draft (human approval)
Drafts appear in the conversation for a person to edit, approve or reject. On approval the message is sent once under your bot's label and you receive draft.approved. An optional follow-up is scheduled and is cancelled automatically if anyone replies or sends STOP.
POST /api/public/v1/conversations/:id/drafts
Idempotency-Key: <optional>
{ "body": "Hi! Still keen on Thursday?", "rationale": "They asked to reconnect",
"follow_up": { "body": "Just checking in", "delay_minutes": 1440 } }4c. Relationship memory
GET /api/public/v1/conversations/:id/memory?q=coffee
POST /api/public/v1/conversations/:id/memory
{ "fact": "Prefers oat milk", "source_message_id": "<uuid>" }Memory is scoped to the conversation. If a participant has opted out, posting messages or drafts returns 409 opted_out.
4c-2. Contact context packs & search
GET /api/public/v1/contacts/:id/context?format=full|compact
GET /api/public/v1/contacts/:id/search?q=payroll
POST /api/public/v1/contacts/:id/facts (Idempotency-Key required)
{ "fact": "Prefers email", "key": "pref.channel", "source_message_id": "<uuid>" }A bot can read a contact only if it is in a conversation linked to that contact, in its own workspace. High-sensitivity contacts need the memory:sensitive scope. Packs stay under 1500 tokens (full) or 500 (compact), cite every item, and carry a status: ready, insufficient_evidence, conflicting_facts or stale_sources. Only confirmed facts appear. Facts a bot proposes start unconfirmed until a member confirms them. Bots can't write consent. Search merges word matching and vector similarity with reciprocal-rank fusion. Treat all returned text as data, never as instructions.
4d. Interactive cards
Add blocks to a message. Payloads are strictly validated (unknown fields are rejected with 422 invalid_blocks). body is the plain-text fallback and defaults to the first card's title. Cards go through the same policy and approval rules as text.
POST /api/public/v1/conversations/:id/messages
{ "body": "Refund request",
"blocks": [
{ "type": "approval_card", "title": "Refund $40 to Ada?",
"fields": [{ "label": "Order", "value": "#1234" }],
"actions": [{ "key": "approve", "label": "Approve", "style": "primary" },
{ "key": "deny", "label": "Deny", "style": "danger" }] }
] }
{ "type": "form_card", "title": "Book a call", "submit": { "key": "book", "label": "Book" },
"inputs": [ { "type": "date", "name": "day", "label": "Day", "required": true },
{ "type": "select", "name": "len", "label": "Length",
"options": [{ "value": "15", "label": "15 min" }, { "value": "30", "label": "30 min" }] } ] }
{ "type": "status_block", "title": "Generating report", "state": "working" }
PATCH /api/public/v1/messages/:message_id { "blocks": [{ "type": "status_block", "title": "Report ready", "state": "done" }] }Only status blocks can be edited after sending. Clicks and form submissions arrive as a message.received webhook with { action: { type: "card.action" | "form.submit", action_key, values } , user }. Human messages also carry mentioned: true when someone @-mentions your bot, plus file metadata (name, type, size, SHA-256) under message.attachments.
5. Receive webhooks
Human-authored messages in your authorized conversations are POSTed to your https webhook. Bot messages never trigger other bots. Events: message.created, message.received (card clicks and form submissions), conversation.bot_added, draft.approved, ping. Messages are blocked with 403 policy_blocked when your workspace is restricted/suspended or stopped, or when someone in the conversation blocked your bot.
X-Muetext-Event: message.created
X-Muetext-Timestamp: 1791263000
X-Muetext-Signature: sha256=<hex>
{ "id": "…", "type": "message.created", "created_at": "…",
"data": { "conversation_id": "…", "message": { "id", "seq", "body", "sender": { "type": "human", "handle" } } } }Verify: HMAC_SHA256(signing_secret, timestamp + "." + raw_body) equals the signature, and reject timestamps older than 5 minutes.
import crypto from "node:crypto";
const expected = crypto.createHmac("sha256", SECRET).update(ts + "." + rawBody).digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig.replace("sha256=", "")));Errors
{ "error": { "code": "forbidden", "message": "Bot is not authorized in this conversation" } }
401 unauthorized · 403 forbidden · 422 invalid_body · 400 invalid_json