Integration connectors API
Connect a vendor account — a store, a CRM, a database, a WhatsApp number — to your workspace over HTTP, so an agent can use it mid‑call. What the dashboard's Integrations page does with connections — connect, update, test, remove — a server can now do with an API key. Running a connector action is not part of it — see What is not here.
This is the surface to build against if you are shipping an installable app (a Shopify app, a marketplace listing) or provisioning workspaces from your own system. Your installer already holds the merchant's credentials at the end of its own OAuth; there is no reason to then tell them to open Telenow and paste a token in by hand.
Authentication
Key‑authed, under /api/v1. The organization comes from the key itself — see Authentication:
X-API-Key: your_key
A key for one workspace cannot be pointed at another — there is no org id in these paths to change.
Roles. On this surface the API key's own role decides. Reads are open to any key. Every mutation — create, update, delete, test, authorize — needs a key whose role is owner, admin or developer. A viewer key can list connections but not touch them, and cannot run a verification either: a verification spends a real request against the vendor using your stored credential.
The dashboard applies a different rule to people signed in to it: only an owner or admin can add, edit or remove a connection there, and any member can run Test. So a developer can manage connections with a developer key, but not from the Integrations page.
Responses are flat JSON, like the rest of /api/v1 — no { success, data } wrapper.
Secrets are write‑only
A credential field marked secret is encrypted at rest and never returned. Read a connection back and you get a fixed mask (••••••••) in its place.
That has one consequence worth knowing before you write the client:
- On update, sending the mask back means keep the stored secret. So the read‑edit‑write round trip works: fetch a connection, change one setting, send the whole thing back.
- On create, the mask is refused, because there is nothing stored to keep. Send the real value.
Step 1 — find out what a connector needs
GET /api/v1/integrations/providers
{
"providers": [
{
"providerId": "shopify",
"name": "Shopify",
"category": "ecommerce",
"authKind": "api_key",
"docsUrl": "https://shopify.dev/docs/api/admin-rest",
"connect": {
"credentials": [
{ "key": "api_token", "label": "Admin API access token",
"required": true, "secret": true,
"help": "Shopify admin → Settings → Apps and sales channels → Develop apps → your app → Admin API access token (starts with shpat_)." }
],
"settings": [
{ "key": "store_domain", "label": "Store domain", "required": true,
"placeholder": "mystore.myshopify.com" }
]
},
"capabilities": [
{ "capability": "order.lookup", "name": "Look up an order", "description": "…" },
{ "capability": "customer.lookup", "name": "Look up a customer", "description": "…" }
]
}
]
}
connect.credentials and connect.settings are exactly the keys to send in step 2 — build your form, or your install step, from this rather than hard‑coding field names. GET /api/v1/integrations/providers/{providerId} returns one.
Connectors set up by their own dashboard flow are not listed here, and creating one is refused. WhatsApp on a Telenow number is the current example: it binds a WhatsApp Business Account that a credentials body cannot carry, so it is set up on the WhatsApp page instead.
authKind tells you which of the two flows below applies: api_key, basic and builtin connect in one call; oauth2 needs a consent round trip.
Step 2 — connect
POST /api/v1/integrations/connections
X-API-Key: your_key
Idempotency-Key: 0f4a… ← see below
Content-Type: application/json
{
"providerId": "shopify",
"label": "Acme Store",
"credentials": { "api_token": "shpat_xxxxxxxxxxxx" },
"settings": { "store_domain": "acme.myshopify.com" }
}
{
"connection": {
"id": "8f2c…", "providerId": "shopify", "label": "Acme Store",
"status": "active",
"credentials": { "api_token": "••••••••" },
"settings": { "store_domain": "acme.myshopify.com", "account": "Acme Store" },
"capabilities": ["order.lookup", "customer.lookup", "product.search", "checkout.create_link", "order.update"]
},
"verification": { "ok": true, "warnings": [] },
"next": null
}
It verifies before it answers. The connector's own check runs against the vendor, and the result comes back in verification. The dashboard makes this two steps because a person is standing there to press Test; your installer has nobody to press it, and "did that token actually work?" is the entire question it is asking. Pass "verify": false to skip it.
A failed verification still returns 201:
{
"connection": { "id": "8f2c…", "status": "error", "lastError": "401 Unauthorized" },
"verification": { "ok": false, "error": "401 Unauthorized", "warnings": [] }
}
The connection exists and holds your credentials, so retrying the create would only leave a second broken one behind. Fix it with PATCH, not by posting again.
One connection per account
A workspace may connect the same connector more than once — a merchant with two stores needs two — but not twice to the same account. Connect Shopify to acme.myshopify.com a second time and you get a 409 that names the connection already holding it:
{
"success": false,
"error": "this workspace already connects Shopify to acme.myshopify.com — update connection 8f2c… instead of connecting it a second time"
}
That id is there so a retry has somewhere to go: PATCH it rather than posting again.
What counts as "the same account" is per connector — the store domain for Shopify, the sender number for a WhatsApp connector, the instance URL for Salesforce, the sub-account Location ID for GoHighLevel, the environment URL for Dynamics 365, the Zendesk URL for Zendesk. Connectors with no such identifier (Razorpay, Stripe, HubSpot, Intercom, Airtable) are unconstrained, because for them a second connection is a second account and nothing distinguishes it from a duplicate. OAuth connectors are unconstrained too: their identity is the account the user consents with, which does not exist until consent lands — and Cal.com is unconstrained for the same reason even though it takes an API key, because the account it belongs to is only known once the key has been verified.
Send an Idempotency-Key
The 409 is a backstop, not the mechanism. It tells you a duplicate happened; it does not make your retry succeed.
So send Idempotency-Key: <uuid> on every create. A retry carrying the same key and the same body replays the original 201 — the same connection id, as if the first call had simply returned — instead of connecting again or failing. A same‑key‑different‑body retry is refused outright. The key is honoured for 24 hours.
If you have lost the id — a reinstalled app, a rebuilt worker — find it instead of guessing:
GET /api/v1/integrations/connections?providerId=shopify
OAuth connectors
For authKind: "oauth2" (Google, Salesforce, Zoho CRM) there is no headless grant, and there should not be: the point of a consent screen is that a human at the vendor approves what you are about to be able to do with their account.
Two CRMs are token-based rather than OAuth and so can be connected headlessly. GoHighLevel takes a Private Integration Token plus the sub-account Location ID. Dynamics 365 takes the tenant ID, client ID and secret of an app registration in the customer's own Entra tenant, plus the environment URL — Telenow then mints and refreshes the short-lived Dataverse token itself, so nothing long-lived from Microsoft is stored and no browser round-trip is involved.
Stripe, Zendesk and Cal.com are token-based too, so a platform that already holds the merchant's credentials can provision them in one POST. Stripe takes a secret or restricted key and no settings. Zendesk takes the agent email and an API token, plus the workspace's https://<subdomain>.zendesk.com URL — the host is validated, so a help-centre or vanity domain is refused rather than silently pointed somewhere else. Cal.com takes just the cal_live_… key; the account's username, email and timezone are read back from the vendor during verification and stored on the connection, so connect it with verify on — a Cal.com connection created without verification is missing the values its reschedule action needs.
POST /api/v1/integrations/connectionswith just{"providerId": "google"}— these connectors need no credentials in the body. The connection is createddisconnected, andverificationisnull.POST /api/v1/integrations/connections/{id}/authorize→{ "authorizeUrl": "https://accounts.google.com/o/oauth2/v2/auth?…", "returnsTo": "https://app.telenow.ai/workplace/integrations", "pollUrl": "/api/v1/integrations/connections/8f2c…" }- Redirect your end user to
authorizeUrl. - They consent. The vendor redirects them to
returnsTo— the Telenow dashboard, not your app. There is no return‑URL parameter, on purpose: a redirect target supplied by whoever holds an API key is an open redirect. Tell your user where they are about to land. - Poll
pollUrluntilstatusisactive.
The consent link is short‑lived. Mint it when the user is ready to click, not minutes ahead.
Rotating a credential
PATCH /api/v1/integrations/connections/{id}
{ "credentials": { "api_token": "shpat_the_new_one" } }
Partial: an omitted field keeps its stored value. Re‑verifies by default, for the same reason create does — a rotation that quietly failed looks exactly like one that worked, right up until a live call needs it.
providerId cannot change. A connection's credentials are shaped by its connector, so re‑pointing one is a delete and a fresh connect.
Moving a connection to a different account of the same connector is fine — repoint store_domain at your second store — unless another connection already holds that account, which is the same 409 as above.
PUT is accepted as an alias and behaves identically — it is still a partial update, not a replace.
Re‑testing
POST /api/v1/integrations/connections/{id}/test
{ "ok": true, "warnings": [] }
Always 200 — whether the credentials work is in ok, not in the status code. The result is recorded on the connection, so the dashboard shows what your job found. warnings carries configuration that is wrong but not fatal.
Disconnecting
DELETE /api/v1/integrations/connections/{id}
Refused with 409 while an agent or a WhatsApp channel still uses it, and the message names them.
Take that one seriously in a script. Removing a connection cascades the WhatsApp channel behind it and every message on it, including numbers that were removed but are still restorable. There is no undo, and a loop deleting connections has no human reading the warning.
Rate limits
Per API key, per hour: 600 writes and 600 verifications, counted separately — a write is a database row, a verification is a request to someone else's API using your credential. Both are runaway guards, far above any real provisioning flow. POST /connections with verification on spends one of each.
What is not here
Running a connector action over HTTP — looking up an order, creating a checkout link — is not part of this API. Connector actions execute inside a call, as agent tools, where they are metered, logged against the call, and subject to the confirmation rules that money‑moving actions require. The dashboard's action bench is a build‑time tool for exactly that reason and stays behind a dashboard session.
If you want your agent to look up orders, connect the store here and add the capability as a tool on the agent.
Remote MCP servers
/api/orgs/{orgId}/mcp
Helpers for connecting an agent to a remote Model Context Protocol server. User JWT only — the organization comes from the path, so no X-Org-Id is needed. Membership is enough for the reads and for POST /verify; starting a sign-in (POST /oauth/start) and disconnecting (DELETE /connections/{id}) need owner or admin.
| Method | Path | Purpose |
|---|---|---|
POST | /verify | Probe a server and list its tools |
GET | /directory | Curated pick-list of known servers |
POST | /oauth/start | Begin OAuth sign-in for a server that needs it |
GET | /connections | This org's authenticated MCP OAuth connections |
DELETE | /connections/{id} | Disconnect one |
GET | /api/public/mcp/oauth/callback | The OAuth return leg — no JWT, authenticated by signed state |
POST /verify returns one of two shapes
{ "url": "https://mcp.example.com", "token": "…", "mcpConnectionId": "…" }
token and mcpConnectionId are both optional — pass mcpConnectionId to probe using an existing OAuth connection instead of a static token. Passing one that has not completed sign-in is a 400.
The response is either the tool list or a request to authenticate. Handle both:
{ "success": true, "data": { "tools": [ … ] } }
{ "success": true, "data": { "needsAuth": true, … } }
A server that requires OAuth is not an error — it returns 200 with needsAuth, and you then call POST /oauth/start with { url, label? } and send the user to the authorizeUrl you get back.
How a connected server reaches a call
MCP tools are stored inline on the agent, as entries in metadata.tools with kind: "mcp" — they are not a separate resource. A tool bound to an OAuth server carries config.mcpConnectionId, and the dispatcher injects a fresh token at call time rather than storing one on the agent. So a token refresh on the connection reaches every tool bound to it, on every agent, without editing any of them.
That reference is also why you can't pull a connection out from under an agent: DELETE /connections/{id} is refused with 409 while any agent's metadata.tools still carries its mcpConnectionId, and the message names those agents. Remove the tools from them first.
Call destinations
/api/orgs/{orgId}/destinations
Configure call destinations — where each call is written once it ends. Everything the dashboard's destination editor does is available over the API. Authenticate with a user JWT + X-Org-Id. Reads need membership; writes need owner, admin or developer.
| Method | Path | Purpose |
|---|---|---|
GET | /kinds | What a call can be written to |
GET | /fields | What a call can be written about — the mapping surface |
GET | /tables?connectionId= | Tables in a connected database |
GET | /columns?connectionId=&table= | Columns in one of those tables |
GET | /?agentId= | This org's destinations; agentId narrows to one agent |
POST | / | Create or update one |
DELETE | /{id} | Remove one |
kinds and fields are endpoints rather than constants you hard-code, on purpose: adding a destination kind is a migration on our side, not a release on yours. Read them at runtime and your integration picks up new destinations without a deploy.
GET / deliberately excludes campaign write-backs. Those are configured in the campaign's own importer, and listing them here would offer two places to edit one thing.
Creating a destination
curl -X POST https://api.telenow.ai/api/orgs/$ORG/destinations \
-H "Authorization: Bearer $JWT" -H "X-Org-Id: $ORG" \
-H "Content-Type: application/json" \
-d '{
"connectionId": "…",
"capability": "…",
"name": "Leads sheet",
"mapping": { "their_field": "our.field.ref" },
"firesOn": ["answered"],
"scope": "non_campaign",
"chainMode": "final_only"
}'
| Field | Required | Default | Notes |
|---|---|---|---|
connectionId | Yes | — | The connected integration or database |
capability | Yes | — | What this destination does, from /kinds |
mapping | Yes | — | { "<their field>": "<our field ref>" }. Field refs come from /fields |
id | No | — | Present = update, absent = create |
agentId | No | org-wide | Omit for a default that applies to every agent |
name | No | Call log | Display label |
config | No | {} | Kind-specific settings |
firesOn | No | all four | Any of answered, no-answer, busy, failed |
scope | No | all | all · non_campaign · campaign_only |
chainMode | No | every_call | See below |
enabled | No | true |
What each field ref holds is listed under Call destinations → What you can write.
An unknown value in firesOn, scope or chainMode is a 400 naming the valid set.
chainMode — the one that changes your numbers
A call-back chain is N calls for what the customer experienced as one request. every_call writes each of them: full history, but a report that counts rows counts that lead N times. final_only writes just the call that ended the thread.
The default is every_call so that no existing destination changes behaviour — but if your destination feeds a report that counts leads, final_only is almost certainly what you want.
Mappings that need post-call analysis
A mapping may reference fields the post-call analysis produces — the summary, the disposition, your own extracted customFields. When it does, the write-back waits for analysis to finish before firing, bounded by a 30-minute grace period after which it writes without them, because never writing is worse than writing without a summary.
Two consequences worth knowing: analysis must be enabled on the agent or those fields are always blank, and a call that falls under the agent's minimum-substance threshold is never analysed at all, so it takes the full grace period before writing.