> ## Documentation Index
> Fetch the complete documentation index at: https://docs.upsolve.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Embed Upsolve in your product

> One API key on your server, one call per sign-in, one iframe.

Your users get an Upsolve **workspace** (the AI chat) or **application** (dashboards) inside your product, signed in as themselves and limited by row-level security to their own data. It takes three things:

1. An **API key**, kept on your server.
2. **One backend call** per sign-in — [`POST /v1/api/projects/embed-token`](/api-reference/endpoint/create-embed-token) — which returns a token for the user.
3. An **iframe** that carries the token.

## Set it up with an AI coding assistant

The fastest way: open your workspace's **Deploy** page in Upsolve, click **Create an API key**, and copy the **Setup prompt**. It already contains your organization, project and workspace ids and the exact properties your row-level security reads. Paste it into Claude Code, Cursor or any coding assistant working in your product's codebase.

Or copy this one and replace the values in angle brackets — all of them are on the Deploy page:

```text Setup prompt theme={null}
Embed an Upsolve AI chat in this app, signed in as the current user. Do the whole integration: a backend endpoint, a page with the iframe, and a navigation entry for it.

## Values (from the Upsolve Deploy page — use them as constants)
- Upsolve API: https://api.upsolve.ai
- Embed host: https://ai-hub.upsolve.ai
- Upsolve organization ID: <ORGANIZATION_ID>
- Project ID: <PROJECT_ID>
- Workspace ID: <WORKSPACE_ID>

## The one secret
An Upsolve API key. I create it at https://ai-hub.upsolve.ai/account/settings?tab=developer and give it to the server as the environment variable UPSOLVE_API_KEY. Read it from the environment on the server only — add it to this app's server env config (and to .env.example without a value). Never send it to the browser, put it in client code, or log it.

## 1. Backend
Add an authenticated server endpoint (for example GET /api/upsolve/embed-token) behind this app's existing auth. Take the signed-in user and the customer/tenant/account they belong to from the session, never from the request. It makes one call:

POST https://api.upsolve.ai/v1/api/projects/embed-token
Authorization: Bearer <UPSOLVE_API_KEY>
Content-Type: application/json
{
  organizationId: "<ORGANIZATION_ID>",
  projectId: "<PROJECT_ID>",
  organization: {
    externalId: tenant.id, // your id for the customer this user belongs to
    name: tenant.name,
    properties: { tenant_id: tenant.id },
  },
  user: {
    externalId: user.id, // your id for the user
    name: user.email,
    properties: { email: user.email },
  },
  expiration: "1h",
}

The response is { "status": "success", "data": { "token": "...", "expiresAt": "<ISO time>", "projectUserId": "...", "projectOrganizationId": "..." } }. Return { token, expiresAt } to the browser. The call is idempotent — it creates the organization and the user the first time, merges in the properties you send after that (a key sent as null is removed, a key left out is kept), and writes nothing when nothing changed — so call it every time the page loads. "externalId" is this app's own id for the tenant and for the user (any string). If the app has no tenants, use one constant externalId for the organization. "expiration" may be 5m, 15m, 30m, 1h, 2h, 6h, 12h, 24h or 7d.

## 2. Row-level security
Upsolve row-level security (RLS) reads organization.properties.<key> as {{organization.<key>}} and user.properties.<key> as {{user.<key>}}. Send every property the workspace's RLS rules read, filled from this app's own data for the signed-in user. If it is not obvious which field holds a value, ask me rather than guessing: a wrong value shows a user someone else's data.

## 3. Frontend
Add a page (and a link to it in the app's navigation) that calls the endpoint above and renders:

<iframe title="AI Chat" style="border:none;width:100%;height:100%" src="https://ai-hub.upsolve.ai/share/workspace/<PROJECT_ID>/<WORKSPACE_ID>?jwt=<URL-encoded token>"></iframe>

Give it the page's full height. Show a loading state while the token is fetched, and an error with a Retry button if it fails. Fetch a new token once about 80% of its lifetime has passed (never sooner than 30 seconds), and update the src (that reloads the chat; its history is kept). If this app sends a Content-Security-Policy, add https://ai-hub.upsolve.ai to frame-src.

## 4. Check
Sign in as users from two different tenants and confirm each one sees only their own data.

Full reference: https://docs.upsolve.ai/embedding
```

