> This page is for version v2 (default).
> For other versions, use one of these documentation indexes:
> - v2 (default): https://docs.paid.ai/v-2/llms.txt
> - v1: https://docs.paid.ai/v-1/llms.txt

> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.paid.ai/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.paid.ai/_mcp/server.

# Embed a custom view

> Embed a published per-customer dashboard in your app with a short-lived, Paid-minted token scoped to one customer

Once you've [created and published a custom view](/documentation/custom-views/create-a-custom-view-with-claude), 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.

> **Warning**
>
> 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](/documentation/custom-views/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:**

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

### Option A: use the SDK (recommended)

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.

#### TypeScript

```typescript
import Paid from "@paid-ai/sdk";

const paid = new Paid({ apiKey: process.env.PAID_API_KEY });

const { token, expiresAt } = await paid.customViews.getCustomViewEmbedToken(
  VIEW_DISPLAY_ID,
  { customerId, ttlSeconds: 3600 },
);
```

#### Python

```python
import os
from paid import Paid

paid = Paid(api_key=os.environ["PAID_API_KEY"])

result = paid.custom_views.get_custom_view_embed_token(
    VIEW_DISPLAY_ID,
    customer_id=customer_id,
    ttl_seconds=3600,
)
token, expires_at = result.token, result.expires_at
```

#### Go

```go
client := paidclient.NewClient(option.WithApiKey(os.Getenv("PAID_API_KEY")))

resp, err := client.CustomViews.GetCustomViewEmbedToken(
    ctx,
    viewDisplayID,
    &paid.GetCustomViewEmbedTokenRequest{
        CustomerId: customerID,
        TtlSeconds: paid.Int(3600),
    },
)
```

#### Ruby

```ruby
paid = Paid::Client.new(api_key: ENV["PAID_API_KEY"])

result = paid.custom_views.get_custom_view_embed_token(
  VIEW_DISPLAY_ID,
  customer_id: customer_id,
  ttl_seconds: 3600,
)
```

#### Java

```java
Paid paid = Paid.builder().apiKey(System.getenv("PAID_API_KEY")).build();

var result = paid.customViews().getCustomViewEmbedToken(
    viewDisplayId,
    GetCustomViewEmbedTokenRequest.builder()
        .customerId(customerId)
        .ttlSeconds(3600)
        .build());
```

### Option B: call the REST endpoint directly

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

#### Node.js

```typescript
// Your backend route. The browser calls this, never the Paid API directly.
app.get("/api/paid-view-token", async (req, res) => {
  const customerId = getAuthenticatedCustomerId(req); // from your session

  const response = await fetch(
    `https://api.agentpaid.io/api/v2/experimental/views/${VIEW_DISPLAY_ID}/embed-token` +
      `?customerId=${encodeURIComponent(customerId)}&ttlSeconds=3600`,
    { headers: { Authorization: `Bearer ${process.env.PAID_API_KEY}` } },
  );

  if (!response.ok) {
    return res.status(502).json({ error: "Could not fetch embed token" });
  }

  const { token, expiresAt } = await response.json();
  res.json({ token, expiresAt });
});
```

#### Python

```python
import os
import httpx
from flask import Flask, jsonify, abort

app = Flask(__name__)
PAID_API_KEY = os.environ["PAID_API_KEY"]
VIEW_DISPLAY_ID = os.environ["VIEW_DISPLAY_ID"]

@app.route("/api/paid-view-token")
def paid_view_token():
    customer_id = get_authenticated_customer_id()  # from your session

    response = httpx.get(
        f"https://api.agentpaid.io/api/v2/experimental/views/{VIEW_DISPLAY_ID}/embed-token",
        params={"customerId": customer_id, "ttlSeconds": 3600},
        headers={"Authorization": f"Bearer {PAID_API_KEY}"},
    )

    if not response.is_success:
        abort(502, "Could not fetch embed token")

    data = response.json()
    return jsonify({"token": data["token"], "expiresAt": data["expiresAt"]})
```

## Step 2: Embed with the SDK

Install the embed library:

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

```typescript
import { renderCustomView } from "@paid-ai/embed";

