Base URL
Everything lives under https://muro.chat/api/v1. The version is in the path, so we never break you silently.
[DEVELOPERS · REST API v1]
Read conversations, send replies, and sync contacts from your own code. JSON in, JSON out, one Bearer key. No SDK required — every example below is a plain curl you can paste into a terminal.
[AUTHENTICATION]
Create a key under Settings → API keys. It looks like mr_live_… and is shown once — store it somewhere safe. Pass it on every request:
Keys carry scopes. read can list and fetch; write can send messages. Revoke a key anytime and it stops working immediately, or set an optional expires_at when you create one.
curl https://muro.chat/api/v1/conversations \ -H "Authorization: Bearer mr_live_xxxxxxxxxxxxxxxx"
{
"object": "list",
"data": [
{
"object": "conversation",
"id": "cnv_8f2a…",
"status": "open",
"unread_count": 1,
"visitor": { "id": "vis_19c…", "name": "Marie", "email": "[email protected]" },
"last_message": { "content": "Hi! Is the Pro plan…", "sender_type": "visitor", "at": "2026-06-05T09:12:04Z" },
"updated_at": "2026-06-05T09:12:04Z"
}
],
"has_more": true,
"next_cursor": "MjAyNi0wNi0wNVQwOToxMjowNFp8Y29udl84ZjJh"
}[CONVENTIONS]
Everything lives under https://muro.chat/api/v1. The version is in the path, so we never break you silently.
Keyset, not offset. Lists carry has_more and next_cursor in the JSON body (no Link header). When has_more is true, pass the opaque next_cursor token back as ?cursor= until it's null. ?limit= defaults to 25, max 100.
120 requests per minute per key. Each response carries X-RateLimit-Remaining; a 429 includes Retry-After.
Non-2xx responses return { "error": { "type", "message" } } with a matching HTTP status. Types: authentication_error, permission_error, rate_limit_error, invalid_request_error, not_found, api_error. Every response — success or error — carries an X-Request-Id; quote it to support.
Send an Idempotency-Key header on any POST (create conversation, send message, bulk). An identical retry replays the first response instead of acting twice; reusing a key with a different body is a 409. Keys are remembered ~24h.
Writes require Content-Type: application/json (else 415). Caps: content ≤ 32 KB, tags ≤ 50 × 64 chars, metadata ≤ 16 KB, bulk ids ≤ 100, q ≤ 128 chars.
Keys are server-side, so CORS is open (*) and every route answers OPTIONS. A machine-readable OpenAPI 3.1 spec is served at /api/v1/openapi.json — import it into Postman, Bruno, or a client generator.
[REFERENCE]
/conversationsscope: readList conversations, newest activity first. Filter with ?status=open|closed|snoozed, search contact name/email and message text with ?q=, and page with ?cursor= and ?limit=.
/conversationsscope: writeOpen a new conversation toward a known contact — proactive outreach from your own admin. Body:
{
"visitor_id": "vis_19c…",
"content": "Hi! Saw you signed up — need a hand?"
}content is optional; omit it to open an empty thread. Fires conversation.opened. Returns the conversation with a 201.
/conversations/{id}scope: writeTriage a thread: status, tags, assignment, and the unread badge. Body (any subset):
{
"status": "closed", // open | closed | snoozed
"tags": ["vip", "billing"],
"assigned_user_id": "usr_…", // or null to unassign
"mark_read": true, // zero the unread badge
"mark_unread": true, // bump it back into the queue
"metadata": { "ticket_id": "ZD-4821" } // shallow-merged
}Returns the updated conversation. Moving to closed fires conversation.closed.
/conversations/bulkscope: writeApply one action to up to 100 conversations at once. Body:
{
"ids": ["cnv_a…", "cnv_b…"],
"action": "close", // close|open|snooze|read|unread|assign
"assigned_user_id": "usr_…" // required when action = assign
}Returns a per-id result so partial failures are visible.
/conversations/{id}/typingscope: writeBroadcast an agent.typingevent on the conversation's realtime channels. Rides the realtime bus (see below) — other agent UIs see it; visitor-widget consumption is on the roadmap.
/conversations/{id}/summaryscope: writeGenerate (or refresh) an AI recap of the thread — a few bullets covering what the visitor wanted, what was done, and any follow-up. Closed conversations are auto-summarized; GET the same path to read the stored summary. 503 if AI is off for the workspace.
/saved-repliesscope: readList canned replies. POST to create ({ shortcut, title, body }), PATCH/DELETE /saved-replies/{id} to manage. Agents insert them in the composer with /shortcut.
/automation-rulesscope: readCRUD for automation rules that run on a new conversation or visitor message: if the conditions match, the actions run.
{
"name": "Tag urgent refunds",
"trigger": "message.created",
"conditions": { "match": "all",
"items": [{ "field": "content", "op": "contains", "value": "refund" }] },
"actions": [
{ "type": "add_tag", "value": "refund" },
{ "type": "reply", "text": "We'll sort this out right away." }
]
}Actions: add_tag, assign (user_id), set_status, reply.
/conversations/{id}scope: readFetch a single conversation with its visitor and website attached. Returns 404 if the id isn't in your workspace.
/conversations/{id}/messagesscope: readMessages in chronological order, keyset-paginated like the other lists (has_more + next_cursor). Poll new ones with ?since= (ISO timestamp).
/conversations/{id}/messagesscope: writeSend a reply the visitor sees, or an internal note. Body:
{
"content": "Thanks for reaching out!",
"kind": "agent" // or "note"
}Returns the created message with a 201.
/contactsscope: readList contacts (visitors), most recently seen first. Filter by ?website_id=.
/contacts/{id}scope: readA single contact with email, metadata, and lifetime conversation count.
/contacts/{id}scope: writeEnrich a contact from your backend so it shows in the contact panel. Body (any subset):
{
"name": "Marie",
"email": "[email protected]",
"metadata": { "plan": "PRO", "mrr": 9, "workspace_id": "ws_123" }
}metadata is shallow-merged (send a key with null to drop it). Setting an email fires visitor.identified.
/realtimescope: readbeta Connection details for the realtime bus — drop polling and subscribe. Returns the pusher-js config plus the channel names for your workspace. See Realtime below.
Send a reply
curl -X POST \
https://muro.chat/api/v1/conversations/cnv_8f2a/messages \
-H "Authorization: Bearer mr_live_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "content": "On it — give me 5 minutes." }'{
"object": "message",
"id": "msg_4b71…",
"conversation_id": "cnv_8f2a",
"sender_type": "agent",
"sender_id": "usr_…",
"content": "On it — give me 5 minutes.",
"attachments": [],
"created_at": "2026-06-05T09:13:40Z"
}Page through contacts
curl "https://muro.chat/api/v1/contacts?limit=50&cursor=MjAy…" \ -H "Authorization: Bearer mr_live_xxxxxxxxxxxxxxxx"
[BEYOND REST]
Prefer to be pushed to instead of polling? muro POSTs every chat event to your URL, signed with HMAC-SHA256 and retried with backoff. Subscribe to message.created, conversation.opened, visitor.identified, and conversation.closed.
[WEBHOOK EVENTS]
Each delivery is a POST with the same envelope. The data object differs by type — all four shapes are below, so you can type your receiver without guessing.
Envelope (every event)
{
"id": "evt_…",
"type": "message.created",
"organization_id": "org_…",
"created_at": "2026-06-07T10:00:00.000Z",
"data": { … } // shape depends on "type" →
}Request headers
x-muro-event: message.created x-muro-event-id: evt_… x-muro-timestamp: 1733570400 x-muro-signature: t=1733570400,v1=<hmac-sha256 hex>
Verify the signature (Node)
import crypto from 'node:crypto';
// rawBody = the exact bytes we POSTed — verify BEFORE JSON.parse,
// do not re-serialize (key order would change the digest).
const sig = req.headers['x-muro-signature']; // "t=…,v1=…"
const v1 = sig.split(',').find(p => p.startsWith('v1=')).slice(3);
const expected = crypto
.createHmac('sha256', process.env.MURO_WEBHOOK_SECRET) // whsec_…
.update(rawBody)
.digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));v1 is the HMAC-SHA256 of the raw body alone; the t= timestamp is an anti-replay value, not part of the signed bytes. Deliveries retry on backoff (30s → 6h, then given up).
data by event type
// message.created — every visitor message, and agent replies
"data": {
"message": {
"id": "msg_…",
"conversation_id": "cnv_…",
"sender_type": "visitor", // or "agent"
"sender_id": "vis_…", // visitor id, agent user id, or null
"content": "Hi! Is the Pro plan…",
"created_at": "2026-06-07T10:00:00.000Z"
}
}
// conversation.opened — a brand-new thread
"data": {
"conversation": {
"id": "cnv_…",
"website_id": "web_…",
"visitor_id": "vis_…",
"created_at": "2026-06-07T10:00:00.000Z"
}
}
// conversation.closed — resolved by an agent or the API
"data": {
"conversation": {
"id": "cnv_…",
"status": "closed",
"tags": ["billing"],
"unread_count": 0,
"assigned_user_id": "usr_…",
"last_message_at": "2026-06-07T10:00:00.000Z"
}
}
// visitor.identified — a visitor handed over an email
"data": {
"visitor": { "id": "vis_…", "email": "[email protected]", "name": "Marie" }
}On message kind / sender_type: outbound messages — whether sent by a human agent, the API, or a bot built on the API — are all agent. Internal notes are note (and never trigger a webhook), inbound is visitor, automated system lines are system. There is no separate bot kind today.
[REALTIME · BETA]
muro pushes live events over the Pusher protocol (Soketi). Connect any pusher-js client, point its authEndpointat our authorizer with your Bearer key, and subscribe to your workspace channel. Webhooks stay the canonical, signed, retried contract for anything you can't miss — realtime is the low-latency mirror.
GET /api/v1/realtimetells you whether it's live for your workspace and returns the public config.
import Pusher from 'pusher-js';
// 1. fetch connection details
const rt = await fetch('https://muro.chat/api/v1/realtime', {
headers: { Authorization: 'Bearer mr_live_…' },
}).then(r => r.json());
// 2. connect, authorizing private channels with your key
const pusher = new Pusher(rt.config.key, {
wsHost: rt.config.host, wsPort: rt.config.port,
forceTLS: rt.config.forceTLS, enabledTransports: ['ws', 'wss'],
channelAuthorization: {
endpoint: rt.auth_endpoint,
headers: { Authorization: 'Bearer mr_live_…' },
},
});
// 3. subscribe to the workspace channel
const ch = pusher.subscribe(rt.channels.workspace); // private-org-…
ch.bind('message.new', (m) => { /* agent + visitor msgs, and notes */ });
ch.bind('conversation.update', (c) => { /* status / unread / assignment */ });
ch.bind('agent.typing', (t) => { /* show indicator */ });Event shapes are listed in the GET /api/v1/realtime response. message.new carries the same fields as the message.created webhook, and additionally streams internal notes (sender_type: "note"), which webhooks never deliver. Per-conversation channels (private-conv-{id}) carry the same events scoped to one thread.
[MCP · AI AGENTS]
muro ships a hosted Model Context Protocol server. Point any MCP client — Claude, Cursor, ChatGPT, or your own agent — at https://muro.chat/api/mcp with a Bearer API key, and the agent can read conversations and act on them. Same scopes as the REST API: read to read, write to reply.
Tools: list_conversations (status + full-text search), get_conversation (with messages), send_reply (reply to the visitor or add an internal note), update_conversation (triage: status, tags, assignee), list_contacts, get_contact. A full agent loop — find, read, reply, close.
# Connect Claude / any MCP client over the remote bridge
npx mcp-remote https://muro.chat/api/mcp \
--header "Authorization: Bearer mr_live_…"
# Or call it directly — it's plain JSON-RPC over HTTP
curl https://muro.chat/api/mcp \
-H "Authorization: Bearer mr_live_…" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"list_conversations",
"arguments":{"status":"open","limit":10}}}'Hosted and stateless — nothing to run on your side. The agent reasons over live inbox data and can reply in the visitor's chat, all within the key's workspace and scopes. Pairs with the visitor.identified webhook and the realtime stream above for fully agentic support.
[WIDGET JS API]
The install snippet defines a global muro() command queue. Calls made before widget.js finishes loading queue safely and replay in order — no readiness checks needed.
identify is the one to know: pass the signed-in user from your server-rendered session and they skip the pre-chat email form, their conversation history follows them across devices, and the visitor.identified webhook fires for your integrations.
// boot — from the install snippet (Dashboard → Sites → your site)
muro('init', { widgetId: 'wgt_…' });
// identify a signed-in user — skips the pre-chat email form.
// Only send identities you trust (server-rendered session values).
muro('identify', {
email: currentUser.email, // ties the thread to the user
name: currentUser.name, // optional
});
// open / close the panel programmatically
document.querySelector('#help-button')
.addEventListener('click', () => muro('open'));
muro('close');Colour, position, language, agent name and welcome message are configured per-site in the dashboard — they are not init options. identify persists across page loads via the visitor token, so calling it once after login is enough.
[CHANGELOG]
The version lives in the path, so additions never break you. We only bump to /v2 for a breaking change, and keep /v1running. Bookmark this section — it's the source of truth for new endpoints.
June 2026
Earlier
Keys are live-mode only today (mr_live_…). A test-mode sandbox is on the roadmap; until then, spin up a separate workspace for development. No public RSS yet — this section is the changelog of record.
Sync conversations to your CRM, auto-reply from your own logic, or pipe contacts into a warehouse. Generate a key and you're live in a minute.