Partner links and token exchange

Embed
Preview of the card
<a href="https://forumulate.com/guides/partner-links-and-token-exchange"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/d25b689e-027e-4c98-bdb3-c11fd84c343c/social-card.svg?v=3e7ae33eec8b" alt="Partner links and token exchange" width="600" height="315"></a>

Two ways for software that is not a person to act on an organization without anybody pasting a long-lived key into it:

  • a partner link — a partner product (a consent platform, a DSAR orchestrator, a chat or knowledge vendor, a referral program, a status aggregator) holds a scoped, expiring credential that an owner approved once;

  • a token exchange — your own operators, signed in at your identity provider, trade that sign-in for a one-hour token for one organization.

Neither is specific to one vendor. The partner field is a label; the identity provider is any OIDC issuer you trust.

A partner's setup flow (or an admin, by hand) calls POST /v1/partner_links:

{
  "partner": "intercom",
  "resource": "org:acme",
  "scopes": ["kb:read"],
  "space_ids": ["docs"],
  "expires_at": "2027-06-30T00:00:00Z"
}

expires_at is optional: a link lives a year by default, and a year is also the most it may have — a time further out, or null for never, is a 422 with reason: max_key_lifetime. The organization's owners are e-mailed 30, 7 and 1 days before a link lapses, like every other key.

The caller needs org.manage_integrations. The answer carries the link (status pending), its client_id, the client_secret — shown once, the partner stores it — the approval_url and the token_endpoint.

Scope

What it opens

privacy:execute

The privacy executor contract, instead of an executor key

consent:read / consent:write

Reading and connecting the organization's consent setup

kb:read

The knowledge-source feed and retrieval, for the link's space_ids, and subscribing the link itself to kb.changed (POST /connector/kb/webhook). There is no kb:webhook scope: reading a space is what entitles a link to hear that it changed

health:read

The integration-health report

status:write

Creating and updating status-page incidents

community:standing:write

External standing by sign-in subject

A link is a whitelist, not a login: a token minted from it is refused (401 reason: wrong_audience) on every route that does not accept its scope.

2. An owner approves it, once

The link does nothing until an organization owner, signed in, approves it at the approval_url (the Integrations page, where the link is highlighted). A token cannot approve a link — a credential minting a credential is exactly what the step is there to stop.

3. The partner exchanges its credentials

Standard OAuth 2.0 client credentials, at POST /v1/oauth/token:

curl -H "Forumulate-Version: 2027-03-01" -s https://forumulate.com/api/v1/oauth/token \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=client_credentials -d scope=kb:read

The answer is {access_token, token_type: "Bearer", expires_in: 3600, scope}. Errors are RFC 6749 shaped — {error, error_description} — so any OAuth client library reads them: invalid_client (401: an unknown client, a wrong secret, a revoked or expired link — deliberately the same), invalid_scope, unsupported_grant_type, invalid_request (a malformed request).

One refusal is told apart: the correct secret of a link an owner has not approved yet. That is 400 unauthorized_client with error_description: "partner link pending approval: …" and the link's approval_url — the URL you were handed beside the secret at create, so it reveals nothing to a caller who did not already hold it. Send the owner that URL; nothing on your side is wrong. A wrong secret on a pending link is the plain invalid_client.

{
  "error": "unauthorized_client",
  "error_description": "partner link pending approval: an organization owner must approve it at approval_url.",
  "approval_url": "https://forumulate.com/o/…/integrations?approve=…#partner-links"
}
``` The token exchange adds `invalid_grant` and `invalid_target` (see "Exchange errors" below).

### Rotating

`POST /v1/partner_links/{id}/rotate` answers a new `client_id` and `client_secret`, shown once.
The pair it replaces keeps working at the token endpoint until `previous_valid_until`, 24 hours
later, so you can deploy the new pair without a cutover.

- A rotation replaces both values. The client id is derived from the secret, so a new secret
  always comes with a new client id.
- Access tokens you already minted keep working until their hour is up.
- It takes a management credential or the link's own access token.

## Connecting without a pasted secret

A partner product can get an approved link with no secret ever copied by a person. The
partner registers with us once: its exact callback URLs, and the issuer and JWKS URL whose
signed assertions name its users and organizations.

1. **The partner sends the person to us.**

https://forumulate.com/connect/{partner}?assertion=<JWT>&spaces=docs,guides&scopes=kb:read &state=<opaque, single use>&redirect_uri=<a registered callback, exactly>


The assertion is a short-lived RS256 JWT from the partner's own identity provider (an Atlas
template token, for example). It must carry:
- `aud: "forumulate"`
- `sub`, the person
- `org`, their organization at the partner
- a `jti`
- an `exp`

We check it against the partner's registered JWKS, never against keys derived from `iss`. A
signed-out person signs in first and comes straight back to this page.
2. **An owner approves.** The page shows:
- the partner;
- what it may do (`kb:webhook` reads as `kb:read`, which already lets a link subscribe
  itself to `kb.changed`);
- the organization;
- the spaces, with the requested ones ticked.

Only an organization owner, signed in, can approve. The first approval also records that the
partner's organization is this one. That record works both ways: a partner organization can
be connected to only one of ours, and ours to only one at that partner.
3. **The callback receives a code.** We redirect to `redirect_uri` with `state` and a
`link_code`. The code lasts 120 seconds and can be used once. Cancelling sends
`error=access_denied` and `state` instead.
4. **The partner redeems it, server to server.**

```bash
curl -s https://forumulate.com/api/v1/oauth/token \
  -d grant_type=urn:forumulate:params:oauth:grant-type:link-code \
  -d link_code=… -d redirect_uri=<the same callback>

