# Embed the connections widget

> Source: https://truto.one/docs/guides/link-sdk/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-reading{cli="truto session-tokens create --tenant-id <tenant_id>" skill="truto-link-sdk"}
:::

The widget is different from `authenticate`. `authenticate` opens the [Connect UI](/docs/sdks/link-sdk#truto-connect) 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:

:::code-tabs

```bash label="cURL"
curl https://api.truto.one/environment \
  -H "Authorization: Bearer <API token>"
```

```bash label="CLI"
truto environments list
```

:::

## 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.

:::code-tabs

```bash label="cURL"
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
  }'
```

```bash label="CLI"
truto session-tokens create --tenant-id acme-corp --expires-in 900
```

```typescript label="TypeScript"
// 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 }
})
```

```python label="Python"
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`:

```json
{
  "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. |

:::callout{type="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.

```bash
npm install @truto/truto-link-sdk
```

```javascript
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);
    }
  },
});
```

```css
#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](#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:

```javascript
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](/docs/guides/webhooks/processing-webhook-events) 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:

```javascript
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](/docs/guides/link-sdk/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.

:::callout{type="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:

```javascript
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:

```css
[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](https://www.npmjs.com/package/@truto/truto-link-sdk).
- **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](/docs/guides/link-sdk/branding).

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.

```bash
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.

:::code-tabs

```bash label="cURL"
curl https://api.truto.one/session-token/me \
  -H "Authorization: Bearer <session token>"
```

```bash label="CLI"
truto session-tokens inspect <session token>
```

:::

```json
{
  "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.

:::code-tabs

```bash label="cURL"
curl -X DELETE https://api.truto.one/session-token \
  -H "Authorization: Bearer <API token>" \
  -H "Content-Type: application/json" \
  -d '{ "tenant_id": "acme-corp" }'
```

```bash label="CLI"
truto session-tokens revoke --tenant-id acme-corp
```

:::

```json
{ "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](/docs/guides/link-sdk/branding). For every SDK option in one place, see the [Link SDK reference](/docs/sdks/link-sdk), and for tenant IDs and metadata (which **Who can see this** matches against), see [Tenants](/docs/guides/tenants/overview).
