Backend (Node & Python)

Backend SDKs (Node & Python)

Drive Telenow from your own backend: place phone calls, mint browser/app sessions, transfer and end live calls, verify webhooks, manage agents, and build Custom API endpoints that stream your own LLM into a call. Both SDKs are zero-dependency wrappers over the same REST API.

npm i @telenow/server        # Node 18+ / Bun / Deno / Cloudflare Workers
pip install telenow          # Python 3.8+  ("telenow[django]" adds the Django helper)

Before you start

  • An agent (dashboard → Agents) and its agent ID.
  • An org API key — dashboard → Developers → API keys → New key (the secret vai_live_… is shown once). It's sent as the X-API-Key header; keep it server-side only. You need a developer, admin, or owner role to mint keys.
  • Phone calls additionally need a phone number attached to the agent (dashboard → Numbers).

The same key also works against the /api/v1 automation surface (flat JSON, read/chat/webhook endpoints) — handy when you want a quick HTTP call without the SDK, or are wiring Telenow into Zapier/Make/n8n. The SDKs wrap the most-used parts of it directly — including text chat (tn.chat.send / tn.chat(...)).

import { Telenow } from '@telenow/server';
const tn = new Telenow({ apiKey: process.env.TELENOW_API_KEY }); // baseUrl defaults to api.telenow.ai
import os
from telenow import Telenow
tn = Telenow(api_key=os.environ["TELENOW_API_KEY"])

Place a phone call

const { sessionId, callId, status } = await tn.calls.create({
  agentId: 'AGENT_UUID',
  to: '+15551234567',                                 // E.164
  variables: { order_id: 'A-1042' },                  // context variables
  identifier: 'customer-9',                           // trusted caller identity for tools
  firstResponse: 'Hi! Calling about your delivery.',  // optional opener override
  machineDetection: 'true',                           // 'true' = auto-voicemail, 'hangup'
});
result = tn.create_call(
    "AGENT_UUID", "+15551234567",
    variables={"order_id": "A-1042"},
    identifier="customer-9",
    first_response="Hi! Calling about your delivery.",
    machine_detection="true",
)
Field (Node / Python)RequiredWhat it does
agentId / first argWhich agent makes the call.
to / second argDestination number, E.164.
variablesif the agent requires themContext variables substituted into the prompt.
identifierTrusted caller identity, injected into tool calls when the agent opts in.
firstResponse / first_responseOverride the agent's opener for this one call.
machineDetection / machine_detectionAnswering-machine handling: 'true' auto-voicemail, 'hangup' drop on machine. Plivo, Twilio, Vobiz, and Vonage outbound only (the per-dial field is ignored on Exotel and SIP-trunk).

Manual / softphone calls

Place a human call instead of an AI one — the building block for click-to-call inside your own CRM or helpdesk. Telenow rings the customer (to) from a caller-ID your org owns (from) and bridges the carrier leg to a softphone running in your frontend (the web / React Native client SDK). No AI, STT, LLM, or TTS runs — a person is on the line. It works on every carrier (Plivo, Twilio, Vobiz, Exotel, Vonage, SIP trunks); BYOC numbers dial on your own carrier account.

Two steps — your backend mints the session, your frontend connects the audio:

// 1) Backend: mint the softphone session (the API key never leaves the server).
app.post('/crm/dial', async (req, res) => {
  const session = await tn.calls.createManual({
    to: req.body.customerPhone,    // E.164 number to ring
    from: '+15550001111',          // your org's caller-ID (Numbers / BYOC / SIP)
    userId: req.body.agentUserId,  // optional attribution: who placed the call
  });
  res.json(session); // { sessionId, websocketUrl, callId, callMode: 'manual', fromNumber, toNumber }
});
@app.post("/crm/dial")
async def crm_dial(req):
    return tn.create_manual_call(
        req.customer_phone,           # E.164 number to ring
        from_number="+15550001111",   # your org's caller-ID (Numbers / BYOC / SIP)
        user_id=req.agent_user_id,    # optional attribution
    )
