Embed a custom view

Once you’ve created and published a custom view, you can embed it in your own app: Paid renders the dashboard in a sandboxed iframe, and a signed token scopes the data to exactly one of your customers. Only per-customer custom views can be embedded this way; organization-level custom views are for internal use in Paid and are never embeddable, regardless of their status. The customer never logs into Paid, and the underlying queries are filtered to that customer in the database, not in the browser.

This is the recommended, easiest integration: your backend asks Paid for a short-lived token using your API key, and the @paid-ai/embed SDK handles the rest. You never manage a signing secret.

Custom views are experimental. Routes, token claims, and SDK surface may change without notice and are not covered by the v2 backwards-compatibility guarantees.

Prerequisites

  • A published, per-customer custom view (an organization-level view can never be embedded) and its displayId (e.g. view_5oi3qvfaFLK). See create a custom view with Claude.
  • A Paid API key, used server-side only.

Step 1: Fetch an embed token from your backend

Your backend calls the Paid embed-token endpoint using your API key and returns the token to your frontend. Never expose your API key to the browser.

The endpoint mints a short-lived signed token scoped to a specific customer:

GET https://api.agentpaid.io/api/v2/experimental/views/{displayId}/embed-token
?customerId=<customer-id>
&ttlSeconds=3600 # optional, defaults to 3600, which is also the maximum

Response:

1{ "token": "<signed-jwt>", "expiresAt": "2026-07-01T01:00:00Z" }

customerId accepts the customer’s Paid display id (cus_...), external id, or internal UUID.

Calling this endpoint for an organization-level view’s displayId returns 400 with code ORG_SCOPED_VIEW_NOT_EMBEDDABLE, since organization-level views are never embeddable.

If Paid publishes an SDK for your stack, use it. The typed method makes the request for you and returns the token and its expiry. Call it from your own authenticated backend route and return token / expiresAt to the frontend.

1import Paid from "@paid-ai/sdk";
2
3const paid = new Paid({ apiKey: process.env.PAID_API_KEY });
4
5const { token, expiresAt } = await paid.customViews.getCustomViewEmbedToken(
6 VIEW_DISPLAY_ID,
7 { customerId, ttlSeconds: 3600 },
8);

Option B: call the REST endpoint directly

If there’s no SDK for your stack, call the endpoint over HTTP from your backend:

1// Your backend route. The browser calls this, never the Paid API directly.
2app.get("/api/paid-view-token", async (req, res) => {
3 const customerId = getAuthenticatedCustomerId(req); // from your session
4
5 const response = await fetch(
6 `https://api.agentpaid.io/api/v2/experimental/views/${VIEW_DISPLAY_ID}/embed-token` +
7 `?customerId=${encodeURIComponent(customerId)}&ttlSeconds=3600`,
8 { headers: { Authorization: `Bearer ${process.env.PAID_API_KEY}` } },
9 );
10
11 if (!response.ok) {
12 return res.status(502).json({ error: "Could not fetch embed token" });
13 }
14
15 const { token, expiresAt } = await response.json();
16 res.json({ token, expiresAt });
17});

Step 2: Embed with the SDK

Install the embed library:

$npm install @paid-ai/embed

Pass a getToken callback that fetches a fresh token from your backend. The SDK calls getToken for the initial token and again automatically whenever a token expires. You do not need to manage refresh yourself.

1import { renderCustomView } from "@paid-ai/embed";
2
3const handle = renderCustomView({
4 el: "#custom-view-container", // element or CSS selector to mount into
5 baseUrl: "https://app.paid.ai", // your Paid app base URL
6 token: "view_5oi3qvfaFLK", // the view's displayId, NOT the JWT
7 getToken: async () => {
8 // Fetch a fresh embed token from your backend on load and on expiry
9 const res = await fetch("/api/paid-view-token");
10 const { token } = await res.json();
11 return token;
12 },
13});
14
15// Later, to tear it down:
16// handle.destroy();

renderCustomView returns a handle:

MemberDescription
destroy()Remove the iframe and detach listeners.
iframeThe underlying <iframe> element, if you need it.
updateToken(jwt)Push a fresh JWT to the iframe manually (use only if you manage token refresh yourself instead of getToken).

token is the view’s displayId (which dashboard to show); getToken returns the signed access token (which customer to scope to). They are different values, do not pass the JWT as token.