The answer comes once, with the same values POST /v1/partner_links gives: {partner_link_id, client_id, client_secret, token_endpoint, resource, scopes, space_ids, expires_at}.

The link is created at this moment and is already active. From here on it is an ordinary partner link: mint access tokens with client_credentials.

Renewing with no click. In the link's last 30 days, POST /v1/partner_links/{id}/renew with the link's own access token and {"assertion": "<a fresh JWT>"} extends it by a year. The assertion must name the same partner organization the owner approved. Revoking the link ends renewal.

Failure

Answer

redirect_uri not an exact registered callback

422 redirect_uri_not_registered, before anything is shown

Assertion expired

401 assertion_expired: start again from the partner

aud not forumulate

401 wrong_audience

Assertion already used

401 assertion_replayed

Bad signature, or no sub/org

401 assertion_invalid

The partner's JWKS can't be fetched

503 jwks_unavailable: retry, it is not a forged assertion

Partner organization already connected to another of ours (or the reverse)

409 identity_linked_elsewhere

The person is not an owner

403 owner_required

link_code unknown, expired or already redeemed

400 invalid_grant, all three the same

redirect_uri at redemption differs from the request's

400 invalid_request

Renewing before the last 30 days

409 not_due

Renewing with an assertion for another organization

403 identity_mismatch

Watching and revoking

GET /v1/partner_links lists every link with last_used_at and calls_30d. DELETE /v1/partner_links/{id} revokes it: the client credentials are refused and every token already minted stops working on its next call. It takes an owner's or integration manager's credential, or the link's own access token (Authorization: Bearer forumulate_<env>_oat_… minted from that link). A partner whose customer disconnects revokes its own grant that way. The link's token can revoke that one link and nothing else: another link's id is 401 reason: wrong_audience. A revoked link cannot be restored; request a new one. Leaked secrets and tokens reported to POST /v1/keys/revoke_leaked are revoked the same way. A link with kb:read that subscribed itself to push (POST /connector/kb/webhook, see Give a third party read access) has its webhook paused by the revoke; nothing more is delivered.

Fourteen days before a link expires, a partner_link.expiring webhook goes out once (to the organization's endpoints, and to the link's own push subscription) and the owner who approved it is e-mailed. Request a new link before the day.

