Skip to main content

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:


Prerequisites

Enable Public call share under Dashboard → Account Settings → Organization. Once on, your webhook payloads include:

FieldWhat it is
call_details_urlPublic URL to a ready-made page showing Call Details, Insights, Recording & Transcript, and Appointments.
share_tokenThe same token embedded in call_details_url, provided separately for direct API use.
call_recordingPublic URL to the audio file (instead of a 5-minute signed URL).
call_transcriptPublic 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:

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 }, ...]

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;
Unicode text (Hindi, Arabic, emoji, etc.) just works

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 &amp; 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.

Don't hand-roll a \uXXXX decoder

It'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 &amp; 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 = `
<div class="bubble">
${escapeHtml(turn.text)}
<span class="meta">${role === 'agent' ? 'Agent' : 'Customer'} · ${formatTimestamp(turn.timestamp)}</span>
</div>`;
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 = `<div class="status">Failed to load transcript: ${err.message}</div>`;
}
}

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 / the token query 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.