← All posts
Engineering7 min read

[ENGINEERING] · May 7, 2026 · 08:30

Webhooks 101: build your own notifications, CRM sync, and AI handlers

Every chat event in muro is delivered to your URL with HMAC-SHA256 signing and exponential retries. Here's how to build receivers that actually behave.

LM

Léa Marchand

Engineering

#webhooks#engineering#integrations#hmac#retry

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.

LM

✎ Written by

Léa Marchand

Engineering