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:

  1. Log in to your Niche dashboard at https://app.nicheandleads.com

  2. Go to Organization SettingsProfile

  3. Scroll to the Iframe Embed section

  4. Add your application's origin(s) to the Allowed Parent Origins list

  5. Click Save Embed Settings

    1. For local development: http://localhost:3000

    2. For staging: https://staging.your-app.com

    3. 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:

  1. In Niche, go to Organization SettingsAPI Keys (or Developer section)

  2. Click Create API Key

  3. Name it something descriptive (e.g., "Embed Token Minting - Production")

  4. Select the required scopes:

    1. embed:token:mint (required)

    2. workflow:read, workflow:write (for workflow editing)

    3. agent:read, agent:write, agent:deploy, agent:call (for full phone agent UI)

  5. 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:

  1. Production origin https://your-app.com) is in the allowlist

  2. API key is stored securely (environment variable, secret manager)

  3. API key has only the scopes you need (principle of least privilege)

  4. Token minting happens server-side only

  5. postMessage listener validates event.origin

  6. Error states are handled gracefully

  7. Token refresh is implemented for long sessions

  8. 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-ancestors is enforced on /embed/* via CSP and bound to a single parentOrigin.

    • 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

workflow:read

View workflows

workflow:write

Create/edit/save workflows

agent:read

View agent config (voice, personality, deployments)

agent:write

Update voice/personality settings

agent:deploy

Save drafts, deploy to sandbox/production

agent:call

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 (includes token)

  • 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>

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 parentOrigin is not allowlisted or does not match exactly; CSP frame-ancestors will 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. Add agent:read (and others as needed) to see the full phone agent configuration UI.