// 2) Frontend: connect the softphone — this becomes the rep's mic + speaker.
import { TelenowCall } from '@telenow/client';
const session = await fetch('/crm/dial', { method: 'POST', /* … */ }).then((r) => r.json());
const call = new TelenowCall({ session });
await call.start();   // mic permission → bridged to the customer over the carrier
Field (Node / Python)RequiredWhat it does
to / first argDestination number to ring, E.164.
from / from_number✓ with an API keyCaller-ID — an E.164 number your org owns (Numbers, BYOC, or a SIP trunk). On a user JWT it defaults to the member's allocated number.
userId / user_idAttribution: the CRM user placing the call.

The returned sessionId works with calls.transfer / calls.end just like any other call. Manual calls are recorded server-side (both legs mixed) and fire the same webhooks as AI calls — so call.ended and recording.ready deliver the recording URL and call data straight to your CRM. For the raw REST shape and the carrier bridge mechanics, see the Web-call API (mode: "manual") and Making & receiving calls.

Mint a web session for your frontend

The recommended browser/app flow: mint here, return { sessionId, websocketUrl } to your client, and the web / React Native SDK connects — the API key never reaches the client, and server-set variables can't be tampered with.

app.post('/voice/session', async (_req, res) => {
  res.json(await tn.calls.createWeb({
    agentId: 'AGENT_UUID',
    variables: { customer_name: 'Sarah' },
    firstResponse: 'Hi Sarah! How can I help today?', // optional per-session opener override
  }));
});
@app.post("/voice/session")
async def voice_session():
    return tn.init_web_call(
        "AGENT_UUID",
        variables={"customer_name": "Sarah"},
        first_response="Hi Sarah! How can I help today?",  # optional per-session opener override
    )

firstResponse / first_response overrides the agent's opening line for this web session only (the same field as on a phone call) — the agent speaks it first, before the visitor talks. Omit it to use the agent's saved opener; variables resolve inside it ("Hi {customer_name}!").

Control a live call

Works for phone and web calls — the client SDKs expose call.sessionId for exactly this:

await tn.calls.transfer(sessionId, '+15557654321'); // warm transfer to a human
await tn.calls.end(sessionId);                      // hang up
tn.transfer_call(session_id, "+15557654321")
tn.end_call(session_id)

Text chat (Chat API)

Run a text conversation with an agent over plain REST — same brain, knowledge bases (RAG), and HTTP tools as a voice call, no audio and no RAG pipeline of your own to build. Chats settle as chat calls and fire the same webhooks as voice. Full protocol + error catalog: Chat API · chat bot guide.

The protocol: omit sessionId on the first turn (a session is created and returned), then pass it on every follow-up. identifier is your stable end-user id (1–128 chars) — it binds the session so a leaked sessionId can't be hijacked. A 410 means the session expired (resend the same message without sessionId to restart); a 409 means a reply is still generating (wait, then retry).

// Low-level: one turn at a time.
const first = await tn.chat.send({ agentId: 'AGENT_UUID', identifier: 'user-42', input: 'Hello!' });
// → { sessionId, reply, turn: 1, identifier }
const next = await tn.chat.send({ agentId: 'AGENT_UUID', identifier: 'user-42', input: 'Tell me more', sessionId: first.sessionId });
const { messages } = await tn.chat.messages(first.sessionId); // transcript
await tn.chat.end(first.sessionId);                            // settle now (idempotent)
first = tn.chat("AGENT_UUID", "user-42", "Hello!")            # → {"sessionId", "reply", "turn", ...}
nxt = tn.chat("AGENT_UUID", "user-42", "Tell me more", session_id=first["sessionId"])
msgs = tn.chat_messages(first["sessionId"])["messages"]        # transcript
tn.chat_end(first["sessionId"])                                # settle now (idempotent)

Or skip the bookkeeping with the built-in send-loop helper, which holds the sessionId, restarts on 410, and waits out 409 for you — keep one per end user:

import { chatLoop } from '@telenow/server';
const convo = chatLoop(tn, { agentId: 'AGENT_UUID', identifier: 'user-42', variables: { plan: 'Pro' } });
const a = await convo.send('Hello!');               // turn 1
const b = await convo.send('What are your hours?'); // turn 2 (or a transparent restart)
await convo.end();
convo = tn.chat_conversation("AGENT_UUID", "user-42", variables={"plan": "Pro"})
a = convo.send("Hello!")                # turn 1
b = convo.send("What are your hours?")  # turn 2 (or a transparent restart)
convo.end()

