Skip to main content

Advanced Analytics

Overview​

This document describes the frontend implementation for embedding ThoughtSpot analytics into rhapsody using ThoughtSpot's Trusted Authentication (Trusted Auth) flow. Everything lives in @shared/advanced-analytics — auth context, hooks, low-level SDK wrappers, and the high-level AnalyticsLiveboard component that any app can drop in directly.

@app/advanced-analytics is the first consumer: three flag-gated routes that exercise the embed end to end. See Host app.


Architecture​

Browser (rhapsody)
│
│ GET /v2/thoughtspot/embed_token
│ (Bearer: Salesloft user token)
▼
analytics-api
│ Resolves user + tenant from server-side context only
│ POST /api/rest/2.0/auth/token/custom → ThoughtSpot
▼
ThoughtSpot
│ Short-lived token (expires_in seconds)
▼
analytics-api → rhapsody
│
│ Visual Embed SDK initializes iframe
▼
ThoughtSpot (embedded iframe)
│ Authorized query → Cube (tenant-isolated via ts_var RLS)
▼
Embedded result in rhapsody

Key security properties:

  • The ThoughtSpot Trusted Auth secret never leaves analytics-api
  • User identity and tenant are resolved server-side only — no identity data is trusted from the browser
  • Row-level security (ABAC via ts_var()) enforces tenant isolation at the data layer
  • Tokens are short-lived (expires_in seconds); the SDK refreshes them automatically via getAuthToken

Package structure​

shared/advanced-analytics/
├── package.json
└── src/
├── index.ts
├── types.ts
├── constants.ts
├── services/thoughtspot/
│ └── thoughtspotAuthService.ts
├── context/
│ └── ThoughtSpotAuthContext.tsx
├── hooks/
│ ├── useThoughtSpotAuth.ts
│ ├── useEventCallback.ts ← stable listener identities for the SDK
│ ├── useLiveboardUrlSync.ts ← tab ⇄ URL synchronization
│ └── useLiveboardUrlParams.ts ← React Router v5 query param adapter
├── utils/
│ └── liveboardUrlState.ts ← query param key + route parsing
└── components/
├── AnalyticsLiveboard/ ← recommended entry point for pages
├── ThoughtSpotLiveboard/ ← low-level SDK wrapper
├── ThoughtSpotSearch/
└── ThoughtSpotSpotter/

The app that consumes it:

apps/advanced-analytics/
├── entry.ts ← routes, nav items, shortcuts, feature flag
└── src/
├── bridge-loaders/routesBridge.tsx
└── pages/
├── AdvancedAnalytics/ ← flag guard + auth provider + <Switch>
├── SimpleExample/
├── TabsExample/
└── FiltersExample/

Host app: @app/advanced-analytics​

The package is consumed today by a single app whose only purpose is to exercise the embed in a real rhapsody shell. Each route is a minimal page that renders AnalyticsLiveboard against a different Liveboard.

Routes​

Registered in apps/advanced-analytics/entry.ts via registerRoutes, all resolved through one bridge loader (src/bridge-loaders/routesBridge.tsx → AdvancedAnalytics).

RoutePageLiveboardNotes
/advanced-analytics/simpleSimpleExampledfe15989-…21639urlSync with an explicit defaultTabId
/advanced-analytics/tabsTabsExampled5f75047-…0e395urlSync without defaultTabId — first tab wins
/advanced-analytics/filtersFiltersExamplec31b5ec1-…cc86bLiveboard with persisted filters; filter state is not synced to the URL

All three pages call useLiveboardUrlParams() and pass GENERAL_VISIBLE_ACTIONS.

Feature flag​

Everything is gated behind advanced_analytics:

  • entry.ts — each registerGlobalNavMenuItem declares shouldShow: ({ hasFeatureFlag }) => hasFeatureFlag('advanced_analytics'), so the nav entries and their shortcuts stay hidden without it.
  • AdvancedAnalytics.tsx — re-checks with hasFeatureFlag('advanced_analytics') from @rhapsody/feature-flags and returns <Redirect to="/" /> when absent, so direct URL access is blocked too.

Three items under the advancedAnalytics category, with keyboard shortcuts:

NameTargetOrderShortcut
Simple/advanced-analytics/simple1a 1
Tabs/advanced-analytics/tabs2a 2
Filters/advanced-analytics/filters3a 3

