[DEVELOPERS · REST API v1]

Your inbox, over HTTP.

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]

One key, one header.

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]

Predictable by design.

Base URL

Everything lives under https://muro.chat/api/v1. The version is in the path, so we never break you silently.

Pagination

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.

Rate limits

120 requests per minute per key. Each response carries X-RateLimit-Remaining; a 429 includes Retry-After.

Errors

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.

Idempotency

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.

Request bodies

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.

CORS & OpenAPI

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]

Endpoints.

GET/conversationsscope: read

List 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=.

POST/conversationsscope: write

Open 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.

PATCH/conversations/{id}scope: write

Triage 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.

POST/conversations/bulkscope: write

Apply 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.

POST/conversations/{id}/typingscope: write

Broadcast 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.

POST/conversations/{id}/summaryscope: write

Generate (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.

GET/saved-repliesscope: read

List canned replies. POST to create ({ shortcut, title, body }), PATCH/DELETE /saved-replies/{id} to manage. Agents insert them in the composer with /shortcut.

GET/automation-rulesscope: read

CRUD 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.

GET/conversations/{id}scope: read

Fetch a single conversation with its visitor and website attached. Returns 404 if the id isn't in your workspace.

GET/conversations/{id}/messagesscope: read

Messages in chronological order, keyset-paginated like the other lists (has_more + next_cursor). Poll new ones with ?since= (ISO timestamp).

POST/conversations/{id}/messagesscope: write

Send 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.

GET/contactsscope: read

List contacts (visitors), most recently seen first. Filter by ?website_id=.

GET/contacts/{id}scope: read

A single contact with email, metadata, and lifetime conversation count.

PATCH/contacts/{id}scope: write

Enrich 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.

GET/realtimescope: read

beta 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]

Events & guides.

Webhooks

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]

Every payload, spelled out.

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]

Stop polling.

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]

Plug your AI agent into the inbox.

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]

Drive the widget from your page.

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]

What's new in v1.

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

  • Shipped an MCP server at /api/mcp — connect Claude, Cursor or any agent with an API key; tools to read conversations and reply.
  • Added PATCH /conversations/{id} — status, tags, assignment, and mark read/unread.
  • Added POST /conversations — open a thread toward a known contact (proactive outreach).
  • Added POST /conversations/bulk — close/open/snooze/read/assign up to 100 at once.
  • Added POST /conversations/{id}/typing — broadcast an agent typing indicator.
  • Added PATCH /contacts/{id} — shallow-merge custom metadata, set name/email.
  • Added ?q= search on GET /conversations (contact + message text).
  • Added GET /api/v1/realtime + /realtime/auth — WebSocket events, beta.
  • Documented every webhook payload shape and the signature scheme.
  • Published an OpenAPI 3.1 spec at /api/v1/openapi.json.
  • Idempotency-Key on POSTs; X-Request-Id on every response; CORS + OPTIONS.
  • Strict validation (400s for bad limit/cursor/since); JSON error envelope on 5xx too.
  • Keyset pagination on messages; create responses now match the retrieve shape.
  • Durable Idempotency-Key store; conversation-level metadata; optional API-key expiry.
  • Trigram-indexed ?q= search; per-key attribution in the audit log.
  • AI conversation summaries (auto on close + POST /conversations/{id}/summary).
  • Saved replies CRUD (/saved-replies) — agents insert with /shortcut.
  • Automation rules (/automation-rules) — tag/assign/set-status/reply on new chats + messages.

Earlier

  • GET/POST conversations, messages, and contacts with keyset pagination, scoped Bearer keys, and HMAC-signed webhooks.

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.

Build something on top of your inbox.

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.