Developers

API reference

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.

Base URL
https://muro.chat/api/v1
Auth
Authorization: Bearer mr_live_…
Rate limit
120 requests per minute per key
On this page

Getting started

Authentication

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:

Request
curl https://muro.chat/api/v1/conversations \
  -H "Authorization: Bearer mr_live_xxxxxxxxxxxxxxxx"
Response
{
  "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.

Conventions

The same rules apply to every endpoint.

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 is 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 when you write to us.
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 for about 24 hours.
Request bodies
Writes require 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.
CORS and 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

Paths are relative to https://muro.chat/api/v1.

API root

GET/Scope read

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

Conversations

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

GET/conversations/{id}Scope read

Fetch a single conversation with its visitor and website attached. Returns 404 if the id is not in your workspace.

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

Messages

GET/conversations/{id}/messagesScope read

Messages in chronological order, keyset-paginated like the other lists (has_more and 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. Example, send a reply:

Request
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." }'
Response
{
  "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"
}

AI summaries

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 summarized automatically; GET the same path to read the stored summary. 503 if AI is off for the workspace.

Contacts

GET/contactsScope read

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

Request
curl "https://muro.chat/api/v1/contacts?limit=50&cursor=MjAy…" \
  -H "Authorization: Bearer mr_live_xxxxxxxxxxxxxxxx"
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": "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 replies

GET/saved-repliesScope read

List canned replies. The full set comes back, with no pagination. Agents insert them in the composer with /shortcut.

POST/saved-repliesScope write

Create 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 rules

GET/automation-rulesScope read

List automation rules: if the conditions match, the actions run. Rules run highest-priority first.

POST/automation-rulesScope write

Create 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

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.

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

Register an endpoint

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.

Request
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" }
  }'
Response (201)
{
  "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.

Rotate the receiver key
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" } }'

Deliveries and signature

Envelope (every event)
{
  "id": "evt_…",
  "type": "message.created",
  "organization_id": "org_…",
  "created_at": "2026-06-07T10:00:00.000Z",
  "data": { … }   // shape depends on "type", see below
}
Request headers
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 any
Signature
v1 = hex( HMAC_SHA256( key = secret (the full "whsec_…" string),
                       message = raw request body ) )
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, 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.

data, by event type
// 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" }
}
On message kind and 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

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.

GET/realtimeBetaScope read

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

Subscribe with pusher-js
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.

MCP server

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.

Terminal
# 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.

Tools

list_conversations
Status filter and full-text search.
get_conversation
One conversation, with its messages.
send_reply
Reply to the visitor, or add an internal note.
update_conversation
Triage: status, tags, assignee.
list_contacts
The people who have talked to the workspace, with name, email and last seen.
get_contact
One contact by id, with their metadata.
list_sites
Your installed widgets and their config.
update_site
Configure the widget: name, color, position, welcome message, agent name, reply time, language, size, email gate.

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.

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

Your page
// 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.

Guides

Walkthroughs that build on the API.

Changelog

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

  • Webhook endpoints over REST (/webhooks): list, create, update, delete and send a signed test event.
  • Per-endpoint custom headers (headers), sent on every delivery, stored encrypted and read back masked.
  • Site-scoped webhook endpoints (website_id), and website_id in message.created and conversation payloads.

June 2026

  • Shipped an MCP server at /api/mcp: connect Claude, Cursor or any agent with an API key, with tools to read conversations and reply.
  • Added PATCH /conversations/{id}: status, tags, assignment, and mark read or unread.
  • Added POST /conversations: open a thread toward a known contact (proactive outreach).
  • Added POST /conversations/bulk: close, open, snooze, read or 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 and email.
  • Added ?q= search on GET /conversations (contact and message text).
  • Added GET /api/v1/realtime and /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 and OPTIONS.
  • Strict validation (400s for bad limit, cursor or 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 (automatic on close, plus POST /conversations/{id}/summary).
  • Saved replies CRUD (/saved-replies): agents insert them with /shortcut.
  • Automation rules (/automation-rules): tag, assign, set status or reply on new chats and messages.

Earlier

  • GET and 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, create a separate workspace for development. There is no public RSS yet: this section is the changelog of record.

Build on top of your inbox.

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.