Developers
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.
How it fits together
- 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.
- MySA POSTs the call's details to your webhook URL as JSON, signed with your secret.
- When you want to play the audio, your server calls
GET /v1/api/calls/{call_id}/recording-urlwith your Org ID and API key. - 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
- Open Detailed Report from the left menu.
- Click Webhook at the top right.
- Enter Your endpoint URL. It must start with
https://and be reachable from the internet. - Leave Sending is on switched on and click Save webhook.
- Copy the Signing secret that appears. You can show it again later with Show.
- Click Send test. Your server receives a sample
call.testmessage, 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.
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:
{
"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):
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):
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 "", 200Get 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). The key needs the calllog.read scope, which every new key has.
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"{
"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.
// 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 };<!-- Your page -->
<audio controls preload="none" src="SIGNED_URL_FROM_YOUR_SERVER"></audio>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.
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.

