# Call webhook & recordings API

> Get every finished call pushed to your server, then fetch a signed link to play its recording on your own side.

Source: https://business.mysmartassistant.ai/docs/call-recordings-api

## How it fits together

1. A call in your workspace ends (answered or failed). A call that was never dialled — the campaign was stopped or ended, or the customer was reached first — sends no webhook; it shows in the Detailed Report as **Not dialled**.
2. MySA POSTs the call's details to **your webhook URL** as JSON, signed with your secret.
3. When you want to play the audio, your server calls `GET /v1/api/calls/{call_id}/recording-url` with your Org ID and API key.
4. MySA answers with a **signed recording link that works for 15 minutes**. Play it, or hand it to your player.

The recording link is never inside the webhook itself. A webhook can be retried hours later, and a link that expires in 15 minutes would already be dead when it arrived. The webhook carries `recording_url_api` instead: an address that does not change, sits behind your API key, and gives you a fresh link every time you call it.

> **Note:** Test and Live are separate workspaces. Each has its own webhook, Org ID and API keys. Set the webhook up in the workspace whose calls you want.

## Set up the webhook

1. Open [Detailed Report](https://business.mysmartassistant.ai/docs/detailed-report) from the left menu.
2. Click **Webhook** at the top right.
3. Enter **Your endpoint URL**. It must start with `https://` and be reachable from the internet.
4. Leave **Sending is on** switched on and click **Save webhook**.
5. Copy the **Signing secret** that appears. You can show it again later with **Show**.
6. Click **Send test**. Your server receives a sample `call.test` message, and the panel shows what your server answered.

**Recent deliveries** in the same panel lists what was sent, what your server answered and anything still being retried. A failed one can be sent again with **Send again**.

> **Note:** You need Webhooks access to change the webhook. Anyone with read access can see it and its deliveries. See [Team & roles](https://business.mysmartassistant.ai/docs/team-roles).

Calls that end after you switch the webhook on are sent, including campaign calls whose contacts were uploaded earlier and campaign test calls (marked `"is_test": true`). Turning it off and on again starts from that moment; calls that ended in between are not sent in a burst.

## What your server receives

One `POST` per call, `Content-Type: application/json`:

```json
{
  "id": "01JABCDEF0123456789XYZWXYZ",
  "type": "call.completed",
  "created_at": "2026-10-02T09:41:12.000Z",
  "organization_id": "org_123",
  "data": {
    "call_id": "01JABCD0000000000000000001",
    "campaign_id": "01JABCD0000000000000000002",
    "campaign_name": "October follow-up",
    "direction": "outbound",
    "phone": "+919800000000",
    "name": "Ravi Kumar",
    "status": "completed",
    "outcome": "answered",
    "duration_seconds": 74,
    "charge_minor": "1200",
    "summary": "Customer wants a test ride on Saturday morning.",
    "sentiment": "positive",
    "customer_interest": "interested",
    "purpose_outcome": "achieved",
    "next_action": "Confirm the Saturday slot by WhatsApp.",
    "tags": [{ "category": "Intent", "name": "Test ride" }],
    "variables": { "var1": "Model X" },
    "follow_up_set": true,
    "visit_reminder": { "visit_date": "2026-10-04", "visit_time": "11:00", "reminder_at": "2026-10-04T03:30:00.000Z", "status": "pending" },
    "is_test": false,
    "has_recording": true,
    "recording_url_api": "https://business.mysmartassistant.ai/v1/api/calls/01JABCD0000000000000000001/recording-url",
    "recording_link": "https://business.mysmartassistant.ai/v1/api/calls/01JABCD0000000000000000001/recording",
    "created_at": "2026-10-02T09:39:40.000Z",
    "completed_at": "2026-10-02T09:40:58.000Z"
  }
}
```

| Field | Meaning |
|---|---|
| `id` | The event ID. The same event can arrive twice (a retry after a slow answer); keep the first and ignore repeats |
| `type` | `call.completed` for a real call, `call.test` for **Send test** |
| `data.status` | `completed` or `failed` |
| `data.name` | The customer's name. If your system sent the contact with the name `Customer` (because it did not know it) and the customer said their name on the call, this is the name they said |
| `data.is_test` | `true` for a campaign's **Send test call**. Skip it if you only want real customer calls |
| `data.has_recording` | `true` when there is audio to fetch |
| `data.recording_url_api` | Call this with your API key to get a 15-minute signed link (below). `null` when there is no recording |
| `data.recording_link` | The same, as a redirect straight to the audio, for tools that just follow a link |
| `data.charge_minor` | What this call cost you, in paise, as a string |
| `data.visit_reminder` | The visit the customer agreed to on this call, if it made a Visit Reminder; otherwise `null`. `visit_date` is `YYYY-MM-DD` and `visit_time` is `HH:MM` (India time, as the customer said it; `null` when only a day was agreed). `reminder_at` is when MySA will call them, as a UTC timestamp. `status` is `pending`, `done`, `cancelled` or `failed` |

A completed call is sent about a minute after it ends, so its summary and result are already in it. Failed calls are sent straight away.

### Headers

| Header | Value |
|---|---|
| `x-mysa-event-id` | Same as `id` in the body |
| `x-mysa-event-type` | Same as `type` in the body |
| `x-mysa-signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>` |

### Answer quickly with a 2xx

Return any `2xx` within 10 seconds to mark the event delivered. Store the message and do the slow work afterwards. Anything else, a timeout included, is retried after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour and then every few hours, for 24 hours. Redirects are not followed.

When many calls end at once, each one still gets its own POST. Up to four are sent to you side by side, so your endpoint should handle a few requests at the same time.

## Verify the signature

The signature is an HMAC-SHA256, keyed with your signing secret, of the timestamp, a dot and the **raw** request body: `t + "." + body`. Compute it over the bytes you received, before parsing the JSON, and refuse a `t` more than 5 minutes from your clock so an old message cannot be replayed.

Node.js (Express):

```js
const crypto = require("crypto");
const express = require("express");
const app = express();

app.post("/mysa/calls", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("x-mysa-signature") || "";
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const body = req.body.toString("utf8");
  const expected = crypto
    .createHmac("sha256", process.env.MYSA_WEBHOOK_SECRET)
    .update(`${parts.t}.${body}`)
    .digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  const ok =
    fresh &&
    parts.v1 &&
    parts.v1.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
  if (!ok) return res.status(401).end();

  const event = JSON.parse(body);
  // save event.data, keyed by event.id, then process it later
  res.status(200).end();
});
```

Python (Flask):

```python
import hmac, hashlib, os, time, json
from flask import Flask, request, abort

app = Flask(__name__)

@app.post("/mysa/calls")
def mysa_calls():
    header = request.headers.get("x-mysa-signature", "")
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    body = request.get_data(as_text=True)
    expected = hmac.new(
        os.environ["MYSA_WEBHOOK_SECRET"].encode(),
        f"{parts.get('t')}.{body}".encode(),
        hashlib.sha256,
    ).hexdigest()
    try:
        t = int(parts.get("t", ""))
    except ValueError:
        abort(401)  # missing or malformed header: refuse, do not crash
    if abs(time.time() - t) > 300:
        abort(401)
    if not hmac.compare_digest(parts.get("v1", ""), expected):
        abort(401)
    event = json.loads(body)
    # save event["data"], keyed by event["id"], then process it later
    return "", 200
```

## Get a signed recording link

`GET /v1/api/calls/{call_id}/recording-url` returns a link to the call's audio that works for **15 minutes**. Send your Org ID and API key, exactly as for every other API call (see [API keys](https://business.mysmartassistant.ai/docs/api-keys)). The key needs the `calllog.read` scope, which every new key has.

```bash
curl https://business.mysmartassistant.ai/v1/api/calls/CALL_ID/recording-url \
  -H "X-Org-Id: org_123" \
  -H "X-Api-Key: YOUR_API_KEY"
```

```json
{
  "data": {
    "call_id": "01JABCD0000000000000000001",
    "url": "https://…amazonaws.com/…/recording.wav?X-Amz-Expires=900&X-Amz-Signature=…",
    "content_type": "audio/wav",
    "expires_in_seconds": 900,
    "expires_at": "2026-10-02T09:56:12.000Z"
  }
}
```

The API is served from the same host you open the console on. You can also call the `recording_url_api` address from the webhook as it is.

### Play it on your side

Fetch the link from **your server**, then give only the `url` to the browser. Never put your API key in browser or app code.

```js
// Your server: GET /calls/:id/audio → { url }
const r = await fetch(`https://business.mysmartassistant.ai/v1/api/calls/${callId}/recording-url`, {
  headers: { "X-Org-Id": process.env.MYSA_ORG_ID, "X-Api-Key": process.env.MYSA_API_KEY },
});
if (!r.ok) throw new Error((await r.json()).error.code);
const { data } = await r.json();
return { url: data.url, expiresAt: data.expires_at };
```

```html