Pass a date range from your app

By default the embed renders the view’s stored date range (the timeframe set in Paid). If your app has its own date picker, drive the embed’s range from it: post a paid-embed:period message to the iframe whenever your picker changes, and the dashboard re-renders in place with the new window.

1const handle = renderCustomView({
2 el: "#custom-view-container",
3 baseUrl: "https://app.paid.ai",
4 token: VIEW_DISPLAY_ID,
5 getToken,
6 onReady: () => {
7 // The iframe is listening from this point on.
8 pushRange(myDatePicker.value);
9 },
10});
11
12// Call this whenever your date picker changes.
13function pushRange(range: { start: string; end: string }) {
14 handle.iframe.contentWindow?.postMessage(
15 {
16 type: "paid-embed:period",
17 period: { kind: "absolute", start: range.start, end: range.end },
18 },
19 "https://app.paid.ai", // your Paid app base URL
20 );
21}

The period payload accepts the same two shapes as the view’s stored timeframe:

ShapeFieldsExample
Relativekind: "relative", unit (day/month/year), amount{ kind: "relative", unit: "day", amount: 7 }
Absolutekind: "absolute", start, end (inclusive, YYYY-MM-DD){ kind: "absolute", start: "2026-07-01", end: "2026-07-31" }

Send period: null to return to the view’s stored default range.

If you build the embed URL yourself instead of using the SDK, set the range with query parameters on the JWT-backed embed page (/public/views/{displayId} opened with a ?token= embed token): periodKind=relative&periodUnit=day&periodAmount=7, or periodKind=absolute&periodStart=2026-07-01&periodEnd=2026-07-31. Share links generated from the Paid dashboard (/public/custom-views/...) do not accept these parameters.

The range only affects views whose SQL uses the {period_start} / {period_end} placeholders (views created with a timeframe do). The range is a display setting, not an access boundary: the data always stays scoped to the customer in the signed token, whatever range is requested.

Pass filters from your app

A view can declare named filter parameters when it is created (for example a region filter with the values it accepts). If your view declares filters, your app can drive them the same two ways as the date range.

Declare the filter on the view and reference it in the SQL:

1"filters": [
2 { "name": "region", "values": ["us", "eu", "apac"] }
3]
1SELECT ... FROM fact_cost
2WHERE ({filter_region:String} = '' OR JSONExtractString(metadata, 'region') = {filter_region:String})

An unprovided filter binds the empty string, so the = '' branch is the match-all default.

Then push values from your page whenever your own controls change:

1handle.iframe.contentWindow?.postMessage(
2 { type: "paid-embed:filters", filters: { region: "eu" } },
3 "https://app.paid.ai",
4);

Send filters: null to clear back to unfiltered. On a direct embed URL, pass the same values as query parameters instead: filter_region=eu.

The embed’s view endpoint returns the declared filters alongside the render bundle (each filter’s name and, when set, its accepted values), so your app can build its filter controls from the declaration instead of hardcoding them.

Only declared filters are accepted: an unknown name or a value outside the declared list returns 400 with the reason. Like the date range, filters narrow data within the token’s customer: they never widen access, and they are not an isolation mechanism.

If the view stops serving

An embed only resolves for a view that’s currently published. If the view is still a draft, or a previously published view is later unpublished, the embed endpoints return 404 (NOT_FOUND), exactly as they would for a view that never existed, and your customer sees nothing render.

An already-issued embed token isn’t automatically invalidated by unpublishing, but that no longer matters: any request it makes still resolves against the view’s current status, so the embed stops rendering data as soon as the view is unpublished, without waiting for the token to expire.

Unpublishing is available from the view’s page in the Paid dashboard and takes effect immediately. It doesn’t delete the view, so republishing it (from the dashboard, from Claude, or via the API) makes the embed start resolving again with no changes needed on your end.

How scoping and isolation work

  • The customer comes only from the verified token’s sub claim, never from a client parameter. An unknown sub, a missing or invalid token, or a token signed for the wrong resource is rejected.
  • The view’s queries run against a per-customer database layer that filters every row to the token’s customer. The author’s SQL contains no customer filter; isolation is enforced in the database.
  • The dashboard renders in a sandboxed iframe with no network access of its own. The signed token is never passed into that frame, only the already-scoped data for that one customer.