Integration connectors

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. Everything the Integrations page in the dashboard can do, a server can now do with an API key.

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. 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.

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.

  1. POST /api/v1/integrations/connections with just {"providerId": "google"} — these connectors need no credentials in the body. The connection is created disconnected, and verification is null.
  2. 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…" }
    
  3. Redirect your end user to authorizeUrl.
  4. 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.
  5. Poll pollUrl until status is active.

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 + X-Org-Id; membership is enough for all of them.

MethodPathPurpose
POST/verifyProbe a server and list its tools
GET/directoryCurated pick-list of known servers
POST/oauth/startBegin OAuth sign-in for a server that needs it
GET/connectionsThis org's authenticated MCP OAuth connections
DELETE/connections/{id}Disconnect one
GET/api/public/mcp/oauth/callbackThe 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 disconnecting a connection immediately stops every tool bound to it, on every agent, without editing any of them.