Replies are synchronous (the HTTP call returns when the agent's full reply, including tool calls, is ready) — use a generous client timeout (60 s+). Send one turn at a time per sessionId. Voice-only native tools (transfer, hangup) don't run in chat.

Verify webhooks

Telenow signs every delivery with X-VoiceAI-Signature: sha256=<hex> (HMAC-SHA256 of the raw body). Verify before trusting the payload — both SDKs use a constant-time compare:

app.post('/webhooks/telenow', express.raw({ type: 'application/json' }), async (req, res) => {
  const ok = await tn.webhooks.verify(req.body, req.get('x-voiceai-signature') ?? '', SECRET);
  if (!ok) return res.status(401).end();
  const event = JSON.parse(req.body.toString());   // call.started | call.ended | transcript.ready | tool.invoked
  res.status(200).end();
});
from telenow.django import telenow_webhook

@telenow_webhook(secret=settings.TELENOW_WEBHOOK_SECRET)
def hook(request, event):          # signature verified, body parsed
    if event["event"] == "call.ended":
        ...
    return HttpResponse(status=200)

(Non-Django Python: telenow.verify_webhook(raw_body, header, secret) against the raw bytes.) Configure endpoints + events per agent or org-wide in the dashboard; payload shapes in the webhook events reference.

Custom API — stream your own LLM into a call

When an agent's Brain is set to Custom API, Telenow runs STT + TTS and POSTs each user turn to your endpoint (?calling=true&stream=true, body { query, userId?, ...payload }), then speaks your Server-Sent-Events reply as tokens arrive. The SDK helpers emit the exact wire format:

import { customApiNodeHandler, callEnd } from '@telenow/server';

app.post('/telenow-llm', customApiNodeHandler(async function* ({ query, userId }) {
  for await (const token of myLlm.stream(query)) yield token;  // spoken as it streams
  // yield callEnd('Goodbye!');                                // optional hangup after the reply
}, { bearer: process.env.TELENOW_BEARER }));
// Fetch-style runtimes (Next.js routes / Bun / Workers / Deno): customApiFetchHandler(handler)
from telenow import custom_api
from fastapi.responses import StreamingResponse

@app.post("/telenow-llm")
async def llm(request: Request):
    body = await request.json()

    async def gen():
        async for token in my_llm_stream(body["query"]):
            yield token                          # str → spoken token delta
        # yield custom_api.call_end("Goodbye!")  # dict → control event

    return StreamingResponse(custom_api.sse_astream(gen()),
                             media_type=custom_api.MEDIA_TYPE,
                             headers=custom_api.SSE_HEADERS)
# Django/Flask (sync): wrap a generator with custom_api.sse_stream(...) instead.

Three things that save debugging time:

  • Yield strings for spoken tokens, objects/dicts for control events; the [DONE] terminator is appended for you — even if your generator throws.
  • The provided headers disable proxy buffering (X-Accel-Buffering: no) — without them, nginx batches your tokens and the agent speaks in one late burst.
  • Hangup is an SSE event (callEnd() / custom_api.call_end()); transfer is a REST action — call calls.transfer(sessionId, to) from inside your handler.

Errors

Non-2xx responses throw/raise TelenowError with .status and .body; successful responses are unwrapped from the { success, data } envelope.

StatusUsual cause
401 / 403Wrong/revoked API key, or the agent's API access toggle is off (Publish tab).
400Missing required field — often a required context variable or a non-E.164 number.
404Wrong agent/session id, or the session already ended.

Agents & everything else

agents.list/get/create/update (Node) and list_agents/get_agent/create_agent/update_agent (Python) mirror the Agents API. Anything not wrapped: call the REST API directly with the same key.

Rust, cURL & other languages

Call the plain REST API — every endpoint is documented with curl in the API reference, and your agent's Publish tab shows ready-to-paste cURL, Node, Python, and Rust samples with your agent id filled in. You can also generate a typed client from our OpenAPI spec with openapi-generator (Go, Ruby, PHP, C#…).