/Scope readThe API root. Returns metadata about the API itself, the auth scheme, your rate limit, and a map of every resource with its path. Doubles as a cheap “does this key work?” check and a discovery index for scripts and client generators.
Developers
Read conversations, send replies and sync contacts from your own code. JSON in, JSON out, one Bearer key. No SDK needed: most examples below are plain curl you can paste into a terminal.
Getting started
Events and agents
Getting started
Create a key in Settings, API keys. It looks like mr_live_… and is shown once, so store it somewhere safe. Pass it on every request:
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": "marie@acme.io" },
"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"
}Keys carry scopes. read can list and fetch; write can send messages. Revoke a key at any time and it stops working immediately, or set an optional expires_at when you create one.
Keys can also be pinned to one site. Pick a site when you create the key and every request it makes, REST and MCP alike, only sees that site's conversations, contacts and config. Objects that belong to other sites return 404 and never appear in lists. Workspace-wide keys are the default; scoped keys show the site's domain next to their name in the key list. Use them to give a client, a contractor or a per-site bot access to exactly one property.
The same rules apply to every endpoint.
https://muro.chat/api/v1. The version is in the path, so we never break you silently.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 is null. ?limit= defaults to 25, max 100.X-RateLimit-Remaining; a 429 includes Retry-After.{ "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 when you write to us.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 for about 24 hours.Content-Type: application/json (else 415). Limits: content up to 32,000 characters, up to 50 tags of 64 characters each, metadata up to 16 KB, up to 100 ids per bulk call, q up to 128 characters.*) 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
Paths are relative to https://muro.chat/api/v1.
/Scope readThe API root. Returns metadata about the API itself, the auth scheme, your rate limit, and a map of every resource with its path. Doubles as a cheap “does this key work?” check and a discovery index for scripts and client generators.
/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, for 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, plus opening_message_id: the id of the opening message when content was sent, null otherwise.
/conversations/{id}Scope readFetch a single conversation with its visitor and website attached. Returns 404 if the id is not in your workspace.
/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.typing event on the conversation's realtime channels. It rides the realtime bus (see Realtime): other agent UIs see it; visitor-widget consumption is on the roadmap.
/conversations/{id}/messagesScope readMessages in chronological order, keyset-paginated like the other lists (has_more and 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. Example, 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"
}/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 summarized automatically; GET the same path to read the stored summary. 503 if AI is off for the workspace.
/contactsScope readList contacts (visitors), most recently seen first. Filter by ?website_id=. Example, page through contacts:
curl "https://muro.chat/api/v1/contacts?limit=50&cursor=MjAy…" \
-H "Authorization: Bearer mr_live_xxxxxxxxxxxxxxxx"/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": "marie@acme.io",
"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.
/saved-repliesScope readList canned replies. The full set comes back, with no pagination. Agents insert them in the composer with /shortcut.
/saved-repliesScope writeCreate a saved reply. Body:
{
"shortcut": "refund", // ≤ 64 chars, unique per workspace
"title": "Refund policy", // ≤ 256 chars
"body": "We refund within 14 days, no questions asked."
}Returns the created reply with a 201; a duplicate shortcut is a 409. GET, PATCH and DELETE /saved-replies/{id} read, update (any subset of the same three fields, same 409 on a shortcut collision) and remove one.
/automation-rulesScope readList automation rules: if the conditions match, the actions run. Rules run highest-priority first.
/automation-rulesScope writeCreate a rule. PATCH and DELETE /automation-rules/{id} update it (any subset, for example { "enabled": false }) or delete it.
Triggers: conversation.created (a new thread opens), message.created (a visitor sends a message).
{
"name": "Tag urgent refunds",
"trigger": "message.created",
"enabled": true,
"priority": 10, // higher runs first
"conditions": { "match": "all", // "all" = AND, "any" = OR
"items": [
{ "field": "content", "op": "contains", "value": "refund" },
{ "field": "email", "op": "exists" }
] },
"actions": [
{ "type": "add_tag", "value": "refund" },
{ "type": "reply", "text": "We'll sort this out right away." }
]
}Condition fields: content, email, name, website_id, tag. Operators: contains, equals, regex, exists. Actions: add_tag, assign (user_id), set_status, reply.
Events and agents
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.
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. An endpoint can be limited to one site, and can carry its own static headers (for example your receiver's Authorization).
From Settings, Webhooks, or over the API with a write key. POST /api/v1/webhooks takes url (public https), events (default ["*"]), name, website_id (only that site's events; a site-scoped key always sets its own site) and headers. The response carries the signing secret once.
curl -X POST https://muro.chat/api/v1/webhooks \
-H "Authorization: Bearer mr_live_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/muro",
"events": ["message.created"],
"website_id": "web_…",
"headers": { "Authorization": "Bearer your-receiver-key" }
}'{
"object": "webhook_endpoint",
"id": "whe_…",
"name": "hooks.example.com",
"url": "https://hooks.example.com/muro",
"events": ["message.created"],
"enabled": true,
"website_id": "web_…",
"headers": { "Authorization": "****-key" },
"created_at": "2026-11-10T10:00:00.000Z",
"secret": "whsec_…" // shown only here
}headers is an object of up to 10 static headers sent on every delivery, test events included. Values are stored encrypted and never returned: reads show them masked (**** plus the last 4 characters for values of 16 or more). Names are case-insensitive. x-muro-*, Content-Type, User-Agent and transport headers (Host, Content-Length, Connection, Transfer-Encoding…) are reserved and refused with a 400.
GET /api/v1/webhooks and GET /api/v1/webhooks/{id} list and read endpoints. PATCH /api/v1/webhooks/{id} updates any subset of url, events, name, enabled, website_id and headers. headers is merged: a string sets or replaces a header, null removes it, and "headers": null removes them all. DELETE /api/v1/webhooks/{id} removes the endpoint and POST /api/v1/webhooks/{id}/test sends one signed sample message.created (data.test = true) and returns the status your endpoint answered.
curl -X PATCH https://muro.chat/api/v1/webhooks/whe_… \
-H "Authorization: Bearer mr_live_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "headers": { "Authorization": "Bearer new-receiver-key" } }'{
"id": "evt_…",
"type": "message.created",
"organization_id": "org_…",
"created_at": "2026-06-07T10:00:00.000Z",
"data": { … } // shape depends on "type", see below
}content-type: application/json
user-agent: muro-webhooks/1.0
x-muro-event: message.created
x-muro-event-id: evt_…
x-muro-timestamp: 1733570400
x-muro-signature: t=1733570400,v1=<hmac-sha256 hex>
authorization: Bearer your-receiver-key // your custom headers, if anyv1 = hex( HMAC_SHA256( key = secret (the full "whsec_…" string),
message = raw request body ) )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, not timestamp.body. The t= value equals x-muro-timestamp (send time, Unix seconds) and is not signed, so deduplicate on x-muro-event-id (equal to the body's id, stable across retries) rather than trusting t. A failed delivery (anything but a 2xx within 10 seconds) is retried after 30 seconds, 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then given up.
// message.created: every visitor message, and agent replies
"data": {
"message": {
"id": "msg_…",
"conversation_id": "cnv_…",
"website_id": "web_…", // the site the conversation belongs to
"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",
"website_id": "web_…"
}
}
// visitor.identified: a visitor handed over an email
"data": {
"visitor": { "id": "vis_…", "email": "marie@acme.io", "name": "Marie" }
}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.muro pushes live events over the Pusher protocol (Soketi). Connect any pusher-js client, point its authEndpoint at our authorizer with your Bearer key, and subscribe to your workspace channel. Webhooks stay the canonical, signed, retried contract for anything you cannot miss; realtime is the low-latency mirror.
/realtimeBetaScope readConnection details for the realtime bus, so you can drop polling and subscribe. Tells you whether it is live for your workspace and returns the pusher-js config plus the channel names for your workspace.
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 also streams internal notes (sender_type: "note"), which webhooks never deliver. Per-conversation channels (private-conv-{id}) carry the same events scoped to one thread.
muro runs 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, and if the key is pinned to one site, every tool is confined to that site.
# 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.
A full agent loop (find, read, reply, close), and the agent can set the widget up for you. Ready-to-paste client configs (Claude Desktop, Cursor, Windsurf), a stdio bridge and a Dockerfile live in the open-source repo: github.com/Muro-chat/muro-mcp.
The install snippet defines a global muro() command queue. Calls made before widget.js finishes loading queue safely and replay in order, with 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');Color, 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.
Walkthroughs that build on the API.
The version lives in the path, so additions never break you. We only bump to /v2 for a breaking change, and keep /v1 running. This section is the source of truth for new endpoints.
November 2026
June 2026
Earlier
Keys are live-mode only today (mr_live_…). A test-mode sandbox is on the roadmap; until then, create a separate workspace for development. There is no public RSS yet: this section is the changelog of record.
Sync conversations to your CRM, reply from your own logic, or pipe contacts into a warehouse. The API, webhooks and the MCP server are on every plan.