The rest of this page is what that prompt does, step by step.

## 1. Get an API key and your ids

* **API key.** In Upsolve, open **Account settings → Developer** ([ai-hub.upsolve.ai/account/settings?tab=developer](https://ai-hub.upsolve.ai/account/settings?tab=developer)) and generate a key. Store it on your server, for example as `UPSOLVE_API_KEY`. It must never reach the browser.
* **Ids.** A workspace's **Deploy** page lists the **Organization ID** (your Upsolve organization), **Project ID** and **Workspace ID**, each with a copy button. An application's id is on its **Deploy** panel.

## 2. Sign your users in

Call `embed-token` from your backend whenever a user opens a page with the embed. The first call for a customer creates them as a *project organization*, the first call for a user creates them as a *project user*, and every later call finds the same rows by your ids and merges in the properties you send. A call that changes nothing writes nothing, so there is nothing to register in advance and nothing to store.

```bash theme={null}
curl https://api.upsolve.ai/v1/api/projects/embed-token \
  -H "Authorization: Bearer $UPSOLVE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "organizationId": "<ORGANIZATION_ID>",
    "projectId": "<PROJECT_ID>",
    "organization": {
      "externalId": "acme",
      "name": "Acme Inc",
      "properties": { "tenant_id": "acme" }
    },
    "user": {
      "externalId": "user-42",
      "name": "ada@acme.com",
      "properties": { "email": "ada@acme.com" }
    },
    "expiration": "1h"
  }'
```

```json Response theme={null}
{
  "status": "success",
  "data": {
    "token": "eyJhbGciOiJIUzI1NiIs...",
    "expiresAt": "2026-10-10T13:00:00.000Z",
    "projectUserId": "6f1c...",
    "projectOrganizationId": "b52e...",
    "userCreated": false,
    "organizationCreated": false
  }
}
```

| Field | Required | |
| - | - | - |
| `organizationId` | With an Account-settings key | **Your Upsolve organization's** id, from the Deploy page — not your customer. Keys from Account settings are user-scoped and are refused without it; an organization-bound key ignores it, so it is always safe to send. |
| `projectId` | Yes | The project the workspace or application belongs to. |
| `organization.externalId` | Yes | Your id for the customer the user belongs to. Any string. With no customers of your own, use one constant. |
| `organization.name` | No | Shown in Upsolve. Defaults to `externalId`. |
| `organization.properties` | No | Read by RLS as `{{organization.<key>}}`. Merged into what is stored: a key you send replaces its value, a key sent as `null` is removed, a key you leave out is kept. |
| `user.externalId` | Yes | Your id for the user, unique within their organization. |
| `user.name` | No | Shown in Upsolve (chat history, favourites). Defaults to `externalId`. |
| `user.properties` | No | Read by RLS as `{{user.<key>}}`. Merged the same way, so a sign-in that sends only `email` cannot wipe a key set by an import or in the Hub. |
| `user.userRoleId` | No | A [project user role](/api-reference/endpoint/list-project-user-roles). Grants dashboard editing (adding charts and filters). Set on every call that sends it; leave it out to keep the user's current role (none, for a new user, who views and chats); `null` removes it. |
| `user.defaultUserRoleId` | No | The role a **new** user gets when `userRoleId` is left out. Never applied to an existing user, so a role you change in Upsolve survives their next sign-in. |
| `expiration` | No | `5m`, `15m`, `30m`, `1h` (default), `2h`, `6h`, `12h`, `24h` or `7d`. |

The same call in Node:

```ts theme={null}
export async function getUpsolveEmbedToken(user, tenant) {
  const response = await fetch("https://api.upsolve.ai/v1/api/projects/embed-token", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.UPSOLVE_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      organizationId: "<ORGANIZATION_ID>",
      projectId: "<PROJECT_ID>",
      organization: { externalId: tenant.id, name: tenant.name, properties: { tenant_id: tenant.id } },
      user: { externalId: user.id, name: user.email, properties: { email: user.email } },
      expiration: "1h",
    }),
  });
  if (!response.ok) throw new Error(`Upsolve embed-token: ${response.status}`);
  const { data } = await response.json();
  return { token: data.token, expiresAt: data.expiresAt };
}
```

Take the user and tenant from your own session, never from the request, and return only `token` and `expiresAt` to the browser.

## 3. Embed the iframe

<Tabs>
  <Tab title="Workspace (AI chat)">
    ```html theme={null}
    <iframe
      title="AI Chat"
      style="border: none; width: 100%; height: 100%;"
      src="https://ai-hub.upsolve.ai/share/workspace/<PROJECT_ID>/<WORKSPACE_ID>?jwt=<token>"
    ></iframe>
    ```

    The embed serves the workspace's **production** version: publish the workspace and promote a version first.
  </Tab>

  <Tab title="Application (dashboards)">
    ```html theme={null}
    <iframe
      title="Analytics"
      style="border: none; width: 100%; height: 100%;"
      src="https://ai-hub.upsolve.ai/share/application/<APPLICATION_ID>?jwt=<token>"
    ></iframe>
    ```

    Add `&dashboardId=<id>` to open one dashboard without the sidebar. `/share/dashboard/<dashboardId>?jwt=…` does the same when you only have a dashboard's id.
  </Tab>
</Tabs>

* URL-encode the token.
* Fetch a new token once about 80% of its lifetime has passed (never sooner than 30 seconds) and set it as the new `src`. That reloads the embed; a workspace keeps the user's conversation history. To replace it without a reload, use [token handoff](#keep-the-token-out-of-the-url).
* If your app sends a `Content-Security-Policy`, add `https://ai-hub.upsolve.ai` to `frame-src`.

A minimal React page:

```tsx theme={null}
export function AnalyticsPage() {
  const [src, setSrc] = useState<string | null>(null);

  useEffect(() => {
    let active = true;
    let timer: ReturnType<typeof setTimeout> | undefined;
    const load = async () => {
      const { token, expiresAt } = await fetch("/api/upsolve/embed-token").then((r) => r.json());
      if (!active) return; // the page was left while the request was in flight
      setSrc(`https://ai-hub.upsolve.ai/share/workspace/<PROJECT_ID>/<WORKSPACE_ID>?jwt=${encodeURIComponent(token)}`);
      // Refresh once 80% of the token's life has passed — never sooner than 30 seconds.
      const lifetime = Date.parse(expiresAt) - Date.now();
      timer = setTimeout(load, Math.max(lifetime * 0.8, 30_000));
    };
    load();
    return () => {
      active = false;
      clearTimeout(timer);
    };
  }, []);

  return src ? <iframe title="AI Chat" src={src} style={{ border: "none", width: "100%", height: "100%" }} /> : null;
}
```

## Row-level security

The properties you send are what the workspace's or application's row-level security rules filter on. A rule like

```sql theme={null}
SELECT * FROM orders WHERE tenant_id = '{{organization.tenant_id}}'
```

shows each user only their own customer's orders: `{{organization.tenant_id}}` is replaced with `organization.properties.tenant_id`, and `{{user.<key>}}` with `user.properties.<key>`. The Deploy page's setup prompt lists the exact keys your production rules read. Write the rules in the workspace's **Data Model** — see [Row-Level Security](/ai-agent-builder/rls) — and check them by signing in as users of two different customers.

A property the rules read but the user does not have makes the query fail rather than return everything.

<Accordion title="Database-native RLS (Postgres, Redshift, Supabase)">
  If your database enforces row-level security itself, pass the value its policies read as `dbAuthToken` on an application embed: `…/share/application/<APPLICATION_ID>?jwt=<token>&dbAuthToken=<value>`. For Postgres and Redshift it is the value your session variable expects (a tenant or user id); for Supabase it is the user's session access token (`supabase.auth.getSession()`). See [RLS & Schema Filtering](/embedded-bi/data-permissioning/rls-management).
</Accordion>

## Keep the token out of the URL

A token in `?jwt=` is written to every access log between your user and Upsolve. With **token handoff** the iframe asks your page for it over `postMessage` instead, and a refreshed token replaces the old one without reloading the embed. It works on `/share/workspace`, `/share/application` and `/share/dashboard`.

Open the iframe with `tokenHandoff=true` and your page's origin, and no `jwt`:

```js theme={null}
const EMBED_ORIGIN = "https://ai-hub.upsolve.ai";
const iframe = document.getElementById("upsolve");

