Voice Embedding How-To
Voice embedding tutorial
Embed: Voice Workflow Builder (Iframe)
This doc describes how third-party platforms can embed Niche's Voice Workflow Builder (or the full Phone Agent Configuration UI) inside an iframe.
How to Embed Voice: Complete Integration Guide
This section walks you through embedding Niche's Voice Agent configuration into your application, from initial setup to production deployment.
Step 1: Configure Your Organization in Niche
Before you can embed, you need to allowlist your application's domain:
-
Log in to your Niche dashboard at
https://app.nicheandleads.com -
Go to Organization Settings → Profile
-
Scroll to the Iframe Embed section
-
Add your application's origin(s) to the Allowed Parent Origins list
-
Click Save Embed Settings
-
For local development:
http://localhost:3000 -
For staging:
https://staging.your-app.com -
For production:
https://your-app.com
-
Step 2: Create an Organization API Key
You'll need an API key to mint embed tokens from your server:
-
In Niche, go to Organization Settings → API Keys (or Developer section)
-
Click Create API Key
-
Name it something descriptive (e.g., "Embed Token Minting - Production")
-
Select the required scopes:
-
embed:token:mint(required) -
workflow:read,workflow:write(for workflow editing) -
agent:read,agent:write,agent:deploy,agent:call(for full phone agent UI)
-
-
Copy and securely store the API key (you won't see it again)
5.
Step 3: Set Up Your Backend Token Minting Endpoint
Create a server-side endpoint that mints embed tokens. Never expose your API key to the client.
Node.js / Express Example
// server/routes/embed.js
import express from 'express';
const router = express.Router();
const NICHE_API_KEY = process.env.NICHE_ORG_API_KEY;
const NICHE_API_URL = 'https://app.nicheandleads.com/api/partner/v1';
// POST /api/embed/voice-token
router.post('/voice-token', async (req, res) => {
try {
// Get the business ID from your database based on the logged-in user
const businessId = await getBusinessIdForUser(req.user.id);
// Determine scopes based on user's permissions in your system
const scopes = req.user.canEditVoiceAgent
? ['workflow:read', 'workflow:write', 'agent:read', 'agent:write', 'agent:deploy']
: ['workflow:read', 'agent:read'];
const response = await fetch${NICHE_API_URL}/embed/voice-workflow-builder/token, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': NICHE_API_KEY,
},
body: JSON.stringify({
businessId,
parentOrigin: process.env.APP_ORIGIN, // e.g., 'https://your-app.com'
scopes,
ttlSeconds: 600, // 10 minutes
externalUserId: req.user.id, // for audit trail
theme: req.body.theme || 'light',
}),
});
if (!response.ok) {
const error = await response.json();
console.error('Token mint failed:', error);
return res.status(response.status).json({ error: error.message });
}
const { embedUrl, expiresAt } = await response.json();
// Return embedUrl to frontend (never expose the raw token or API key)
res.json({ embedUrl, expiresAt });
} catch (error) {
console.error('Embed token error:', error);
res.status(500).json({ error: 'Failed to generate embed URL' });
}
});
export default router;
Python / FastAPI Example
# routes/embed.py
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
import httpx
import os
router = APIRouter()
NICHE_API_KEY = os.getenv("NICHE_ORG_API_KEY")
NICHE_API_URL = "https://app.nicheandleads.com/api/partner/v1"
class EmbedRequest(BaseModel):
theme: str = "light"
@router.post("/embed/voice-token")
async def get_voice_embed_token(
request: EmbedRequest,
current_user = Depends(get_current_user)
):
# Get the business ID from your database
business_id = await get_business_id_for_user(current_user.id)
# Determine scopes based on user permissions
scopes = ["workflow:read", "agent:read"]
if current_user.can_edit_voice_agent:
scopes.extend(["workflow:write", "agent:write", "agent:deploy"])
async with httpx.AsyncClient() as client:
response = await client.post(
f"{NICHE_API_URL}/embed/voice-workflow-builder/token",
headers={
"Content-Type": "application/json",
"x-api-key": NICHE_API_KEY,
},
json={
"businessId": business_id,
"parentOrigin": os.getenv("APP_ORIGIN"),
"scopes": scopes,
"ttlSeconds": 600,
"externalUserId": str(current_user.id),
"theme": request.theme,
},
)
if response.status_code != 200:
raise HTTPException(status_code=response.status_code, detail="Token mint failed")
data = response.json()
return {"embedUrl": data["embedUrl"], "expiresAt": data["expiresAt"]}
Step 4: Embed in Your Frontend
React Example
tsx
// components/VoiceAgentEmbed.tsx
import { useState, useEffect, useRef, useCallback } from 'react';
interface VoiceAgentEmbedProps {
theme?: 'light' | 'dark';
onReady?: (data: { businessId: string; workflowId?: string }) => void;
onSaved?: (data: { businessId: string }) => void;
onDeployed?: (data: { mode: string; businessId: string }) => void;
}
export function VoiceAgentEmbed({
theme = 'light',
onReady,
onSaved,
onDeployed,
}: VoiceAgentEmbedProps) {
const [embedUrl, setEmbedUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const iframeRef = useRef<HTMLIFrameElement>(null);
// Fetch embed token on mount
useEffect(() => {
async function fetchEmbedUrl() {
try {
const response = await fetch('/api/embed/voice-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ theme }),
});
if (!response.ok) {
throw new Error('Failed to load voice agent configuration');
}
const { embedUrl } = await response.json();
setEmbedUrl(embedUrl);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}
fetchEmbedUrl();
}, [theme]);
// Listen for postMessage events from the iframe
useEffect(() => {
const expectedOrigin = 'https://app.nicheandleads.com';
function handleMessage(event: MessageEvent) {
if (event.origin !== expectedOrigin) return;
const { type, data } = event.data || {};
switch (type) {
case 'niche.embed.v1.ready':
onReady?.(data);
break;
case 'niche.embed.v1.resize':
if (iframeRef.current && typeof data?.height === 'number') {
iframeRef.current.style.height = ${Math.max(600, data.height)}px;
}
break;
case 'niche.embed.v1.agent.saved':
onSaved?.(data);
break;
case 'niche.embed.v1.agent.deployed':
onDeployed?.(data);
break;
}
}
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [onReady, onSaved, onDeployed]);
// Send commands to the iframe
const setIframeTheme = useCallback((newTheme: 'light' | 'dark') => {
iframeRef.current?.contentWindow?.postMessage(
{ type: 'niche.embed.v1.setTheme', data: { theme: newTheme } },
'https://app.nicheandleads.com'
);
}, []);
const requestSave = useCallback(() => {
iframeRef.current?.contentWindow?.postMessage(
{ type: 'niche.embed.v1.requestSave', data: {} },
'https://app.nicheandleads.com'
);
}, []);
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900" />
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-96 text-red-600">
{error}
</div>
);
}
return (
<iframe
ref={iframeRef}
src={embedUrl!}
title="Voice Agent Configuration"
style={{ width: '100%', border: 0, height: '800px', borderRadius: '8px' }}
referrerPolicy="strict-origin-when-cross-origin"
allow="clipboard-read; clipboard-write"
/>
);
}
// Usage in your app:
// <VoiceAgentEmbed
// theme="light"
// onReady={(data) => console.log('Embed ready', data)}
// onSaved={(data) => toast.success('Agent configuration saved!')}
// onDeployed={(data) => toast.successDeployed to ${data.mode})}
// />
Vanilla JavaScript Example
<!DOCTYPE html>
<html>
<head>
<title>Voice Agent Settings</title>
<style>
.embed-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.embed-loading {
display: flex;
align-items: center;
justify-content: center;
height: 400px;
color: #666;
}
.embed-error {
padding: 20px;
background: #fee;
border: 1px solid #f00;
border-radius: 8px;
color: #c00;
}
#voice-agent-iframe {
width: 100%;
border: 0;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
</style>
</head>
<body>
<div class="embed-container">
<h1>Voice Agent Configuration</h1>
<div id="embed-wrapper">
<div class="embed-loading">Loading voice agent configuration...</div>
</div>
</div>
<script>
const NICHE_ORIGIN = 'https://app.nicheandleads.com';
const wrapper = document.getElementById('embed-wrapper');
// Fetch embed URL from your backend
async function loadEmbed() {
try {
const response = await fetch('/api/embed/voice-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ theme: 'light' }),
});
if (!response.ok) throw new Error('Failed to get embed URL');
const { embedUrl } = await response.json();
// Create and insert the iframe
const iframe = document.createElement('iframe');
iframe.id = 'voice-agent-iframe';
iframe.src = embedUrl;
iframe.style.height = '800px';
iframe.referrerPolicy = 'strict-origin-when-cross-origin';
iframe.allow = 'clipboard-read; clipboard-write';
wrapper.innerHTML = '';
wrapper.appendChild(iframe);
// Listen for messages from the iframe
window.addEventListener('message', handleMessage);
} catch (error) {
wrapper.innerHTML = <div class="embed-error">${error.message}</div>;
}
}
function handleMessage(event) {
if (event.origin !== NICHE_ORIGIN) return;
const { type, data } = event.data || {};
const iframe = document.getElementById('voice-agent-iframe');
switch (type) {
case 'niche.embed.v1.ready':
console.log('Voice agent embed ready:', data);
break;
case 'niche.embed.v1.resize':
if (iframe && typeof data?.height === 'number') {
iframe.style.height = Math.max(600, data.height) + 'px';
}
break;
case 'niche.embed.v1.agent.saved':
console.log('Agent saved!');
showNotification('Agent configuration saved successfully!', 'success');
break;
case 'niche.embed.v1.agent.deployed':
console.log('Agent deployed to:', data.mode);
showNotificationAgent deployed to ${data.mode}!, 'success');
break;
}
}
function showNotification(message, type) {
// Implement your notification system here
alert(message);
}
// Load the embed on page load
loadEmbed();
</script>
</body>
</html>
Step 5: Handle Token Expiration
Embed tokens expire after 10 minutes (default). Implement token refresh for long sessions:
// React example with token refresh
function VoiceAgentEmbedWithRefresh() {
const [embedUrl, setEmbedUrl] = useState<string | null>(null);
const [expiresAt, setExpiresAt] = useState<Date | null>(null);
const refreshToken = useCallback(async () => {
const response = await fetch('/api/embed/voice-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ theme: 'light' }),
});
const data = await response.json();
setEmbedUrl(data.embedUrl);
setExpiresAt(new Date(data.expiresAt));
}, []);
useEffect(() => {
refreshToken();
}, [refreshToken]);
// Refresh token 1 minute before expiry
useEffect(() => {
if (!expiresAt) return;
const timeUntilRefresh = expiresAt.getTime() - Date.now() - 60_000;
if (timeUntilRefresh <= 0) return;
const timer = setTimeout(refreshToken, timeUntilRefresh);
return () => clearTimeout(timer);
}, [expiresAt, refreshToken]);
// ... rest of component
}
Step 6: Production Checklist
Before going live, verify:
-
Production origin
https://your-app.com) is in the allowlist -
API key is stored securely (environment variable, secret manager)
-
API key has only the scopes you need (principle of least privilege)
-
Token minting happens server-side only
-
postMessagelistener validatesevent.origin -
Error states are handled gracefully
-
Token refresh is implemented for long sessions
-
Users see clear loading states
Overview
-
Token mint endpoint:
POST /api/partner/v1/embed/voice-workflow-builder/token -
Embed URL:
GET /embed/voice-workflow-builder?businessId=...&token=... -
Security:
-
frame-ancestorsis enforced on/embed/*via CSP and bound to a singleparentOrigin. -
Embed tokens are short-lived (default 10 minutes, max 15 minutes).
-
Prereqs
1) Allowlist your embed host origin
Niche must store your allowed iframe host origins on the Organization:
-
OrganizationConfig key:
embedAllowedParentOrigins -
Format: either JSON array string (recommended) or comma/space-separated list
Examples:
-
JSON string:
["https://partner.example.com","https://admin.partner.example.com"] -
CSV-ish:
https://partner.example.com, https://admin.partner.example.com
You can configure this in Organization Settings → Profile → Iframe Embed.
2) Organization API key + scopes
Token minting uses an org-scoped API key (sent server-to-server).
The API key must include:
-
embed:token:mint– required to mint tokens
For workflow-only embed:
-
workflow:read(always needed) -
workflow:write(if you want embedded editing/saving)
For full phone agent embed (includes voice, personality, deployments):
-
agent:read– view agent config, deployments, voice settings -
agent:write– update voice/personality settings -
agent:deploy– save drafts and deploy to sandbox/production -
agent:call– make test calls (call-me feature)
Send the API key as:
-
x-api-key: <your_api_key>(recommended), or -
Authorization: ApiKey <your_api_key>/Authorization: Bearer <your_api_key>
Scopes Reference
|
Scope |
Permission |
|
|---|---|---|
|
|
View workflows |
|
|
|
Create/edit/save workflows |
|
|
|
View agent config (voice, personality, deployments) |
|
|
|
Update voice/personality settings |
|
|
|
Save drafts, deploy to sandbox/production |
|
|
|
Make test calls (call-me feature) |
Note: If any agent:* scope is present, the embed renders the full Phone Agent Configuration UI (with workflow visualizer, voice selection, personality settings, and phone number controls). Otherwise, it renders the workflow-only view.
Mint an embed token
Request
POST /api/partner/v1/embed/voice-workflow-builder/token
Body:
-
businessId(required) -
workflowId(optional; if omitted, embed can create a new workflow) -
parentOrigin(required; exact iframe host origin, e.g.https://partner.example.com) -
scopes(optional; defaults to["workflow:read"]) -
ttlSeconds(optional; 60–900, default 600) -
externalUserId(optional; for auditing) -
readonly(optional; if true, forces read-only regardless of scopes) -
theme(optional;light|dark) -
locale(optional; reserved for future)
Example: Workflow-only embed
curl -X POST "https://app.nicheandleads.com/api/partner/v1/embed/voice-workflow-builder/token" \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_ORG_API_KEY" \
-d '{
"businessId": "YOUR_BUSINESS_UUID",
"parentOrigin": "https://your-app.example.com",
"scopes": ["workflow:read", "workflow:write"],
"ttlSeconds": 600
}'
Example: Full phone agent embed
curl -X POST "https://app.nicheandleads.com/api/partner/v1/embed/voice-workflow-builder/token" \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_ORG_API_KEY" \
-d '{
"businessId": "YOUR_BUSINESS_UUID",
"parentOrigin": "https://your-app.example.com",
"scopes": ["workflow:read", "workflow:write", "agent:read", "agent:write", "agent:deploy", "agent:call"],
"ttlSeconds": 600
}'
Response
-
token: short-lived embed token (treat as a secret) -
embedUrl: fully formed iframe URL (includestoken) -
expiresAt: ISO timestamp
Embed the builder
Minimal HTML
<iframe
id="niche-voice-workflow"
src="EMBED_URL_FROM_TOKEN_MINT"
style="width: 100%; border: 0; height: 700px"
referrerpolicy="strict-origin-when-cross-origin"
allow="clipboard-read; clipboard-write"
></iframe>
Recommended sandbox
Start permissive, then tighten based on your product needs:
<iframe
sandbox="allow-scripts allow-forms allow-same-origin allow-popups"
allow="clipboard-read; clipboard-write"
...
></iframe>
postMessage contract (v1)
All messages are objects:
{ type: "niche.embed.v1.*", data: { ... } }
Child → Parent events
-
niche.embed.v1.ready -
data:{ businessId, workflowId?, readonly, scopes? } -
niche.embed.v1.resize-
data:{ height }
-
-
niche.embed.v1.resize -
niche.embed.v1.workflow.saved-
data:{ workflowId }
-
-
niche.embed.v1.workflow.validationError-
data:{ message, workflowId }
-
-
niche.embed.v1.agent.saved-
data:{ businessId }– emitted when agent config is saved
-
-
niche.embed.v1.agent.deployed-
data:{ mode, businessId }– emitted when agent is deployed (mode = "SANDBOX" | "PRODUCTION")
-
Parent → Child commands
-
niche.embed.v1.setTheme-
data:{ theme: "light" | "dark" }
-
-
niche.embed.v1.requestSave-
data:{}
-
Example: auto-resize listener
<script>
const iframe = document.getElementById("niche-voice-workflow");
const expectedOrigin = "https://app.nicheandleads.com"; // the origin hosting the iframe content
window.addEventListener("message", (event) => {
if (event.origin !== expectedOrigin) return;
const { type, data } = event.data || {};
if (type === "niche.embed.v1.resize" && data && typeof data.height === "number") {
iframe.style.height = ${Math.max(600, data.height)}px;
}
if (type === "niche.embed.v1.workflow.saved") {
console.log("Workflow saved:", data.workflowId);
}
if (type === "niche.embed.v1.agent.deployed") {
console.log("Agent deployed to:", data.mode);
}
});
</script>
Troubleshooting
-
Blank iframe / blocked render: your
parentOriginis not allowlisted or does not match exactly; CSPframe-ancestorswill block. -
Unauthorized: token invalid, token not valid for
businessId, or token expired. -
Readonly: token lacks write scopes or embed URL uses
readonly=true. -
No agent controls visible: token does not include any
agent:*scopes. Addagent:read(and others as needed) to see the full phone agent configuration UI.