Give a third party read access

Embed
Preview of the card
<a href="https://forumulate.com/guides/give-a-third-party-read-access"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/988ca5f3-4acc-4082-9d17-04bd7c82b895/social-card.svg?v=3e7ae33eec8b" alt="Give a third party read access" width="600" height="315"></a>

A chat widget that answers from your docs, a help desk that suggests articles, a retrieval pipeline you run yourself — each needs to READ some of your content, and none of them should hold a credential that can do anything else. An integration token is that credential.

It is:

  • owned by the organization, not by the person who made it, so the vendor's sync does not stop the day that person leaves;

  • read-only — it cannot write, post, publish, ask our AI on your account, or reach a single management route;

  • limited to the spaces you name, and refused (403) on every other;

  • visible: the token list shows when it was last used, and from where.

It works with any vendor. The audience you give it (intercom, zendesk, chatly, custom, …) is a label for the people reading the list, not a switch in our code.

Mint one

You need org.manage_integrations (owners and admins have it), and an admin token if you do it from a script.

POST /orgs/{orgId}/integration-tokens
{
  "name": "Help-centre bot",
  "audience": "intercom",
  "spaceIds": ["docs", "guides"],
  "capabilities": ["kb.read", "search", "llms"],
  "expiresInDays": 90
}
  • spaceIds takes space uuids or slugs; the answer carries uuids.

  • capabilities defaults to all three:

    • kb.read — the manifest and the chunked corpus, and the published pages of a protected space through the public reader;

    • search — ranked retrieval;

    • llms — the space's llms.txt.

  • expiresInDays defaults to 90 and is at most 365: no new key lives longer than a year, so null (never) is a 422 reason: max_key_lifetime. The organization's security.max_token_lifetime_days can cap it further; an explicit value over the cap is a 422, and the default is shortened to fit it. The organization's owners are e-mailed 30, 7 and 1 days before it expires. The consumer can see it too: the manifest's expiresAt (ISO 8601) is the token's expiry, so a sync that reads the manifest each run can warn weeks ahead. To have nothing expire at all, give the partner a partner link instead (kb:read, limited to chosen space_ids): it mints its own one-hour tokens, and expiresAt is then the link's own expiry, usually none. See Partner links and token exchange.

The answer is { integrationToken, token, data }. token is the secret, and this is the only time it is readable — it is stored hashed. It looks like forumulate_live_int_…, a shape GitHub's secret scanning recognises, so one committed to a public repository is reported to us and revoked.

From the CLI:

forumulate integration-tokens create "Help-centre bot" \
  --audience intercom --spaceIds docs,guides --capabilities kb.read,search
forumulate integration-tokens list
forumulate integration-tokens list --include revoked

What it can read

Everything is under /connector/kb, with Authorization: Bearer <token>:

Request

Needs

Answers

GET /connector/kb/manifest

—

whose token it is (organizationId, organizationName), when it expires (expiresAt), the spaces it reads (spaces[]), its capabilities, the chunk policy, every endpoint

GET /connector/kb/chunks?spaceId=…

kb.read

retrieval-grade chunks, paged, resumable (sync.checkpoint)

GET /connector/kb/search?q=…

search

ranked chunks across every granted space, or one with ?spaceId=

GET /connector/kb/llms.txt?spaceId=…

llms

the space's llms.txt

?spaceId= takes a uuid or a slug. A token over several spaces must name one on chunks and llms.txt (a cursor walks one space); without it those answer 422 naming spaceId. A space the token does not cover is 403 with reason: space_not_granted, whatever the space's visibility.

Protected spaces through the public reader. The same bearer, with kb.read, opens the published pages of a private space it names on the public read API (/public/orgs/{orgId}/spaces/{spaceId}/…), exactly as a member's session would — published content only; drafts are never reachable this way. Responses opened by the token are private, no-store.

Everything else refuses it. POST /connector/kb/ask, questions and suggestions answer 403 reason: read_only — those belong to a connector token, bound to one connector the space owner installed. Any management route answers 401 with reason: wrong_audience and the token's id. The one write it may make is its own push subscription, below.

Push instead of polling

The vendor subscribes ITSELF — no one in your organization pastes a URL. With the integration token (it needs kb.read) or a partner link's access token (kb:read):

Request

Answers

POST /connector/kb/webhook

{url, spaceIds?} → 201 {webhook, secret}, the signing secret ONCE

GET /connector/kb/webhook

{webhook}: url, events, spaceIds, active, health — never the secret; 404 not_subscribed when there is none

DELETE /connector/kb/webhook

unsubscribes; deliveries stop

POST /connector/kb/webhook/rotate-secret

{webhook, secret, previousValidUntil}: a new secret ONCE; the old one keeps signing beside it for 24 hours