const handle = renderCustomView({
  el: "#custom-view-container", // element or CSS selector to mount into
  baseUrl: "https://app.paid.ai", // your Paid app base URL
  token: "view_5oi3qvfaFLK", // the view's displayId, NOT the JWT
  getToken: async () => {
    // Fetch a fresh embed token from your backend on load and on expiry
    const res = await fetch("/api/paid-view-token");
    const { token } = await res.json();
    return token;
  },
});

// Later, to tear it down:
// handle.destroy();
```

`renderCustomView` returns a handle:

| Member             | Description                                                                                                    |
| ------------------ | -------------------------------------------------------------------------------------------------------------- |
| `destroy()`        | Remove the iframe and detach listeners.                                                                        |
| `iframe`           | The 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`). |

> **Note**
>
> `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.

```typescript
const handle = renderCustomView({
  el: "#custom-view-container",
  baseUrl: "https://app.paid.ai",
  token: VIEW_DISPLAY_ID,
  getToken,
  onReady: () => {
    // The iframe is listening from this point on.
    pushRange(myDatePicker.value);
  },
});

// Call this whenever your date picker changes.
function pushRange(range: { start: string; end: string }) {
  handle.iframe.contentWindow?.postMessage(
    {
      type: "paid-embed:period",
      period: { kind: "absolute", start: range.start, end: range.end },
    },
    "https://app.paid.ai", // your Paid app base URL
  );
}
```

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

| Shape    | Fields                                                       | Example                                                        |
| -------- | ------------------------------------------------------------ | -------------------------------------------------------------- |
| Relative | `kind: "relative"`, `unit` (`day`/`month`/`year`), `amount`  | `{ kind: "relative", unit: "day", amount: 7 }`                 |
| Absolute | `kind: "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.

> **Note**
>
> 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:

```json
"filters": [
  { "name": "region", "values": ["us", "eu", "apac"] }
]
```

```sql
SELECT ... FROM fact_cost
WHERE ({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:

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

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.

> **Note**
>
> 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.

## Discover a customer's hierarchy

For an organization with customer hierarchies enabled, your backend can read the active units available to a published per-customer view. The API key needs `read:analytics`; an authoring-only key cannot read unit names.

```bash
curl --get "https://api.agentpaid.io/api/v2/experimental/views/$VIEW_DISPLAY_ID/customer-units" \
  -H "Authorization: Bearer $PAID_API_KEY" \
  --data-urlencode "customerId=$CUSTOMER_ID" \
  --data-urlencode "limit=100" \
  --data-urlencode "offset=0"
