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:

SectionFires 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 passedNo (a rule, or a workflow) or Yes (a webhook)
workflows[]a trigger event fires — then runs many durable stepsNo
inboundHooks[]a 3rd-party service POSTs to a URL you give themNo

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's map/when paths resolve against the bare event context (fields at the top level). A webhook receives the whole EventRequest envelope { event, appId, data: <context> }, so the very same fields live one level deeper under data.. 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.

TopicWhen it firesExtra scope
call.starteda call connects
call.endeda call hangs up
call.analyzedpost-call analysis (summary, sentiment, disposition) is ready
recording.readythe call recording is stored
call.turneach speaker turn, mid-callcalls:read
call.barge_inthe caller interrupts the agent, mid-callcalls:read
call.silencea silence threshold is crossed, mid-callcalls:read
call.dtmfthe caller presses a keypad digit, mid-callcalls:read
call.node_enteredthe call enters a flow node, mid-callcalls:read
object.<type>.createda row of <type> is created (e.g. object.lead.created)
object.*.createdwildcard — any object type is created
charge.settleda call's charge is settled (~2-3 min after hangup)billing:read
call.completedany call finishes — including one nobody answeredcalls:read
campaign.call.completeda campaign target reaches a terminal state — including one nobody answeredcampaigns:write
whatsapp.message.receivedan inbound WhatsApp message arrives (full content + raw Meta payload)whatsapp
whatsapp.message.statusa WhatsApp message you sent changes state (sent/delivered/read/failed)whatsapp
whatsapp.account.healtha WABA quality / limit / verification / review changewhatsapp

call.ended fires 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.completed instead: 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.completed remains 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.choicesWhat 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.topicsneeds 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 traceWhat happened
delivered · handleryour call.completed handler was dispatched
delivered · storeda postCall row was written; no handler declared
delivered · stored+handlerboth
delivered · handler (org-wide)reached via calls:read:org — not bound, so nothing was stored
delivered · no-opnothing 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:

FieldMeaning
do"upsert" (the only action)
objectwhich declared object to write into
keythe field used to dedupe the upsert (e.g. "phone")
whenoptional single predicate — run only if it holds
maptarget_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:

PathWhat it is
summarythe call summary text
sentimentoverall sentiment
dispositionthe call disposition/outcome label
topicsarray of detected topics
keywordsarray of detected keywords
custom.<key>each custom analysis field, nested under custom (e.g. custom.condition)
caller_numberthe telephony caller number (added by the platform; may be null)
session_idthe 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_number and session_id are injected by Telenow. There is no outcome, caller_name, or agent_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.

HandlerWhat it receivesWhere the fields live
rule (in-core upsert)the bare event contexttop 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 triggerthe run context's trigger is the raw payloadfor 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.event is only checked for being non-empty. A typo like object.appointmnt.created passes 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 the calls:read scope 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):

kindConfig keysDoes
create-objectobject, data{}insert a new row
update-objectobject, match, matchValue, data{}update the newest row where match == matchValue
delayseconds or delay:"5m"pause the run (durably)
outbound-callagentId, toNumber, variables{}place an agent voice call
send-messagechannelId, to, body or template/language/variables[]send a WhatsApp message
httpurl, method?, headers{}?, body?, timeoutSecs?call an external API (SSRF-guarded)
connectorcapability, args{}?, binding{}?, connectionId?, provider?call a connected integration with the org's own credentials

update-object gotcha: the match value is the literal key matchValue (not set, not pulled from data), and the fields to write live under data — there is no set key. Both match and matchValue must 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.

KeyRequiredNotes
capabilityThe 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.
connectionIdPin one specific connection (uuid). Omit it in a published app.
providerNarrow resolution to one provider id (e.g. google) without pinning a uuid.
retryOnErrorDefault true. Set false for non-idempotent actions — see below.

⚠️ retryOnError and duplicate writes. Like every step kind, a failed connector step retries with backoff — up to 6 attempts. That re-sends the action. For sheets.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 a provider you never consented to; when the step omits provider, 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 under data).
  • {{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-call returns as soon as the dial is placed, so step 2's sessionId says 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):

BehaviourValue / 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 run6 attempts, then the run is marked failed.
Retry backoff5s × 2^attempt, capped at 7 days.
delay ceilingclamped to a max of 7 days.
http retry ruleretries on a 5xx or a transport error; a 4xx is Done (the run continues — branch on status/ok).
update-object no-matcha success (output null), not a retry.
HTTP request body cap256 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-call runs your org's prepaid/usage wallet gate — if the wallet can't cover the call it fails, backs off, and eventually fails the run. agentId must be a valid UUID of a real, app-bound agent.
  • send-message resolves the WhatsApp channel by channelId, which must be an existing org channel UUID (an unknown channel retries forever, then fails). It needs either a non-empty body, or an approved template plus language (default en_US) and an array of variables.

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:

FieldMeaning
idhook id (key-safe) — the <hookId> URL segment
objectwhich declared object to write into
keyoptional dedup field → upsert by it; absent → insert every call
maptarget_field ← dot-path into the POST JSON body
verifyoptional 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 verify block — the platform recomputes the signature over the raw body and compares it constant-time. Fields:
    • header — which header carries the signature.
    • algosha256 (default) or sha1. Any value that is not exactly sha1 is treated as sha256.
    • prefix — stripped off the presented header value before comparing (e.g. sha256=). After stripping, the value is also .trim()ed.
    • encodinghex (default) or base64.
    • secret — the HMAC key. Absent ⇒ your install's own signing secret is used. A literal secret sits 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:

StatusBodyMeaning
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

FieldNotes
sessionIdthe call's session id
agentIdthe agent that took the call
userIdthe user the session belongs to
fromthe telephony number (null for web/chat)
variablesresolved {placeholder} context-variable values (null when none)
startTimecall start timestamp

call.ended

FieldNotes
sessionIdthe call's session id
agentIdthe agent
userIdthe user
durationSecscall duration in seconds (camelCase)
messageCountnumber of conversation messages
fromOrTothe call's number — fromOrTo, NOT caller_number
variablesresolved context-variable values (null when none)

Apps get the LEAN call.ended payload. Recording + transcript enrichment is added only to customer webhooks, never to installed-app deliveries. If you need a recording, subscribe to recording.ready; if you need the summary, use call.analyzed.

recording.ready

FieldNotes
recordingIdthe recording id
sessionIdthe call's session id
agentIdthe agent
durationSecsrecording duration in seconds
recording{ id, url, expiresAt } — a signed, expiring URL

There is no top-level recordingUrl — the URL is recording.url, with recording.expiresAt. The url/expiresAt keys may be absent (just { id }) if no signed URL could be built. recording.ready is dispatched to apps only when the recording has an agent_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

FieldNotes
objectTypethe object type that was created
idthe new row's id
datathe 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-object step does not re-fire object.*.created, so you can't (and don't want to) chain workflow→workflow that way — see Loop safety above. clinic-crm's lead-callback workflow does the row-write half of this; swap its create-object step for an outbound-call step to ring the lead directly.


Next