Embedding & Parsing Call Data
When Public call share is enabled for your org (see Public Call Share Links),
your call.completed webhook payload includes public, no-login links to a call's recording, transcript, and a
ready-made details page. This page covers the two ways to use them in your own app:
- Embed the ready-made page — zero code, just an
<iframe>. - Build your own player — fetch and render the recording/transcript yourself.
Prerequisites
Enable Public call share under Dashboard → Account Settings → Organization. Once on, your webhook payloads include:
| Field | What it is |
|---|---|
call_details_url | Public URL to a ready-made page showing Call Details, Insights, Recording & Transcript, and Appointments. |
share_token | The same token embedded in call_details_url, provided separately for direct API use. |
call_recording | Public URL to the audio file (instead of a 5-minute signed URL). |
call_transcript | Public URL to the transcript JSON (instead of a 5-minute signed URL). |
Full field reference: Outbound Webhooks → Media & Analysis.
Option 1: Embed the ready-made page
Drop call_details_url straight into an <iframe> — nothing to fetch or parse:
<iframe
src="https://studio.huskyvoice.ai/public/call/call_987654321?share_token=8f2a1c9d4e7b0a3c6f5d2e1b9a8c7d6e5f4a3b2c"
style="width: 100%; height: 700px; border: 0;"
title="Call details"
></iframe>
Confirmed cross-origin safe (verified directly against production): the page sends no X-Frame-Options or
Content-Security-Policy: frame-ancestors header, and its backing API sends Access-Control-Allow-Origin: * —
it renders correctly regardless of what domain you embed it on. No cookies are involved; access is entirely via
the share_token query parameter.
If your embedding page sets a restrictive sandbox attribute on the <iframe>, include at least
allow-scripts allow-same-origin allow-popups (the "add to Google Calendar" link, shown when a call has a
matched appointment, opens in a new tab and needs allow-popups). The page fills whatever size you give the
<iframe> — it doesn't auto-resize to content, so set an explicit height.
Option 2: Build your own player
Use this when you want your own UI instead of embedding ours — e.g. a custom audio player, or a transcript styled to match your app.
Parsing call_transcript
call_transcript is normally a URL (signed, or permanent if Public call share is on) to a JSON file — fetch it
and parse the response, same as any JSON API call. It only arrives as an inline array directly in the payload
for calls that never produced a transcript file. Handle both:
- JavaScript
- Python
const { call_transcript } = payload.data;
let transcript;
if (typeof call_transcript === 'string') {
transcript = await fetch(call_transcript).then(r => r.json()); // already decoded JSON
} else if (Array.isArray(call_transcript)) {
transcript = call_transcript; // inline fallback
} else {
transcript = []; // null — no transcript available
}
// transcript: [{ speaker: 'agent' | 'user', text: string, timestamp: number }, ...]
call_transcript = data.get("call_transcript")
if isinstance(call_transcript, str):
transcript = requests.get(call_transcript).json()
elif isinstance(call_transcript, list):
transcript = call_transcript
else:
transcript = []
Shortcut: if share_token is present, you can skip the string-vs-array check entirely by calling the
combined endpoint directly — its transcript field is always a plain, already-decoded array:
const res = await fetch(`https://api.huskyvoice.ai/public/call/${call_id}?token=${share_token}`);
const { transcript } = (await res.json()).data;
Transcript text is standard JSON — non-ASCII characters may appear as \uXXXX escapes in the raw file, but
JSON.parse() / response.json() (or Python's .json()) decode these automatically as part of ordinary
parsing. If you ever open a transcript URL directly in a browser tab (rather than through fetch/requests),
you'll see the raw \uXXXX escapes as literal text — that's just the browser showing unparsed file content, not
corrupted data. Pipe it through jq or python3 -m json.tool to view it decoded.
Minimal example: recording + transcript
The smallest possible page that plays the recording and dumps the transcript — a good starting point before reaching for the fuller styled example below:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Call Recording & Transcript</title>
</head>
<body>
<audio id="player" controls style="width: 100%"></audio>
<pre id="transcript">Loading transcript…</pre>
<script>
// Replace {call_id} / {share_token} with the values from your webhook payload
// (or use call_recording / call_transcript directly if you already have those
// full URLs — these two routes are what those fields point to).
const RECORDING_URL = "https://api.huskyvoice.ai/public/call/recording/{call_id}?token={share_token}";
const TRANSCRIPT_URL = "https://api.huskyvoice.ai/public/call/transcript/{call_id}?token={share_token}";
document.getElementById("player").src = RECORDING_URL;
fetch(TRANSCRIPT_URL)
.then(res => res.json()) // fetch follows the redirect and decodes \uXXXX automatically — nothing manual needed
.then(transcript => {
document.getElementById("transcript").textContent = JSON.stringify(transcript, null, 2);
})
.catch(err => {
document.getElementById("transcript").textContent = "Error: " + err;
});
</script>
</body>
</html>
/public/call/recording/{call_id} and /public/call/transcript/{call_id} are the same redirect endpoints
backing the call_recording / call_transcript fields in your webhook payload — hitting them directly with
{call_id} + token={share_token} behaves identically to using the URLs handed to you in the payload.
\uXXXX decoderIt's tempting to write a regex step that scans for \u sequences and manually converts them — but
response.json() already fully decodes standard JSON escapes as part of parsing, before your code ever sees the
string. \uXXXX text only exists in the raw, unparsed bytes of the file; by the time you have a JS
object/string from .json(), there's nothing left for a manual decode step to do. If you're seeing literal
\uXXXX in transcript text after parsing, look for double-encoding upstream instead — e.g. code that calls
JSON.stringify() on a value that was already a JSON-escaped string, or a Content-Type mismatch causing
fetch to treat the body as plain text rather than JSON (use res.text() + JSON.parse() as a fallback if
res.json() ever throws on a response you expect to be JSON).
Full example: audio player + transcript turns
A self-contained HTML page — no dependencies, no build step. Pass your call_recording and call_transcript
values in as query params (or template them in server-side), and it renders an audio player plus the transcript
as agent/user chat turns, in light or dark mode automatically:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Call Recording & Transcript</title>
<style>
:root {
--bg: #ffffff; --text: #0f172a; --muted: #64748b; --border: #e2e8f0;
--agent-bg: #f1f5f9; --agent-text: #0f172a;
--user-bg: #0f172a; --user-text: #ffffff;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f172a; --text: #f1f5f9; --muted: #94a3b8; --border: #1e293b;
--agent-bg: #1e293b; --agent-text: #f1f5f9;
--user-bg: #f1f5f9; --user-text: #0f172a;
}
}
* { box-sizing: border-box; }
body { margin: 0; padding: 20px; background: var(--bg); color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
.card { max-width: 640px; margin: 0 auto; }
audio { width: 100%; margin-bottom: 20px; }
.status { color: var(--muted); font-size: 14px; padding: 20px 0; }
.turn { display: flex; margin-bottom: 12px; }
.turn.agent { justify-content: flex-start; }
.turn.user { justify-content: flex-end; }
.bubble { max-width: 75%; padding: 10px 14px; border-radius: 14px; font-size: 14px; line-height: 1.4; }
.turn.agent .bubble { background: var(--agent-bg); color: var(--agent-text); border-bottom-left-radius: 4px; }
.turn.user .bubble { background: var(--user-bg); color: var(--user-text); border-bottom-right-radius: 4px; }
.meta { display: block; font-size: 11px; opacity: 0.6; margin-top: 4px; }
</style>
</head>
<body>
<div class="card">
<audio id="player" controls></audio>
<div id="transcript"></div>
</div>
<script>
// ?audio=<call_recording>&transcript=<call_transcript> — or hardcode both below.
const params = new URLSearchParams(window.location.search);
const AUDIO_URL = params.get('audio') || '';
const TRANSCRIPT_URL_OR_JSON = params.get('transcript') || '';
const player = document.getElementById('player');
const container = document.getElementById('transcript');
if (AUDIO_URL) player.src = AUDIO_URL;
function formatTimestamp(seconds) {
if (seconds == null || isNaN(seconds)) return '';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60).toString().padStart(2, '0');
return `${m}:${s}`;
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str ?? ''; // Unicode-safe: no manual regex escaping needed
return div.innerHTML;
}
function renderTranscript(turns) {
container.innerHTML = '';
if (!Array.isArray(turns) || turns.length === 0) {
container.innerHTML = '<div class="status">No transcript available for this call.</div>';
return;
}
for (const turn of turns) {
const role = turn.speaker === 'agent' ? 'agent' : 'user';
const row = document.createElement('div');
row.className = `turn ${role}`;
row.innerHTML = `
`;
container.appendChild(row);
}
}
async function loadTranscript() {
if (!TRANSCRIPT_URL_OR_JSON) {
container.innerHTML = '<div class="status">No transcript URL provided.</div>';
return;
}
container.innerHTML = '<div class="status">Loading transcript…</div>';
try {
let turns;
if (TRANSCRIPT_URL_OR_JSON.startsWith('http')) {
const res = await fetch(TRANSCRIPT_URL_OR_JSON);
turns = await res.json(); // decoding (including Unicode escapes) is automatic
} else {
turns = JSON.parse(TRANSCRIPT_URL_OR_JSON);
}
renderTranscript(turns);
} catch (err) {
container.innerHTML = ``;
}
}
loadTranscript();
</script>
</body>
</html>
In practice, template AUDIO_URL/TRANSCRIPT_URL_OR_JSON server-side (or pass them via postMessage after the
iframe loads) rather than relying on query params for long signed S3 URLs, which can bump into URL length limits.
Security summary
- Access is entirely token-based (
share_token/ thetokenquery param) — no cookies, no login, nothing for your site's own session/cookie policy to conflict with. - Verified against production: no frame-blocking headers on the page,
Access-Control-Allow-Origin: *on the API. - One token gates all three public links for a call (
call_details_url,call_recording,call_transcript). - Disabling Public call share for your org instantly invalidates all previously-issued links — no per-link revocation needed.