Page shape​

Every page is the same four lines — the package absorbs auth, loading, error and URL sync:

import {
AnalyticsLiveboard,
GENERAL_VISIBLE_ACTIONS,
useLiveboardUrlParams,
} from '@shared/advanced-analytics'

const LIVEBOARD_ID = 'c31b5ec1-0c4a-43dc-b7cf-ce487e3cc86b'
const DEFAULT_TAB_ID = '20335746-9db3-4a00-9d4b-43e8f2cd1e92'

export function SimpleExample() {
const { urlState, setUrlState } = useLiveboardUrlParams()

return (
<AnalyticsLiveboard
liveboardId={LIVEBOARD_ID}
visibleActions={GENERAL_VISIBLE_ACTIONS}
urlSync={{
urlState,
onUrlStateChange: setUrlState,
defaultTabId: DEFAULT_TAB_ID,
}}
/>
)
}

Token flow and rotation​

Token refresh is driven by the SDK, not a manual timer. There is no client-side token cache anywhere in this package — analytics-api already caches short-lived tokens server-side per (tenant, user), and duplicating that on the frontend would risk handing the same single-use token to two embeds at once.

ThoughtSpotAuthProvider mounts
│
└─ init() called once with a getAuthToken callback (synchronous).
status becomes 'ready' immediately — the SDK does not need a completed
handshake to start rendering.
│
├─ embed mounts and its iframe boots ─┐ in parallel
│ │
└─ SDK calls getAuthToken() on init and on every │
token expiry │
│ ─┘
└─ fetchThoughtSpotToken() → GET /v2/thoughtspot/embed_token
Always issues a fresh network request — tokens are
single-use.

No upfront probe request. The provider issues no token request of its own before init(). An earlier version fetched a token up front purely to fail fast; that token was discarded and the SDK immediately fetched another, so it produced a duplicate GET /v2/thoughtspot/embed_token and put a full round-trip in front of the entire load — the skeleton stayed up for the whole probe before the iframe could even start. Auth failures are now surfaced by the SDK's own AuthStatus.FAILURE event and by the first getAuthToken rejection, which report the same condition at no cost.

Only the first handshake collapses the UI. Once a token has been issued the embed is live; a later refresh failure is left to the SDK to retry rather than blanking a rendered board.

callPrefetch: true is set on init() so ThoughtSpot's static assets warm in parallel with the handshake.

Token rotation requirement: ThoughtSpot Trusted Auth tokens are single-use. Returning the same token string twice triggers a "Duplicate token" error. getAuthToken therefore always calls fetchThoughtSpotToken() directly, uncached, on every invocation.


New API client: thoughtSpotApi​

Added to @shared/requests.

export const thoughtSpotApi = createInstance({
prefixUrl: () => `${window.ENV.ANALYTICS_API}/v2/thoughtspot`,
hooks: {
beforeRequest: [includeUserTokenHook, requestorSourceHook],
afterResponse: [showErrorModal],
},
})

Same host as analyticsApi, different namespace (/v2/thoughtspot vs /v2/analytics).

Embedded components​

All components require ThoughtSpotAuthProvider to be mounted above them in the tree.

ThoughtSpotLiveboard, ThoughtSpotSearch, and ThoughtSpotSpotter are built on the official SDK React components from @thoughtspot/visual-embed-sdk/react. This means:

  • No manual useEffect, DOM refs, or .render() calls — the SDK manages the iframe lifecycle.
  • Props update reactively (the SDK uses deep-compare internally, so passing objects like runtimeFilters directly is safe).
  • Event handlers are passed as onEventName props instead of chained .on(EmbedEvent.X, ...) calls.

The high-level wrapper for pre-built dashboards. Handles the auth lifecycle internally so pages only need a liveboardId. See the Integration guide for full usage and override props.

import { AnalyticsLiveboard } from '@shared/advanced-analytics'

<AnalyticsLiveboard liveboardId="<liveboard-guid>" />

ThoughtSpotLiveboard​

SDK React component wrapper for pre-built dashboards. Does not manage loading or error states — use this only when you need full control over the surrounding UI, or when building something other than a standard page embed.

<ThoughtSpotLiveboard
liveboardId="<liveboard-guid>"
vizId="<optional-viz-guid>" // pin to a single visualization
runtimeFilters={[...]} // UI context filters (not authorization)
visibleActions={[...]} // mutually exclusive with hiddenActions
hiddenActions={[...]} // mutually exclusive with visibleActions
/>