curl -s https://forumulate.com/api/connector/kb/webhook \
  -H "Authorization: Bearer $INTEGRATION_TOKEN" -H "Forumulate-Version: 2027-03-01" \
  -H 'content-type: application/json' \
  -d '{"url": "https://hooks.chatly.example/forumulate", "spaceIds": ["docs"]}'
  • What it hears: kb.changed for the credential's spaces — all of them, or the spaceIds subset (uuids or slugs; one it does not cover is 422 space_not_granted naming every extra) — and its OWN expiry warning: integration_token.expiring about this token, or partner_link.expiring about this link. Never another vendor's, never any other event. page.published is an organization event and is not delivered to a credential's subscription: kb.changed is the credential's page signal. It fires when pages in its spaces are published, unpublished, moved or trashed (a publish is one of them), and its cursor reads exactly what changed.

  • One per credential. POST again with the same url and you get 200, the same id and secret: null — a retried setup call is harmless. A different url or spaceIds updates that endpoint in place (still no new secret; use rotate-secret).

  • An ordinary webhook underneath: url must be https and publicly reachable; every delivery is signed (x-forumulate-signature: v1,sha256=… over <x-forumulate-timestamp>.<body>), retried, and logged — see Webhooks. GET /connector/kb/manifest lists it as endpoints.webhook and adds webhook to capabilities when the credential may subscribe.

  • Your owners see it. It appears in the organization's Webhooks list, labelled "Owned by integration token …" (or "… partner link …", "… connector …"), with its deliveries; an owner can pause or delete it there.

  • A connector token (fmwc_…) subscribes for its connector, not itself, so the endpoint survives every token rotation. It hears kb.changed only (there is no connector expiry event). Deleting the connector deletes it; switching the connector's knowledge binding off stops deliveries until it is on again.

Verifying a delivery

Two headers, exactly:

x-forumulate-timestamp: 1763078400
x-forumulate-signature: v1,sha256=5f2c…e9
  • The timestamp is Unix seconds, in its own header.

  • The signed payload is <x-forumulate-timestamp>.<raw body>: the header value, one ., then the body bytes as received, before any JSON parsing.

  • The digest is HMAC-SHA256 with the secret POST /connector/kb/webhook answered, written as lowercase hex.

  • A credential's own subscription is signed v1 only. While a rotated secret is still in its overlap window, the header carries one signature per live secret, joined by , (v1,sha256=<new>, v1,sha256=<old>). Accept the delivery if any one of them matches.

  • Refuse a timestamp more than 5 minutes from your clock.

const [t, sigs] = [req.headers['x-forumulate-timestamp'], req.headers['x-forumulate-signature']];
const want = 'v1,sha256=' + createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const ok = sigs.split(', ').some((s) => s.length === want.length && timingSafeEqual(Buffer.from(s), Buffer.from(want)));

Errors on the push routes

Every refusal is the API's one envelope. code is the class, reason is the specific case (when there is one), and requestId is what to quote to us:

{ "error": { "code": "validation.failed", "message": "…", "reason": "space_not_granted",
             "details": { "spaceIds": ["Not covered by this credential: billing"] },
             "docs": "https://forumulate.com/guides/…", "requestId": "…" },
  "requestId": "…" }

Status

code

reason

When

401

auth.unauthenticated

no_credentials, malformed, unknown_token, wrong_audience

No credential, or not one this surface takes (a personal token is wrong_audience)

401

auth.unauthenticated

revoked, expired

The token or its partner link was revoked or ran out; mint or ask for a new one

403

rbac.forbidden

scope_missing

The credential lacks kb.read (integration token), kb:read (partner link) or chunks (connector token)

403

rbac.forbidden

space_not_granted

A partner link that names no spaces

404

not_found

not_subscribed

GET/DELETE/rotate with no subscription

422

validation.failed

space_not_granted

spaceIds names a space the credential does not cover; details.spaceIds lists each

422

validation.failed

—

A bad body: url not https or not publicly reachable, an unknown key; details names each field

429

rate_limited

—

Too many calls; Retry-After says when

There is no event-name field to get wrong: a credential's subscription is always kb.changed plus its own expiry warning.

The unauthorized_client / invalid_client answers belong to the token endpoint (POST /v1/oauth/token), not to these routes. They use the OAuth shape ({error, error_description}), described in Partner links.

Per vendor

The vendor side is configured in the vendor's own product, which we do not control and which changes without notice; the steps below say where the setting lives today, and what to paste. Every one of them takes the same two things: a URL, and the token as a Bearer header.

Chatly

Chatly's knowledge sources accept a Forumulate source natively: choose Forumulate, paste the token, and Chatly reads GET /connector/kb/manifest to discover your spaces. Mint the token with audience: chatly.

Intercom

Intercom's Fin reads external content through Knowledge → Sources → Add content → Website / Custom sources, or through its content import API. Point a small sync job (below) at /connector/kb/chunks and push each chunk as an external article, keyed by chunkId. Mint with audience: intercom.

Zendesk

Zendesk's generative search and bots take external content through the Help Center federated-search API (POST /api/v2/guide/external_content/records). Run the same sync job, writing one record per chunk with the chunk's public URL. Mint with audience: zendesk.

Crisp

