External backends & API
External backends & the app-key API
Most apps never need a server. Declarative apps run entirely on Telenow's runtime: your objects, tools, UI, events and workflows are all described in the manifest, and Telenow hosts them. You only need your own backend when your logic can't be expressed declaratively — a tool that calls a pricing engine, an event handler that posts to your data warehouse, a cron job that reconciles records with an in-house system.
This page is for those apps. It covers the external runtime, how Telenow signs the requests it sends you, how to read and write your data from your server with an app key, how to mint and verify a session token, and the full REST surface (Data, Files, Agents, Campaigns, live-stream).
If you're still deciding, prefer declarative: see Data & objects, Agent tools and Events, webhooks & workflows. A single app can mix tiers — keep your objects and most tools declarative, and add one http tool that hits your server.
Every shape on this page is shown in full in the Response reference — real JSON, taken from the code that builds it.
When you need a server (and when you don't)
| You want to… | Stay declarative? | How |
|---|---|---|
| Store/read structured records | Yes | object.* tools + the object store (app-data) |
| Upsert a record after a call | Yes | An event rule handler (app-automation) |
| Call a 3rd-party API from the UI | Yes | telenow.http proxy + http:<host> scope (app-ui) |
| Receive a 3rd-party webhook (Meta, Stripe, Calendly) | Yes | inboundHooks[] — platform-hosted, no server (app-automation) |
| Run custom logic inside an agent tool call | No → external | An http tool handler POSTs to your base_url |
| Push call/event data into your own system | No → external | An event webhook handler POSTs to your base_url |
| Provision agents / launch campaigns from CI | No → app key | The app-key REST API (below) |
Declaring an external app
Set runtime: "external" and a base_url. Any tool with handler.kind: "http" POSTs to base_url + the handler's path; any event with handler.kind: "webhook" does the same.
// telenow.app.json
{
"id": "clinic-crm",
"version": "1.0.0",
"runtime": "external",
"base_url": "https://clinic.example.com",
"scopes": ["objects:appointment", "data:read", "data:write"],
"tools": [
{
"name": "book_appointment",
"description": "Book a clinic appointment for the caller.",
"parameters": {
"type": "object",
"properties": {
"slot_start": { "type": "string" },
"reason": { "type": "string" }
}
},
"handler": { "kind": "http", "method": "POST", "path": "/tools/book" }
}
],
"events": [
{
"on": "call.analyzed",
"handler": { "kind": "webhook", "path": "/events" }
}
]
}
base_url is required as soon as any handler is http — telenow validate flags it if it's missing. Keep tool handlers fast: app-tool timeouts are clamped to 1–30 seconds (default 15) because a slow tool on a live call is dead air. Set a handoff filler phrase so the caller hears something while you work.
The Express quickstart
Install the SDK (the npm package is telenow — unscoped, Node 18+, zero runtime deps):
npm install telenow
Your server does two jobs: verify the signature on every inbound request, then handle the tool call or event. The most important rule is the first comment below — verify against the raw bytes, captured before JSON parsing.
import express from 'express';
import {
verifySignature,
DataClient,
type ToolCallRequest,
type EventRequest,
} from 'telenow';
const SIGNING_SECRET = process.env.TELENOW_SIGNING_SECRET!; // app's "signing secret" (dashboard)
const APP_KEY = process.env.TELENOW_APP_KEY!; // a per-install app key you minted
const db = new DataClient('https://api.telenow.ai', APP_KEY);
const app = express();
// IMPORTANT: verify against the RAW body, so capture it before JSON parsing.
app.use(express.json({ verify: (req, _res, buf) => ((req as any).rawBody = buf) }));
// An agent tool call — your manifest tool with handler.kind = "http".
app.post('/tools/book', async (req, res) => {
if (!verifySignature(SIGNING_SECRET, (req as any).rawBody, req.header('x-telenow-signature'))) {
return res.status(401).json({ error: 'bad signature' });
}
// ToolCallRequest = { tool, arguments, caller?{ number, identifier, channel, session_id } }
const { arguments: args, caller } = req.body as ToolCallRequest;
const appt = await db.create('appointment', {
phone: caller?.number,
slot_start: args.slot_start,
reason: args.reason,
status: 'booked',
});
// Whatever JSON you RETURN goes straight back into the live conversation.
res.json({ ok: true, id: appt.id, when: args.slot_start });
});
// A post-call event — your manifest event with handler.kind = "webhook".
app.post('/events', async (req, res) => {
if (!verifySignature(SIGNING_SECRET, (req as any).rawBody, req.header('x-telenow-signature'))) {
return res.status(401).end();
}
// EventRequest = { event, appId, data: { sessionId, ...fields } }
const evt = req.body as EventRequest;
console.log('event', evt.event, evt.data.sessionId);
res.status(204).end();
});
app.listen(3000);
Two things to internalise:
- The tool-call response is spoken. Return a small, clean JSON object — the agent reads it and continues the conversation. Don't return stack traces or huge payloads.
- Events are fire-and-forget. Reply
2xxquickly and do slow work in the background; the platform doesn't wait on your business logic.
Signatures: verify every request
The platform signs every http tool call and every event webhook with this header:
X-Telenow-Signature: sha256=<hex>
<hex> is HMAC-SHA256 over the raw request body, keyed with the install's signing secret. verifySignature parses the sha256= prefix, recomputes the HMAC, and compares in constant time — always use it (or an equivalent constant-time compare) rather than ==, which leaks timing.
If your backend isn't Node, the formula is simple. In pseudocode:
expected = hex( HMAC_SHA256(key = signing_secret, message = raw_request_bytes) )
provided = strip_prefix("sha256=", header["X-Telenow-Signature"])
ok = constant_time_equal(expected, provided)
Python, for example:
import hmac, hashlib
def verify(signing_secret: str, raw_body: bytes, header: str) -> bool:
provided = header.split("=", 1)[1] if header.startswith("sha256=") else header
expected = hmac.new(signing_secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, provided)
Reject anything that fails with 401. The signing secret never leaves your server, so a valid signature proves the request came from Telenow.
App keys: mint, list, revoke, rotate
An app key is a bearer credential bound to exactly one (org, app) pair. Every call is automatically scoped to your app's data in that one org — you can never read or write another tenant's or another app's records, no matter what you pass in the path.
You can mint and manage keys from the dashboard (your installed app → API keys) or over REST. The REST routes are user-authenticated (an owner/admin's dashboard session), not app-key-authenticated — they're the bootstrap before you have a key.
Mint a key — POST /api/orgs/:orgId/apps/:appId/keys
Owner/admin only. You must install the app first — minting before install returns 400 "install the app before creating a key". Optional JSON body { "label": "ci-prod" }.
POST /api/orgs/<orgId>/apps/clinic-crm/keys
content-type: application/json
{ "label": "ci-prod" }
{
"success": true,
"data": {
"key": {
"id": "…",
"installationId": "…",
"appId": "clinic-crm",
"lastFour": "8f3a",
"label": "ci-prod",
"createdAt": "2026-07-01T10:00:00Z",
"revokedAt": null
},
"secret": "vai_app_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
}
The plaintext secret (format vai_app_<base64url>) is shown exactly once — only its hash is stored. Copy it into your backend's environment (TELENOW_APP_KEY) now. If you lose it, mint a new one and revoke the old; you can't recover it.
List keys — GET /api/orgs/:orgId/apps/:appId/keys
Owner/admin. Returns only un-revoked keys (newest first); the secret/hash is never included:
{
"success": true,
"data": {
"keys": [
{ "id": "…", "installationId": "…", "appId": "clinic-crm",
"lastFour": "8f3a", "label": "ci-prod",
"createdAt": "2026-07-01T10:00:00Z", "revokedAt": null }
]
}
}
Revoke a key — DELETE /api/orgs/:orgId/apps/:appId/keys/:keyId
Owner/admin. Returns { "success": true } when a live key was revoked ({ "success": false } if it was already revoked / not found). Revocation is immediate.
Read the signing secret — GET /api/orgs/:orgId/apps/:appId/signing-secret
Owner/admin. The signing secret is the value verifySignature and verifyAppToken use; store it as TELENOW_SIGNING_SECRET.
{ "success": true, "data": { "signingSecret": "…" } }
When a key stops working
A key only resolves while the install is enabled + active AND the publishing developer is active. If the org disables the install, or Telenow suspends the publisher, the key silently stops authenticating (every call 401s) — independent of the key's own revoke state.
Send the key as a bearer header on every REST call:
Authorization: Bearer <app key>
The app-key REST API
All endpoints below use Authorization: Bearer <app key> and base host https://api.telenow.ai. Every response shares one envelope — check success, then read data or error — except blob downloads, which return raw bytes (see Files).
{ "success": true, "data": { "objects": [/* … */] } }
{ "success": false, "error": "token does not include the data:write scope" }
The whole API is rate-limited per (org, app); a 429 ("Data API rate limit reached — slow down") means slow down and retry after a moment. Scopes are consented at install — see Scopes & permissions.
Endpoint & scope reference
| Method & path | Purpose | Scope |
|---|---|---|
GET /api/app-data/:objectType?limit=&sort=&dir=&numeric=&view=&expand=&search=&topK= | List records (filters become equality matches) | data:read (OAuth only) |
POST /api/app-data/:objectType | Create a record (JSON body = data). :objectType must be declared in your manifest's objects[] — an undeclared type is 400, not a silent new type | data:write (OAuth only) |
PATCH /api/app-data/:objectType/:id | Merge-update a record by id | data:write (OAuth only) |
DELETE /api/app-data/:objectType/:id | Delete a record by id | data:write (OAuth only) |
GET /api/app-files/ , GET /api/app-files/?prefix= | List stored blobs | files:read |
PUT /api/app-files/*path | Upload / overwrite a blob | files:write |
GET /api/app-files/*path | Download a blob (raw bytes) | files:read |
DELETE /api/app-files/*path | Delete a blob | files:write |
GET /api/app-agents | List the agents this app created | agents:read |
POST /api/app-agents | Create an agent (body = flattened agent spec) | agents:write |
DELETE /api/app-agents/:id | Delete an agent this app created | agents:write |
POST /api/app-agents/:id/eval | Run eval scenarios → pass/fail per scenario (LLM-judge) | agents:read |
GET /api/app-campaigns | List this app's campaigns | campaigns:read |
POST /api/app-campaigns | Create a paced + retried bulk outbound campaign | campaigns:write |
GET /api/app-campaigns/:id | Campaign status (counts) | campaigns:read |
POST /api/app-campaigns/:id/pause , POST /api/app-campaigns/:id/cancel | Control a campaign | campaigns:write |
GET /api/app-calls?agentId=&mode=&status=&from=&to=&sort=&includeAnalysis=&includeCost=&limit=&offset= | Paginated call history (analytics) | calls:read (bound agents) or calls:read:org (whole org); includeCost adds billing:read |
GET /api/app-calls/:sessionId | One call: summary + analysis + transcript | calls:read + agent binding, or calls:read:org |
POST /api/app-calls/:sessionId/stream-ticket | Mint a one-time live-stream ticket | calls:read + agent binding, or calls:read:org |
GET /api/app-billing/wallet | Wallet balance + display rate | billing:read |
GET /api/app-billing/charges?from=&to=&agentId=&callType=&includeEvents=&limit=&offset= | Settled per-call charges (org price only) | billing:read |
GET /api/app-billing/charges/:sessionId | One charge with its event breakdown | billing:read |
GET /api/app-kb , POST /api/app-kb | List / create the app's own KBs | kb:read / kb:write |
GET /api/app-kb/:kbId , DELETE /api/app-kb/:kbId | One KB / delete it | kb:read / kb:write |
GET|POST /api/app-kb/:kbId/documents , PUT|DELETE /api/app-kb/:kbId/documents/:docId | List, add, replace, delete documents | kb:read / kb:write |
GET /api/app-kb/:kbId/attachments , POST /api/app-kb/:kbId/attach , DELETE /api/app-kb/:kbId/attach/:agentId | See / manage which agents answer from the KB | kb:read / kb:write + agent binding |
GET /api/app-links/ | List links this app minted (tokens are never returned) | links:read |
POST /api/app-links/ | Mint a tokenized public link — see Links | links:write |
POST /api/app-links/:id/revoke | Kill a link immediately | links:read |
GET /api/app-ai/models | Coarse tiers + the org's model catalog | any ai:* |
POST /api/app-ai/llm | Run a completion. Billed to the installing org's wallet | ai:llm |
POST /api/app-ai/llm/stream | The same, streamed (SSE) | ai:llm |
POST /api/app-ai/extract | Plain text out of a stored .pdf / .docx / .txt / .md | files:read |
POST /api/app-ai/tts | Synthesise speech. Billed to the org | ai:tts |
On agent scopes: creating or deleting agents needs
agents:write, while listing agents and running eval need onlyagents:read.
There is no
/api/app-ai/stt.ai:sttis a scope the platform accepts and shows at consent, but no route consumes it — declaring it grants nothing. For transcript of a call in progress usecalls:transcribe:live; for a finished call,GET /api/app-calls/:sessionIdalready returns the transcript.
Static app keys vs OAuth tokens — how scopes are enforced
Two kinds of bearer credential can authenticate here, and they're scope-gated differently:
- A static app key (the
vai_app_…secret you minted) has no per-token scope list (app.scopesisNone). - An OAuth-issued token carries an explicit
scopeclaim.
The Data-API CRUD routes (GET/POST/PATCH/DELETE /api/app-data/...) are checked with static_install_check=false. That means data:read / data:write are NOT enforced for a static app key — a static key is always allowed to read/write its own data. Those two scopes only constrain OAuth-token scoping. (A static key is already tenant+app bound, which is the real boundary.)
Every other surface — Files, Agents, Campaigns, streaming — is checked with static_install_check=true and does enforce the install's declared scopes, even for a static key. So a static key must still have the app's manifest declare e.g. files:write to upload a blob.
Two distinct 403 strings tell you which gate failed:
"token does not include the <scope> scope"— an OAuth token is missing a scope."app did not declare the <scope> scope"— a static key, but the install never consented to that scope.
The DataClient
For the Data API, the SDK's DataClient wraps the raw HTTP and the response envelope. On any non-2xx or success:false, it throws new Error(json.error) — wrap calls in try/catch:
import { DataClient } from 'telenow';
const db = new DataClient('https://api.telenow.ai', process.env.TELENOW_APP_KEY!);
try {
const { objects } = await db.list('appointment', { phone: '+14155550142' });
const created = await db.create('appointment', { phone: '+14155550142', slot_start, status: 'booked' });
const updated = await db.update('appointment', created.id, { status: 'cancelled' });
await db.remove('appointment', created.id);
// Records come back as a full AppObject — field values live under `.data`:
console.log(objects[0].id, objects[0].data.phone, objects[0].createdAt);
} catch (e) {
// e.message is the server's `error` string, e.g. "app data store is full (…)"
}
list filters are equality matches on stored fields, expressed as the query string. Records are scoped to your app in the installing org automatically. US (+1…) and international (+44…) phone values are just data — store them in whatever format your numbers use.
Record shape: AppObject
list returns { objects: AppObject[] }. create and update return the full AppObject — NOT just the data you sent:
{
"id": "…", // the record id (top-level — use obj.id)
"orgId": "…",
"appId": "clinic-crm",
"objectType": "appointment",
"data": { // YOUR fields live here — read obj.data.phone
"phone": "+14155550142",
"slot_start": "2026-07-02T09:00:00Z",
"status": "booked"
},
"createdBy": "api",
"createdAt": "2026-07-01T10:00:00Z",
"updatedAt": "2026-07-01T10:00:00Z"
}
The
AppObjectshape (id,appId,objectType,data,createdBy?,createdAt,updatedAt?) is exported as a TypeScript type fromtelenow. Always read your own fields through.data—obj.data.phone, notobj.phone.
Row-cap on writes. The object store caps each app at 100,000 records per app. A create past the cap returns 413 with error: "app data store is full (max 100000 records per app)" (DataClient surfaces it as a thrown Error). See Limits & quotas.
Querying records over REST
The Data API GET query string supports these reserved params; everything else is an equality filter:
| Param | Meaning |
|---|---|
| equality filters | any other key=value pair matches a stored field exactly |
limit | max rows (default 100) |
topK (or lowercase topk) | alias for limit — caps result count |
sort | field to sort by |
dir | desc triggers descending; anything else (including asc or omitting it) is ascending |
numeric | numeric ordering when the value is exactly "true" or "1" (else lexicographic) |
view | apply a manifest-declared saved view (its stored filter + sort) |
expand | comma list of relation fields to embed as {field}__expanded |
search | semantic NL ranking (needs the object's semantic:true); pair with topK to cap |
GET /api/app-data/appointment?status=booked&sort=slot_start&dir=asc&limit=20
Authorization: Bearer <app key>
GET /api/app-data/appointment?search=patients%20who%20might%20cancel&topK=10
Authorization: Bearer <app key>
Operator filters (
$gt,$in,$contains, …) are not expressible in a query string. Use the in-dashboard bridgedata.list(type, query, opts)from your UI for those — see Data & objects and Dashboard UI.
Files: per-app blob storage
Blobs are private bytes scoped to your (org, app) — never publicly readable. The blob path is the wildcard *path segment (so PUT /api/app-files/reports/2026/q3.pdf stores under reports/2026/q3.pdf).
| Op | Request | Response |
|---|---|---|
| List | GET /api/app-files/ (optional ?prefix=reports/) | { success, data: { files: [...] } } |
| Upload / overwrite | PUT /api/app-files/*path (raw body = the bytes) | { success, data: { path, size } } |
| Download | GET /api/app-files/*path | raw bytes (see note) |
| Delete | DELETE /api/app-files/*path | { success, data: { deleted: <bool> } } |
# upload
curl -X PUT https://api.telenow.ai/api/app-files/reports/q3.pdf \
-H "authorization: Bearer $TELENOW_APP_KEY" \
--data-binary @q3.pdf
# → { "success": true, "data": { "path": "reports/q3.pdf", "size": 48213 } }
# download (raw bytes — NOT the JSON envelope)
curl -L https://api.telenow.ai/api/app-files/reports/q3.pdf \
-H "authorization: Bearer $TELENOW_APP_KEY" -o q3.pdf
Download returns raw bytes, not the
{ success, data }envelope. The platform forcesContent-Disposition: attachmentandX-Content-Type-Options: nosniffon every download, so a stored blob can never render inline in a browser (defense-in-depth — an app could store HTML/JS). Per-file and per-app size/count caps live in Limits & quotas.
Provisioning agents from your backend
POST /api/app-agents builds a real agent, auto-bound to your app's tools, from a flattened agent spec (the same shape as a manifest agents[] template — systemPrompt, llmProvider/llmModel, sttProvider, ttsProvider/ttsVoice, sessionConfig, metadata.flow, …). Use it to give each end-customer their own tailored agent, or to provision from CI. Needs agents:write.
curl -X POST https://api.telenow.ai/api/app-agents \
-H "authorization: Bearer $TELENOW_APP_KEY" \
-H "content-type: application/json" \
-d '{
"name": "Dr. Kim front desk",
"systemPrompt": "You are the receptionist for Dr. Kim'\''s clinic…",
"llmModel": "openai/gpt-4o-mini"
}'
# → { "success": true, "data": { "agentId": "…", "kind": "single" } }
What happens on create:
- The response is
data { agentId, kind }.kindis"flow"when the spec'smetadata.flowhas more than one node or any edge; otherwise"single". - The created agent auto-binds to the app (so it has your app's tools at call time) and any bundled knowledge bases auto-attach (RAG) — exactly like the manifest template path.
namedefaults to"Untitled agent"when absent/blank.- The spec is untrusted and sanitized identically to a manifest template: secrets and embedded tool definitions are stripped, and flow
tool/code/transfernodes are defused — except the app's own tool nodes. A shipped spec can't smuggle an SSRF/cross-app/code/transfer node. - Per-app cap: 100 agents per org (
MAX_AGENTS_PER_APP). Over the cap returns413("this app has reached its agent limit (100) for this org"). See Limits & quotas.
List with GET /api/app-agents (agents:read) → { success, data: { agents: [{ id, name, kind }] } } (only agents THIS app created). Delete with DELETE /api/app-agents/:id (agents:write).
Evaluating an agent (CI gate)
POST /api/app-agents/:id/eval runs an agent against a suite of simulated-call scenarios and LLM-judges each against an expect rubric. Wire it into CI to fail the build when the suite regresses. Needs agents:read, and the agent must be bound to this app (agents you created are already bound).
Request:
{
"scenarios": [
{
"name": "reschedule", // optional label
"scenario": "A patient calls to move tomorrow's 9am appointment to next week.", // REQUIRED
"expect": "The agent offers an alternative slot and confirms the new time.", // optional rubric
"max_turns": 4 // optional; default 4, clamped to EVAL_MAX_TURNS = 6
}
]
}
Response:
{
"success": true,
"data": {
"results": [
{
"name": "reschedule",
"passed": true,
"score": 88, // 0-100; OMITTED when the judge gives no score (e.g. critic-only verdict)
"reasoning": "The agent offered a new slot and confirmed it.",
"turns": 3,
"errored": false // true if the sim/judge itself failed (then passed=false)
}
],
"passed": 1, // how many scenarios passed
"total": 1,
"allPassed": true // gate your CI on this
}
}
Caps and gates:
- Max 3 scenarios per request (
400 "at most 3 scenarios per eval request"); an emptyscenariosarray is400 "at least one scenario is required". Batch larger suites across calls. max_turnsdefaults to 4 and is clamped toEVAL_MAX_TURNS = 6.- The whole request has a 200-second budget — exceeding it returns
400 "eval timed out — try fewer scenarios or turns". - The agent must be bound to the app or you get
403 "app is not bound to this agent", and the platform's agent simulation must be configured or you get400 "agent simulation is not configured on this platform".
See Bundled agents & teams for the spec fields and flow node kinds.
Bulk outbound campaigns
POST /api/app-campaigns launches a paced, retried outbound campaign over your own data. Target an explicit list, or a targetQuery over your app objects (each matched row's whole data becomes that call's {placeholder} variables). Needs campaigns:write.
Create body (POST /api/app-campaigns)
| Field | Type | Default | Notes |
|---|---|---|---|
agentId | uuid | — | REQUIRED. Must be bound to this app or 403 "agent is not bound to this app — bind it or create it via the app". Agents you create via the app auto-bind. |
name | string | "App campaign" | Display name. |
targets | array | — | Explicit targets (see below). Provide this OR targetQuery. |
targetQuery | object | — | { object, phoneField, filter } over your app objects (see below). |
concurrency | number | 5 | Simultaneous calls. |
maxAttempts | number | 3 | Retry attempts per target. |
retryBackoffSecs | number | 300 | Seconds between retries. |
retryOnNoAnswer | boolean | true | Retry on no-answer. |
machineDetection | string | — | Answering-machine-detection mode, if your provider supports it. |
window | object | — | { start, end, timezone } — local-time strings (e.g. "09:00", "18:00", "America/New_York"). Calls only dial inside the window. |
autostart | boolean | true | Start dialing immediately; false leaves it as a draft. |
result | object | — | Write-back config: { object (required), map (required), key? }. After each call reaches a terminal state its outcome is upserted into object, keyed by the key field (default "phone"). map is a required object of target-field → template pairs (templates resolve {{outcome.*}} — phone, disposition, attempt, completedAt, sessionId, campaignId — and {{variables.*}} from the target's variables). With no map, nothing is written back. |
targets[]usesphoneNumber, notphone. Each explicit target is{ "phoneNumber": "+1…", "variables": { … } }. The builder readst.phoneNumber; aphonekey is silently skipped → an empty target list →400 "no dialable targets (…)". This is a different field fromtargetQuery.phoneField, which names the stored object field that holds the number (default"phone"). Don't confuse the two.
Explicit targets example:
curl -X POST https://api.telenow.ai/api/app-campaigns \
-H "authorization: Bearer $TELENOW_APP_KEY" \
-H "content-type: application/json" \
-d '{
"name": "Reminder blast",
"agentId": "00000000-0000-0000-0000-000000000000",
"targets": [
{ "phoneNumber": "+14155550142", "variables": { "name": "Sarah", "slot": "Tue 9am" } },
{ "phoneNumber": "+442079460958", "variables": { "name": "Emma", "slot": "Wed 2pm" } }
],
"concurrency": 5,
"maxAttempts": 3,
"window": { "start": "09:00", "end": "18:00", "timezone": "America/New_York" }
}'
Query-driven targets example (dial every booked appointment; row data → call variables; write the outcome back):
curl -X POST https://api.telenow.ai/api/app-campaigns \
-H "authorization: Bearer $TELENOW_APP_KEY" \
-H "content-type: application/json" \
-d '{
"name": "Appointment reminders",
"agentId": "00000000-0000-0000-0000-000000000000",
"targetQuery": { "object": "appointment", "phoneField": "phone", "filter": { "status": "booked" } },
"concurrency": 5,
"maxAttempts": 3,
"result": {
"object": "appointment",
"key": "phone",
"map": { "last_outcome": "{{outcome.disposition}}", "last_called_at": "{{outcome.completedAt}}" }
}
}'
Create response:
{
"success": true,
"data": {
"campaignId": "…",
"queued": 1240, // targets enqueued after phone-normalization
"suppressed": 12, // dropped by DNC suppression
"status": "running" // "running" when autostart, else "draft"
}
}
Caps: max 50 active campaigns per app (draft/running/paused) → 400 "app campaign limit reached (50 active) — cancel finished ones"; max 5,000 targets per campaign (explicit lists are truncated; a targetQuery pages up to 5,000); 2 MB request body. See Limits & quotas.
Status, pause, cancel
GET /api/app-campaigns/:id returns the live status:
{
"success": true,
"data": {
"id": "…",
"name": "Appointment reminders",
"agentId": "…",
"status": "running",
"totalTargets": 1240,
"completedTargets": 803,
"failedTargets": 41,
"createdAt": "2026-07-01T10:00:00Z",
"callCounts": { "answered": 700, "no_answer": 90, "busy": 13 } // per-outcome map
}
}
GET /api/app-campaigns lists this app's campaigns (the same summary, without callCounts). POST /api/app-campaigns/:id/pause and POST /api/app-campaigns/:id/cancel control a running campaign (campaigns:write). An app can only see and control campaigns it created — another app's or a user's campaign returns 404 "campaign not found".
The optional result block writes each call's outcome back into your objects as the campaign finalizes, so your data reflects who was reached without any extra polling.
Call history & analytics (/api/app-calls)
The surface analytics apps are built on: paginated historical calls, per-call analysis, and transcripts — straight from your backend with the app key. Visibility follows your scope:
calls:read— you see calls of agents the org bound your app to.calls:read:org— you see every agent's calls in the installing org, including agents created after install. No bindings needed. This is the scope to declare when org-wide visibility is your product; the consent dialog presents it prominently.
# One page of history, newest first, with post-call analysis merged in
curl "https://api.telenow.ai/api/app-calls?from=2026-07-01T00:00:00Z&includeAnalysis=true&limit=100" \
-H "authorization: Bearer $TELENOW_APP_KEY"
# → { "success": true, "data": { "calls": [...], "total": 4213, "limit": 100, "offset": 0, "hasMore": true } }
Query params: agentId, mode (agent | manual), status, from/to (RFC3339, on start time), sort (newest default | oldest | longest | shortest), includeAnalysis, limit (1–200, default 50), offset.
Each call row carries the identifiers and shape of the call — id, agentId/agentName, status, channel (telephony | web_call | web_chat | whatsapp | softphone | simulation — the last is an agent-test dry-run, not a real conversation), startTime/endTime/durationSec, fromNumber/toNumber, answeredBy (AMD verdict), disposition, wrapupDisposition, hasRecording, and a per-stage latency block (sttMsAvg, llmMsAvg, ttsMsAvg, netRttMsAvg, respMsAvg, respMsMax, respSamples, audioGapCount). With includeAnalysis=true, analysis holds the post-call result (summary, sentiment, disposition, QA, score, topics, keywords, talk-ratio) or null when the call has no analysis — read the sibling analysisEnabled to know why. false means post-call analysis is switched OFF for that agent, so it will stay null until someone turns it on (an admin in the agent builder, or an app holding agents:config:write:analysis). true with a null means it is either still pending OR the call fell under the agent's minimum-substance threshold (analysis.minDurationSec for voice, analysis.minCustomerTurns for chat channels) — a call under the threshold is never claimed, so it stays null permanently rather than resolving. Read those two off the agent's analysis config group to tell the cases apart. Note a pending/failed row is a non-null object with status set and the fields null.
What a call row deliberately does not include: the dashboard user who placed the call (operator PII) and the free-form session metadata/variables blobs.
# One call in full: summary + analysis + transcript (system turns hidden)
curl https://api.telenow.ai/api/app-calls/<sessionId> \
-H "authorization: Bearer $TELENOW_APP_KEY"
The detail route is gated like the stream ticket: your app must be bound to the call's agent or hold calls:read:org, else 403 "app is not bound to this call's agent".
From Node, use the SDK's CallsClient:
import { CallsClient } from 'telenow';
const calls = new CallsClient('https://api.telenow.ai', process.env.TELENOW_APP_KEY!);
let page = await calls.list({ from: '2026-07-01T00:00:00Z', includeAnalysis: true, limit: 200 });
for (const c of page.calls) aggregate(c);
while (page.hasMore) {
page = await calls.list({ from: '2026-07-01T00:00:00Z', includeAnalysis: true, limit: 200, offset: page.offset + page.limit });
for (const c of page.calls) aggregate(c);
}
Building an org analytics app end-to-end: declare
calls:read:org(+data:writeif you aggregate into your own objects), subscribe tocall.analyzedfor the forward stream, and use this API to backfill history at install. Your dashboard then renders from your aggregates — see Dashboard UI. Addbilling:read+includeCost=trueand every call row also carries what it cost — analytics becomes ROI.
Billing & spend (/api/app-billing)
The FinOps surface — spend dashboards, ROI-per-campaign, budget alerts, spend-anomaly detection. Needs billing:read. One invariant everywhere: you see the org's price (chargeUsd and the *Usd components); the platform's provider cost never crosses this boundary.
# The org's wallet, as its own Billing page shows it
curl https://api.telenow.ai/api/app-billing/wallet -H "authorization: Bearer $TELENOW_APP_KEY"
# → { "mode": "prepaid", "balanceUsd": 41.2, "currency": "EUR", "walletRate": 0.92, "walletNative": 37.9, "suspended": false }
# Settled charges in a window, per call, newest settlement first
curl "https://api.telenow.ai/api/app-billing/charges?from=2026-07-01T00:00:00Z&limit=200" \
-H "authorization: Bearer $TELENOW_APP_KEY"
Each charge row: sessionId, agentId/agentName, callType, startTime, the component lines (llmUsd, sttUsd, ttsUsd, telephonyUsd, platformAiUsd, postCallAnalysisUsd, simulationUsd, embeddingUsd, platformFeeUsd, featureSurchargeUsd), totalChargeUsd, hasEstimates, ratedAt, and the fee context in breakdown (billedMinutes, feePercent, durationSecs, …). Money fields are decimal strings (exact); pass includeEvents=true (or use the per-session route) for the per-event provider/model/quantity lines.
For the push side, subscribe to the charge.settled event — it fires at settlement (~2-3 min after hangup) with the charge and the post-charge walletBalanceUsd, so a manifest when predicate makes a zero-code budget alert:
"events": [{
"on": "charge.settled",
"when": { "path": "walletBalanceUsd", "lt": 10 },
"handler": { "kind": "webhook" } // → your low-balance pager
}]
From Node: new BillingClient(base, key) → wallet(), charges(params), charge(sessionId).
Knowledge bases at runtime (/api/app-kb)
The KB-sync surface — keep an agent's knowledge fresh from an external source (Notion, Confluence, Drive, your CMS) with no dashboard clicks. Needs kb:read/kb:write. Your app sees only its own KBs — the ones its manifest bundled at install plus any it creates here; the org's other KBs don't exist as far as this API is concerned.
import { KbClient } from 'telenow';
const kb = new KbClient('https://api.telenow.ai', process.env.TELENOW_APP_KEY!);
// Idempotent on `key` — safe to run on every sync
const faq = await kb.create({ key: 'help-center', name: 'Help Center' });
// Push docs; embedding is async (status: pending → embedded | failed)
const doc = await kb.addDocument(faq.id, { title: 'Refunds', body: pageText });
// On the next sync, diff by contentHash and replace what changed
const { documents } = await kb.documents(faq.id);
const prev = documents.find((d) => d.title === 'Refunds');
if (prev && prev.contentHash !== sha256(pageText)) {
await kb.replaceDocument(faq.id, prev.id, { body: pageText }); // returns a NEW doc id
}
// Wire it to an agent your app is bound to (app-created agents are auto-bound)
await kb.attach(faq.id, agentId);
The mechanics worth knowing:
- Documents are text, ≤ 1 MiB each, max 200 per KB and 10 KBs per app. Same chunk (~500-token windows) + embed pipeline as the dashboard; embedding tokens are metered to the org like any KB ingestion.
replaceDocumentrotates the document id (old chunks purge, new body re-embeds). Key your sync state on your own source ids, and usecontentHash(sha256 of the body) to skip unchanged pages.- Attach/detach requires the agent binding — an app steers retrieval only for agents it's integrated with.
attachments(kbId)shows where a KB is live. - Deletes are immediate for retrieval (soft-deleted KBs/docs stop being searched at once).
Live-call streaming from your server
To stream a call your agent is on, POST /api/app-calls/:sessionId/stream-ticket. You get a one-time ticket (30-second TTL); open the returned WebSocket URL to receive live frames. Your server never holds a long-lived API socket — the ticket is single-use.
curl -X POST https://api.telenow.ai/api/app-calls/<sessionId>/stream-ticket \
-H "authorization: Bearer $TELENOW_APP_KEY"
# → { "success": true, "data": { "ticket": "…", "wsUrl": "<base>/ws/live-call-stream?ticket=…" } }
wsUrl is <base>/ws/live-call-stream?ticket=<ticket> — connect to it directly. Four gates apply:
calls:read(orcalls:read:org) scope.- The call must belong to your app's org, else
404 "call not found". - The app must be bound to the call's agent — unless it holds
calls:read:org— else403 "app is not bound to this call's agent". - The call must be live (
Active), else409 "call is not live".
For mid-call data in the UI, prefer telenow.stream (the host holds the socket) — see Dashboard UI.
There is no app-key knowledge-base API — KBs are manifest-only and auto-attach to your agents at install. See Bundled agents & teams.
App session tokens (server-to-server identity)
A signature proves Telenow sent the request. To know which signed-in user is acting from your dashboard UI, use a session token.
Mint — POST /api/orgs/:orgId/apps/:appId/session-token
This route is user-authenticated (the caller must be an org member) and requires the session:token scope, or you get 403 "app did not declare the session:token scope". In the iframe UI, the bridge mints one for you:
import { useSession, useHttp } from 'telenow/react';
const { token } = useSession(); // needs the session:token scope
const jwt = await token(); // short-lived JWT, aud: "app:<id>"
// send it to your own server (e.g. via the proxy, or your UI's own fetch)
await fetch('https://clinic.example.com/me', {
headers: { authorization: `Bearer ${jwt}` },
});
The raw response is { success, data: { token, expiresIn: 1800 } } — the token's TTL is exactly 1800 seconds (30 minutes).
Claims
The token is HS256, signed with the install's signing secret (the same one webhooks use). Its claims:
{
"sub": "<userId>",
"email": "[email protected]", // ONLY present when the app holds user:profile
"org_id": "<orgId>",
"app_id": "clinic-crm",
"role": "admin", // the user's org role (RBAC for your backend)
"aud": "app:clinic-crm", // app:<appId> — can't be replayed against another app
"exp": 0, "iat": 0,
"jti": "<uuid>"
}
Verify on your backend — same signing secret
import { verifyAppToken } from 'telenow';
app.get('/me', (req, res) => {
try {
const claims = verifyAppToken(
req.headers.authorization!.slice(7), // strip "Bearer "
SIGNING_SECRET,
'clinic-crm', // checks aud === "app:clinic-crm"
);
// → { sub, org_id, app_id, role, aud:"app:clinic-crm", exp, iat, jti, email? }
res.json({ userId: claims.sub, org: claims.org_id, role: claims.role });
} catch {
res.status(401).end();
}
});
verifyAppToken checks the signature, the expiry, and (when you pass the appId) that aud === "app:<appId>" — so a token minted for another app can't be replayed against yours. email is only present if the app holds the user:profile scope. Minting needs the session:token scope (see Scopes & permissions).
Keeping credentials server-side
Never put a 3rd-party secret in the iframe UI — it runs on an opaque origin with no tokens. Two mechanisms keep credentials server-side:
- The HTTP proxy (
telenow.http/useHttp) calls an allow-listedhttp:<host>over the dashboard's server. It's HTTPS-only, SSRF-guarded, follows no redirects, caps responses at 1 MB, is rate-limited per org and circuit-broken per host. - Stored connections + secret settings. A
connection: "<provider>"on a proxy call injects an org-configured, auto-refreshed credential server-side (needsconnection:<provider>), andsecret/type:"secret"settings are encrypted at rest and only ever injected server-side. Either way the secret never enters your code or the browser.
These are detailed in Scopes & permissions and Dashboard UI.
Next
- Scopes, permissions & the security model — what each scope grants and how access is enforced.
- Limits & quotas — every cap referenced above (rows, blobs, campaigns, agents, eval).
- Events, webhooks, inbound hooks & workflows — the declarative side of integrations, including no-server inbound webhooks.
- Packaging & publishing — ship your app once the backend is ready. </content>