ThoughtSpotSearch​

For search and Answers.

<ThoughtSpotSearch
dataSourceIds={['<worksheet-guid>']}
searchQuery="revenue by region"
hideDataSources={true}
runtimeFilters={[...]}
visibleActions={[...]}
/>

ThoughtSpotSpotter​

For conversational analytics (Spotter AI).

<ThoughtSpotSpotter
worksheetId="<worksheet-guid>"
visibleActions={[...]}
/>

visibleActions / hiddenActions: Mutually exclusive — configure one or the other, never both. They shape the UI only; ThoughtSpot RBAC enforces authorization regardless of what is shown.

runtimeFilters: Carry Salesloft UI context into the analysis but are not an authorization control. ThoughtSpot RLS (ts_var()) remains the authoritative tenant isolation layer.


SDK events handled​

EventBehavior
AuthInitToken handshake initiated (expected on load and after expiry)
AuthExpireLogs a warning; SDK automatically calls getAuthToken to refresh
Loadiframe loaded successfully
LiveboardRenderedAnalyticsLiveboard removes the skeleton overlay and reveals the iframe content. useLiveboardUrlSync also uses it as the gate that unlocks host events
RouteChangeSource of the active tab GUID — the SDK has no dedicated tab event, so the tab is parsed out of data.currentPath
ErrorCaptured to Sentry for engineering visibility. Does not collapse the UI — ThoughtSpot fires onError for recoverable conditions (auth handshake retries, non-fatal init warnings) during normal load. Only ThoughtSpotAuthContext failures (status === 'error') surface the error UI.

Host events triggered into the iframe (via the embed ref):

Host eventTrigger
SetActiveTabThe URL carries a tab different from the one the embed is showing

URL synchronization (tabs)​

The active Liveboard tab is kept in sync with the browser query string in both directions, so a Liveboard view can be linked, bookmarked, and restored on reload.

Query paramContent
ts_tabGUID of the active tab
iframe              AnalyticsLiveboard (urlSync)        useLiveboardUrlParams
│ │ │
│ RouteChange │ │
├─────────────────────────────>│ onUrlStateChange(next) │
│ ├───────────────────────────────>│ history.replace(?ts_tab=…)
│ │ │
│ │ urlState (new props) │
│ SetActiveTab │<───────────────────────────────┤
│<─────────────────────────────┤ │

AnalyticsLiveboard runs the synchronization internally. Pages only pass the URL state and the setter — no hook call, no prop spreading:

<AnalyticsLiveboard
liveboardId={LIVEBOARD_ID}
urlSync={{
urlState, // LiveboardUrlState read from the query string
onUrlStateChange, // called when the iframe reports a change
defaultTabId, // tab used when the URL carries none
}}
/>

Omitting urlSync leaves the component untouched — the sync stays inert and ts_tab is never written.

Your own onLiveboardRendered and onRouteChange props still work while urlSync is active; the component calls the internal handler first and then yours.

useLiveboardUrlSync​

The hook behind urlSync, exported for ThoughtSpotLiveboard or a custom wrapper. Router-agnostic — it never touches window.location. The caller supplies the current state and a setter, which keeps the package usable from React Router v5, v6, or any other router.

const { liveboardProps, embedRef } = useLiveboardUrlSync({
urlState,
onUrlStateChange,
defaultTabId,
})

liveboardProps is spread onto the Liveboard component and carries ref, activeTabId, onLiveboardRendered and onRouteChange. embedRef is exposed for callers that need to trigger additional host events.

Four behaviors are worth knowing about:

  • activeTabId is frozen on mount. It is only the initial tab. Later tab changes go through HostEvent.SetActiveTab, because changing the prop would remount the iframe and reload the whole Liveboard.
  • Every listener is wrapped in useEventCallback. The SDK mounts the embed inside useDeepCompareEffect(..., [viewConfig, listeners]), and deep comparison falls back to reference equality for functions. A handler that gets a new identity on re-render destroys the embed and builds a new one — the iframe visibly reloads and the just-selected tab snaps back.
  • Host events are gated on LiveboardRendered. Triggering before the embed is ready is a no-op in the SDK.
  • Loop guards. The last tab pushed in either direction is recorded, so an update coming from the iframe is never echoed back into it, and vice versa.