// Attach before setting the src.
window.addEventListener("message", async (event) => {
  if (event.origin !== EMBED_ORIGIN || event.source !== iframe.contentWindow) return;
  if (event.data?.type === "embed:ready" && event.data.awaitingToken) {
    const { token } = await fetch("/api/upsolve/embed-token").then((r) => r.json());
    iframe.contentWindow.postMessage({ type: "embed:set-token", v: 1, token }, EMBED_ORIGIN);
  }
});

iframe.src =
  `${EMBED_ORIGIN}/share/workspace/<PROJECT_ID>/<WORKSPACE_ID>?tokenHandoff=true` +
  `&parentOrigin=${encodeURIComponent(location.origin)}`;
```

Post `embed:set-token` again with a fresh token before `expiresAt` to replace it in place. The iframe accepts a token only from the declared `parentOrigin` and its own parent window, keeps it in memory only, and shows *No token received from the embedding page* after 15 seconds without one. The full protocol — live filter sync and auto-height included — is in [iframe messaging](/embedded-bi/sharing-and-export/iframe-messaging).

## Iframe options

<Tabs>
  <Tab title="Workspace (AI chat)">
    | Parameter | |
    | - | - |
    | `prompt` | A first message, sent when the chat opens. |
    | `theme` | `light` or `dark`. |
    | `placeholderOverrides` | JSON: any of `userName`, `assistantName`, `inputPlaceholder`, `thinkingPlaceholder`, `disclaimerText`. |
    | `exampleQuestions` | JSON array of up to three starter questions for the welcome screen. |
    | `nav` | `favorites` or `canvas` to open that panel. |
    | `tokenHandoff`, `parentOrigin` | See [token handoff](#keep-the-token-out-of-the-url). |
  </Tab>

  <Tab title="Application (dashboards)">
    | Parameter | |
    | - | - |
    | `dashboardId` | Open this dashboard, with the sidebar and tabs hidden. |
    | `hideHeader`, `hideDownloadButton` | `true` hides the dashboard title, or the CSV download. |
    | `transparent` | `true` renders on a transparent background. |
    | `filter_<key>` | A controlled filter's value — see [URL filter parameters](/embedded-bi/filters/url-filter-parameters). |
    | `f_<source>.<table>.<column>` | An omnibar filter's value — same page. |
    | `parentOrigin`, `autoHeight` | Live filter sync and content height over `postMessage` — see [iframe messaging](/embedded-bi/sharing-and-export/iframe-messaging). |
    | `dbAuthToken` | See [database-native RLS](#row-level-security). |
    | `tokenHandoff` | See [token handoff](#keep-the-token-out-of-the-url). |

    An application's colors and fonts come from its theme in Upsolve; there is no `theme` parameter.
  </Tab>
</Tabs>

## Troubleshooting

| What you see | Why, and the fix |
| - | - |
| `400 organizationId is required when using a user-scoped API key` | Keys from Account settings are user-scoped. Send `organizationId` (the Deploy page's Organization ID) in the body. |
| `401` | The API key is missing, mistyped or revoked. |
| `403` | The key's owner must be an owner, admin or member of the organization; a read-only member's key cannot sign users in. |
| `404 Project not found or does not belong to this organization` | `projectId` and `organizationId` don't match. Copy both from the same Deploy page. |
| `404 User role not found…` | `userRoleId` belongs to another project, or was deleted. |
| `400` naming `expiration` | Use one of the lifetimes listed above. |
| The embed says *Session expired* | The token expired. Fetch a new one before `expiresAt`. |
| The embed says *This workspace has not been published* | Publish the workspace and promote a version to production. |
| A user sees no data, or an error about a property | The RLS rules read a property the user was not sent. Compare your `properties` with the keys on the Deploy page. |

## Lower-level endpoints

`embed-token` does in one call what [register-organization](/api-reference/endpoint/register-project-organization), [register-user](/api-reference/endpoint/register-project-user) and [user-token](/api-reference/endpoint/get-project-user-token) do in three. Those still work, for integrations that manage Upsolve's ids themselves; a token from `user-token` goes in the iframe the same way.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.