```

`customerId` accepts the customer's Paid display ID, external ID or internal UUID. Each directory entry contains `externalId`, `name`, `externalType`, `parentExternalId`, `isRoot`, `billable` and `effectiveBillable`. `billable` is the unit's own setting; `effectiveBillable` is `false` when the unit or any unit above it is non-billable. Both describe the unit now. A change applies only to signals received afterwards, so classify past usage by each signal's own billing mark (`data._billing.excluded`), not by these fields.

Use the response's `hasHierarchy` field to distinguish customers with active child units from customers with only a root or deleted children. A child with no usage still qualifies. This value describes the whole customer, even when the current page contains only the root or no entries.

Follow `pagination.hasMore`, increasing `offset` by `limit`, to load the remaining entries. The maximum page size is 100. Build parent relationships from the complete directory; a parent can be on another page. Deleted units are excluded, and names and relationships reflect the next request after a rename or move.

The directory is also available from `/api/public/views/{displayId}/customer-units` with a signed embed token in the `Authorization: Bearer` header, and `/api/public/custom-view-links/{token}/customer-units` for a live shared link. These routes derive the customer from the credential and accept only pagination parameters. They use the same publication, expiry and revocation rules as the view.

Hierarchy discovery returns structure only. It does not add a selector to the dashboard or filter the view's datasets. When customer hierarchies are disabled for the organization, discovery returns `403 FEATURE_NOT_ENABLED`.

## Label units inside the view

The view itself does not need to call the directory. For a hierarchy-enabled, per-customer view, every `paid:data` message the dashboard receives carries a `context` object (version 2) with the customer's units and the current selection. This applies equally to the Paid preview, an embed and a shared link.

```json
{
  "type": "paid:data",
  "data": { "usage": [{ "unit": "northstar-eng", "runs": "412" }] },
  "context": {
    "version": 2,
    "unitSelection": { "mode": "subtree", "externalId": "northstar-eng", "name": "Engineering" },
    "period": { "kind": "relative", "unit": "day", "amount": 30 },
    "units": [
      { "externalId": "northstar-labs", "name": "Northstar Labs", "externalType": "customer", "parentExternalId": null, "isRoot": true, "billable": true, "effectiveBillable": true },
      { "externalId": "northstar-eng", "name": "Engineering", "externalType": "department", "parentExternalId": "northstar-labs", "isRoot": false, "billable": true, "effectiveBillable": true }
    ],
    "unitsTotal": 2,
    "unitsTruncated": false
  }
}
```

Select the unit key in your query, for example `ifNull(external_customer_unit_id, '') AS unit`, and look up its name when the message arrives:

```js
// A Map, not {}: unit keys are arbitrary strings, "__proto__" included.
var units = new Map();
window.addEventListener("message", function (e) {
  var m = e.data;
  if (!m || typeof m !== "object" || m.type !== "paid:data") return;
  units = new Map();
  ((m.context && m.context.units) || []).forEach(function (u) {
    units.set(u.externalId, u);
  });
  render(m.data);
});
function unitName(key) {
  if (!key) return "Not assigned to a unit";
  var unit = units.get(key);
  return unit ? unit.name : key;
}
```

* `unitSelection` is `{ "mode": "all" }`, or the selected unit's `externalId` and `name` with `direct` or `subtree`.
* `units` lists only the viewing customer's active units, at most 1,000, oldest first. Above that, `unitsTruncated` is `true` and `unitsTotal` is the full count; fall back to the key for any unit not listed.
* A deleted unit is not listed, but its history still counts in its ancestors' subtree totals, so keep the key fallback.
* Views without hierarchy navigation, and organizations without customer hierarchies, receive no `units`. Older bundles that read only `data`, `unitSelection.mode`, `unitSelection.externalId` and `period` keep working unchanged.

## Show cap usage per unit

A view can also receive the customer's unit caps as `caps` in the same `context`. This is opt-in: create or update the view with `capsEnabled: true`, alongside `hierarchyEnabled` on a per-customer view. Turn it on only for views that draw cap usage, since each load then reads cap usage. Views without it receive no `caps`. Caps are spend policy, so the API key that reads the view's data or mints its embed token also needs `read:billing`: data read without it leaves `caps` out, and minting returns `403 INSUFFICIENT_SCOPE`. A token records this when it's minted, so only a token from a key with `read:billing` shows caps; any other token, including one minted before caps existed, never does, even if they're turned on later.

Each entry is one cap in force on one of the customer's units and credits currencies. Its fields are exactly what the customer unit cap endpoints return, and `used` covers the unit and every unit under it.

```json
{
  "caps": [
    {
      "externalId": "northstar-sales",
      "creditsCurrencyId": "7f4f5d4c-55e9-4d5b-a3e7-c9eb3d2d01bf",
      "creditsCurrencyKey": "agent_credits",
      "creditsCurrencyName": "Agent credits",
      "amount": 9000,
      "frequency": "MONTHLY",
      "schedule": { "type": "CALENDAR" },
      "currentPeriod": {
        "window": { "start": "2026-10-01T00:00:00.000Z", "end": "2026-11-01T00:00:00.000Z" },
        "used": 6705,
        "remaining": 2295
      }
    }
  ],
  "capsTotal": 1,
  "capsTruncated": false
}
```

Work out the share used and whether a unit is over its cap in the view, so you choose the rounding and how exactly 100% reads:

```js
var p = cap.currentPeriod; // null when no usage was reported for this cap
var percent = p ? Math.round((p.used / cap.amount) * 100) : null;
var over = p ? p.used > cap.amount : false; // exactly 100% is at the cap, not over
```

* A unit can have one cap per credits currency, so match caps by `externalId` and `creditsCurrencyId` together.
* At most 25 caps are sent, in the directory's order. Above that, `capsTruncated` is `true` and `capsTotal` is the full count. For more caps, or caps that follow the unit selection, query `dim_customer_unit_cap_current` instead (see [spend caps](/documentation/analytics-api/query-cookbook#spend-caps)).
* `currentPeriod` is `null` when no usage was reported for that cap.
* When cap usage can't be read in time, the view still loads: `caps` is left out and `capsUnavailable` is `true`. Show the rest of the view and leave the cap figures out rather than showing zeros.

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

## Related guides

* [Create a custom view with Claude](./authoring)