Embed the connections widget
Put an integrations page inside your product: your backend mints a session token for one tenant, and widgets.connections() from @truto/truto-link-sdk renders that tenant's catalog and connections inline, in an element you own. Your customers connect, reconnect, and remove their own accounts without ever holding an API token.
Skip the reading — fast path for coding agents
truto session-tokens create --tenant-id <tenant_id>
/skill truto-link-sdk
The widget is different from authenticate. authenticate opens the Connect UI as an overlay for one connection at a time. The widget stays on the page and lists everything the tenant has connected.
1. Allow your site to frame the widget
Open Settings → Connections widget (app.truto.one/settings/connections-widget) for the environment you are embedding, and add every origin that hosts the widget under Where it can be embedded → Allowed origins. Click Save changes.
- One origin per entry: scheme, host and an optional port. No path, query, or trailing slash.
https://only, excepthttp://localhostandhttp://127.0.0.1for local development.- Up to 20 origins per environment.
Until the list has at least one origin, the page shows No sites can embed this widget and the browser refuses to frame it anywhere. Truto turns the list into the widget's frame-ancestors policy, so a page that is not on it is blocked by the browser. A change reaches the widget within about a minute.
The widget also needs the environment's ID. List your environments to find it:
truto environments listcurl https://api.truto.one/environment \
-H "Authorization: Bearer <API token>"2. Mint a session token on your backend
A session token is a short-lived credential scoped to exactly one tenant in one environment. Mint it on your server after you have authenticated your own user and resolved their tenant. Never mint it in the browser: the call needs your API token.
truto session-tokens create --tenant-id acme-corp --expires-in 900curl -X POST https://api.truto.one/session-token \
-H "Authorization: Bearer <API token>" \
-H "Content-Type: application/json" \
-d '{
"tenant_id": "acme-corp",
"expires_in": 900
}'// Express route your frontend calls from getToken().
app.post('/api/truto/session-token', requireUser, async (req, res) => {
const tenantId = req.user.organizationId // resolve the tenant from *your* session
const response = await fetch('https://api.truto.one/session-token', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.TRUTO_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ tenant_id: tenantId, expires_in: 900 }),
})
if (!response.ok) {
return res.status(502).send(await response.text())
}
res.json(await response.json()) // { session_token, expires_at, max_lifetime_at }
})import os
import requests
def mint_session_token(tenant_id: str) -> dict:
response = requests.post(
"https://api.truto.one/session-token",
headers={"Authorization": f"Bearer {os.environ['TRUTO_API_TOKEN']}"},
json={"tenant_id": tenant_id, "expires_in": 900},
timeout=10,
)
response.raise_for_status()
return response.json() # {"session_token", "expires_at", "max_lifetime_at"}The response is 201:
{
"session_token": "<session token>",
"expires_at": "2026-11-02T10:15:00.000Z",
"max_lifetime_at": "2026-11-02T22:00:00.000Z"
}Return it to your frontend as-is. The widget reads session_token and expires_at.
| Field | Type | Default | Notes |
|---|---|---|---|
tenant_id |
string | required | The tenant the session is scoped to. Must already exist in the environment, or the call answers 404 Tenant not found. |
environment_id |
string | the API token's environment | Required when you mint with a dashboard user session instead of an API token. |
expires_in |
integer | 900 |
Seconds until the token stops working. 60–3600. |
max_lifetime |
integer | 43200 |
Seconds after minting that the session ends for good, however often it is renewed. 60–86400. If it is smaller than expires_in, the token expires at max_lifetime. |
is_refreshable |
boolean | true |
Whether the token may be renewed. Set false to force a fresh mint at every expiry. |
create_tenant_if_missing |
boolean | false |
Create the tenant instead of answering 404 when it does not exist yet. |
allowed_origins |
string[] | the environment's list | Narrows the environment's allowed origins for this one session. Origins the environment does not allow are dropped; [] narrows to nothing. |
allowed_origins on a session token is recorded but not enforced. Which pages can frame the widget is decided only by the environment's list under Settings → Connections widget → Allowed origins. Narrowing a session lowers the allowed_origins_count that GET /session-token/me reports and nothing else, so do not rely on it as a per-session boundary.
Whatever you pass, the token can only reach that tenant's data in that environment: Truto forces the tenant and environment from the token on every request, and a session token cannot call any admin route or mint another token.
3. Mount the widget
Install the SDK and mount the widget into a container that has a height. getToken calls the backend route from step 2.
npm install @truto/truto-link-sdkimport { widgets } from '@truto/truto-link-sdk';
const widget = widgets.connections({
container: document.getElementById('integrations'),
environmentId: '<environment_id>',
getToken: async () => {
const response = await fetch('/api/truto/session-token', { method: 'POST' });
if (!response.ok) throw new Error(await response.text());
return response.json(); // { session_token, expires_at }
},
onEvent: (e) => {
if (e.event === 'connected') {
console.log('connected', e.payload.integration, e.payload.integrated_account_id);
}
},
});#integrations {
min-height: 32rem;
}The SDK appends an iframe that fills the container. In a container with no height the frame falls back to 150px, which is the usual cause of a cropped widget. Pass autoResize: true to let the widget set the frame's height to its content instead.
getToken runs on mount, after an expiry, and after a connect flow, so expect several calls per page. A refreshable token is renewed by the widget on its own while the tab is visible; getToken covers everything renewal cannot. The SDK hands the token to the widget over postMessage, never puts it in a URL, and never stores it.
| Option | Type | Default | Notes |
|---|---|---|---|
container |
Element | required | The widget's iframe is appended to it. |
environmentId |
string | required | The environment the session is minted in. A wrong value renders an empty box. |
getToken |
function | required | Returns { session_token, expires_at } or a promise of it. |
onEvent |
function | none | Called with { widget, instance, event, payload }. See step 4. |
theme |
object | the environment's theme | Per-embed colour, radius, font, and density overrides. See Style the widget. |
integrations |
string[] | all | Show only these integrations. Presentation only. |
view |
cards · list |
the environment's setting | Starting layout. |
groupBy |
none · category · status |
the environment's setting | Starting grouping. |
status |
all · attention · connecting · connected · not_connected |
all |
Starting status filter. |
search |
boolean | the environment's setting | Whether the search box is offered. |
autoResize |
boolean | false |
Let the widget drive the iframe's height. |
baseUrl |
string | https://app.truto.one |
Origin serving the widget, for self-hosted or staging deployments. |
title |
string | Connected integrations |
The iframe's accessible name. |
4. Listen for events
onEvent receives { widget, instance, event, payload }. Refresh your own UI on the connection events, and log the failures:
onEvent: ({ event, payload }) => {
switch (event) {
case 'connected':
case 'reauthorized':
case 'disconnected':
refreshMyIntegrationsState(payload.integrated_account_id);
break;
case 'session_error':
case 'error':
reportToMonitoring(event, payload);
break;
}
},event |
payload |
Emitted when |
|---|---|---|
ready |
{} |
The session was accepted and the first render is done. |
session_error |
{ message } |
getToken failed, or the API rejected the token it returned. message is one line, at most 500 characters. |
connected |
{ integration, integrated_account_id } |
An account finished connecting. |
reauthorized |
{ integration, integrated_account_id } |
An account was reconnected. |
configured |
{ integration, integrated_account_id } |
A connection's setup form was submitted from the widget. |
refreshed |
{ integration, integrated_account_id } |
Credentials were refreshed. |
disconnected |
{ integration, integrated_account_id } |
An account was removed. |
error |
{ code, message, integration?, integrated_account_id? } |
Any surfaced failure. code is session_expired, forbidden_action, rate_limited, connect_failed, app_credentials_not_configured, network, or unknown. |
resize |
{ height } |
The content height changed. Only with autoResize: true. |
Treat integration and integrated_account_id as optional on every event: they are omitted when the widget does not know which connection an event is about. Events never carry the session token. If the widget has not loaded 15 seconds after mounting, the SDK emits error with code: "network"; the most common cause is a page origin missing from Allowed origins.
Use webhooks such as integrated_account:created as the source of truth on your backend. Widget events are for updating the page the user is looking at.
5. Unmount the widget
widgets.connections() returns { instance, destroy }. Call destroy() when the page unmounts, the route changes, or the user signs out:
widget.destroy();destroy() removes the iframe, its message listener, and any pending timer. It is safe to call twice, and safe to call while getToken is still running. instance is this mount's ID and is echoed on every event, so several widgets for different tenants can share one page.
Choose what end users can do
Settings → Connections widget → What customers can do turns each action on or off for the whole environment. The API enforces each one as well as hiding it in the widget, so switching one off is a real restriction.
| Switch | Default | What it allows |
|---|---|---|
| Connect new accounts | On | Connect an integration that isn't connected yet. |
| Reconnect accounts | On | Re-authorise a connection that has stopped working. |
| Refresh credentials | Off | A Refresh action, shown only where credentials can be refreshed. |
| Delete connections | Off | Permanently remove a connection. |
| More than one account per integration | Off | An Add another action. With it off, a second connect for the same integration is refused. |
| Show error details | Off | The raw upstream error in a collapsed Details section, and on error events. Upstream errors can contain account identifiers. |
| Reopen setup forms | Off | A Configure action that reopens an integration's setup form on a live connection. |
Connecting opens the provider's OAuth screen in a popup; the widget's frame is never replaced. If the browser blocks the popup, the widget says so inline. The connect flow inside the widget uses your Connect UI branding.
Set the layout and grouping
The Layout card sets where the catalog starts: Default view (Cards or List, default Cards), Default grouping (No grouping, Category, or Status, default No grouping), and Show the search bar (default on). The view, groupBy, status, and search options override these per embed, and end users can change them in the widget's toolbar. The toolbar is hidden when there is only one card to show.
Grouping by category uses each integration's category. To change what a card says or where it sits, open the integration's settings page in the dashboard and use its Connections widget card:
- Card description — the text on the integration's card.
- Group — a custom group for the catalog, replacing the category.
- Who can see this — show the integration only to tenants whose metadata matches your conditions. Truto enforces it when the catalog is listed and again when a connect is attempted, which answers
404as if the integration were not installed.
Rename group headings in the Group names card, and override any other wording the widget uses in the Custom text card.
The integrations option only hides cards; the API still returns those integrations to the session. To keep a tenant away from an integration, use Who can see this.
Style the widget
The widget is a separate document from your page, so your stylesheets and webfonts do not reach inside it. Style it with theme tokens first, and custom CSS for what tokens cannot express.
Theme tokens. Set them for the environment under Settings → Connections widget → Appearance, or per embed with the theme option. Per-embed values apply over the environment's, key by key:
widgets.connections({
container,
environmentId: '<environment_id>',
getToken,
theme: {
color_primary: '#1e5eff',
color_primary_foreground: '#ffffff',
radius: '6px',
font_family: 'Inter, system-ui, sans-serif',
font_url: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap',
density: 'comfortable',
},
});The colour keys are color_background, color_foreground, color_card, color_card_foreground, color_primary, color_primary_foreground, color_muted, color_muted_foreground, color_border, color_success, color_warning, and color_destructive. Colours accept #rgb, #rrggbb, rgb()/rgba(), and hsl()/hsla(); an invalid value is ignored. radius takes px or rem, font_url must be a https://fonts.googleapis.com/css… stylesheet, and density is compact (default) or comfortable. Each colour becomes a CSS custom property, such as --primary and --border, that your custom CSS can use.
Custom CSS. Turn on Enable custom CSS in the Custom CSS card on the same page. It applies to every embed in the environment; there is no per-embed CSS. Selectors must target the widget's data-truto-el attributes:
[data-truto-el="card"] {
border-radius: 4px;
border-color: var(--border);
}
[data-truto-el="card"]:hover {
border-color: var(--primary);
}The limits:
- Selectors. Every selector must name a
data-truto-elelement outside any functional pseudo-class. The elements areroot,content,toolbar,search,filters,group-by,status-filter,view-toggle,group,grid,list,card,card-description,group-chip,row,actions,status,no-results,empty, andskeleton. The full contract, including allowed pseudo-classes and at-rules, is in the Link SDK README. - Dropped, not rejected. A rule outside the contract is dropped and the rest still applies. The editor shows what was kept and what was dropped as you type.
- No positioning out of flow.
position: fixedandposition: stickyare removed from any rule; the rest of the rule is kept. - Refused on save.
@import,@charset,</, and control characters. The stylesheet is capped at 20,480 characters. - Troubleshooting. Add
custom_css=offto the query string of the widget frame's URL to see it without your CSS.
This stylesheet is separate from the Connect UI's, which lives under Settings → Branding. See Brand the Connect UI.
Click Preview on the settings page to run the real widget for the truto-connections-preview tenant. Connecting an account there creates a real connection for that tenant.
Renew, inspect, and revoke sessions
Renew. POST /session-token/renew, authenticated with the session token itself, slides a refreshable token forward by its original expires_in. It never moves max_lifetime_at. The widget does this for you; call it yourself only if you hold the token somewhere else. It answers 403 for a token minted with is_refreshable: false and for one that has reached its maximum lifetime, and it is rate-limited.
curl -X POST https://api.truto.one/session-token/renew \
-H "Authorization: Bearer <session token>"Inspect. GET /session-token/me describes the token that authenticates it. For a session token it returns the tenant, environment, expiry, issuer, the effective allowed_origins_count, and the resolved capabilities and layout. An allowed_origins_count of 0 means no page can frame the widget.
truto session-tokens inspect <session token>curl https://api.truto.one/session-token/me \
-H "Authorization: Bearer <session token>"{
"type": "tenant",
"id": "acme-corp",
"scope": { "tenant_id": "acme-corp", "environment_id": "<environment_id>" },
"issued_at": "2026-11-02T10:00:00.000Z",
"expires_at": "2026-11-02T10:15:00.000Z",
"max_lifetime_at": "2026-11-02T22:00:00.000Z",
"is_refreshable": true,
"issuer": { "type": "api_token", "id": "<api_token_id>" },
"team_name": "Acme",
"allowed_origins_count": 2,
"capabilities": {
"allow_connect": true,
"allow_reauth": true,
"allow_refresh": false,
"allow_delete": false,
"allow_multiple_per_integration": false,
"show_error_details": false,
"allow_configure": false
},
"layout": { "default_view": "cards", "default_group_by": "none", "show_search": true }
}Revoke. DELETE /session-token is the kill switch for one tenant: it revokes every live session token for that tenant in the environment and returns how many it revoked. Tokens minted afterwards work normally. Use it when you remove a user from a customer account or suspect a token has leaked.
truto session-tokens revoke --tenant-id acme-corpcurl -X DELETE https://api.truto.one/session-token \
-H "Authorization: Bearer <API token>" \
-H "Content-Type: application/json" \
-d '{ "tenant_id": "acme-corp" }'{ "revoked_count": 3 }Like minting, revoking takes an API token or a dashboard user session, and environment_id defaults to the API token's environment.
Next
Match the connect flow the widget opens to your product with Brand the Connect UI. For every SDK option in one place, see the Link SDK reference, and for tenant IDs and metadata (which Who can see this matches against), see Tenants.