# Receive Miraven events in your webhook endpoint

Get a signed HTTPS request after each call a funeral home’s agent answers, with the call’s details and a link to its recording.

Source: https://www.miraven.com/developers/webhooks · Updated 2026-11-11

## How it works

When something happens in Miraven, like a call finishing, we send an event to your endpoint: a `POST` with a JSON body, signed so you can check it came from us.

1. **A caller hangs up.** The funeral home’s agent finishes the call. Miraven processes it and saves the recording.
2. **Miraven sends an event.** Your endpoint receives a signed [`call.analyzed`](https://www.miraven.com/developers/webhooks/call-analyzed) event, usually within a few minutes of the call ending.
3. **You verify it and respond.** Check the signature, record the event, and answer with a `2xx` within 15 seconds. Do slow work, like downloading the recording, afterwards.

```http
POST /webhooks/miraven HTTP/1.1
Host: api.example.com
Content-Type: application/json
User-Agent: Miraven-Webhooks/1.0
webhook-id: evt_2b7c41e09f6a4b1d8f3e5a7c9d0b1e2f
webhook-timestamp: 1762197124
webhook-signature: v1,33DcwBe0+lAhKqXAgY05/QROBF4z3Xnd7cyJbTZaURY=

{
  "id": "evt_2b7c41e09f6a4b1d8f3e5a7c9d0b1e2f",
  "type": "call.analyzed",
  "created_at": "2026-11-03T19:12:04.000Z",
  "data": {
    "call_id": "6f1d2c3b-8a9e-4f70-b1c2-d3e4f5a6b7c8",
    "agent_id": "39d63614-2b1a-4c5d-9e8f-7a6b5c4d3e2f",
    "caller_phone": "+15551234567",
    "called_phone": "+15557654321",
    "forwarded_from": "+15553334444",
    "funeral_home_phone": "+15553334444",
    "started_at": "2026-11-03T19:05:22.000Z",
    "ended_at": "2026-11-03T19:11:40.000Z",
    "duration_seconds": 378,
    "recording": {
      "url": "https://app.miraven.com/api/recordings/6f1d2c3b-8a9e-4f70-b1c2-d3e4f5a6b7c8?exp=1777835524&sig=EXAMPLE",
      "content_type": "audio/wav",
      "expires_at": "2027-05-04T19:12:04.000Z"
    }
  }
}
```

## Set up your endpoint

Endpoints are set up by the Miraven team for now. Email [support@miraven.com](mailto:support@miraven.com) with your endpoint URL and the funeral homes you’re integrating with. We’ll send you:

- **A signing secret** starting with `whsec_`, for [verifying requests](https://www.miraven.com/developers/webhooks/verifying-signatures). Store it like a password.
- **The agent IDs** for those funeral homes. Every event carries one, so one endpoint can serve several funeral homes.

### Endpoint URL requirements

- **HTTPS** with a valid certificate. Plain `http://` URLs aren’t accepted.
- **Publicly reachable.** Use a tunnel such as ngrok to [test a local server](https://www.miraven.com/developers/webhooks#test-locally).
- **The final URL.** Redirects aren’t followed and count as a failed delivery.
- **No credentials in the URL.** If your system needs an API key, we can send it in a header on every request, stored encrypted on our side.

## Create a handler

Your handler verifies the signature, skips events it has already handled, queues the work, and responds. These examples use the [Standard Webhooks](https://www.standardwebhooks.com) library, which implements Miraven’s signature scheme.

Node.js (Express):

```javascript
import express from "express";
import { Webhook } from "standardwebhooks";

const app = express();
const webhook = new Webhook(process.env.MIRAVEN_WEBHOOK_SECRET); // "whsec_…"

// express.raw keeps the body exactly as it arrived, which verification needs.
app.post("/webhooks/miraven", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = webhook.verify(req.body, req.headers);
  } catch {
    return res.sendStatus(400);
  }

  // Retries and resends deliver the same event again, with the same id.
  if (await db.processedEvents.has(event.id)) return res.sendStatus(200);

  switch (event.type) {
    case "call.analyzed":
      await queue.add("miraven-call", event.data); // download the recording later, not here
      break;
    default:
      // Ignore event types you don't handle; we add new ones over time.
      break;
  }

  await db.processedEvents.add(event.id);
  res.sendStatus(202);
});

app.listen(3000);
```

Next.js:

```typescript
// app/api/webhooks/miraven/route.ts
import { Webhook } from "standardwebhooks";

const webhook = new Webhook(process.env.MIRAVEN_WEBHOOK_SECRET!); // "whsec_…"

export async function POST(request: Request) {
  // Read the body as text: request.json() would re-serialize it and break the signature.
  const body = await request.text();

  let event;
  try {
    event = webhook.verify(body, Object.fromEntries(request.headers)) as { id: string; type: string; data: any };
  } catch {
    return new Response(null, { status: 400 });
  }

  if (await alreadyProcessed(event.id)) return new Response(null, { status: 200 });

  if (event.type === "call.analyzed") {
    await enqueue("miraven-call", event.data);
  }

  await markProcessed(event.id);
  return new Response(null, { status: 202 });
}
```

Python (Flask):

```python
import os

from flask import Flask, request
from standardwebhooks.webhooks import Webhook

app = Flask(__name__)
webhook = Webhook(os.environ["MIRAVEN_WEBHOOK_SECRET"])  # "whsec_…"


@app.post("/webhooks/miraven")
def miraven_webhook():
    # get_data() is the body exactly as it arrived, which verification needs.
    try:
        event = webhook.verify(request.get_data(), dict(request.headers))
    except Exception:
        return "", 400

    # Retries and resends deliver the same event again, with the same id.
    if already_processed(event["id"]):
        return "", 200

    if event["type"] == "call.analyzed":
        enqueue("miraven-call", event["data"])  # download the recording later, not here
    # Ignore event types you don't handle; we add new ones over time.

    mark_processed(event["id"])
    return "", 202
```

Python (Django):

```python
import os

from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from standardwebhooks.webhooks import Webhook

webhook = Webhook(os.environ["MIRAVEN_WEBHOOK_SECRET"])  # "whsec_…"


@csrf_exempt  # Miraven can't send your CSRF token; the signature protects this route instead.
@require_POST
def miraven_webhook(request):
    try:
        event = webhook.verify(request.body, dict(request.headers))
    except Exception:
        return HttpResponse(status=400)

    if already_processed(event["id"]):
        return HttpResponse(status=200)

    if event["type"] == "call.analyzed":
        enqueue("miraven-call", event["data"])

    mark_processed(event["id"])
    return HttpResponse(status=202)
```

> **Don’t change the raw body**
>
> Verification needs the body exactly as it arrived. Parsing the JSON and serializing it again changes the bytes, and the signature won’t match. See [the raw body in each framework](https://www.miraven.com/developers/webhooks/verifying-signatures#raw-body).

## Test your handler

### Send yourself a test event

This script builds a `call.analyzed` event, signs it with your secret the way Miraven does, and sends it to your handler. Use your real secret, or generate one for local testing.

```shell
# Any whsec_ secret works for local testing:
node -e "console.log('whsec_' + require('crypto').randomBytes(32).toString('base64'))"
```

Node.js:

```javascript
// send-test-event.mjs
// MIRAVEN_WEBHOOK_SECRET=whsec_… node send-test-event.mjs http://localhost:3000/webhooks/miraven
import crypto from "node:crypto";

const url = process.argv[2];
const secret = process.env.MIRAVEN_WEBHOOK_SECRET;

const id = `evt_test_${crypto.randomUUID().replaceAll("-", "")}`;
const now = new Date();
const body = JSON.stringify({
  id,
  type: "call.analyzed",
  created_at: now.toISOString(),
  data: {
    call_id: crypto.randomUUID(),
    agent_id: "00000000-0000-0000-0000-000000000000",
    caller_phone: "+15551234567",
    called_phone: "+15557654321",
    forwarded_from: "+15553334444",
    funeral_home_phone: "+15553334444",
    started_at: new Date(now - 378_000).toISOString(),
    ended_at: now.toISOString(),
    duration_seconds: 378,
    recording: {
      url: "https://app.miraven.com/api/recordings/test", // test events have no real recording
      content_type: "audio/wav",
      expires_at: new Date(+now + 182 * 86_400_000).toISOString(),
    },
  },
});

// Sign it the way Miraven does: HMAC-SHA256 over "id.timestamp.body".
const timestamp = Math.floor(now / 1000);
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const signature = crypto.createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest("base64");

const response = await fetch(url, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "webhook-id": id,
    "webhook-timestamp": String(timestamp),
    "webhook-signature": `v1,${signature}`,
  },
  body,
});
console.log(response.status, await response.text());
```

Python:

```python
# send_test_event.py
# MIRAVEN_WEBHOOK_SECRET=whsec_… python send_test_event.py http://localhost:8000/webhooks/miraven
import base64, hashlib, hmac, json, os, sys, time, uuid
from datetime import datetime, timedelta, timezone

import requests

url = sys.argv[1]
secret = os.environ["MIRAVEN_WEBHOOK_SECRET"]

now = datetime.now(timezone.utc)
event_id = f"evt_test_{uuid.uuid4().hex}"
body = json.dumps({
    "id": event_id,
    "type": "call.analyzed",
    "created_at": now.isoformat(),
    "data": {
        "call_id": str(uuid.uuid4()),
        "agent_id": "00000000-0000-0000-0000-000000000000",
        "caller_phone": "+15551234567",
        "called_phone": "+15557654321",
        "forwarded_from": "+15553334444",
        "funeral_home_phone": "+15553334444",
        "started_at": (now - timedelta(seconds=378)).isoformat(),
        "ended_at": now.isoformat(),
        "duration_seconds": 378,
        "recording": {
            "url": "https://app.miraven.com/api/recordings/test",  # test events have no real recording
            "content_type": "audio/wav",
            "expires_at": (now + timedelta(days=182)).isoformat(),
        },
    },
})

# Sign it the way Miraven does: HMAC-SHA256 over "id.timestamp.body".
timestamp = str(int(time.time()))
key = base64.b64decode(secret.removeprefix("whsec_"))
signature = base64.b64encode(hmac.new(key, f"{event_id}.{timestamp}.{body}".encode(), hashlib.sha256).digest()).decode()

response = requests.post(url, data=body, headers={
    "Content-Type": "application/json",
    "webhook-id": event_id,
    "webhook-timestamp": timestamp,
    "webhook-signature": f"v1,{signature}",
})
print(response.status_code, response.text)
```

Expect a `2xx`. Send the same event twice to check your handler skips the repeat.

### Receive real calls

Before we turn an endpoint on for every call, we send it one or two real calls. Tell us when they’ve arrived and the recordings downloaded, and we’ll switch it on.

## Event delivery behaviors

### Automatic retries

If your endpoint doesn’t answer with a `2xx` within 15 seconds, Miraven retries with exponential back-off for about 3 days. A `4xx` other than `408` or `429` stops retries straight away, since it usually means something needs fixing first.

| Attempt | Wait before it | Time since the first attempt |
| --- | --- | --- |
| 1 | None | 0 |
| 2 | 1 minute | 1 minute |
| 3 | 5 minutes | 6 minutes |
| 4 | 30 minutes | 36 minutes |
| 5 | 2 hours | 2.5 hours |
| 6 | 6 hours | 8.5 hours |
| 7 | 12 hours | 20.5 hours |
| 8 | 1 day | 1.9 days |
| 9 | 1 day | 2.9 days |

The Miraven team is alerted when an endpoint has been failing for more than 30 minutes, and again when an event runs out of retries.

### Manual retries

Ask [support@miraven.com](mailto:support@miraven.com) to resend any event, for example after an outage or a bug in your handler. A resent event keeps its `id`.

### Event ordering

Events aren’t guaranteed to arrive in order: a retried event can arrive after a newer one. Order calls by `data.started_at`, not by when the event arrived.

### Debug failed deliveries

| What we see | What it means | Fix |
| --- | --- | --- |
| Could not connect | DNS, TLS or the connection failed. | Check the URL is public, the certificate is valid, and the server is up. |
| No response in 15s | Your handler took too long. | Respond first and do the work in a queue. |
| `3xx` | We don’t follow redirects. | Give us the final URL. |
| `401` / `403` | Your endpoint rejected the request. | Check your signing secret and any custom header. |
| Other `4xx` | Your endpoint rejected the request, and we stopped. | Fix the cause, then ask us to resend. |
| `5xx` | Your server errored. | Check your logs. We retry automatically. |

## Best practices

### Handle duplicate events

Your endpoint can receive the same event more than once, after a retry or a resend. Record the `id` of each event you process and skip ones you’ve seen. Answer repeats with a `2xx` so they aren’t retried.

### Only handle the event types you need

We’ll add event types over time. Check `type` and ignore any you don’t handle, still answering with a `2xx`.

### Handle events asynchronously

Put each event on a queue and process it separately. Recordings can be large, so download them from the queue, not inside the request.

### Quickly return a 2xx response

Respond within 15 seconds, or the delivery counts as failed and is retried. Respond before any slow work.

### Verify events are sent from Miraven

[Verify the signature](https://www.miraven.com/developers/webhooks/verifying-signatures) on every request before acting on it.

### Prevent replay attacks

Reject requests whose `webhook-timestamp` is more than 5 minutes from your clock. The Standard Webhooks libraries do this for you. Each retry is signed with a new timestamp, so retries still pass.

### Exempt your webhook route from CSRF protection

Frameworks like Django and Rails reject `POST`s without a CSRF token, which Miraven can’t send. Exempt the webhook route only: the signature protects it instead.

### Store the recordings you need

Recording links work for about 6 months, or until the funeral home deletes the call. Download and store recordings you need longer, and don’t log the links: anyone with one can download the recording.

## See also

- [The event object](https://www.miraven.com/developers/webhooks/events): The envelope every event shares, and every event type.
- [call.analyzed](https://www.miraven.com/developers/webhooks/call-analyzed): The call and recording sent after each call.
- [Verify webhook signatures](https://www.miraven.com/developers/webhooks/verifying-signatures): Check that each request came from Miraven.