GET /connector/kb/manifest, called with a link's access token, answers tokenKind: "partner_link", status: "active" and the LINK's expiresAt (not the one-hour token's). status is always active: a token can only be minted for an approved link, and a revoked or expired one is a 401 before any manifest is built.

The same idea runs the other way. When Forumulate calls a vendor on your behalf — registering a space as a knowledge source with a chat product, checking a referral program's credentials — it used to need that vendor's API key, pasted into our console and kept. A vendor that issues partner links gives your organization a grant instead: a client id and a secret, scoped to what we need and approved once on its side. Paste the grant, not a key:

Where

Fields

A connector (POST/PATCH …/spaces/{spaceId}/connectors)

config.partnerLinkGrantId, config.partnerLinkSecret, optional config.partnerLinkTokenUrl

A referral program (PUT …/referrals/providers/{provider})

partnerLink: {grantId, secret, tokenUrl?}

On each call we exchange the grant at the vendor's token endpoint — OAuth 2 client_credentials, the client authenticated with HTTP Basic — for a short-lived access token, cache it until a minute before it expires, and send that where the key used to go. Chatly's and Referalli's endpoints are known; for any other vendor name it with tokenUrl (https). A grant the vendor refuses is a 422 reason: partner_link_refused with the vendor's own words, never a silent fall back to an API key. The API key fields stay, for a vendor that issues no partner links; when both are set, the partner link wins.

Operator token exchange

For your own staff and automation that already sign in at an identity provider — Auth0, Okta, Entra, Keycloak, Cognito, Clerk, WorkOS, Atlas, any OIDC issuer.

  1. Trust the issuer — POST /orgs/{orgId}/operator-issuers with {"preset": "oidc", "issuer": "https://login.example.com"}. Discovery runs at once (/.well-known/openid-configuration), so a wrong issuer fails there. {"preset": "atlas"} fills in the Atlas issuer.

  2. Each operator links their subject once — signed in here, they call POST /orgs/{orgId}/operator-issuers/{issuerId}/link with a token from the issuer. Subjects are never matched by email.

  3. Exchange (RFC 8693) whenever a token is needed:

curl -H "Forumulate-Version: 2027-03-01" -s https://forumulate.com/api/v1/oauth/token \
  -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
  -d subject_token="$IDP_TOKEN" \
  -d subject_token_type=urn:ietf:params:oauth:token-type:jwt \
  -d resource=org:acme \
  -d scope="read write"

The token lives one hour and works in that ONE organization; any other answers 403 reason: wrong_organization. It can do what the operator's role can do.

Exchange errors

Every refusal is 400 with {error, error_description}. The error tells you what to do next:

error

When

What to do

invalid_request

The request is malformed: subject_token or resource missing, a parameter sent twice, a subject_token_type other than a JWT, id_token or access_token type, or a requested_token_type other than an access token.

Fix the request. Retrying it unchanged fails the same way.

invalid_grant

The subject token is present but no good: not a JWT, from an issuer this organization does not trust, a bad signature, expired, without an exp claim, a subject nobody here has linked, or a person who is no longer a member.

Get a fresh token from your IdP (or link the subject, or restore the membership), then exchange again.

invalid_target

resource names no organization, or audience is not forumulate.

Send resource=org:<id or slug>.

invalid_scope

A scope other than read, write and admin.

Ask for those only.

unsupported_grant_type

A grant other than client_credentials and token exchange.

Use one of the two.

We use invalid_grant for a bad subject token, as RFC 6749 §5.2 defines it ("invalid, expired, revoked"), so an OAuth client can tell "get a new token" from "fix your code". RFC 8693 §2.2.2 lists invalid_request for this case. Most identity providers and client libraries use invalid_grant, and so do we. The answer never says whether the person exists in another organization.

Step-up

Root actions — deleting or transferring the organization, billing, members, roles, single sign-on, integrations, webhooks, minting tokens — need the identity provider to have reported a second factor (amr containing mfa, otp, hwk, … or an MFA acr) with auth_time in the last five minutes. Otherwise the answer is 403 auth.step_up_required, reason: mfa_missing or mfa_stale: sign in again at the IdP with a second factor and exchange the fresh token.

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.

Operator Issuers

Operation

Request

What it does

Shortcuts

OperatorIssuers_list

GET /orgs/{orgId}/operator-issuers

List operator issuers

client.operatorIssuers.list()

OperatorIssuers_create

POST /orgs/{orgId}/operator-issuers

Trust an operator issuer

client.operatorIssuers.create()

OperatorIssuers_remove

DELETE /orgs/{orgId}/operator-issuers/{issuerId}

Stop trusting an operator issuer

client.operatorIssuers.remove()

OperatorIssuers_link

POST /orgs/{orgId}/operator-issuers/{issuerId}/link

Link your operator subject

client.operatorIssuers.link()

Operation

Request

What it does

Shortcuts

PartnerLinks_rotate

POST /v1/partner_links/{id}/rotate

Rotate a partner link’s credentials

client.partnerLinks.rotate()

PartnerLinks_renew

POST /v1/partner_links/{id}/renew

Renew a connect-handshake link

client.partnerLinks.renew()

PartnerLinks_list

GET /v1/partner_links

List partner links

client.partnerLinks.list()

PartnerLinks_create

POST /v1/partner_links

Request a partner link

client.partnerLinks.create()

PartnerLinks_get

GET /v1/partner_links/{id}

Get a partner link

client.partnerLinks.get()

PartnerLinks_revoke

DELETE /v1/partner_links/{id}

Revoke a partner link

client.partnerLinks.revoke()

PartnerLinks_approve

POST /v1/partner_links/{id}/approve

Approve a partner link

client.partnerLinks.approve()

The Forumulate teamUpdated