Router adapter (React Router v5)​

useLiveboardUrlParams ships with the package and binds LiveboardUrlState to the query string. It reads ts_tab from useLocation and writes it back with history.replace — not push — so interacting with the Liveboard does not fill the browser history. Params other than the Liveboard ones are left untouched.

Consumers that use a different router (or none) can skip it and pass their own urlState / onUrlStateChange pair to urlSync.

Full page example​

import {
AnalyticsLiveboard,
GENERAL_VISIBLE_ACTIONS,
useLiveboardUrlParams,
} from '@shared/advanced-analytics'

export function SimpleExample() {
const { urlState, setUrlState } = useLiveboardUrlParams()

return (
<AnalyticsLiveboard
liveboardId={LIVEBOARD_ID}
visibleActions={GENERAL_VISIBLE_ACTIONS}
urlSync={{
urlState,
onUrlStateChange: setUrlState,
defaultTabId: DEFAULT_TAB_ID,
}}
/>
)
}

Exported helpers​

ExportPurpose
AnalyticsLiveboard's urlSync propRuns the synchronization inside the component
useLiveboardUrlSyncThe synchronization hook, for custom wrappers
LIVEBOARD_TAB_PARAMQuery param key (ts_tab)
LiveboardUrlStateState type
ThoughtSpotLiveboardRefEmbed handle exposing trigger(hostEvent, payload)

Integration guide​

1. Wrap routes with the provider​

Mount ThoughtSpotAuthProvider once above all ThoughtSpot pages. It initializes the SDK and manages token rotation for the entire subtree. Guard the subtree with the feature flag at the same level.

import { Route, Switch, Redirect } from 'react-router-dom'

import { hasFeatureFlag } from '@rhapsody/feature-flags'

import { ThoughtSpotAuthProvider } from '@shared/advanced-analytics'

export const AdvancedAnalytics = () => {
if (!hasFeatureFlag('advanced_analytics')) {
return <Redirect to="/" />
}

return (
<ThoughtSpotAuthProvider>
<Switch>
<Route exact path="/advanced-analytics/simple" component={SimpleExample} />
<Route exact path="/advanced-analytics/tabs" component={TabsExample} />
<Route exact path="/advanced-analytics/filters" component={FiltersExample} />
</Switch>
</ThoughtSpotAuthProvider>
)
}

AnalyticsLiveboard is exported from @shared/advanced-analytics and handles the ThoughtSpot auth lifecycle internally. Any app that imports this package gets the same loading skeleton, friendly error state, and Sentry reporting out of the box — for free.

liveboardId is the only required prop. All other ThoughtSpotLiveboardProps are optional and forwarded as-is.

import { AnalyticsLiveboard } from '@shared/advanced-analytics'

export function SimpleExample() {
return <AnalyticsLiveboard liveboardId="<liveboard-guid>" />
}

Internally, AnalyticsLiveboard handles:

Auth stateDefault output
loadingPulsing dashboard skeleton (KPI cards + chart + table rows)
errorStarlight EmptyState with DisconnectedImage pictogram, localized title and body + captureException to Sentry (tagged with liveboardId)
ready<ThoughtSpotLiveboard liveboardId={...} {...rest} /> with our skeleton overlaid until onLiveboardRendered fires

Once auth is ready, the embed itself tracks a mutually exclusive lifecycle (EmbedStatus), reset whenever liveboardId changes:

Embed statusMeaningDefault output
pendingMounted, no render confirmed yetEmbed mounted with the skeleton overlaid
renderedonLiveboardRendered firedEmbed, no overlay
failedAn unrenderable error arrived before any renderError state instead of the embed

The pending → failed transition is guarded on purpose: an unrenderable error that arrives after a successful render is reported to Sentry but leaves the board on screen, because the SDK reports single-visualization failures through the same onError channel and blanking a board the user is already working with would be a regression.

Override props​

Both states can be replaced per-usage with optional props. The Sentry call on error is always fired internally regardless of errorFallback:

// Replace only the loading state
<AnalyticsLiveboard
liveboardId="<guid>"
loadingFallback={<MySpinner />}
/>

// Replace only the error state
<AnalyticsLiveboard
liveboardId="<guid>"
errorFallback={<MyErrorBanner />}
/>

