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 recordsYesobject.* tools + the object store (app-data)
Upsert a record after a callYesAn event rule handler (app-automation)
Call a 3rd-party API from the UIYestelenow.http proxy + http:<host> scope (app-ui)
Receive a 3rd-party webhook (Meta, Stripe, Calendly)YesinboundHooks[] — platform-hosted, no server (app-automation)
Run custom logic inside an agent tool callNo → externalAn http tool handler POSTs to your base_url
Push call/event data into your own systemNo → externalAn event webhook handler POSTs to your base_url
Provision agents / launch campaigns from CINo → app keyThe 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 httptelenow 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 2xx quickly 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 errorexcept 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 & pathPurposeScope
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/:objectTypeCreate a record (JSON body = data). :objectType must be declared in your manifest's objects[] — an undeclared type is 400, not a silent new typedata:write (OAuth only)
PATCH /api/app-data/:objectType/:idMerge-update a record by iddata:write (OAuth only)
DELETE /api/app-data/:objectType/:idDelete a record by iddata:write (OAuth only)
GET /api/app-files/ , GET /api/app-files/?prefix=List stored blobsfiles:read
PUT /api/app-files/*pathUpload / overwrite a blobfiles:write
GET /api/app-files/*pathDownload a blob (raw bytes)files:read
DELETE /api/app-files/*pathDelete a blobfiles:write
GET /api/app-agentsList the agents this app createdagents:read
POST /api/app-agentsCreate an agent (body = flattened agent spec)agents:write
DELETE /api/app-agents/:idDelete an agent this app createdagents:write
POST /api/app-agents/:id/evalRun eval scenarios → pass/fail per scenario (LLM-judge)agents:read
GET /api/app-campaignsList this app's campaignscampaigns:read
POST /api/app-campaignsCreate a paced + retried bulk outbound campaigncampaigns:write
GET /api/app-campaigns/:idCampaign status (counts)campaigns:read
POST /api/app-campaigns/:id/pause , POST /api/app-campaigns/:id/cancelControl a campaigncampaigns: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/:sessionIdOne call: summary + analysis + transcriptcalls:read + agent binding, or calls:read:org
POST /api/app-calls/:sessionId/stream-ticketMint a one-time live-stream ticketcalls:read + agent binding, or calls:read:org
GET /api/app-billing/walletWallet balance + display ratebilling: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/:sessionIdOne charge with its event breakdownbilling:read
GET /api/app-kb , POST /api/app-kbList / create the app's own KBskb:read / kb:write
GET /api/app-kb/:kbId , DELETE /api/app-kb/:kbIdOne KB / delete itkb:read / kb:write
GET|POST /api/app-kb/:kbId/documents , PUT|DELETE /api/app-kb/:kbId/documents/:docIdList, add, replace, delete documentskb:read / kb:write
GET /api/app-kb/:kbId/attachments , POST /api/app-kb/:kbId/attach , DELETE /api/app-kb/:kbId/attach/:agentIdSee / manage which agents answer from the KBkb: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 Linkslinks:write
POST /api/app-links/:id/revokeKill a link immediatelylinks:read
GET /api/app-ai/modelsCoarse tiers + the org's model catalogany ai:*
POST /api/app-ai/llmRun a completion. Billed to the installing org's walletai:llm
POST /api/app-ai/llm/streamThe same, streamed (SSE)ai:llm
POST /api/app-ai/extractPlain text out of a stored .pdf / .docx / .txt / .mdfiles:read
POST /api/app-ai/ttsSynthesise speech. Billed to the orgai:tts

On agent scopes: creating or deleting agents needs agents:write, while listing agents and running eval need only agents:read.

There is no /api/app-ai/stt. ai:stt is 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 use calls:transcribe:live; for a finished call, GET /api/app-calls/:sessionId already 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.scopes is None).
  • An OAuth-issued token carries an explicit scope claim.

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 AppObject shape (id, appId, objectType, data, createdBy?, createdAt, updatedAt?) is exported as a TypeScript type from telenow. Always read your own fields through .dataobj.data.phone, not obj.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:

ParamMeaning
equality filtersany other key=value pair matches a stored field exactly
limitmax rows (default 100)
topK (or lowercase topk)alias for limit — caps result count
sortfield to sort by
dirdesc triggers descending; anything else (including asc or omitting it) is ascending
numericnumeric ordering when the value is exactly "true" or "1" (else lexicographic)
viewapply a manifest-declared saved view (its stored filter + sort)
expandcomma list of relation fields to embed as {field}__expanded
searchsemantic 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 bridge data.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).

OpRequestResponse
ListGET /api/app-files/ (optional ?prefix=reports/){ success, data: { files: [...] } }
Upload / overwritePUT /api/app-files/*path (raw body = the bytes){ success, data: { path, size } }
DownloadGET /api/app-files/*pathraw bytes (see note)
DeleteDELETE /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 forces Content-Disposition: attachment and X-Content-Type-Options: nosniff on 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 }. kind is "flow" when the spec's metadata.flow has 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.
  • name defaults 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/transfer nodes 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 returns 413 ("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 empty scenarios array is 400 "at least one scenario is required". Batch larger suites across calls.
  • max_turns defaults to 4 and is clamped to EVAL_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 get 400 "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)

FieldTypeDefaultNotes
agentIduuidREQUIRED. 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.
namestring"App campaign"Display name.
targetsarrayExplicit targets (see below). Provide this OR targetQuery.
targetQueryobject{ object, phoneField, filter } over your app objects (see below).
concurrencynumber5Simultaneous calls.
maxAttemptsnumber3Retry attempts per target.
retryBackoffSecsnumber300Seconds between retries.
retryOnNoAnswerbooleantrueRetry on no-answer.
machineDetectionstringAnswering-machine-detection mode, if your provider supports it.
windowobject{ start, end, timezone }local-time strings (e.g. "09:00", "18:00", "America/New_York"). Calls only dial inside the window.
autostartbooleantrueStart dialing immediately; false leaves it as a draft.
resultobjectWrite-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[] uses phoneNumber, not phone. Each explicit target is { "phoneNumber": "+1…", "variables": { … } }. The builder reads t.phoneNumber; a phone key is silently skipped → an empty target list → 400 "no dialable targets (…)". This is a different field from targetQuery.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:write if you aggregate into your own objects), subscribe to call.analyzed for the forward stream, and use this API to backfill history at install. Your dashboard then renders from your aggregates — see Dashboard UI. Add billing:read + includeCost=true and 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.
  • replaceDocument rotates the document id (old chunks purge, new body re-embeds). Key your sync state on your own source ids, and use contentHash (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:

  1. calls:read (or calls:read:org) scope.
  2. The call must belong to your app's org, else 404 "call not found".
  3. The app must be bound to the call's agent — unless it holds calls:read:org — else 403 "app is not bound to this call's agent".
  4. The call must be live (Active), else 409 "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-listed http:<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 (needs connection:<provider>), and secret/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

</invoke>