Skip to content

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
CLI
truto session-tokens create --tenant-id <tenant_id>
Skill
View truto-link-sdk skill on GitHub Cursor / Claude Code: /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, except http://localhost and http://127.0.0.1 for 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 list
curl 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 900
curl -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.
Warning

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-sdk
import { 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 404 as 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.

Info

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-el element outside any functional pseudo-class. The elements are root, content, toolbar, search, filters, group-by, status-filter, view-toggle, group, grid, list, card, card-description, group-chip, row, actions, status, no-results, empty, and skeleton. 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: fixed and position: sticky are 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=off to 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-corp
curl -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.