// Replace both
<AnalyticsLiveboard
liveboardId="<guid>"
loadingFallback={<MySpinner />}
errorFallback={<MyErrorBanner />}
/>
PropTypeRequiredDescription
liveboardIdstring✅ThoughtSpot Liveboard GUID. Must be the bare GUID — never append /tab/<guid>.
activeTabIdstringGUID of the tab the Liveboard opens on. Read once on mount — later changes require HostEvent.SetActiveTab (see URL synchronization). With urlSync set it acts as the last fallback, after urlState.tabId and urlSync.defaultTabId.
urlSyncUseLiveboardUrlSyncParamsEnables tab ⇄ query param synchronization inside the component. Takes urlState, onUrlStateChange and an optional defaultTabId (see URL synchronization).
loadingFallbackReactNodeReplaces the default skeleton.
errorFallbackReactNodeReplaces the default error message. Sentry capture still fires.
vizIdstringPin the embed to a single visualization inside the Liveboard.
runtimeFiltersRuntimeFilter[]UI context filters applied at query time. Not an authorization control — ThoughtSpot RLS (ts_var()) is the authoritative isolation layer.
visibleActionsAction[]Allowlist of actions shown in the UI. Mutually exclusive with hiddenActions.
hiddenActionsAction[]Denylist of actions hidden from the UI. Mutually exclusive with visibleActions.

3. Low-level alternative: useThoughtSpotAuth​

Use the hook directly only when you need full control over the auth lifecycle outside of a Liveboard context (e.g. to guard other UI, or to build a different kind of embed):

import { ThoughtSpotLiveboard, useThoughtSpotAuth } from '@shared/advanced-analytics'

export function CustomPage() {
const { status, error } = useThoughtSpotAuth()

if (status === 'loading') return <MyCustomSkeleton />
if (status === 'error') return <MyCustomError error={error} />

return <ThoughtSpotLiveboard liveboardId="<liveboard-guid>" />
}

Controlling visible actions​

ThoughtSpot embeds expose a large set of toolbar and context-menu actions. You control which ones appear with visibleActions (allowlist) or hiddenActions (denylist) — pick one, never both.

Important: visibleActions / hiddenActions affect the UI only. ThoughtSpot RBAC and row-level security (ts_var()) remain the authoritative authorization layer regardless of what is shown or hidden.

GENERAL_VISIBLE_ACTIONS — the read-only consumer preset​

@shared/advanced-analytics exports a curated preset for standard read-only pages. It enables AI highlights, Spotter/Ask AI, scheduling, all download formats, drill-down, filtering, and the personalized views dropdown — and hides everything else (edit, share, manage, etc.):

import { AnalyticsLiveboard, GENERAL_VISIBLE_ACTIONS } from '@shared/advanced-analytics'

<AnalyticsLiveboard
liveboardId="<guid>"
visibleActions={GENERAL_VISIBLE_ACTIONS}
/>

Current contents of GENERAL_VISIBLE_ACTIONS (defined in constants.ts):

import { Action } from '@thoughtspot/visual-embed-sdk'

export const GENERAL_VISIBLE_ACTIONS: Action[] = [
Action.AskAi,
Action.AddFilter,
Action.AIHighlights,
Action.Download,
Action.DownloadAsPdf,
Action.DownloadAsCsv,
Action.DownloadAsXlsx,
Action.DownloadAsPng,
Action.DrillDown,
Action.Schedule,
Action.SchedulesList,
Action.PersonalizedViewsDropdown,
]

Extending the preset for a specific page​

Spread GENERAL_VISIBLE_ACTIONS and add the extra Action.* values you need:

import { AnalyticsLiveboard, GENERAL_VISIBLE_ACTIONS } from '@shared/advanced-analytics'
import { Action } from '@thoughtspot/visual-embed-sdk'

<AnalyticsLiveboard
liveboardId="<guid>"
visibleActions={[
...GENERAL_VISIBLE_ACTIONS,
Action.Edit, // allow editing for this specific page
Action.ShareViz, // allow sharing individual visualizations
]}
/>

Creating a role-based action set​

If different roles should see different actions, compute the list before rendering. Keep the logic co-located with the page — do not add role branches to constants.ts:

