Scopes, permissions & security
Scopes, permissions & connections
This page explains how a Telenow app asks for permission, how the org grants it, and the security guarantees the platform enforces around your app. It also documents the two ways your app uses a credential without ever putting it in your bundle — stored connections and per-install secret headers — and the exact error messages you will hit when something is misconfigured.
Read it before you ship — it determines what your app is allowed to do and why your code stays safe even when it runs inside someone else's dashboard.
If you are new, two ideas carry the whole page:
- You declare scopes in your manifest. The org consents to exactly those scopes when they install your app. Nothing you didn't declare is reachable.
- The browser never holds a token. Every privileged call your UI makes is relayed to the dashboard and re-checked server-side under the signed-in user. RBAC helpers like
can()only hide buttons — they never grant access. Secrets are injected by the server, after the host has already been allowlisted.
The consent model
A scope is a single permission string in your manifest's scopes[] array. When an org admin installs your app, they see the full list and approve it. After that, the granted set is frozen.
// telenow.app.json — Doctor CRM declares exactly what it uses
"scopes": [
"objects:patient",
"objects:appointment",
"objects:visit",
"agents:read",
"calls:initiate",
"calls:read",
"whatsapp",
"whatsapp:templates",
"whatsapp:campaign",
"softphone:dial",
"user:profile",
"campaigns:write",
"campaigns:read"
]
Three rules to keep in mind:
- Frozen at install. The scopes the org consented to are the scopes you have. You cannot widen them at runtime.
- Adding scopes forces re-consent. If a later version of your app adds a scope, the org must approve the new set before that version goes live for them. Plan ahead — declaring a scope you'll need soon (but don't yet use) is cheaper than a forced re-consent later, but see Least privilege below for the tradeoff.
- There is no server-side scope whitelist. This is important and surprises people — see the next section.
Scopes are free-form strings — there is no whitelist
validate() (the check npx telenow validate and the server run at upload) does not validate scopes[] at all. There is no KNOWN_SCOPES list on the server, so:
- An unrecognised or typo'd scope is never rejected or warned at upload.
objcts:patient(note the typo) uploads fine. - A typo'd scope silently grants nothing. It is treated as a meaningless string nobody ever checks for.
- The feature that needed the real scope then fails at runtime with a 403 — not at build time. You won't find out until a call actually tries to use the capability.
Only settings keys are validated against the manifest (an unknown settings key is rejected). Scopes are not — there is no server enforcement of a scope whitelist. The CLI (
telenow validate) carries a local typo-guard; as of the current CLI its known-scope list is complete (every scope in the catalogue below, plus theobjects:/http:/connection:prefixes). Older CLI versions warned "unrecognised scope" on perfectly valid scopes (agents:write,files:*,campaigns:*,data:*,calls:read:org) — those were false positives; update the CLI rather than renaming a scope to silence one.
Scopes are enforced only at the point of consumption, by string match, at runtime:
| Scope string | Enforced where |
|---|---|
http:<host> | the HTTP proxy, when you call that host |
connection:<provider> | the proxy, when you inject a stored connection |
session:token | minting a session JWT |
files:read / files:write | the Files API |
calls:read | call history, mid-call live topics, live-stream ticket — for agents your app is bound to |
calls:read:org | the same call surfaces, org-wide (every agent, no binding needed) |
billing:read | the billing API, includeCost on the calls API, the charge.settled event |
kb:read / kb:write | the runtime KB API (reads / mutations) |
agents:read, campaigns:write, … | the matching bridge op / REST route |
Takeaway: spell your scopes exactly. A silent typo is the single most common "why doesn't my feature work?" bug, and the only signal is a runtime 403.
Two planes enforce scopes, and they don't use identical names
This trips up almost everyone, so read it before the catalogue.
| Plane | Who checks | Where |
|---|---|---|
| UI bridge | the dashboard, in the browser | every window.telenow.* call your page makes |
| REST | the Rust backend | app-key and OAuth calls from your server |
They are enforced independently and a few names differ. The clearest case is WhatsApp: the bridge gates sending on whatsapp:send, while the REST API gates it on whatsapp. Declare only one and the other plane refuses. An app that sends from both its UI and its backend needs both.
Scopes the UI bridge enforces today:
agents:read · agents:write · agents:config:read · agents:config:write (and its :<group> forms) · ai:llm · calls:initiate · calls:transcribe:live · files:read · files:write · kb:read · links:read · links:write · members:read · session:token · softphone:dial · whatsapp:send
A bridge-only scope is not a fake scope — it is real, enforced, and the call fails without it. It just isn't a string the Rust backend has ever heard of, so grepping the backend for it finds nothing.
Workflows are the third case: they check nothing. A workflow step runs with the install's full authority regardless of what you declared. Don't treat a missing scope as a safety net there.
Scope catalogue
What does each of these return? Every endpoint, bridge op, tool and event on this page has a worked response example in the Response reference, derived from the code that builds it and indexed by scope.
These are every scope the platform understands. Most map one-to-one to a capability in the window.telenow bridge or the app-key REST API.
| Scope | Grants |
|---|---|
user:profile | the signed-in user's name + email in the bridge and session token |
session:token | mint a signed identity JWT for your backend (telenow.session.token() / POST /:appId/session-token) |
agents:read | list org agents; create agents/teams from your manifest templates; run evals |
agents:write | create or delete agents from your backend (POST/DELETE /api/app-agents), and publish an agent so a link can reach it (agents.setPublic, owner/admin only). Merely reading the link back (agents.publicLink) needs only agents:read. |
agents:config:read | read the FULL configuration of an agent your app is bound to — system prompt, model, voice, STT, behaviour, telephony and the flow graph. Secret-free: BYOK keys stripped, tools reduced to name/description. The read scope an agent-improver app starts from |
agents:config:write:<group> | change one setting group on a bound agent. Groups: prompt · model · voice · stt · behavior · flow · telephony · analysis. The group is decided by the FIELD, so editing a flow node's prompt needs …:write:prompt, not …:write:flow. See Agents |
agents:config:write | superset of every group above. Shown prominently at consent — declare the specific groups instead unless your app genuinely rebuilds an agent end to end |
calls:read | read call history; receive mid-call live event topics; open the live-call stream — scoped to agents your app is bound to |
calls:read:org | everything calls:read grants, org-wide: every agent's calls (including agents created later), no per-agent binding — the analytics-app scope. Shown prominently at consent. One exception: declarative postCall capture stays binding-only |
calls:initiate | place outbound voice calls |
calls:transcribe:live | tap the live STT stream of a call in progress. Additive to calls:read |
whatsapp | Send messages, list channels, read threads & message history, upload/download media, mark messages read (also gates the whatsapp.message.* webhook events). REST plane — the UI bridge uses whatsapp:send |
whatsapp:send | UI-bridge plane: list channels and send from your app's pages |
whatsapp:templates | Create & edit message templates and upload template header media |
whatsapp:web | also list and send on WhatsApp Web channels (a paired phone session). Without it, whatsapp.channels() returns Cloud API channels only — see the note below |
whatsapp:campaign | Create and control WhatsApp broadcasts (distinct from campaigns:*, which gate voice campaigns) |
softphone:dial | open the dashboard dialer prefilled |
files:read / files:write | per-app blob storage read / write. files:read also covers text extraction from a stored .pdf / .docx / .txt / .md (telenow.files.extractText()) |
ai:llm | the App AI Gateway's LLM: telenow.ai.llm(), ai.llmStream(), POST /api/app-ai/llm. Billed to the installing org's wallet |
ai:tts | the gateway's text-to-speech, POST /api/app-ai/tts. App-key REST only — there is no ai.tts() on the bridge. Billed to the org |
ai:stt | reserved — grants nothing today. It validates and appears at consent, but no /api/app-ai/stt route exists. Use calls:transcribe:live for live call audio |
links:write / links:read | mint / list + revoke tokenized public links (telenow.links.*) — see below |
members:read | the installing org's member roster — user id, name, email, role (telenow.members.list()) |
campaigns:read / campaigns:write | app-key Campaigns API (list/status / create/pause/cancel) |
billing:read | wallet balance + settled per-call charges (your org's prices — the platform's provider cost is never exposed), includeCost on the calls API, and the charge.settled event |
kb:read / kb:write | read / manage the app's own knowledge bases at runtime (/api/app-kb): create KBs, push/replace documents, attach to bound agents. Never the org's other KBs |
objects:<type> | data access to that object type, e.g. objects:patient |
http:<host> | let the HTTP proxy call that host; supports a leading wildcard, e.g. http:*.googleapis.com |
connection:<provider> | inject a stored credential for that provider server-side, e.g. connection:salesforce |
data:read / data:write | OAuth-token-scoped Data API CRUD (static app keys are not CRUD-scope-gated) |
A few notes that trip people up:
objects:<type>is per object type. Doctor CRM declaresobjects:patient,objects:appointment, andobjects:visitseparately. There is noobjects:*.- Mid-call live topics need
calls:read. The coarsecall.started/call.ended/call.analyzedtopics don't, but the live ones —call.turn,call.barge_in,call.silence,call.dtmf,call.node_entered— carry transcript content, so they additionally requirecalls:read(orcalls:read:org). See Automation and the live stream in Dashboard UI. calls:readis binding-scoped;calls:read:orgis org-wide. With plaincalls:read, call events/history/streams cover only agents the org bound your app to. Withcalls:read:orgyour app receives every agent's call events (bound or not, including agents created after install) and can list the whole org's history — that's the scope an analytics dashboard needs. It's a broad grant, so the consent dialog calls it out explicitly; declare it only when org-wide visibility is genuinely the product.calls:read:orgdoes NOT widen declarativepostCallcapture. Everything the scope grants is a read — events you receive, history you can list.postCallis a standing write into your own objects, so it stays scoped to agents the org bound you to, even when the same install could read those calls anyway. An org-wide analytics app therefore getscall.completedfor every agent, but stores rows only for bound ones. If you want capture on an agent, ask to be bound to it; the scope alone won't do it.http:<host>is an allowlist, not a wildcard-everything. Declare each host (or one leading-wildcard pattern). The proxy refuses any host you didn't list.whatsapp:webwidens a list you already have.whatsapp.channels()works without it, but returns Cloud API channels only. An org whose only channel is a paired WhatsApp Web session sees an empty picker and concludes your app is broken. Branch onchannel.kind—'web'sends plain text any time;'cloud'needs an approved template outside the 24-hour window. They are not interchangeable.ai:*spends the org's money. The App AI Gateway bills the installing org's wallet per call. Declare only the modality you use, and expect an admin to ask what it costs.
Per-recipient public links
links:write mints a tokenized URL at https://<your-dashboard>/l/<token> that someone outside the org can open with no account — a candidate taking an interview, a customer confirming a delivery, a hiring manager reading a shortlist.
const link = await telenow.links.mint({
action: 'agent_call', // 'agent_call' | 'form' | 'page'
targetType: 'candidate',
targetId: row.id,
singleUse: true,
code: '481027', // optional second factor, sent separately
ttlSecs: 7 * 24 * 3600,
opensAt: '2026-08-11T11:15:00Z',
context: { slug: agentSlug, variables: { candidate_name: 'Navin' } },
});
// link.url ← the ONLY time the token is returned. Store it or lose it.
What the recipient sees, per action:
action | The page shows |
|---|---|
agent_call | a web-call widget bound to context.slug, with context.variables handed to the agent |
form | fields you declared in context, submitted back to your app |
page | read-only context.sections — headings, text, labelled rows |
Rules worth knowing before you build on it:
contextis captured server-side at mint. The recipient's browser cannot supply or alter it — that is the whole point. The same data in a query string would be spoofable.- The token is returned once.
links.list()never returns tokens. Storelink.urlon your own row; re-minting hands out a second link and silently kills the first. contextis public. It is served to an unauthenticated page. No secrets, no internal ids you wouldn't hand a stranger.- Mint before you revoke. When replacing a link, record the new one first — minting can fail, and a recipient holding nothing is worse than one holding a stale link.
- Links resolve on your dashboard's origin, not the API host.
/l/:tokenis a frontend route.
http:<host> wildcard semantics (read this carefully)
The exact matching rule decides whether a request is allowed. The matcher is case-insensitive and trailing-dot tolerant (a host like api.example.com. is normalised to api.example.com).
A http:*.example.com wildcard scope:
| Request host | Matched by http:*.example.com? | Why |
|---|---|---|
foo.example.com | ✅ yes | a strict subdomain |
a.b.example.com | ✅ yes | any deeper subdomain |
example.com | ❌ no | the apex is not a subdomain |
evil-example.com | ❌ no | the char before example.com must be a literal ., not - |
notexample.com | ❌ no | same — no . boundary |
The rule in words: the request host must end with .example.com — i.e. the character immediately before the suffix is a literal dot. A lookalike suffix like evil-example.com is rejected because there is no dot boundary.
An exact scope like http:api.example.com matches only that exact host.
If your integration also calls the apex domain, declare BOTH. Many vendors serve their API on the apex and on subdomains. To call
salesforce.comandlogin.salesforce.com, declare:"scopes": ["http:salesforce.com", "http:*.salesforce.com"]
http:*.salesforce.comalone will not let you reachsalesforce.com.
A host you call but never declared fails with:
host `<host>` is not in the app's granted http: scopes (403)
RBAC is UI gating only
Your UI can read the current user and their role:
import { useUser } from 'telenow/react';
function NewAgentButton() {
const { user, can } = useUser(); // user = { id, role, permissions[], name?, email? }
if (!can('agents:create')) return null; // hide the button for non-admins
return <button onClick={buildAgent}>Create agent</button>;
}
user.role, user.permissions[], and the derived can(permission) helper exist so you can show the right UI — hide an admin-only button, grey out a control. That is all they do.
Real permission enforcement is server-side, on every relayed call. When your iframe calls telenow.agents.createFromTemplate(...) or telenow.calls.initiate(...), the dashboard re-checks the user's actual role and your app's scopes before doing anything. A user who edits your bundle in DevTools to force can() to return true gains nothing — the server says no. So treat can() as a UX convenience, never as a security boundary.
(user.name and user.email are only populated when you hold user:profile. Without that scope you still get user.id and role.)
Static app key vs OAuth token — scope enforcement differs per route
The app-key REST API (app-backend) can be called two ways, and they are scoped differently per route. This catches people: a static key skips the Data-API CRUD scope checks entirely, but still gets checked on Files and the live-stream ticket.
- Static app key (
app.scopesisNone): bound to one(org, app). Its boundary is the tenant+app binding itself. - OAuth Bearer token: carries a
scopeclaim, and every route hard-checks that claim.
| Route group | Static app key | OAuth token |
|---|---|---|
Data API CRUD (GET/POST/PATCH/DELETE /api/app-data/...) | always allowed — data:read / data:write are not enforced | hard-checks the token's data:read / data:write claim |
Files API (/api/app-files/...) | checks the install's declared scopes — files:read / files:write | hard-checks the token's files:* claim |
Calls API (GET /api/app-calls, GET /api/app-calls/:id) | checks the install's declared calls:read / calls:read:org | hard-checks the token's calls:read / calls:read:org claim |
Live-stream ticket (POST /api/app-calls/:id/stream-ticket) | checks the install's declared calls:read / calls:read:org (plus agent binding, below) | hard-checks the token's calls:read / calls:read:org claim |
The reason: Data-API CRUD was never scope-gated for static keys (a static key is already tenant+app bound), whereas Files and the live-stream ticket were added later with a stricter, scope-gated gate that applies even to static keys.
The two distinct 403 strings tell you which path failed:
token does not include the <scope> scope // an OAuth token is missing the scope
app did not declare the <scope> scope // a static key's install never declared the scope
Live-stream ticket has four gates
POST /api/app-calls/:sessionId/stream-ticket is the strictest Data-API route. In order:
calls:readorcalls:read:org— enforced even for a static key (it's astatic_install_check=trueroute).- The call must belong to the key's org, else
404 "call not found". - The install must be BOUND to the call's agent — unless it holds
calls:read:org, which covers every agent — else403 "app is not bound to this call's agent". - The call must be live, else
409 "call is not live".
On success it returns { ticket, wsUrl } — a single-use, 30-second ticket. Connect to wsUrl (/ws/live-call-stream?ticket=…) immediately.
The security model
Everything below is enforced by the platform, not by your code. You get these guarantees for free; you cannot turn them off, and you don't need to re-implement them.
Sandboxed iframe, no tokens in the browser
Your built UI runs in a sandboxed iframe on an opaque origin. It cannot read the dashboard's cookies or auth tokens, and there are no API keys in the browser. The dashboard injects window.telenow; each call is relayed to the parent window, performed under the signed-in user, scoped to your app and the current org, and enforced server-side. If a host is older than a bridge feature, hooks degrade gracefully (empty result or a clear rejected promise) — they never blank-screen.
App keys are tenant + app bound
An app key (used by an external backend) is bound to exactly one (org, app). Every Data/Files/Agents/Campaigns call it makes is automatically scoped to that pair — you cannot reach another tenant's or another app's data, because the app id is never taken from the request path. Mint keys and read your signing secret from your installed app's API keys screen.
The HTTP proxy
When your UI or a tool calls a third-party API, it goes through the dashboard's server-side proxy (telenow.http(...) / useHttp()), never directly from the iframe. The proxy is deliberately strict.
Request contract
- Host-allowlisted — only hosts in your
http:<host>scopes (leading wildcard allowed, per the rules above). - HTTPS-only — no plaintext; SSRF-guarded and DNS-pinned, so internal/loopback/metadata addresses are blocked and a target can't pivot into Telenow's network.
- No redirects — a
3xxis not followed, so an allowlisted host can't bounce you to a forbidden one. - Methods —
GET | POST | PUT | PATCH | DELETE | HEAD. Anything else is rejected with400 "method<M>not allowed". - Forbidden request headers are silently dropped (you cannot set them):
host,content-length,connection,transfer-encoding,keep-alive,upgrade,proxy-authorization,proxy-connection,te,trailer. - Body — a JSON value is sent as
application/json; a string body is sent verbatim (you control the content type viaheaders). - Bring-your-own
Authorization— anAuthorizationheader you pass is forwarded to the allowlisted host and never stored by the proxy. It is overridden if you also use aconnectionor a secret-headerAuthorization(those win — see below).
Response contract
- ≤ 1 MB — the body is capped and returned as text; over-cap fails with
413 "response exceeds 1 MB". Set-Cookieand hop-by-hop headers are stripped from the response.- Response header names are lowercased. Read
body.headers['content-type'], not'Content-Type'. - Returned shape:
{ status, headers, body }(inside the standard{ success, data }envelope).
"scopes": ["http:api.hubspot.com", "http:*.googleapis.com"]
The proxy is rate-limited per org and circuit-broken per host — a flaky upstream trips the breaker instead of dragging down the dashboard. See Limits & quotas for the exact rate-limit and circuit-breaker numbers.
Stored connections and secret headers
There are two ways to use a credential without ever putting it in your bundle. Both inject the secret server-side, after the host is already allowlisted, so your browser code never sees it.
| Stored connection | Secret header | |
|---|---|---|
| What it injects | the org's OAuth/API credential for a provider | the app's own encrypted setting value |
| Who owns the secret | the org admin (via the integrations framework) | you (the app), via a secret:true setting |
| Body field | connection: "<provider>" | secret_headers: { "<Header>": "<settingKey>" } |
| Required scope | connection:<provider> | none (but see the exact-scope rule) |
| Host requirement | exactly one of the provider's bound hosts | the host must be granted by an exact http:<host> scope |
| Always targets header | Authorization | any header name you choose |
Per-install secret headers
secret_headers is a { "<Header-Name>": "<secret setting key>" } map. The browser names only the setting key; the platform looks up that key in your app's own encrypted install config, decrypts it server-side, and sets it as that request header. The value never enters the browser.
// `api_key` is a manifest setting with secret:true — the value lives encrypted,
// server-side. Your code only references the KEY name.
await telenow.http({
url: 'https://api.example.com/v1/things',
method: 'GET',
secret_headers: { Authorization: 'api_key' }, // server decrypts `api_key` → Authorization: <value>
});
// manifest: the secret setting + the EXACT host scope
"settings": [
{ "key": "api_key", "label": "API key", "type": "secret", "required": true }
],
"scopes": ["http:api.example.com"] // EXACT — not http:*.example.com
Rules — these matter:
-
The value is decrypted and set server-side. Any app-supplied value for the same header name is dropped first (so the injected value isn't duplicated or overridden by browser-side data).
-
Injection is allowed ONLY when the host is granted by an EXACT
http:<host>scope. A*.wildcard scope is not enough — a wildcard apex would let the app steer the credential to an attacker-owned sibling host. With only a wildcard scope you get:secret injection requires an exact `http:{host}` scope (a wildcard scope is not enough) (403) -
On decrypt failure the header is silently omitted — never the ciphertext, never a
500. (If you rotate the platform secrets key and an old value can't be decrypted, the call simply goes out without that header rather than failing or leaking ciphertext.) -
Forbidden proxy header names (see the list above) are dropped even when named in
secret_headers.
See the settings form in Dashboard UI and reading secrets server-side in External backends.
Stored connections
A stored connection is a credential the org admin set up once under the dashboard's integrations screen (the OAuth-broker / integrations framework). Your app references it by provider id and the proxy injects the org's stored, auto-refreshed credential as Authorization, server-side.
await telenow.http({
url: 'https://acme.my.salesforce.com/services/data/v60.0/sobjects/Contact',
method: 'POST',
connection: 'salesforce', // Authorization injected server-side, token auto-refreshed
body: { LastName: 'Doe' },
});
// declare the connection scope AND the host(s) you call
"scopes": ["http:*.salesforce.com", "http:salesforce.com", "connection:salesforce"]
Provider ids the platform ships (use the exact id): salesforce, hubspot, shopify, stripe, calendly, notion, airtable, google (one connection covers Calendar, Sheets, and Gmail send).
How injection resolves:
- It picks an active connection first, then the most-recently-updated one.
- It auto-refreshes an expiring OAuth token before injecting.
- It injects, in order of what the connection has:
Bearer <access_token>→Basic <basic>→Bearer <api_key>as theAuthorizationheader. - A stored connection's
Authorizationalways wins — over any app-suppliedAuthorizationheader and over a duplicateAuthorizationnamed insecret_headers. Both are dropped so only the connection's credential goes out.
Prerequisites — both must be true:
- The org admin has connected the provider under integrations and the connection is active / usable.
- The requested host is exactly one of the provider's bound hosts.
The bound-host set is the provider's vendor-fixed proxyHosts (e.g. api.hubapi.com for HubSpot) UNION the org's own host from the provider's proxyHostSetting (e.g. the Salesforce instance_url, like acme.my.salesforce.com). It is a strict allowlist of concrete hosts — never a domain suffix — so an app can't declare http:*.salesforce.com + connection:salesforce and have the token injected into an attacker-owned *.salesforce.com sibling.
Exact errors you'll hit (so you can debug fast):
| Error | Status | Cause |
|---|---|---|
app did not declare the connection:<provider> scope | 403 | you used connection:'<provider>' without the matching scope |
unknown connection provider <provider>`` | 400 | the provider id isn't one the platform ships |
no <provider> connection in this org | 400 | the org admin hasn't connected this provider yet |
`<provider>` connection has no bound host — its credential can't be injected safely (use bring-your-own-bearer instead) | 400 | the provider has no proxyHosts and the org set no host — nothing safe to bind to |
connection <provider> may only be used with: <hosts> | 403 | your url host isn't in the provider's bound-host set |
`<provider>` connection has no usable credential | 400 | the connection exists but carries no access token / basic / api key |
If a provider's credential genuinely can't be bound to a concrete host, fall back to bring-your-own-bearer (pass your own Authorization) or a secret header against an exact host.
The rule of thumb
Secrets stay on the server. If a value would be dangerous in a user's browser DevTools, model it as a stored connection (org-owned, third-party provider) or a secret setting + secret_headers (app-owned). Never put it in plain UI state.
Manifest hardening (shipped templates are sanitized)
Your app can ship ready-made agents and teams. Because a manifest is untrusted input, the platform strips and sanitizes the parts of a template that could otherwise smuggle in dangerous behaviour. You don't call these functions — they run automatically when an agent or team is built from your template.
Embedded tool defs are stripped (strip_embedded_tools)
Removed from the top level and from every flow node: the tools array, precallLookups, and the editor-scratch flowDraft. The app's real tools are merged at call time from the agent's binding, so an embedded tools array in a template can't introduce an http/SSRF/exfil tool.
Flow-node configs are sanitized (sanitize_template_flow_nodes)
The sanitizer reads each node's kind from kind, falling back to type, defaulting to conversation if neither is present (so a node is defused regardless of which key a hostile manifest used). Then, per node kind:
toolnode — survives only if it runs your own app's tool. The exact condition is:node.config.kind == "app"AND the nestednode.config.config.app_idequals this app's id exactly. Any other tool node — arbitrary http/connector/MCP, or one pointing at a different app's id (cross-app read/write) — loses itsconfigand fails safe. (Note the nestedconfig.config.app_id: it's the inner config that carries the app id.)codenode — loses itsconfig. No arbitrary JS the installer never wrote.transfernode — its destination is defused: the top-levelnumberis blanked, every per-destinationnumberis blanked, and per-destinationnumbersarrays are removed. No attacker-chosen number baked into the flow for toll-fraud or call interception.
On export, no tool nodes survive
When a template is exported (there is no install context, so allow_app is None), no tool nodes are kept at all — the app_own check can never be satisfied without an installing app id. The flow graph itself — prompts, nodes, edges — is preserved; only the dangerous config is defused.
The takeaway: a template can declare deterministic app-tool nodes (running your own tools), but it can never smuggle an http/SSRF/cross-app/code/transfer node into the org.
Inbound hooks authenticate every POST
A platform-hosted inbound hook receives third-party POSTs at /webhooks/app/<installationId>/<hookId> with no dev backend. Each request must authenticate — there is no fail-open path:
- HMAC via a
verifyblock (e.g. Meta'sx-hub-signature-256, verified against the install's signing secret), or - an unguessable
?token=<signing_secret>in the URL when noverifyis declared.
// clinic-crm: Meta lead-ads → lead object, HMAC verified
"inboundHooks": [
{
"id": "lead-intake",
"object": "lead",
"key": "phone",
"map": { "name": "full_name", "phone": "phone_number", "source": "ad_id" },
"verify": { "header": "x-hub-signature-256", "prefix": "sha256=", "algo": "sha256", "encoding": "hex" }
}
]
Bodies are capped at 256 KB and the endpoint is rate-limited. A bad signature is rejected with 401 — it never falls through to "accept anyway".
Least-privilege guidance
Declare only the scopes you actually use. This is both good security hygiene and a real adoption lever — many teams in India and abroad have a security or compliance reviewer who reads your scope list before approving an install. A short, justified list installs faster and earns trust; a kitchen-sink list raises questions.
Practical checklist:
- Request
objects:<type>only for the object types your tools and UI touch. Doctor CRM listspatient,appointment,visit— not a blanket grant. - Add
calls:readonly if you actually read history or consume live/mid-call events. Reach forcalls:read:orgonly when org-wide visibility is the product (an analytics dashboard, a QA/compliance monitor) — it's the single broadest data grant an app can ask for, and reviewers will treat it that way. - Add
http:<host>per host you call; prefer a specific host over a broad*.wildcard when you can — and remember a wildcard scope alone can't carry a secret header (those need an exact host). - Use
connection:<provider>+ secret settings instead of asking users to paste credentials into your UI. - Spell every scope exactly — there is no server-side typo guard, so a misspelt scope silently grants nothing and fails at runtime with a 403.
- Don't pre-declare scopes "just in case" unless re-consent friction genuinely outweighs the smaller ask — every extra scope is something a reviewer has to justify approving.
Next
- External backends & the app-key REST API — signatures, session tokens, secret settings server-side, and the scoped Data/Files/Agents/Campaigns API.
- Dashboard UI — the
window.telenowbridge,telenow.http, the settings form, and the design system. - Manifest reference — every field, including
scopes,settings, andinboundHooks. - Limits & quotas — the proxy rate-limit, circuit-breaker, body-cap and other numbers.
- Packaging & distribution — versioning, re-consent on scope changes, and publishing.