muro POSTs every event to your registered URL. You verify the signature, do something useful, return 2xx. We retry with exponential backoff if you don't (30s · 1m · 5m · 30m · 2h · 6h, then we give up). This article is the contract + a sample receiver you can copy.
The contract
- →Method:
POST(always) - →Body: JSON. Top-level fields:
id,type,organization_id,created_at,data. - →Headers:
x-muro-event(event type),x-muro-event-id(idempotency key),x-muro-timestamp(unix seconds),x-muro-signature(HMAC). - →Timeout: 10 s. Anything longer is treated as a failure.
- →Success: any 2xx. We don't parse your response body; just status code.
Verifying the signature
Every request carries x-muro-signature: t=<ts>,v1=<hex>. The signature is HMAC-SHA256(secret, "<ts>.<rawBody>"). Always use a constant-time compare (timingSafeEqual in Node, hmac.compare_digest in Python) — naive === leaks timing information that an attacker can exploit.
typescriptimport crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.MURO_WEBHOOK_SECRET!;
app.post('/muro/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.header('x-muro-signature') ?? '';
const m = /^t=(\d+),v1=([0-9a-f]+)$/.exec(header);
if (!m) return res.status(401).end();
const [, ts, sig] = m;
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${ts}.${req.body.toString('utf8')}`)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'))) {
return res.status(401).end();
}
// Optional: reject events older than 5 minutes (replay protection)
if (Math.abs(Date.now()/1000 - Number(ts)) > 300) return res.status(401).end();
const event = JSON.parse(req.body.toString('utf8'));
// Process event.type, event.data — return 2xx fast and do work async if heavy
res.json({ ok: true });
});Idempotency
Retries mean the same event will arrive multiple times if your first 2xx didn't make it back to us. Treat `x-muro-event-id` as the dedup key. Store it for ~24h in Redis or a seen_events table; ignore duplicates.
Common patterns we see
Slack notification on every conversation
Subscribe to conversation.opened. POST a message to a Slack incoming-webhook URL with the visitor's first message + a link back to the muro inbox. ~15 lines.
CRM sync on visitor identification
Subscribe to visitor.identified. When fired, the visitor handed over their email — push to HubSpot/Salesforce/Pipedrive with the conversation transcript as a note.
AI summariser
Subscribe to conversation.closed. Send the message log to your LLM of choice with a prompt like "summarise the resolution in 50 words" and store the result in your data warehouse for analytics.
On-call paging for VIPs
Subscribe to message.created. If the visitor email is in your VIP list and FRT > 2 minutes, page the on-call engineer via PagerDuty. Saves angry CEOs from churning.
Testing receivers without deploying
In /app/settings/webhooks we have a "Send test event" button that POSTs a synthetic message.created to your endpoint. Pair it with ngrok for local dev or serveo.net if you're behind a strict firewall. The test event has data.test: true so you can branch your logic.
Good webhook receivers are 50% verifying the signature, 50% not blocking. Get those two right and you can build basically any integration on top of muro in an afternoon.