<audio controls preload="none" src="SIGNED_URL_FROM_YOUR_SERVER"></audio>
```

```python
import os, requests

r = requests.get(
    f"https://business.mysmartassistant.ai/v1/api/calls/{call_id}/recording-url",
    headers={"X-Org-Id": os.environ["MYSA_ORG_ID"], "X-Api-Key": os.environ["MYSA_API_KEY"]},
    timeout=10,
)
r.raise_for_status()
url = r.json()["data"]["url"]
```

### When the link expires

Do not store the signed `url`; it stops working after 15 minutes. Store the `call_id` (or `recording_url_api`) and ask for a fresh link each time someone presses play. A player that is already playing when the 15 minutes run out keeps the audio it has loaded; seeking further may need a new link.

### Redirect instead of JSON

`GET /v1/api/calls/{call_id}/recording` with the same headers answers `302` straight to the audio. Use it from tools that only follow a link; use `/recording-url` when you need to know when the link expires.

### Errors

| HTTP | `error.code` | Meaning |
|---|---|---|
| 401 | `AUTH_REQUIRED` | Org ID or API key missing, wrong, revoked or expired, or the two do not belong together |
| 403 | `PERMISSION_DENIED` | The key does not have the `calllog.read` scope |
| 404 | `CALL_NOT_FOUND` | No call with this ID in this workspace. A Live call ID asked for with a Test key is not found too |
| 404 | `RECORDING_NOT_AVAILABLE` | The call has no recording: it was not answered, or it is still in progress. Calls that ended a moment ago can take a few seconds |

## Common questions

**Do I need the webhook to use the recordings API?** No. Any call ID from your workspace works, for example one from the Detailed Report or from your own records.

**Can the 15 minutes be changed?** The default is 15 minutes. If your setup needs a different window, raise a ticket from [Getting help](https://business.mysmartassistant.ai/docs/getting-help).

**My test says "must be a public address".** The webhook URL cannot point at `localhost` or a private network address. Use a public HTTPS URL, or a tunnel such as ngrok while you develop.
