Programmatic web calls

Guide: Programmatic web calls

This recipe runs an agent voice conversation in your own browser UI: your backend mints a session with an API key, hands the browser a short‑lived WebSocket URL, and the browser streams audio both ways. If you don't need a custom UI, the embeddable widget does all of this for you. If you only need text, the Chat API is far simpler (plain REST, no socket).

Shortcut: the Web & React SDK implements everything below (audio pipeline, reconnect, barge-in) — pass it the minted session and you're done. This guide documents the raw protocol for anyone building the pipeline by hand.

Anonymous variant: to run this for the public widget without an API key, replace step 1 with POST /api/public/widget/{slug}/session — see the widget API. Everything from the WebSocket on is identical.

Architecture

Browser ──(1) ask for session──▶ Your backend ──(2) init-web-call (x-api-key)──▶ Telenow
Browser ◀─(3) websocketUrl──────  Your backend ◀──── { sessionId, websocketUrl } ──
Browser ──(4) WebSocket audio ⇄ Telenow ────────────────────────────────────────

The API key never reaches the browser — only the short‑lived websocketUrl (which carries a scoped token) does.

1. Mint a session on your server

// server.js (Node)
app.post('/voice/session', async (req, res) => {
  const name = req.body?.name ?? 'there';
  const r = await fetch('https://api.telenow.ai/api/sessions/init-web-call', {
    method: 'POST',
    headers: {
      'x-api-key': process.env.TELENOW_API_KEY, // server-side only
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      agentId: process.env.AGENT_ID,
      variables: { name },                            // fills {name} in the prompt/opener
      firstResponse: `Hi ${name}! How can I help?`,   // optional: a personalized opener for THIS session
    }),
  });
  const { data } = await r.json();
  // Forward only what the browser needs.
  res.json({ sessionId: data.sessionId, websocketUrl: data.websocketUrl });
});

firstResponse overrides the agent's saved opening line for this session only — the agent speaks it first, before the visitor says anything. Omit it to use the agent's configured opener. It's the same field as on the phone-call API, and you can reference variables inside it ("Hi {name}!").

2. Connect the WebSocket from the browser

Messages are JSON frames with an event field. Audio is base64‑encoded μ‑law, 8 kHz, mono in ~20 ms frames. See the Web‑call API for the full message catalog.

const { sessionId, websocketUrl } = await fetch('/voice/session', { method: 'POST' }).then(r => r.json());
const ws = new WebSocket(websocketUrl);

ws.onmessage = (evt) => {
  const msg = JSON.parse(evt.data);
  switch (msg.event) {
    case 'ready':
      ws.send(JSON.stringify({ event: 'start', sessionId }));
      break;
    case 'connected':
      startMic(ws);           // begin streaming microphone frames
      break;
    case 'media':
      playFrame(msg.data, msg.format, msg.sampleRate); // agent audio → speakers
      break;
    case 'transcript':
      renderTurn(msg.role, msg.text, msg.isFinal);     // optional live captions
      break;
    case 'ping':
      ws.send(JSON.stringify({ event: 'pong', t: msg.t })); // keepalive — must echo `t`
      break;
    case 'session_end':
      ws.close();
      break;
  }
};

Echo every ping with a matching pong (same t). If you don't, the server may treat the socket as idle and drop the call. The SDK handles this for you.

3. Send microphone audio

Capture mic audio with the Web Audio API, downsample to 8 kHz, encode to μ‑law, base64‑encode, and send as media frames:

function sendFrame(ws, mulawBytes) {
  ws.send(JSON.stringify({
    event: 'media',
    data: btoa(String.fromCharCode(...mulawBytes)), // base64 μ-law 8k
  }));
}

Implementing the PCM→μ‑law 8 kHz conversion and ~20 ms framing is the bulk of the work. If you'd rather not, the hosted widget already ships a tuned implementation (with barge‑in and jitter handling) — embed it and skip this entirely.

4. Handle the end

On session_end, stop the microphone, close the socket, and tear down your audio nodes. The finished call appears in call history and fires call.ended (and later recording.ready if recording is enabled) to your webhooks.

Manual (bridge) mode

Pass mode: "manual" with fromNumber and toNumber to init-web-call to bridge a human browser session to a carrier call instead of talking to the AI — this is how the dashboard dialer works.