Give a third party read access
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
}spaceIdstakes space uuids or slugs; the answer carries uuids.capabilitiesdefaults 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'sllms.txt.
expiresInDaysdefaults to 90 and is at most 365: no new key lives longer than a year, sonull(never) is a 422reason: max_key_lifetime. The organization'ssecurity.max_token_lifetime_dayscan 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'sexpiresAt(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 chosenspace_ids): it mints its own one-hour tokens, andexpiresAtis 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 revokedWhat it can read
Everything is under /connector/kb, with Authorization: Bearer <token>:
Request | Needs | Answers |
|---|---|---|
| — | whose token it is ( |
|
| retrieval-grade chunks, paged, resumable ( |
|
| ranked chunks across every granted space, or one with |
|
| the space's |
?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 |
|---|---|
|
|
|
|
| unsubscribes; deliveries stop |
|
|
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.changedfor the credential's spaces — all of them, or thespaceIdssubset (uuids or slugs; one it does not cover is422 space_not_grantednaming every extra) — and its OWN expiry warning:integration_token.expiringabout this token, orpartner_link.expiringabout this link. Never another vendor's, never any other event.page.publishedis an organization event and is not delivered to a credential's subscription:kb.changedis 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 itscursorreads exactly what changed.One per credential. POST again with the same
urland you get200, the sameidandsecret: null— a retried setup call is harmless. A differenturlorspaceIdsupdates that endpoint in place (still no new secret; use rotate-secret).An ordinary webhook underneath:
urlmust 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/manifestlists it asendpoints.webhookand addswebhooktocapabilitieswhen 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 hearskb.changedonly (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…e9The 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/webhookanswered, written as lowercase hex.A credential's own subscription is signed
v1only. 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 |
|
| When |
|---|---|---|---|
401 |
|
| No credential, or not one this surface takes (a personal token is |
401 |
|
| The token or its partner link was revoked or ran out; mint or ask for a new one |
403 |
|
| The credential lacks |
403 |
|
| A partner link that names no spaces |
404 |
|
|
|
422 |
|
|
|
422 |
| — | A bad body: |
429 |
| — | Too many calls; |
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 |
|---|---|---|---|
| Get the manifest |
| |
| Sync published pages (knowledge-source feed) |
| |
| List chunks |
| |
| Get the search |
| |
| File of llms (connector kb) |
| |
| Ask a question |
| |
| Create a question |
| |
| Create a suggestion |
| |
| This credential’s push subscription |
| |
| Subscribe this credential to push |
| |
| Unsubscribe this credential |
| |
| Rotate this credential’s webhook secret |
|
Integration Token
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List integration tokens |
| |
| Create an integration token |
| |
| Replace an integration token’s tags |
| |
| Revoke an integration token |
|