Events, workflows & hooks
Events, workflows & inbound hooks
This page is about making your app react to things automatically — without anyone clicking a button in the dashboard. A call ends and you log a summary. A new lead arrives and you call them back in five minutes. A Meta lead-ad form is submitted and a row lands in your object store. All of it is declared in your telenow.app.json manifest, and most of it needs no backend at all.
There are four manifest sections that drive automation, each for a different trigger:
| Section | Fires when… | Needs a backend? |
|---|---|---|
events[] | something happens inside Telenow (a call, a new object) | No (a rule) or Yes (a webhook) |
schedules[] | a fixed amount of time has passed | No (a rule, or a workflow) or Yes (a webhook) |
workflows[] | a trigger event fires — then runs many durable steps | No |
inboundHooks[] | a 3rd-party service POSTs to a URL you give them | No |
The running example throughout is clinic-crm ("Doctor CRM"), whose full manifest you can read in Worked example. Its workflows[] and inboundHooks[] snippets below are real lines from it; the events[]/schedules[] snippets are illustrative (clinic-crm declares none).
Read this first — the one big foot-gun. A rule handler (in-core,
kind:"rule") and a webhook handler (POSTed to your backend) see the SAME event under different shapes. A rule'smap/whenpaths resolve against the bare event context (fields at the top level). A webhook receives the wholeEventRequestenvelope{ event, appId, data: <context> }, so the very same fields live one level deeper underdata.. Workflow triggers ALSO wrap object-create payloads, so workflow{{trigger.*}}paths look like the webhook shape. Mixing these up is the single most common reason an automation "silently does nothing" — see Context shapes below.
1. events[] — react to platform events
An event is something Telenow emits during normal operation. You subscribe by adding an entry to events[] with the topic (on) and a handler.
The topic table
These are the only topics you can subscribe to. Subscribing to anything else fails validation at upload.
| Topic | When it fires | Extra scope |
|---|---|---|
call.started | a call connects | — |
call.ended | a call hangs up | — |
call.analyzed | post-call analysis (summary, sentiment, disposition) is ready | — |
recording.ready | the call recording is stored | — |
call.turn | each speaker turn, mid-call | calls:read |
call.barge_in | the caller interrupts the agent, mid-call | calls:read |
call.silence | a silence threshold is crossed, mid-call | calls:read |
call.dtmf | the caller presses a keypad digit, mid-call | calls:read |
call.node_entered | the call enters a flow node, mid-call | calls:read |
object.<type>.created | a row of <type> is created (e.g. object.lead.created) | — |
object.*.created | wildcard — any object type is created | — |
charge.settled | a call's charge is settled (~2-3 min after hangup) | billing:read |
call.completed | any call finishes — including one nobody answered | calls:read |
campaign.call.completed | a campaign target reaches a terminal state — including one nobody answered | campaigns:write |
whatsapp.message.received | an inbound WhatsApp message arrives (full content + raw Meta payload) | whatsapp |
whatsapp.message.status | a WhatsApp message you sent changes state (sent/delivered/read/failed) | whatsapp |
whatsapp.account.health | a WABA quality / limit / verification / review change | whatsapp |
call.endedfires only for calls that were ANSWERED. It comes from the media teardown, which a call nobody picked up never reaches — so an app relying on it works perfectly while you're testing (you answer) and is permanently blind to every no-answer in production. Nothing in the name suggests that, so preflight warns you.Use
call.completedinstead: it is claimed from the call's final state, so answered, busy, no-answer and failed all produce exactly one event. "No event" unambiguously means "not finished yet".campaign.call.completedremains the campaign-specific twin, carrying the campaign target's own fields.Payload:
campaignId·campaignName·callId·phone·disposition(answered/no-answer/busy/failed) ·answered·attempt·sessionId·completedAt·variables— the merge values the target was enqueued with. That last one is what makes write-back addressable: stamp your own row handle into the variables at import time and it comes back here, so{{trigger.variables.sheet_row}}tells you exactly which row to update.
call.completed payload: sessionId · agentId · outcome (answered/no-answer/busy/failed) · from · to · durationSecs · endedAt · variables · analysis (present only once post-call analysis has finished — subscribe to call.analyzed if you need to be told when that lands).
Storing calls without running a server
If all you want is "put every finished call into one of my objects", you don't need an event handler at all. Declare it once and it applies to every agent your app is bound to — the same way your tools do:
{
"objects": [{ "type": "call_log" }],
"postCall": {
"object": "call_log",
"mapping": {
"outcome": "call.outcome",
"phone": "call.to",
"summary": "analysis.summary",
"interested": "analysis.custom.interested"
},
"firesOn": ["answered", "no-answer", "busy", "failed"]
}
}
Each finished call becomes a row of call_log, with sessionId always included so you can reconcile against our records.
The mapping is the same reference language the dashboard's own call destinations use:
call.outcome call.to call.from call.ended_at call.attempt | |
keypad.digits keypad.choices | What the caller pressed, and what each press meant. Campaign calls only — an app postCall capture resolves these to nothing, because the call.completed payload does not carry the keypad log. |
analysis.summary analysis.sentiment analysis.disposition analysis.topics | needs post-call analysis |
analysis.custom.<name> | whatever the agent extracts |
var.<name> | a context variable |
settings.<key> | your own installation setting — what the installing org filled in |
'literal' | fixed text, in single quotes |
Nothing is stored on the agent, so editing your manifest takes effect on the next call — no re-binding, no asking users to re-save anything.
settings.<key> is how the row carries your configuration next to the call's facts — which pipeline the org chose, which region, which owner — instead of your backend joining it back afterwards. It reads the same values as your settings API, so one org's rows and another's are each stamped with their own answers.
Rejected at publish rather than failing silently later: an object you never declared, an empty mapping (it would store a blank row for every call), a firesOn value that isn't a real outcome (it would simply never match), a settings.<key> you never declared, and a settings.<key> marked secret — secrets are never written into your objects.
Use postCall when you want the data stored; use a call.completed handler when you want to act on it. Declaring both is fine — the handler runs and the row is written.
postCall fires only for agents the org bound your app to. A call.completed handler widens with the org-wide calls:read:org scope; the declarative capture deliberately does not. Everything that scope grants is a read — this is a standing write into your objects, so it stays per-agent opt-in. An app holding it that declares both will be called about every agent and store rows for the bound ones.
Did it actually run? Open any call's Debug trace (Calls → a call → Debug). Each delivery to your app appears as a line naming which half ran:
| In the trace | What happened |
|---|---|
delivered · handler | your call.completed handler was dispatched |
delivered · stored | a postCall row was written; no handler declared |
delivered · stored+handler | both |
delivered · handler (org-wide) | reached via calls:read:org — not bound, so nothing was stored |
delivered · no-op | nothing to do — most often your firesOn excluded this outcome (the delivery is created before that filter is applied), or the manifest changed between enqueue and delivery |
pending: … | the write failed and will retry; the message is the reason |
That answers "my handler never fired" without guessing — the line either exists or it doesn't, and it says why.
The first four are coarse lifecycle topics: they fire once, at clear moments. The five call.* topics in the middle are mid-call live topics — they stream while the call is in progress and carry transcript content, so they additionally require the calls:read scope (or calls:read:org). If you only need a summary after the fact, prefer call.analyzed over the live stream.
Which calls do you hear about? Call and recording topics fire to your app for agents the org bound it to. With the org-wide calls:read:org scope, they fire for every agent's calls in the org — no bindings — which is what an analytics app wants (pair the event stream with the historical calls API for backfill). Declarative postCall capture is the one exception: it stays binding-only even under calls:read:org — see above. charge.settled is org-wide by nature (spend isn't per-binding) and fires to every app holding billing:read; its payload carries the charge components as JSON numbers plus the post-charge walletBalanceUsd and billingMode.
when predicates compare numerically too. Besides equals (and the bare-path truthiness check), a when can hold lt / lte / gt / gte bounds; all present conditions must hold. The resolved value must be a number or a numeric string (money fields are decimal strings) — anything else fails closed. { "path": "walletBalanceUsd", "lt": 10 } on charge.settled is a zero-code low-balance alert; { "path": "chargeUsd", "gt": 1 } flags unusually expensive calls.
The object.<type>.created family is what most no-backend apps lean on: write a row (from a tool, an inbound hook, or another app), and a created event fires that other automations can pick up.
Handler kinds
A handler is either a declarative rule (no code runs) or a webhook (Telenow POSTs to your backend).
kind: "rule" — declaratively upsert a row into one of your objects. The only action is "upsert". The handler shape:
| Field | Meaning |
|---|---|
do | "upsert" (the only action) |
object | which declared object to write into |
key | the field used to dedupe the upsert (e.g. "phone") |
when | optional single predicate — run only if it holds |
map | target_field ← source path in the bare event context |
map pulls values out of the event context by dot-path (a leading $. is allowed and stripped). A null or absent source value is silently skipped — so map at least one field that is always present (your key field), or the upsert never fires. when is a single { path, equals } check — the rule runs only if the value at path equals equals (or, if you omit equals, is simply present/truthy). One predicate only; anything branchier belongs in a workflow or your own backend.
call.analyzed rule context — exact fields
This is the topic most rules listen on, and the place developers most often write the wrong paths. For a rule handler, the context is the flattened post-call analysis at the top level — there is no data. prefix, and there is no outcome / caller_name / agent_name field. The exact keys available to a call.analyzed rule's map/when:
| Path | What it is |
|---|---|
summary | the call summary text |
sentiment | overall sentiment |
disposition | the call disposition/outcome label |
topics | array of detected topics |
keywords | array of detected keywords |
custom.<key> | each custom analysis field, nested under custom (e.g. custom.condition) |
caller_number | the telephony caller number (added by the platform; may be null) |
session_id | the call's session id (added by the platform) |
The exact
summary/sentiment/disposition/topics/keywords/custom.*shape comes from your agent's post-call analysis configuration;caller_numberandsession_idare injected by Telenow. There is nooutcome,caller_name, oragent_name— those paths resolve to nothing and produce empty rows.
Here is a lead-capture rule using the real paths (illustrative — clinic-crm itself declares no events[]):
{
"events": [
{
"on": "call.analyzed",
"handler": {
"kind": "rule",
"do": "upsert",
"object": "lead",
"key": "phone",
"when": { "path": "disposition", "equals": "interested" },
"map": {
"phone": "caller_number",
"name": "summary",
"sentiment": "sentiment"
}
}
}
]
}
That says: when a call is analyzed and its disposition was interested, upsert a lead keyed by phone (filled from caller_number), storing the summary and sentiment. No server, no code. To gate on a custom analysis field instead, use a custom.* path, e.g. "when": { "path": "custom.condition", "equals": "fever" }.
kind: "webhook" — POST the signed event to your backend at base_url + path. You must declare base_url in the manifest, or upload fails.
{
"events": [
{ "on": "recording.ready",
"handler": { "kind": "webhook", "path": "/hooks/recording" } }
]
}
The POST body is an EventRequest envelope — the event context lives under data:
{ "event": "recording.ready", "appId": "clinic-crm", "data": { "recordingId": "…", "sessionId": "…", "recording": { "id": "…", "url": "…" } } }
Every webhook (and every http tool call) is signed with the header X-Telenow-Signature: sha256=<hex> — an HMAC-SHA256 over the raw request body, keyed with your install's signing secret. Always verify it against the raw bytes before parsing JSON. The telenow package ships a helper:
import { verifySignature } from 'telenow';
if (!verifySignature(signingSecret, rawBody, req.headers['x-telenow-signature'])) {
return res.status(401).end();
}
Full backend setup — verifying signatures, the app-key REST API, session tokens — lives in External backends.
Context shapes (the #1 foot-gun)
A rule and a webhook receive the same event, but the fields sit at different depths. Get this right and everything else follows.
| Handler | What it receives | Where the fields live |
|---|---|---|
rule (in-core upsert) | the bare event context | top level — summary, caller_number, phone, … |
webhook (POST to your backend) | the EventRequest envelope { event, appId, data } | one level deeper — data.summary, data.caller_number, … |
workflow trigger | the run context's trigger is the raw payload | for object-create topics that's { objectType, id, data }, so {{trigger.data.field}} |
The same call.analyzed summary field, three ways:
rule map: "note": "summary"
webhook body: req.body.data.summary
object.lead.created
rule map: "phone": "data.phone" ← the row is under data
webhook body: req.body.data.data.phone ← envelope.data + payload.data
workflow trigger: {{trigger.data.phone}} ← payload is { objectType, id, data }
Why the difference for object events: an object.<type>.created payload is { objectType, id, data: <the row> }. So even a rule on an object-create topic reads the new row's fields under data. (e.g. data.phone), while a call.analyzed rule reads them at the top level (e.g. summary). The webhook envelope adds one more data. wrapper on top of whatever the payload already is.
2. schedules[] — run on a fixed interval
A schedule runs a handler on a repeating timer — nightly sweeps, hourly syncs, a "remind stale leads" job. The shape:
{
"schedules": [
{ "key": "nightly-sweep", "every": "24h",
"handler": { "kind": "webhook", "path": "/jobs/nightly" } }
]
}
every is a simple duration, not a cron expression. Allowed forms: 30m, 1h, 6h, 24h, 1d. The minimum interval is 5 minutes, and an app may declare at most 20 schedules. The handler is the same rule/webhook shape as events — a rule upserts a row in-core; a webhook pings your base_url (which then needs base_url set). See Limits & quotas for all automation caps.
The schedule tick payload
A schedule has no per-event data, so it fires a synthetic payload. The event name is schedule.<key> and the context is:
{ "schedule": "nightly-sweep", "appId": "clinic-crm", "firedAt": "2026-07-01T00:00:00Z" }
A webhook handler receives this wrapped in the usual envelope (data.schedule, data.firedAt). A rule handler can only map these three constants, so schedule rules are for constant/sweeping upserts. For anything that needs to read data and decide, use a webhook and the REST API.
Running a workflow on a timer
A schedule's handler can upsert a row or POST to your backend — neither of which can reach a connected integration. So "re-sync this sheet every 15 minutes" had no expressible form. It does now: omit the handler and declare a workflow whose trigger is schedule.<key>.
"schedules": [{ "key": "resync", "every": "15m" }],
"workflows": [{
"id": "resync-sheet",
"trigger": { "event": "schedule.resync" },
"steps": [{ "kind": "connector", "capability": "sheets.list_rows",
"binding": { "spreadsheet_id": "…", "sheet_name": "Leads" },
"args": { "offset": 0, "limit": 200 } }]
}]
The trigger payload is { schedule, appId, firedAt }. A schedule may have a handler and workflows — they are independent subscribers to the same tick, and a failing webhook won't cancel the workflow half.
A schedule that has neither a handler nor a matching workflow fails validation: it would fire into nothing, forever, silently.
3. workflows[] — durable, multi-step automations
A workflow is the heavy lifter: a persisted, retrying state machine. When its trigger fires, a run walks an ordered list of steps. Each step retries with backoff on failure, and the run survives restarts — its state lives in a workflow_runs table, so a delay step can wait minutes or hours and the run picks up exactly where it left off.
{
"workflows": [
{
"id": "appointment-followup",
"name": "Auto follow-up on new appointment",
"trigger": { "event": "object.appointment.created" },
"steps": [ /* … */ ]
}
]
}
Limits: at most 20 workflows per app, 20 steps each.
The trigger
trigger takes an event topic only in v1 — typically an object.<type>.created topic, or any of the event topics above. (There are no schedule or call triggers yet.)
⚠️ The trigger topic is NOT validated against the allowlist. Unlike
events[].on(which the server checks against the fixed topic list at upload),trigger.eventis only checked for being non-empty. A typo likeobject.appointmnt.createdpasses upload cleanly and then silently never fires — nothing matches it. Double-check the spelling against the topic table. If you trigger a workflow on a mid-call topic (call.turn, etc.), the app still needs thecalls:readscope for those events to reach it.
The 7 step kinds
A step is { "kind": "...", ...config } — kind selects the action and the rest of the object is that step's config (flattened). These are the only step kinds, with their exact config keys (the executor reads these literal keys — others are ignored):
kind | Config keys | Does |
|---|---|---|
create-object | object, data{} | insert a new row |
update-object | object, match, matchValue, data{} | update the newest row where match == matchValue |
delay | seconds or delay:"5m" | pause the run (durably) |
outbound-call | agentId, toNumber, variables{} | place an agent voice call |
send-message | channelId, to, body or template/language/variables[] | send a WhatsApp message |
http | url, method?, headers{}?, body?, timeoutSecs? | call an external API (SSRF-guarded) |
connector | capability, args{}?, binding{}?, connectionId?, provider? | call a connected integration with the org's own credentials |
update-objectgotcha: the match value is the literal keymatchValue(notset, not pulled fromdata), and the fields to write live underdata— there is nosetkey. BothmatchandmatchValuemust be non-empty strings or the step fails.
One copy-paste example per kind:
// create-object
{ "kind": "create-object", "object": "visit",
"data": { "patient_name": "{{trigger.data.patient_name}}", "phone": "{{trigger.data.phone}}" } }
// update-object — find the lead whose `phone` == the trigger's phone, then patch it
{ "kind": "update-object", "object": "lead",
"match": "phone", "matchValue": "{{trigger.data.phone}}",
"data": { "status": "called" } }
// delay — two equivalent forms
{ "kind": "delay", "seconds": 300 }
{ "kind": "delay", "delay": "5m" } // units: s/sec, m/min, h/hr, d/day
// outbound-call — `variables` is an OBJECT of {placeholder} context vars
{ "kind": "outbound-call",
"agentId": "5f0c…-uuid", "toNumber": "{{trigger.data.phone}}",
"variables": { "patient_name": "{{trigger.data.name}}" } }
// send-message — free-form body
{ "kind": "send-message", "channelId": "a1b2…-uuid", "to": "+14155550142",
"body": "Hi {{trigger.data.name}}, your appointment is confirmed." }
// send-message — approved WhatsApp template; `variables` is an ARRAY (ordered)
{ "kind": "send-message", "channelId": "a1b2…-uuid", "to": "+16465550198",
"template": "appt_reminder", "language": "en_US",
"variables": ["{{trigger.data.name}}", "10:30 AM"] }
// http — SSRF-guarded; method defaults to GET, timeoutSecs defaults to 15 (clamped 1–30)
{ "kind": "http", "url": "https://postman-echo.com/post", "method": "POST",
"headers": { "x-source": "telenow-workflow" },
"body": { "lead_phone": "{{trigger.data.phone}}" },
"timeoutSecs": 10 }
// connector — call a Google Sheets / CRM / calendar action the ORG connected
{ "kind": "connector", "capability": "sheets.update_row",
"binding": { "spreadsheet_id": "1AbC…", "sheet_name": "Leads" },
"args": { "row_number": "{{steps.0.result.row_number}}", "status": "No answer" } }
http allows methods GET | POST | PUT | PATCH | DELETE | HEAD. A body that is an object/array is sent as JSON (with content-type: application/json); a string body is sent verbatim. The request body is capped at 256 KB.
connector — reach the org's connected apps
http can only call public endpoints (HTTPS-only, private/loopback IPs blocked). connector is the way to reach a service the org has already authorized under Workplace → Integrations — Google Sheets, a CRM, a calendar — so a workflow can pull a list or write a result back without your app ever handling an OAuth token. It dispatches through the same vetted request templates as the agent-side connector tools.
| Key | Required | Notes |
|---|---|---|
capability | ✅ | The action id, e.g. sheets.list_rows, sheets.find_row, sheets.update_row, sheets.append. |
args{} | — | The action's own parameters. |
binding{} | — | The action's bind settings — which spreadsheet/tab, which pipeline. Same values an agent's connector-tool config carries. |
connectionId | — | Pin one specific connection (uuid). Omit it in a published app. |
provider | — | Narrow resolution to one provider id (e.g. google) without pinning a uuid. |
retryOnError | — | Default true. Set false for non-idempotent actions — see below. |
⚠️
retryOnErrorand duplicate writes. Like every step kind, a failedconnectorstep retries with backoff — up to 6 attempts. That re-sends the action. Forsheets.append(and any other create/POST action), a call that times out after the row was written appends it again on every remaining attempt: one transient blip, six duplicate rows. The platform can't tell which capabilities are safe to repeat, so for non-idempotent actions set"retryOnError": false— the step then returns{ ok: false, error }instead of retrying, and you branch on{{steps.N.ok}}. Update-style actions keyed on a row (sheets.update_row) are idempotent and safe to leave on the default.
Connections resolve per install, by capability. Ship "capability": "sheets.update_row" and the step binds to whatever the installing org connected — you never hard-code a connection id, and provider aliases (the per-service google_sheets and the unified google) both resolve. If no connection provides the capability, or several do, the step fails with a message telling the installer to connect one or to disambiguate with connectionId / provider.
⚠️ You must declare the
connection:<provider>scope, exactly as you would to inject that connection through the HTTP proxy — e.g."scopes": ["connection:google"]. Without it the step is refused at run time, after the run has already started. Publish preflight warns (WF_CONNECTOR_SCOPE) when a step names aprovideryou never consented to; when the step omitsprovider, the provider isn't knowable until run time, so nothing can warn you — declare the scope anyway.
Calls count against the org's integration quota, and a failure is recorded against the connection's health exactly like an agent-triggered call — so a broken connection surfaces in the same place.
Templating
Anywhere in a step's config you can interpolate values from the run context with {{...}} placeholders:
{{trigger.*}}— fields from the triggering event payload, e.g.{{trigger.data.phone}}(for object-create triggers the new row is underdata).{{steps.N.*}}— the output of an earlier step (0-indexed), e.g.{{steps.0.id}}.
A placeholder that is the whole string preserves the resolved value's type (so a data value can be a number or object); a placeholder embedded in surrounding text interpolates as a string. A missing path resolves to null (whole-string) or empty (embedded).
Step outputs — what {{steps.N.*}} gives you
Each step records an output object you can read in later steps:
kind | {{steps.N.*}} output |
|---|---|
create-object | { id, data } → e.g. {{steps.0.id}}, {{steps.0.data.phone}} |
update-object | { id, data } on a match, or null when no row matched |
outbound-call | { sessionId } |
send-message | { sent: true } |
http | { status, ok, body } |
connector | { ok: true, result } — result is the action's own response (e.g. a matched sheet row). With retryOnError: false, a failure yields { ok: false, error } instead of retrying |
For http, ok is true for a 2xx, and body is parsed JSON if the response is JSON, otherwise the raw text, truncated to ~8 KB before it is stored in the run context. Branch on {{steps.N.status}} / {{steps.N.ok}}, not on the (possibly truncated) body.
Example: appointment follow-up (no backend)
This is clinic-crm's real appointment-followup workflow — when an appointment is created, wait, then create a follow-up task row:
{
"id": "appointment-followup",
"name": "Auto follow-up on new appointment",
"trigger": { "event": "object.appointment.created" },
"steps": [
{ "kind": "delay", "seconds": 1 },
{
"kind": "create-object",
"object": "visit",
"data": {
"patient_name": "{{trigger.data.patient_name}}",
"phone": "{{trigger.data.phone}}",
"notes": "Auto follow-up task created by workflow"
}
}
]
}
Example: notify an external CRM over HTTP
clinic-crm's lead-http-notify workflow POSTs each new lead to an outside system. The http step is SSRF-guarded (HTTPS-only, host-checked, no redirects, size-capped):
{
"id": "lead-http-notify",
"name": "Notify external CRM on new lead",
"trigger": { "event": "object.lead.created" },
"steps": [
{
"kind": "http",
"method": "POST",
"url": "https://postman-echo.com/post",
"headers": { "x-source": "telenow-workflow" },
"body": {
"lead_name": "{{trigger.data.name}}",
"lead_phone": "{{trigger.data.phone}}",
"source": "{{trigger.data.source}}"
}
}
]
}
Example: import a whole sheet, a page at a time
sheets.find_row is a mid-call lookup — it returns at most 20 matches and never looks past row 1000. To read a list you want sheets.list_rows, which pages with offset/limit and tells you when to stop:
{
"kind": "connector",
"capability": "sheets.list_rows",
"binding": { "spreadsheet_id": "1AbC…", "sheet_name": "Leads" },
"args": { "offset": "{{trigger.data.offset}}", "limit": 200 }
}
Each row comes back as { row_number, cells }, plus headers, has_more and next_offset for the page after it. Keep the row_number with the contact: it is the row you write the outcome back to with sheets.update_row, and it stays correct even when the sheet has blank rows in the middle.
Example: call a lead, then write the outcome back to Google Sheets
The pattern behind a "sheet dialer": a new lead row triggers a call, and the result lands back in the customer's own spreadsheet — no OAuth handling in your app, no external adapter.
{
"//": "manifest must declare: \"scopes\": [\"connection:google\", \"calls:initiate\"]",
"id": "dial-and-log",
"name": "Call the lead, log the outcome to the sheet",
"trigger": { "event": "object.lead.created" },
"steps": [
{
"kind": "connector",
"capability": "sheets.update_row",
"binding": { "spreadsheet_id": "1AbC…", "sheet_name": "Leads" },
"args": { "row_number": "{{trigger.data.row_number}}", "status": "Dialing" }
},
{
"kind": "outbound-call",
"agentId": "5f0c…-uuid",
"toNumber": "{{trigger.data.phone}}",
"variables": { "customer_name": "{{trigger.data.name}}" }
},
{
"kind": "connector",
"capability": "sheets.update_row",
"binding": { "spreadsheet_id": "1AbC…", "sheet_name": "Leads" },
"args": {
"row_number": "{{trigger.data.row_number}}",
"status": "Called",
"session_id": "{{steps.1.sessionId}}"
}
}
]
}
Write "Dialing" before the call, not after — that first step is what stops a re-run or a duplicate trigger from dialing the same person twice.
This workflow does not observe the call's outcome.
outbound-callreturns as soon as the dial is placed, so step 2'ssessionIdsays a call started, not that anyone answered. For real answered/no-answer/busy write-back — including retries — dial through a campaign instead and let the campaign engine own pacing and retry; a workflow step cannot wait for a call to finish.
Engine behaviour: caps, retries & backoff
The workflow engine has hard limits and a single retry path. Knowing them keeps you from chasing "stuck" or "never-ran" runs (see also Limits & quotas):
| Behaviour | Value / rule |
|---|---|
| In-flight runs per (org, app) | 1000. Over this cap a new trigger is silently dropped — the run is never created. |
| Step retries before failing the run | 6 attempts, then the run is marked failed. |
| Retry backoff | 5s × 2^attempt, capped at 7 days. |
delay ceiling | clamped to a max of 7 days. |
http retry rule | retries on a 5xx or a transport error; a 4xx is Done (the run continues — branch on status/ok). |
update-object no-match | a success (output null), not a retry. |
| HTTP request body cap | 256 KB; stored response slice ~8 KB. |
Prerequisites for outbound-call and send-message
These two steps touch real money / real channels, so they have prerequisites. If a prerequisite is missing the step retries (with backoff) and then fails the run after 6 attempts:
outbound-callruns your org's prepaid/usage wallet gate — if the wallet can't cover the call it fails, backs off, and eventually fails the run.agentIdmust be a valid UUID of a real, app-bound agent.send-messageresolves the WhatsApp channel bychannelId, which must be an existing org channel UUID (an unknown channel retries forever, then fails). It needs either a non-emptybody, or an approvedtemplatepluslanguage(defaulten_US) and an array ofvariables.
Loop safety — create-object does NOT re-trigger
A workflow's create-object step writes via the raw db layer, which deliberately does not re-emit object.<type>.created. So a create-object step cannot re-trigger another workflow or rule that listens on that object's create event — your automations can't fan out into themselves through the workflow engine. (It does feed the realtime object-change stream for live dashboards.)
In contrast, inbound hooks and agent-tool / Data-API creates DO fire object.<type>.created. So the "no-backend lead loop" below chains via the inbound hook's create event, not via a workflow create-object.
4. inboundHooks[] — platform-hosted receivers
An inbound hook is a webhook Telenow hosts for you. A 3rd-party service (Meta lead-ads, Stripe, Calendly, Typeform, …) POSTs JSON to a Telenow URL, and the platform verifies it, maps the body into one of your objects, and fires object.<type>.created. No dev backend, no self-hosting — the receiver is part of the platform.
Each install gets a unique URL per hook:
POST https://api.telenow.ai/webhooks/app/<installationId>/<hookId>
You never have to assemble that URL yourself, and the API keys page does not show it.
Open the installed app from Apps and read the Inbound webhooks section: it lists the
ready-made URL for every declared hook, already carrying the right ?token= where the hook
uses the token form. (Owners and admins only — the URL carries the install signing secret.)
The same list is in Apps → the app → Details → Webhooks.
The shape:
| Field | Meaning |
|---|---|
id | hook id (key-safe) — the <hookId> URL segment |
object | which declared object to write into |
key | optional dedup field → upsert by it; absent → insert every call |
map | target_field ← dot-path into the POST JSON body |
verify | optional HMAC verification (see below) |
Map dot-paths
map source paths are dot-paths into the POST JSON body. A leading $. is allowed and stripped, and nested paths work (e.g. entry.0.field walks into an array element). A source that is null or absent is silently skipped — which interacts with the mapped:0 response below — so always map at least one field that the source always sends.
Verification — mandatory, no fail-open
The route is unauthenticated (the 3rd party has no Telenow JWT), so one of two checks gates every call:
- HMAC via a
verifyblock — the platform recomputes the signature over the raw body and compares it constant-time. Fields:header— which header carries the signature.algo—sha256(default) orsha1. Any value that is not exactlysha1is treated as sha256.prefix— stripped off the presented header value before comparing (e.g.sha256=). After stripping, the value is also.trim()ed.encoding—hex(default) orbase64.secret— the HMAC key. Absent ⇒ your install's own signing secret is used. A literalsecretsits in plaintext in the manifest, so prefer omitting it (use the install signing secret) unless the source signs with a fixed, known key you must hard-code.
- URL token — if you omit
verify, the URL must instead carry?token=<install signing_secret>. The unguessable URL plus token is the auth.
The payload is capped at 256 KB, rate-limited, and never fails open.
The Meta GET handshake
Meta (and similar providers) verify a webhook subscription by sending a GET to the same URL with hub.mode, hub.challenge, and hub.verify_token query params:
GET /webhooks/app/<installationId>/<hookId>?hub.mode=subscribe&hub.challenge=ABC123&hub.verify_token=YOUR_TOKEN
Telenow echoes back the raw hub.challenge value with 200 iff hub.verify_token constant-time-equals the install signing secret; otherwise it returns 403 (or 404 if the install is stale/disabled). So: in Meta's "Verify Token" field, paste your install's signing secret (from the app's API keys page). That makes the handshake pass and the subscription activate.
Inbound hook response codes
The receiver answers with a small JSON body. Read these to debug a 3rd-party integration:
| Status | Body | Meaning |
|---|---|---|
200 | { "ok": true, "id": "<row id>" } | success — a row was written and object.<type>.created fired |
200 | { "ok": true, "mapped": 0 } | your map matched nothing — accepted (so the source won't retry) but nothing was written. Your dot-paths are wrong, or every source value was null/absent. |
400 | { "error": "missing key field" } | a declared key mapped to an empty value — the upsert has no dedup key |
401 | { "error": "unauthorized" } | HMAC or ?token= verification failed |
404 | { "error": "not found" } / { "error": "no such hook" } | install is stale/disabled, or the <hookId> doesn't exist |
413 | { "error": "store full" } | the app's object store is at the row cap |
500 | { "error": "write failed" } | the row write itself errored |
On success the platform does an upsert-by-key (or a plain insert when no key) and fires object.<type>.created → your workflows/rules pick it up.
Example: Meta lead-ads → lead object
This is clinic-crm's real lead-intake hook. Meta POSTs a lead form; the platform verifies the x-hub-signature-256 HMAC, maps three fields into a lead, and upserts by phone:
{
"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"
}
}
]
}
When Meta POSTs { "full_name": "Sarah Chen", "phone_number": "+442079460958", "ad_id": "123" }, you get a deduped lead row — and an object.lead.created event fires. (Because verify has no secret, the HMAC key is the install's signing secret — the same value you paste as Meta's Verify Token.)
Event payload reference (per topic)
These are the fields each topic's payload carries (the bare context — a rule's map/when paths read these directly; a webhook receives them under data, a workflow trigger reads them under {{trigger.*}}).
call.started
| Field | Notes |
|---|---|
sessionId | the call's session id |
agentId | the agent that took the call |
userId | the user the session belongs to |
from | the telephony number (null for web/chat) |
variables | resolved {placeholder} context-variable values (null when none) |
startTime | call start timestamp |
call.ended
| Field | Notes |
|---|---|
sessionId | the call's session id |
agentId | the agent |
userId | the user |
durationSecs | call duration in seconds (camelCase) |
messageCount | number of conversation messages |
fromOrTo | the call's number — fromOrTo, NOT caller_number |
variables | resolved context-variable values (null when none) |
Apps get the LEAN
call.endedpayload. Recording + transcript enrichment is added only to customer webhooks, never to installed-app deliveries. If you need a recording, subscribe torecording.ready; if you need the summary, usecall.analyzed.
recording.ready
| Field | Notes |
|---|---|
recordingId | the recording id |
sessionId | the call's session id |
agentId | the agent |
durationSecs | recording duration in seconds |
recording | { id, url, expiresAt } — a signed, expiring URL |
There is no top-level
recordingUrl— the URL isrecording.url, withrecording.expiresAt. Theurl/expiresAtkeys may be absent (just{ id }) if no signed URL could be built.recording.readyis dispatched to apps only when the recording has anagent_id, and only to apps bound to that agent (a signed URL must never leak to apps on unrelated agents).
call.analyzed
Flattened analysis at the top level — see the exact-fields table above (summary, sentiment, disposition, topics, keywords, custom.*, caller_number, session_id).
object.<type>.created
| Field | Notes |
|---|---|
objectType | the object type that was created |
id | the new row's id |
data | the full row — your fields live under here (e.g. data.phone) |
Mid-call topics (call.turn, call.barge_in, call.silence, call.dtmf, call.node_entered)
These stream live and are reshaped before delivery: internal/sensitive fields are stripped (for example, call.node_entered has the node's system_prompt removed), any credential-shaped value is scrubbed, and then a sessionId is injected. So every mid-call payload is guaranteed to carry sessionId plus the topic's own (sanitised) fields. These require the calls:read scope.
WhatsApp events
If your app holds the whatsapp scope, it can subscribe to the three native-WhatsApp topics and receive an installing org's WhatsApp traffic — inbound messages, delivery-status updates, and account-health changes — with no per-customer setup. Declare them in events[] like any other topic:
{
"name": "order-bot",
"scopes": ["whatsapp"],
"base_url": "https://bot.example.com/telenow",
"events": [
{ "on": "whatsapp.message.received", "handler": { "kind": "webhook", "path": "/wa/received" } },
{ "on": "whatsapp.message.status", "handler": { "kind": "webhook", "path": "/wa/status" } },
{ "on": "whatsapp.account.health", "handler": { "kind": "webhook", "path": "/wa/health" } }
]
}
Each fires per installing org — you receive the WhatsApp events of every org that installed your app, so branch on the appId/install to know which one. The delivery is the standard { event, appId, data } envelope signed with X-Telenow-Signature (above); data is the WhatsApp topic payload — the full field lists (inbound message shape, status errors[], account-health value) are documented in WhatsApp → Webhooks & events. whatsapp.message.received/.status carry message content, so they require the whatsapp scope, enforced at dispatch.
Tie it together: the no-backend loop
The four sections compose into a complete inbound-lead pipeline that runs entirely on Telenow's runtime — you write only manifest JSON:
3rd-party POST → inboundHooks (verify + map) → lead row written
│
fires object.lead.created ← from the inbound hook
│
workflows[]
│
delay → outbound-call (your agent rings the lead)
Concretely: a Meta lead form lands via lead-intake, which writes a lead and — because inbound hooks fire object.<type>.created — emits object.lead.created; a workflow triggered on that topic waits a few minutes and then runs an outbound-call step so your agent calls the lead back, all without a single line of server code.
Note the loop chains through the inbound hook's create event. A workflow
create-objectstep does not re-fireobject.*.created, so you can't (and don't want to) chain workflow→workflow that way — see Loop safety above. clinic-crm'slead-callbackworkflow does the row-write half of this; swap itscreate-objectstep for anoutbound-callstep to ring the lead directly.
Next
- External backends — verifying
X-Telenow-Signature, the app-key REST API, session tokens. - Bundled agents & KBs — the agents your
outbound-callsteps and handoffs use. - Data & objects — the object store, fields, relations and views that events and hooks write into.
- Limits & quotas — every automation cap (runs, retries, schedules, hook body size).
- Manifest reference — every field of
telenow.app.json.