import { AnalyticsLiveboard, GENERAL_VISIBLE_ACTIONS } from '@shared/advanced-analytics'
import { Action } from '@thoughtspot/visual-embed-sdk'
import { hasPermission } from '@rhapsody/permissions'

function useAnalyticsActions(): Action[] {
// Start from the read-only baseline
const actions = [...GENERAL_VISIBLE_ACTIONS]

// Managers and admins can also edit liveboards
if (hasPermission('manage_analytics') || hasPermission('admin_analytics')) {
actions.push(Action.Edit, Action.EditDetails)
}

// Admins can also share and manage access
if (hasPermission('admin_analytics')) {
actions.push(Action.Share, Action.ManageMonitor)
}

return actions
}

export function AnalyticsPage() {
const visibleActions = useAnalyticsActions()

return (
<AnalyticsLiveboard
liveboardId="<guid>"
visibleActions={visibleActions}
/>
)
}

Full Action reference​

All available values are in the ThoughtSpot SDK enum. You can also browse them in your IDE from the import:

import { Action } from '@thoughtspot/visual-embed-sdk'
// Action.Edit, Action.Share, Action.Download, Action.DrillDown, ...

Full reference: https://developers.thoughtspot.com/docs/embed-actions


Tests​

pnpm test -- shared/advanced-analytics
SpecCovers
utils/liveboardUrlState.spec.tsTab extraction from the RouteChange path
hooks/useLiveboardUrlSync.spec.tsInitial tab resolution, activeTabId freezing, embed → URL publishing, URL → embed host events, and the loop guards in both directions
utils/embedError.spec.tsEmbed-error classification — unrenderable error codes vs. recoverable ones, GRAPHQL_API_ERRORS message matching, allowlist sanitization (toEmbedErrorReport) and signature dedup
components/AnalyticsLiveboard/AnalyticsLiveboard.spec.tsxSanitized Sentry reporting and dedup, state reset on liveboardId change, forwarded-ref cleanup, and auth status rendering

Pending items​

ItemOwnerNotes
Liveboard / Worksheet GUIDs for production pagesAnalytics AcesThe example pages hardcode demo GUIDs (SimpleExample, TabsExample, FiltersExample)
Filter ⇄ URL synchronizationAnalytics AcesOnly the tab is synced today; ts_filters was removed pending a reliable filter contract
THOUGHTSPOT_URL in deploy manifestsInfraAdd to dev.env, ci.env, and prod manifests
analytics-api PR #2030 mergedBackendUnblocks end-to-end testing
ThoughtSpot access groups and RBACThoughtSpot / ProductConsumption vs authoring capabilities
Tenant RLS (ts_var) configurationThoughtSpot / DataTenant isolation validation required before production

Environment variable​

Registered in infrastructure/server/src/envs.ts:

THOUGHTSPOT_URL: {
owner: '@Salesloft/analytics-aces',
fallback: 'https://salesloft-clari.thoughtspot.cloud',
},

References​

TopicURL
Visual Embed SDK — getting startedhttps://developers.thoughtspot.com/docs/tsembed
Trusted Authentication (token rotation, getAuthToken)https://developers.thoughtspot.com/docs/trusted-auth-secret-key
Embed auth overviewhttps://developers.thoughtspot.com/docs/embed-auth
React embed components (LiveboardEmbed, SearchEmbed, SpotterEmbed)https://developers.thoughtspot.com/docs/react-app-embed
Liveboard embedhttps://developers.thoughtspot.com/docs/embed-liveboard
Search embedhttps://developers.thoughtspot.com/docs/embed-search
Spotter embedhttps://developers.thoughtspot.com/docs/embed-spotter
Supported locales (locale prop)https://developers.thoughtspot.com/docs/set-locale
Runtime filtershttps://developers.thoughtspot.com/docs/runtime-filters
Actions (visibleActions / hiddenActions)https://developers.thoughtspot.com/docs/embed-actions
EmbedEvent referencehttps://developers.thoughtspot.com/docs/embed-events
HostEvent reference (triggering events into the iframe)https://developers.thoughtspot.com/docs/host-events
SDK changeloghttps://developers.thoughtspot.com/docs/changelog

Backend contract​

PropertyValue
Serviceanalytics-api
EndpointGET /v2/thoughtspot/embed_token
AuthSalesloft Bearer token (resolved server-side)
Response{ token: string, expires_in: number }
Referenceanalytics-api PR #2030