Crisp's MagicReply / knowledge base reads website sources and imported articles. Either give it the public URLs (no token needed for public spaces), or use the sync job to import articles from /connector/kb/chunks for a protected space. Mint with audience: crisp.

Help Scout, Freshchat, Front, Drift and others

Each of these has an "external knowledge" or "custom source" import in its AI settings. Where it can call a URL with a header, give it /connector/kb/search?q= or /connector/kb/llms.txt?spaceId=…; where it can only import, use the sync job. The token, the endpoints and the refusals are the same.

A custom retrieval pipeline

const base = 'https://forumulate.com/api/connector/kb';
const headers = { authorization: `Bearer ${process.env.FORUMULATE_KB_TOKEN}` };

const manifest = await (await fetch(`${base}/manifest`, { headers })).json();
for (const space of manifest.spaces) {
  let cursor: string | undefined;
  do {
    const url = new URL(`${base}/chunks`);
    url.searchParams.set('spaceId', space.id);
    if (cursor) url.searchParams.set('cursor', cursor);
    const page = await (await fetch(url, { headers })).json();
    for (const item of page.items) await upsert(space, item.chunks); // your index
    cursor = page.page.nextCursor ?? undefined;
  } while (cursor);
}

Store sync.checkpoint per space and pass it back as cursor for an incremental sync. Store chunkId only if you are ready to re-embed when chunkPolicy.version changes.

Revoke and rotate

DELETE /orgs/{orgId}/integration-tokens/{id}
forumulate integration-tokens revoke "Help-centre bot"

Revocation is immediate: the next request with the token is a 401 with reason: revoked, and the webhook it subscribed (if any) is paused — nothing more is delivered to it, even if someone resumes it. It cannot be undone — mint a new token, give it to the vendor, then revoke the old one. That order is the rotation: there is no overlap window to configure, because the old token keeps working until you revoke it.

Why a separate kind of token

The connector token (fmwc_…) already let a chat vendor read one space. It stays, for what it is for: a connector installed on ONE space, which may also write questions and suggestions back. An integration token is a different answer to a different question — read access across several spaces for any vendor, owned by the organization and refused everywhere but the read surface — so it is its own kind rather than a widened connector token. Widening the connector token would have given every installed connector a path to more spaces and kept write-backs on a credential meant to be handed to vendors who only read.

In the API

Every operation below is also reachable from the other surfaces without a dedicated command. forumulate api <Operation> calls it from the CLI, the JavaScript SDK has a typed method for each one (the last column names it; the Python and Go SDKs are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP agent server — its api_* family tools, or call_api. Where a CLI command or a hand-made MCP tool exists too, the last column names it. Operation names link to the API reference.

Connector Kb

Operation

Request

What it does

Shortcuts

ConnectorKb_manifest

GET /connector/kb/manifest

Get the manifest

client.integrations.connectorKb.manifest()

ConnectorKb_pages

GET /connector/kb/pages

Sync published pages (knowledge-source feed)

client.integrations.connectorKb.pages()

ConnectorKb_chunks

GET /connector/kb/chunks

List chunks

client.integrations.connectorKb.chunks()

ConnectorKb_search

GET /connector/kb/search

Get the search

client.integrations.connectorKb.search()

ConnectorKb_llms

GET /connector/kb/llms.txt

File of llms (connector kb)

client.integrations.connectorKb.llms()

ConnectorKb_ask

POST /connector/kb/ask

Ask a question

client.integrations.connectorKb.ask()

ConnectorKb_questions

POST /connector/kb/questions

Create a question

client.integrations.connectorKb.questions()

ConnectorKb_suggest

POST /connector/kb/suggestions

Create a suggestion

client.integrations.connectorKb.suggest()

ConnectorKb_webhookStatus

GET /connector/kb/webhook

This credential’s push subscription

client.integrations.connectorKb.webhookStatus()

ConnectorKb_subscribe

POST /connector/kb/webhook

Subscribe this credential to push

client.integrations.connectorKb.subscribe()

ConnectorKb_unsubscribe

DELETE /connector/kb/webhook

Unsubscribe this credential

client.integrations.connectorKb.unsubscribe()

ConnectorKb_rotateWebhookSecret

POST /connector/kb/webhook/rotate-secret

Rotate this credential’s webhook secret

client.integrations.connectorKb.rotateWebhookSecret()

Integration Token

Operation

Request

What it does

Shortcuts

IntegrationToken_list

GET /orgs/{orgId}/integration-tokens

List integration tokens

forumulate integration-tokens list<br>client.integrationToken.list()

IntegrationToken_create

POST /orgs/{orgId}/integration-tokens

Create an integration token

forumulate integration-tokens create<br>client.integrationToken.create()

IntegrationToken_retag

PATCH /orgs/{orgId}/integration-tokens/{id}

Replace an integration token’s tags

client.integrationToken.retag()

IntegrationToken_revoke

DELETE /orgs/{orgId}/integration-tokens/{id}

Revoke an integration token

forumulate integration-tokens revoke<br>client.integrationToken.revoke()

The Forumulate teamUpdated