Widget BuilderIntegration Guide

Getting Started as a Host Application

Widget Builder uses session-based authentication. Your backend mints short-lived sessions (ess_...) for each authenticated user; your frontend passes the session ID to the SDK. Nothing long-lived ever touches the browser.

Integration Guide

Connect your app to Widget Builder so your users can create, customize, and display dynamic widgets — all without leaving your product. This guide walks you through everything from initial setup to production deployment.

Overview

Integrating with Widget Builder is a 5-step process (one bootstrap + 4 integration steps):

  1. 0 — POST /api/setup once with ADMIN_SETUP_SECRET to create the first admin. Subsequent admins are invited from inside the portal.
  2. 1 — so Widget Builder can discover your APIs
  3. 2 — get appId + appSecret
  4. 3 — one short-lived session (ess_...) per logged-in user
  5. 4 — pass sessionId to drop widgets into your frontend

Steps 0–2 are one-time setup per Widget Builder deployment. Steps 3–4 run per user login.

Admin Portal Access

Once bootstrapped, admins sign in at {WIDGET_BUILDER_URL}/admin/login. Enter your email to receive a 6-digit OTP, valid for 10 minutes. All app registration, endpoint sync, template / widget management, and tenant onboarding happens from this portal.

Quick Start

This section shows the embed-time surface. For the server-to-server contracts (register app, mint session, widget CRUD), jump to the API Reference.

1. Get a session ID from your backend

Your backend calls POST /api/embed/sessions (see API Reference) and returns the sessionId to your frontend however you like — cookie, JSON response, server-rendered prop, etc.

2. Load the SDK + mount the button

In your frontendhtml
<script src="https://your-widget-builder-url.com/embed/sdk.js"></script>

<widget-builder-button
  session-id="ess_YOUR_SESSION_ID"
  base-url="https://your-widget-builder-url.com"
></widget-builder-button>

A styled "+ Add Widget" button appears. Clicking it opens the template gallery — users pick a template, customize it, and save.

3. Display saved widgets

Render Widget Gridhtml
<div id="my-widgets"></div>

<script>
  WidgetBuilder.renderAll({
    sessionId: 'ess_YOUR_SESSION_ID',
    container: '#my-widgets',
    baseUrl: 'https://your-widget-builder-url.com',
    columns: 2
  });
</script>

Integration Methods

Widget Builder offers multiple ways to integrate depending on your needs.

SDK (Recommended)

Load the JavaScript SDK for the full experience — button component, modal, grid rendering, and events.


Integration Pattern

Before you start the numbered steps below, here's the mental model — what runs where, and what you'll wire up. This section describes the flow, not the code. See the API Reference later for each endpoint's request/response contract, then wire it into your stack however you see fit.

Runtime flow (per page load)

  1. 1. Browser → Your backend. Frontend hits your own authenticated endpoint (e.g. /api/widget-builder-session) to request a session.
  2. 2. Your backend → Widget Builder. Call POST /api/embed/sessions with appId + appSecret + userEmail + embedToken. Cache the returned sessionId in your DB keyed by (tenantId, userId) — sessions live 24h, re-minting on every page load wastes a round trip.
  3. 3. Your backend → Browser. Return the sessionId.
  4. 4. Browser → SDK. Pass the sessionId to the SDK (WidgetBuilder.init, <widget-builder-button>, etc.).

One-time setup (the numbered steps below)

  1. Register your app in the admin portal UI — you receive appId + appSecret. Store both server-side. Done once per product.
  2. Onboard each tenant in one call — POST /api/embed/onboard (authenticated with x-app-secret) creates the org, user, owner membership, and embed token in one atomic call. Store embedToken.token in your DB keyed by tenant.
  3. Add additional users (optional) — if a tenant has multiple host-app users, add them with POST /api/embed/users (with x-embed-token) so they can mint sessions.

Edge cases worth handling

Session expiry: 24h TTL. Either refresh before expiry via PUT /api/embed/sessions/{id}/refresh, or re-mint lazily when your frontend gets a 401.

First-time users: /api/embed/sessions 404s if the user doesn't exist in Widget Builder. Handle by calling POST /api/embed/users then retrying.

Tenant switches: when your user changes tenants, the cached session is for the wrong org. Invalidate by keying your cache on (tenantId, userId).

A working example using this pattern lives in the Construction Portal repo at app/api/embed/widget-builder-session/route.ts and components/widgets/WidgetBuilderDashboard.tsx.


Bootstrap the Admin Portal (First Admin)

Before you can register an app you need an admin account in the Widget Builder admin portal. The portal UI has no public "sign up" button — adding admins is restricted to existing admins. So the very first admin has to be created via a one-shot setup API (Postman / curl), after which all subsequent admins are invited from inside the portal. This is a one-time action per Widget Builder deployment.

1. Create the first admin via Postman (or curl)

Use the setup secret provided by the Widget Builder ops team as the Authorization bearer token.

POST/api/setup

Create the very first admin in the Widget Builder admin portal. One-shot — refuses if any admin already exists.

Request Headers

Request Headershttp
Content-Type: application/json
Authorization: Bearer <SETUP_SECRET>

Request Body

Request Bodyjson
{
  "email": "you@your-company.com",   // Required: admin's email
  "name": "Your Name"                // Required: admin's display name
}

Example Request

Example Requestbash
curl -X POST {WIDGET_BUILDER_URL}/api/setup \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <SETUP_SECRET>" \
  -d '{
    "email": "you@your-company.com",
    "name": "Your Name"
  }'

Response

Response (200 OK)json
{
  "data": {
    "message": "Setup completed successfully. Login at /admin/login using email OTP",
    "admin": {
      "email": "you@your-company.com",
      "name": "Your Name"
    }
  },
  "error": null
}

2. One-shot protection

The endpoint refuses to run a second time. A subsequent POST returns:

Response (400 Bad Request)json
{
  "data": null,
  "error": "Setup already completed. Admin user already exists."
}

Check the setup status any time with a GET request (same Authorization header):

GET/api/setup

Check whether setup is still required and how many admins exist.

Example Requestbash
curl {WIDGET_BUILDER_URL}/api/setup \
  -H "Authorization: Bearer <SETUP_SECRET>"
Response (200 OK)json
{
  "data": {
    "setupRequired": false,
    "adminCount": 1
  },
  "error": null
}

3. Log in as the first admin

Open {WIDGET_BUILDER_URL}/admin/login and sign in with the email you just registered. A 6-digit OTP is emailed (valid 10 minutes; also logged to the server in dev).

4. Add more admins from the portal

From Team → Invite Member, enter an email and role (owner / admin / user). The invitee receives an email, logs in via OTP, and joins the organization.


Step 1: Expose a Config Endpoint

Widget Builder auto-discovers your APIs by calling your app's config endpoint. This tells Widget Builder what data your widgets can access.

GET/api/config

Returns your app's available API endpoints and configuration. Widget Builder calls this during setup and sync.

Response Schema

Return a JSON object with appName, version, and an endpoints array. Only expose GET endpoints — never mutating routes. Everything the AI knows about your data comes from this response, so richer metadata = smarter widgets.

/api/config Response — full example (3 endpoints)json
{
  "appName": "Construction Portal",
  "version": "1.0.0",
  "endpoints": [
    {
      "path": "/api/projects",
      "method": "GET",
      "description": "List construction projects with status, budget, site manager, start/end dates. Supports filtering by status and date range, plus pagination and sorting.",
      "module": "projects",
      "listArrayPath": "data",
      "queryParameters": [
        { "name": "status",    "type": "string", "required": false, "description": "Filter by status. One of: planning, active, on_hold, completed, cancelled." },
        { "name": "startDate", "type": "string", "required": false, "description": "ISO date. Returns projects starting on or after this date." },
        { "name": "endDate",   "type": "string", "required": false, "description": "ISO date. Returns projects ending on or before this date." },
        { "name": "limit",     "type": "number", "required": false, "description": "Max rows to return. Default 50, max 500." },
        { "name": "offset",    "type": "number", "required": false, "description": "Rows to skip for pagination." },
        { "name": "sortBy",    "type": "string", "required": false, "description": "Field to sort by. One of: name, startDate, budget, status." },
        { "name": "sortOrder", "type": "string", "required": false, "description": "Sort direction. One of: asc, desc." }
      ],
      "fieldSchema": [
        { "name": "id",          "path": "id",          "type": "string", "description": "Unique project identifier",             "isIdentifier": true },
        { "name": "name",        "path": "name",        "type": "string", "description": "Project name" },
        { "name": "status",      "path": "status",      "type": "string", "description": "Current project status",                "groupable": true, "enumValues": ["planning","active","on_hold","completed","cancelled"] },
        { "name": "budget",      "path": "budget",      "type": "number", "description": "Total budget in USD",                   "numeric": true },
        { "name": "spent",       "path": "spent",       "type": "number", "description": "Amount spent to date in USD",           "numeric": true },
        { "name": "startDate",   "path": "startDate",   "type": "date",   "description": "Project start date",                    "dateLike": true },
        { "name": "endDate",     "path": "endDate",     "type": "date",   "description": "Scheduled end date",                    "dateLike": true },
        { "name": "siteManager", "path": "siteManager", "type": "string", "description": "Site manager's user id",                "lookupRef": "users" },
        { "name": "region",      "path": "region",      "type": "string", "description": "Geographic region",                     "groupable": true, "enumValues": ["north","south","east","west","central"] },
        { "name": "progress",    "path": "progress",    "type": "number", "description": "Completion percentage 0-100",           "numeric": true }
      ]
    },
    {
      "path": "/api/tasks",
      "method": "GET",
      "description": "List tasks across all projects. Can be filtered by project, assignee, priority, or status.",
      "module": "tasks",
      "listArrayPath": "items",
      "queryParameters": [
        { "name": "projectId", "type": "string", "required": false, "description": "Filter tasks belonging to a specific project id." },
        { "name": "assignee",  "type": "string", "required": false, "description": "Filter by assigned user id." },
        { "name": "priority",  "type": "string", "required": false, "description": "One of: low, medium, high, urgent." },
        { "name": "status",    "type": "string", "required": false, "description": "One of: todo, in_progress, blocked, done." },
        { "name": "limit",     "type": "number", "required": false, "description": "Max rows. Default 50." }
      ],
      "fieldSchema": [
        { "name": "id",          "path": "id",          "type": "string", "description": "Task id",                          "isIdentifier": true },
        { "name": "title",       "path": "title",       "type": "string", "description": "Task title" },
        { "name": "projectId",   "path": "projectId",   "type": "string", "description": "Parent project id",                "lookupRef": "projects" },
        { "name": "assignee",    "path": "assignee",    "type": "string", "description": "Assigned user id",                 "lookupRef": "users" },
        { "name": "priority",    "path": "priority",    "type": "string", "description": "Task priority",                    "groupable": true, "enumValues": ["low","medium","high","urgent"] },
        { "name": "status",      "path": "status",      "type": "string", "description": "Task status",                      "groupable": true, "enumValues": ["todo","in_progress","blocked","done"] },
        { "name": "dueDate",     "path": "dueDate",     "type": "date",   "description": "Task due date",                    "dateLike": true },
        { "name": "hoursLogged", "path": "hoursLogged", "type": "number", "description": "Hours logged against this task",   "numeric": true }
      ]
    },
    {
      "path": "/api/invoices",
      "method": "GET",
      "description": "List invoices issued to clients. Returns amount, payment status, and issue/due dates.",
      "module": "finance",
      "listArrayPath": "data",
      "queryParameters": [
        { "name": "status",   "type": "string", "required": false, "description": "One of: draft, sent, paid, overdue, cancelled." },
        { "name": "clientId", "type": "string", "required": false, "description": "Filter by client id." },
        { "name": "fromDate", "type": "string", "required": false, "description": "ISO date. Issued on or after." },
        { "name": "toDate",   "type": "string", "required": false, "description": "ISO date. Issued on or before." },
        { "name": "limit",    "type": "number", "required": false, "description": "Max rows. Default 50." }
      ],
      "fieldSchema": [
        { "name": "id",       "path": "id",       "type": "string", "description": "Invoice id",                  "isIdentifier": true },
        { "name": "number",   "path": "number",   "type": "string", "description": "Human-readable invoice number" },
        { "name": "clientId", "path": "clientId", "type": "string", "description": "Client id",                   "lookupRef": "clients" },
        { "name": "amount",   "path": "amount",   "type": "number", "description": "Total amount in USD",         "numeric": true },
        { "name": "status",   "path": "status",   "type": "string", "description": "Payment status",              "groupable": true, "enumValues": ["draft","sent","paid","overdue","cancelled"] },
        { "name": "issuedAt", "path": "issuedAt", "type": "date",   "description": "Date the invoice was issued", "dateLike": true },
        { "name": "dueAt",    "path": "dueAt",    "type": "date",   "description": "Date payment is due",         "dateLike": true },
        { "name": "paidAt",   "path": "paidAt",   "type": "date",   "description": "Date payment was received",   "dateLike": true }
      ]
    }
  ]
}

Minimal Example

Only path and method are required per endpoint. Everything else is optional — but without fieldSchema and rich descriptions the AI falls back to table/card-list only (no charts, no KPIs).

Minimum viable config (not recommended)json
{
  "appName": "My App",
  "version": "1.0.0",
  "endpoints": [
    { "path": "/api/analytics/revenue", "method": "GET", "description": "Monthly revenue aggregates" }
  ]
}

Endpoints Array

FieldTypeRequiredDescription
pathstringYesThe API route (e.g. /api/projects)
methodstringYesGET, POST, PUT, or DELETE
descriptionstringNoHuman-readable description shown in the widget editor
modulestringNoLogical grouping (e.g. projects, analytics)
queryParametersarrayNoSupported query params (name, type, required, description) — used by the AI to generate pagination/filter/sort URLs
listArrayPathstringNoDot-path to the array of items in list responses (e.g. `data.projects`)
fieldSchemaarrayNoPer-field metadata for AI auto-complete (type, groupable, numeric, dateLike, enum, etc.)

Field Schema Tags

Each entry in fieldSchema describes one field in the endpoint's response items. The more tags you provide, the smarter the AI's widget suggestions get — e.g. a groupable string field becomes a bar/pie-chart candidate; a dateLike field unlocks timeseries charts.

TagTypeMeaning
namestringHuman-readable field name shown in the editor
pathstringDot-path to the value inside each item (e.g. `budget.total`)
typestringOne of `string` / `number` / `boolean` / `date` / `objectId` / `array` / `object`
descriptionstringOne-line description used by the AI to match user intent
groupablebooleanGood choice for groupBy / category axis in charts
numericbooleanCan be summed/averaged/used as a y-axis value
dateLikebooleanCan drive timeseries (over-time) charts
isIdentifierbooleanTitle/label/id field — use as row title, never as group key
enumValuesstring[]Closed list of allowed values (e.g. statuses)
lookupRefstringName of the referenced collection if this field is a foreign-key id (e.g. `sites`)

Writing Good Descriptions (important)

The description field is the AI's #1 signal when picking which endpoint to call. Vague descriptions = wrong endpoint picks = broken widgets.

Don'tDo
"Get projects""List construction projects with status, budget, site manager, start/end dates. Supports filtering by status and date range."
"Returns tasks""List tasks across all projects. Filterable by project, assignee, priority, or status."
"Invoices endpoint""List invoices issued to clients. Returns amount, payment status, and issue/due dates."

Include the domain noun (projects / tasks / invoices), the main fields, and the available filters. Inline enum values in query-parameter descriptions (e.g. "One of: todo, in_progress, blocked, done") — the AI is instructed to never invent values.

Common Mistakes to Avoid

MistakeWhy it breaks thingsFix
Vague descriptions like `"Get projects"`AI can't disambiguate between similar endpoints. Wrong endpoint picks.Write a full sentence naming domain, fields, and filters.
Missing `fieldSchema`RELEVANCE GATE falls back to "safer defaults" — table and card-list only. No charts, no KPIs, no timeseries.Populate `fieldSchema` with at least `name`, `path`, `type`, `description`, plus `numeric`/`groupable`/`dateLike` tags.
Wrong `listArrayPath``findArray()` can't locate items. Every widget shows "No data available".If response is `{ data: [...] }` set `"data"`. If `{ items: [...] }` set `"items"`. If the array is the root, omit the field.
Missing `enumValues` on status/category fieldsAI generates invalid filter URLs and can't colour-code charts.List every allowed value: `"enumValues": ["planning","active","completed"]`.
Grouping/pie-charting an `isIdentifier` fieldEach row becomes its own slice — a useless chart.Mark unique-per-row fields with `"isIdentifier": true` so the RELEVANCE GATE blocks them from group-by viz.
Inventing query-param names the AI would like to haveAI is told `use ONLY these names — never invent`. If you forget `limit`, the AI omits pagination and fetches everything.List every supported param (`limit`, `offset`, `sortBy`, `sortOrder`, filter params) with accurate types.
Exposing POST/PUT/DELETE routesWidget Builder is a read-only viz tool. Mutating endpoints in the catalog = security risk.Filter `endpoints` to `method === "GET"` before returning.
Returning the catalog without authLeaks your full API surface to anyone who hits `/api/config`.Validate the `x-app-secret` header against `WIDGET_BUILDER_APP_SECRET` before returning anything.
Forgetting `numeric` / `dateLike` / `groupable` tagsAI can't pick chart types. Numeric budgets don't become KPIs; dates don't unlock timeseries.Tag every field a human would use as a chart axis.
`path` that doesn't match a real routeWidget executes, proxy forwards, host returns 404. User sees "Failed to load data".The `path` must be the exact route the widget will `fetch()` (same path your Next.js handler answers).

Rule of thumb

If a human looking at your /api/config response couldn't figure out what each endpoint does and which fields are chartable, neither can the AI. Read your own response top-to-bottom before sync — it's the single biggest lever on widget quality.

Auth on Your Config Endpoint

Widget Builder sends an x-app-secret header on every request (matches the appSecret you copied from the admin portal when the app was registered). Validate it to confirm the request is from Widget Builder.

Example: /api/config route (Next.js App Router)typescript
import { NextRequest, NextResponse } from "next/server";
import { getAllApiEndpoints } from "@/lib/api-discovery";

export async function GET(req: NextRequest) {
  // 1. Refuse to run if the secret isn't configured — don't leak the catalog
  const expectedSecret = process.env.WIDGET_BUILDER_APP_SECRET;
  if (!expectedSecret) {
    return NextResponse.json(
      { error: "Server misconfigured: app secret not set" },
      { status: 500 }
    );
  }

  // 2. Validate x-app-secret — this is how Widget Builder authenticates
  const providedSecret = req.headers.get("x-app-secret");
  if (!providedSecret || providedSecret !== expectedSecret) {
    return NextResponse.json(
      { error: "Unauthorized: missing or invalid x-app-secret" },
      { status: 401 }
    );
  }

  // 3. Only expose GET endpoints — never mutating routes
  const allEndpoints = await getAllApiEndpoints();
  const endpoints = allEndpoints.filter((ep) => ep.method === "GET");

  return NextResponse.json({
    appName: "Construction Portal",
    version: "1.0.0",
    endpoints: endpoints.map((ep) => ({
      path: ep.path,
      method: ep.method,
      description: ep.description,
      module: ep.module,
      queryParameters: ep.queryParameters,
      listArrayPath: ep.listArrayPath,
      fieldSchema: ep.fieldSchema,
    })),
  });
}

// Optional: respond to HEAD pings (Widget Builder uses these for health checks)
export async function HEAD() {
  return new NextResponse(null, { status: 200 });
}

Step 2: Register Your App

App registration happens once, in the Widget Builder admin portal UI. There is no public API for creating apps.

Admin Portal Flow

  1. 1. Log into the Widget Builder admin portal.
  2. 2. Go to Connected Apps > Add App.
  3. 3. Enter your app name, apiBaseUrl, and apiConfigEndpoint (defaults to /api/config).
  4. 4. Save. The portal shows an appId and appSecret.
  5. 5. Copy both into your host app's environment. The secret is shown only once — store it securely.

What you walk away with

appId — the public identifier for your connected app. Passed in session-creation requests.

appSecret — the shared secret for server-to-server calls. Used to authenticate session creation, token provisioning, and incoming proxy requests to your host app. Never expose it to the browser.

Environment variables on your host app

.env (host app)bash
# Public — so the SDK script can load in the browser
NEXT_PUBLIC_WIDGET_BUILDER_URL=https://widget-builder.example.com

# Server-only — never expose to the browser
WIDGET_BUILDER_APP_ID=6651b...
WIDGET_BUILDER_APP_SECRET=wbs_...

With these in place you can move on to Step 3 — provisioning a per-tenant embed token and minting user sessions.


Step 3: Onboarding API — Create Tenant, User & Embed Token

When a new host-application tenant signs up, your backend calls POST /api/embed/onboard once. It atomically creates the organization, owner user, membership, and embed token — and returns the emb_... token. Store it server-side, keyed by your tenant; it's shown only once.

POST/api/embed/onboard

Authenticated with x-app-secret. If any step fails, all prior inserts are rolled back.

Request Headers

Request Headershttp
Content-Type: application/json
x-app-secret: wbs_your_app_secret      // or pass appSecret in the body

Request Body

Request Bodyjson
{
  "organizationName": "Acme Corp",          // Required: tenant/org display name
  "firstName": "Jane",                      // Required: owner first name
  "lastName": "Doe",                        // Required: owner last name
  "email": "jane@acme.com",                 // Required: owner email
  "appId": "673abc123def456789012345",      // Required: appId from admin portal (or use appName)
  "appName": "construction-portal",         // Alt to appId: exact connected app name
  "appSecret": "wbs_xxx...",                // Required if not sent as x-app-secret header
  "apiKeyName": "Acme Corp - Embed Token",  // Optional: default = "{orgName} - Embed Token"
  "allowedDomains": ["acme.com"],           // Optional: domain whitelist (empty = allow all)
  "hasExpiry": false,                       // Optional: set true for expiring token
  "expiryDays": 90                          // Optional: days until expiry (1-365), only when hasExpiry: true
}

Example Request

Example Requestbash
curl -X POST https://your-widget-builder.com/api/embed/onboard \
  -H "Content-Type: application/json" \
  -H "x-app-secret: wbs_your_app_secret" \
  -d '{
    "organizationName": "Acme Corp",
    "firstName": "Jane",
    "lastName": "Doe",
    "email": "jane@acme.com",
    "appId": "673abc123def456789012345",
    "allowedDomains": ["acme.com", "*.acme.com"]
  }'

Response

Response (201 Created)json
{
  "success": true,
  "data": {
    "message": "Successfully onboarded. Store the embed token securely — it won't be shown again.",
    "organization": {
      "id": "6651a1b2c3d4e5f6a7b8c9d0",
      "name": "Acme Corp",
      "slug": "acme-corp-1742035200000"
    },
    "user": {
      "id": "65fa1b2c3d4e5f6a7b8c9d0e",
      "firstName": "Jane",
      "lastName": "Doe",
      "email": "jane@acme.com",
      "role": "owner",
      "isExistingUser": false
    },
    "embedToken": {
      "id": "6651c1b2c3d4e5f6a7b8c9d0",
      "name": "Acme Corp - Embed Token",
      "token": "emb_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456789012345678",
      "allowedDomains": ["acme.com", "*.acme.com"],
      "expiresAt": null
    },
    "appId": "673abc123def456789012345"
  }
}

Notes

  • Existing email: if the email already belongs to a Widget Builder user, they're attached as owner of the new organization and user.isExistingUser is true. No duplicate user is created.
  • One call per tenant. Every call creates a new organization — don't retry on the same tenant or you'll get duplicates. Cache the response.

Error Responses

StatusWhen
400Missing required field (organizationName, firstName, lastName, email), invalid email, invalid expiryDays, or connected app not registered
401Missing or invalid `appSecret` / `x-app-secret` — secret doesn't match the registered app

Session Authentication

Widget Builder uses sessions (ess_...) for all frontend authentication. Sessions are short-lived (24 hours), user-scoped, and refreshable. Your backend mints them; your frontend passes them to the SDK.

Why sessions only

The browser is a hostile environment — anything long-lived you ship there leaks. Sessions tie the embed to one logged-in user for one day; stealing one grants limited access, and revocation is as simple as waiting out the clock or calling refresh with invalid input.

How It Works

1. Your backend: POST /api/embed/sessions (appId + appSecret + userEmail + embedToken)

↓

2. Widget Builder: validates, creates session → returns ess_xxx

↓

3. Your frontend: passes sessionId to SDK

↓

4. Widget Builder: validates session on every request (24h TTL, refreshable)

Create a Session

POST/api/embed/sessions

Server-to-server: create a short-lived embed session tied to a specific user. Call this from your backend only.

Create Sessionbash
curl -X POST https://your-widget-builder.com/api/embed/sessions \
  -H "Content-Type: application/json" \
  -d '{
    "appId": "6651b...",
    "appSecret": "wbs_your_app_secret",
    "userEmail": "user@company.com",
    "embedToken": "emb_STORED_FOR_THIS_TENANT"
  }'

Request Body

FieldTypeRequiredDescription
appIdstringYes*Connected app ID (use appId or appName)
appNamestringYes*Connected app name (alternative to appId)
appSecretstringYesYour app secret (from app registration)
userEmailstringYesEmail of the user creating the session
embedTokenstringNoPer-tenant embed token (auto-resolved from the user's org if omitted)

Response

Session Responsejson
{
  "success": true,
  "data": {
    "sessionId": "ess_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
    "expiresAt": "2025-01-02T12:00:00.000Z",
    "userEmail": "user@company.com",
    "organizationId": "6651a...",
    "appId": "6651b..."
  }
}

Use the Session in the SDK

Pass sessionId to any SDK method.

SDK with Sessionjavascript
WidgetBuilder.init({
  sessionId: 'ess_a1b2c3d4...',
  container: '#widget-builder',
  baseUrl: 'https://your-widget-builder.com',
});

// Web component
// <widget-builder-button session-id="ess_xxx" base-url="https://..." />

Pass the Session to the API

If you call the embed API directly (bypassing the SDK), pass the session three ways:

MethodExample
`x-session-id` headerheaders: { 'x-session-id': 'ess_...' }
`Authorization` headerheaders: { 'Authorization': 'Bearer ess_...' }
Query parameter?sessionId=ess_...

Verify a Session

GET/api/embed/verify

Check if a session is valid. Returns the organization, app, and user context.

Verify Sessionbash
curl https://your-widget-builder.com/api/embed/verify \
  -H "x-session-id: ess_YOUR_SESSION_ID"

Refresh a Session

Sessions expire after 24 hours. Refresh them server-side before expiry to keep the user's widget builder active.

PUT/api/embed/sessions/{sessionId}/refresh

Extend a session's TTL by another 24 hours. Requires appId + appSecret.

Refresh Session (Server-Side)javascript
const res = await fetch(
  `${WB_URL}/api/embed/sessions/${sessionId}/refresh`,
  {
    method: "PUT",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      appId: "6651b...",
      appSecret: "wbs_your_app_secret",
    }),
  }
);
const { data } = await res.json();
// data.expiresAt → new expiration timestamp

Widget Permissions (RBAC)

Your app is the authority. Widget Builder is the enforcer. Widget Builder stores no roles and never re-derives an access decision. You resolve what a user may do from your own RBAC, send the answer when you mint their session, and Widget Builder stamps it on the session and checks it on every widget route.

Absent means unrestricted

A session created without permissions is fully permitted. Apps that have not implemented RBAC keep working exactly as before — adopt this whenever you are ready, with no flag day and no coordination with other host apps.

The permission shape

permissionsjson
{
  "create": true,
  "edit":   "ALL",
  "delete": "OWN",
  "drag":   false
}
FieldTypeControls
createbooleanCreating widgets — blank, from a template, or with AI
editNONE | OWN | ALLChanging a widget's name or content
deleteNONE | OWN | ALLDeleting a widget
dragbooleanMoving and resizing widgets on the dashboard

OWN means widgets this user created; ALL means every widget on the dashboard.

Why drag is separate from edit: a widget's grid position is shared. One person dragging rearranges the board for everyone who views that dashboard, and it saves automatically with no Save button. That is a different blast radius from editing one widget's contents.

Viewing is not a permission. If your app rendered the page holding the dashboard, you already decided the user may see it. Widget Builder does not second-guess that — a second source of truth is how two surfaces drift apart.

Step 1 — Resolve from your own RBAC

Do this in your backend, from whatever model you already have — roles, per-user grants, or both. Widget Builder never learns which; it only receives the four values.

Your app, at session-creation timejavascript
const role = await getRoleForUser(userId, tenantId);

const permissions = role.isAdmin
  ? { create: true, edit: "ALL", delete: "ALL", drag: true }
  : role.widgetPermissions ?? UNRESTRICTED;   // absent ⇒ unrestricted

Step 2 — Send them with the session

Add two fields to the POST /api/embed/sessions call you already make. No new endpoint.

POST /api/embed/sessionsjson
{
  "appId": "6651b...",
  "appSecret": "wbs_your_app_secret",
  "userEmail": "user@company.com",
  "embedToken": "emb_STORED_FOR_THIS_TENANT",
  "permissions": { "create": true, "edit": "OWN", "delete": "NONE", "drag": false },
  "roleKey": "site-manager"
}
FieldRequiredDescription
permissionsNoThe resolved answer. Omit for unrestricted. Partial objects allowed — missing keys fall back to unrestricted.
roleKeyNoYour own role identifier, opaque to Widget Builder. Used only to target permission pushes at every live session for a role (Step 4).

Step 3 — What Widget Builder enforces

Checked server-side on every request. Hiding buttons is presentation; these are the gate.

RequestRequires
POST /api/embed/widgetscreate
POST /api/embed/widgets/from-templatecreate
POST /api/embed/ai/widget/codecreate or edit ≠ NONE
PUT /api/embed/widgets/:id — body contains only layoutdrag
PUT /api/embed/widgets/:id — any other bodyedit (+ ownership when OWN)
DELETE /api/embed/widgets/:iddelete (+ ownership when OWN)

Refusals return 403 with a human-readable error. That is deliberately distinct from 401 — the session is valid, so your embed should show a permission message rather than try to re-authenticate.

Ownership: widgets record createdByUserId at creation. Widgets created before that existed have no owner and belong to the dashboard, so only ALL can reach them.

Step 4 — Push updates when your RBAC changes

Sessions last 24 hours, and most hosts cache the session id rather than re-creating it. Without this step, an admin narrowing a role would not take effect until every affected session expired — the user keeps the permission for the rest of the day.

POST/api/embed/sessions/permissions

Update widget permissions on live sessions. Authenticated with appId + appSecret.

Push by rolejson
{
  "appId": "6651b...",
  "appSecret": "wbs_your_app_secret",
  "organizationId": "6651a...",
  "roleKey": "site-manager",
  "permissions": { "create": false, "edit": "OWN", "delete": "NONE", "drag": false }
}
TargetFieldEffect
A whole roleroleKeyEvery live session for that role — one request whether 5 or 500 users hold it
Specific peopleuserEmails (array)Just those users

Add "revoke": true instead of permissions to expire the matched sessions rather than narrow them — use it when someone loses access entirely, so your app mints a fresh session on their next visit.

Sessions are updated, not deleted

The sessionId in an open iframe stays valid, the user is not logged out, and the change applies on their very next request. Deleting would break a live embed for something as routine as an admin flipping a switch.
Change in your appAction
A role's widget permissions editedPush by roleKey
A user moved to a different rolePush by userEmails, and drop your cached session id so the next one carries the new roleKey
A user deactivated or removedPush by userEmails with revoke: true
A new user added to a roleNothing — their first session is already correct

Treat the push as best-effort: log a failure, do not fail the role save. Session expiry is the backstop — a missed update self-heals within 24 hours instead of persisting forever.

Step 5 — Hide what the user cannot do

Via the SDKjavascript
WidgetBuilder.getPermissions({ sessionId: 'ess_...' }).then((p) => {
  addWidgetButton.hidden = !p.create;
});
GET/api/embed/permissions

The caller's own widget permissions. Reports rather than rejects — a user with no rights gets a 200 describing that, not a 401.

Responsejson
{
  "success": true,
  "data": {
    "permissions": { "create": false, "edit": "OWN", "delete": "NONE", "drag": false },
    "userId": "6651c...",
    "userEmail": "user@company.com",
    "restricted": true
  }
}

restricted is false when your app sent no permissions at all. userId lets you tell the user's own widgets from other people's when a level is OWN.

Widget Builder already hides its own in-iframe controls from this same data. Step 5 is for the buttons your app renders — typically your "Add Widget" entry point. Hide it rather than disabling it: a button that only ever opens a "you cannot add widgets" dialog is worse than no button.

Skip the round trip

If your backend already resolved the permissions in Step 1, return them in your own session response and read them from there. Your app is the authority, so it can answer this without asking Widget Builder.

Rollout checklist

  1. Ship Steps 1–2. Nothing changes yet for roles you leave unconfigured.
  2. Add the two level controls and two switches to your role editor.
  3. Add Step 4's push so changes land immediately instead of within 24 hours.
  4. Add Step 5 so your own buttons match what the embed allows.

Step 4: Embed in Your Frontend

Load /embed/sdk.js once, then use window.WidgetBuilder's methods (init, addButton, openModal, render, renderAll, setTenant) to embed widgets. Works in HTML, React, Vue, Svelte, Angular.

The end-to-end flow

Before diving into methods, here's what a complete SDK integration looks like — same shape for every framework:

  1. Backend mints a session. Your server calls POST /api/embed/sessions (see ) and returns only the sessionId to the browser. Never expose appSecret or embed tokens to the client.
  2. Frontend loads the SDK. Add a <script src="{BASE_URL}/embed/sdk.js"> tag. Once loaded, window.WidgetBuilder is available with all SDK methods.
  3. Call SDK methods with the session. Any combination of init (full builder), addButton ("+ Add Widget"), renderAll (widget grid), or render (single widget).
  4. SDK creates iframes / buttons in your DOM. Widgets render inside sandboxed iframes; no CSS or JS leakage into your page.
  5. React to lifecycle events. Wire onSave / onClose callbacks to refresh your UI when a user creates or cancels a widget. Use the same dashboardId across addButton + init to let the SDK auto-refresh the dashboard on save.
Runtime flowtext
╔══════════════════╗     (1) /api/embed/sessions     ╔══════════════════╗
║   Your backend   ║◀─────────────────────────────────║  Widget Builder  ║
║                  ║─────── sessionId: ess_… ─────────▶║                  ║
╚════════╤═════════╝                                   ╚════════▲═════════╝
         │                                                      │
         │ (2) returns sessionId                                 │ (4) iframe
         ▼                                                      │     loads
╔══════════════════╗     (3) WidgetBuilder.init(cfg)    ╔═══════╧═════════╗
║   Your browser   ║─────────────────────────────────▶ ║ sdk.js creates  ║
║                  ║◀─── onSave / onClose callbacks ──║ iframes/buttons ║
╚══════════════════╝                                   ╚══════════════════╝

Pick your path

Jump straight to the example that matches your stack — each is self-contained, no need to read the others.
  • Plain HTML / server-rendered template → below.
  • React / Next.js → (highlighted card).
  • Vue / Svelte / Angular / vanilla JS SPA → after the React card.

Quick start: Button + Dashboard (full page)

A complete working page. The <widget-builder-button> tag renders the "+ Add Widget" button; WidgetBuilder.init() renders the dashboard iframe below it. Both use the same dashboardId — that one match is what wires up the auto-refresh: when a user saves a widget via the button, the dashboard reloads automatically.

Complete page — button + dashboard, auto-syncedhtml
<!DOCTYPE html>
<html>
<body>

  <!-- 1. Load the SDK once, at the top -->
  <script src="https://your-widget-builder.com/embed/sdk.js"></script>

  <!-- 2. The "+ Add Widget" button (opens a modal on click) -->
  <widget-builder-button
    session-id="ess_YOUR_SESSION_ID"
    base-url="https://your-widget-builder.com"
    dashboard-id="main"
    label="+ Add Widget"
    theme="light"
  ></widget-builder-button>

  <!-- 3. Container where the dashboard iframe will mount -->
  <div id="my-dashboard"></div>

  <!-- 4. Mount the dashboard into the container above -->
  <script>
    WidgetBuilder.init({
      sessionId: 'ess_YOUR_SESSION_ID',
      baseUrl:   'https://your-widget-builder.com',
      container: '#my-dashboard',
      dashboardId: 'main',    // ← MUST match dashboard-id on the button
      height:    '700px',
    })
  </script>

</body>
</html>

Why dashboardId must match on both

The SDK's auto-refresh bridge works only when the same dashboardId is on <widget-builder-button> AND WidgetBuilder.init(). If they differ (or either is missing), a widget saved via the button is still created but the dashboard iframe won't refresh until you reload the page.

Just the button (no dashboard)? Drop steps 3–4. You'll get a standalone button — useful when you render widgets elsewhere in your own layout using WidgetBuilder.renderAll or WidgetBuilder.render.

Web component attributes

AttributeRequiredDefaultDescription
session-idYes—Session ID (ess_...) minted by your backend
base-urlYes—Your Widget Builder host URL
tenant-idNo—Tenant ID for multi-tenant data scoping
app-idNo—Scope to a specific connected app
dashboard-idNo—Scope widgets to a named dashboard group
labelNo+ Add WidgetButton text
themeNolight`light` | `dark` | `auto` — themes the button AND the builder modal that opens from it

Listening for custom events on the button

Want to run your own code when a widget is saved (e.g. show a toast, log to analytics)? The button dispatches DOM events — attach listeners like any other element:

Listening for Eventsjavascript
const btn = document.querySelector('widget-builder-button');

// Fires when a widget is saved
btn.addEventListener('widget-saved', function(e) {
  console.log('Widget saved:', e.detail);
  // e.detail contains { _id, name, ... }
});

// Fires when the modal is closed
btn.addEventListener('widget-closed', function(e) {
  console.log('Modal closed');
});

For other SPAs — Vue / Svelte / Angular / vanilla JS

Not using React? You won't get a ready-made component, but the loading logic is small. In any SPA, routing causes components to mount and unmount — so the SDK loader must be idempotent (never load the script twice, always wait for window.WidgetBuilder before calling methods). Drop this one utility into your project and adapt it to your framework's component model:

Idempotent SDK loader (framework-agnostic)javascript
export function loadWidgetBuilderSdk(baseUrl) {
  return new Promise((resolve, reject) => {
    // Already loaded? Done.
    if (window.WidgetBuilder) return resolve(window.WidgetBuilder)

    // Script already appended but still loading? Wait for it.
    const existing = document.querySelector('script[data-wb-sdk]')
    if (existing) {
      existing.addEventListener('load', () => resolve(window.WidgetBuilder))
      existing.addEventListener('error', reject)
      return
    }

    // First time: inject the script.
    const s = document.createElement('script')
    s.src = baseUrl.replace(/\/$/, '') + '/embed/sdk.js'
    s.async = true
    s.dataset.wbSdk = 'true'
    s.onload = () => resolve(window.WidgetBuilder)
    s.onerror = () => reject(new Error('Failed to load Widget Builder SDK'))
    document.head.appendChild(s)
  })
}
Vue 3 Composition API examplejavascript
<script setup>
import { onMounted, ref } from 'vue'
import { loadWidgetBuilderSdk } from './loadWidgetBuilderSdk'

const props = defineProps({ sessionId: String, dashboardId: String })
const container = ref(null)
const BASE_URL = 'https://your-widget-builder.com'

onMounted(async () => {
  const sdk = await loadWidgetBuilderSdk(BASE_URL)
  sdk.init({
    sessionId: props.sessionId,
    baseUrl: BASE_URL,
    container: container.value,
    dashboardId: props.dashboardId,
    height: '680px',
  })
})
</script>

<template>
  <div ref="container"></div>
</template>

SDK Methods

Reference for every method on window.WidgetBuilder. All methods share the same prerequisites.

Prerequisite — load the SDK once

The methods below are properties of window.WidgetBuilder, which only exists after /embed/sdk.js finishes loading. Add the script tag once per page:

Load the SDKhtml
<script src="https://your-widget-builder.com/embed/sdk.js"></script>

In static HTML, place this tag before any SDK method call. In SPAs, wait for script.onload (see the React pattern or SPA loader above). You can verify the SDK is ready with typeof WidgetBuilder === 'object'.

WidgetBuilder.addButton(config)

Renders a styled button. Same functionality as the web component, but as a JS API.

addButtonjavascript
WidgetBuilder.addButton({
  sessionId: 'ess_...',          // required — session minted by your backend
  container: '#my-btn',          // required — CSS selector or HTMLElement
  baseUrl: 'https://...',        // required
  tenantId: 'tenant_123',        // optional
  appId: 'app_456',              // optional
  dashboardId: 'my-dashboard',   // optional — scope widgets to a specific dashboard
  label: '+ Add Widget',         // optional
  theme: 'light',                // optional: 'light' | 'dark' | 'auto' — also themes the builder modal
  buttonStyle: {                 // optional: CSS overrides
    borderRadius: '20px',
    background: '#e11d48',
  },
  onSave: function(widget) {},   // optional — called when widget is saved
  onClose: function() {},        // optional — called when modal closes
});

WidgetBuilder.openModal(config)

Opens the widget builder in a modal programmatically. Use this when you have your own styled button (e.g. in your app's header) and want to trigger the modal on click — no SDK-rendered button needed.

openModaljavascript
document.getElementById('my-custom-button').onclick = function() {
  WidgetBuilder.openModal({
    sessionId: 'ess_...',        // required
    baseUrl: 'https://...',      // required
    tenantId: 'tenant_123',      // optional
    appId: 'app_456',            // optional
    dashboardId: 'my-dashboard', // optional
    path: '/embed/add',          // optional — see table below
    theme: 'dark',               // optional: 'light' | 'dark' | 'auto'
    onSave: function(widget) { console.log('Saved:', widget); },
    onClose: function() { console.log('Closed'); },
  });
};

The path option controls which page loads inside the modal:

ValueWhat loadsUse case
'/embed/builder' (default)Bare builder (HTML/CSS/JS editor)Edit a custom widget from scratch
'/embed/add'Template selector → builderSame UX as addButton — pick a template, then build

If you want your custom button to behave exactly like addButton, pass path: '/embed/add'.

WidgetBuilder.init(config)

Embeds the full widget builder dashboard (widget list + grid + editor + templates + AI + preview).

init — Full Builderjavascript
WidgetBuilder.init({
  sessionId: 'ess_...',          // required — session minted by your backend
  container: '#my-container',    // required
  baseUrl: 'https://...',        // required
  tenantId: 'tenant_123',        // optional
  appId: '...',                  // optional — scope to a specific connected app
  dashboardId: 'my-dashboard',   // optional — scope widgets to a named dashboard
  theme: 'dark',                 // optional: 'light' | 'dark' | 'auto' (follows OS)
  width: '100%',                 // optional, default: '100%'
  height: '600px',               // optional, default: '600px'
});

WidgetBuilder.render(config)

Renders a single saved widget.

render — Single Widgetjavascript
WidgetBuilder.render({
  widgetId: '...',               // required — the widget's _id
  sessionId: 'ess_...',          // required
  container: '#my-widget',       // required
  baseUrl: 'https://...',        // required
  tenantId: 'tenant_123',        // optional
  dashboardId: 'my-dashboard',   // optional — must match what the widget was saved with
  theme: 'dark',                 // optional: 'light' | 'dark' | 'auto'
  width: '100%',                 // optional, default: '100%'
  height: '400px',               // optional, default: '400px'
});

WidgetBuilder.renderAll(config)

Renders all saved widgets in an auto-arranged grid.

renderAll — Widget Gridjavascript
WidgetBuilder.renderAll({
  sessionId: 'ess_...',          // required
  container: '#dashboard',       // required
  baseUrl: 'https://...',        // required
  tenantId: 'tenant_123',        // optional
  dashboardId: 'my-dashboard',   // optional — only load widgets for this dashboard
  theme: 'dark',                 // optional: 'light' | 'dark' | 'auto' — applied to every widget iframe
  columns: 2,                    // optional, default: 2
  widgetHeight: '400px',         // optional, default: '400px'
  gap: '16px',                   // optional, default: '16px'
});

Theme support — dark / light / auto

Every SDK method that creates an iframe (init, addButton, openModal, render, renderAll) accepts an optional theme config. The SDK forwards it as &theme=... on every iframe URL; the embed layout inside widget-builder reads it and toggles .dark on the iframe's root, flipping the entire UI via CSS tokens.

ValueEffect
`'light'` (or omitted)Light theme (default)
'dark'Dark theme
'auto'Follows `prefers-color-scheme` at load and re-applies if the OS setting changes while the iframe is open
Example — dark-mode dashboard + buttonjavascript
WidgetBuilder.init({
  sessionId, container: '#dash', baseUrl, dashboardId: 'main',
  theme: 'dark',
});

WidgetBuilder.addButton({
  sessionId, container: '#add-btn', baseUrl, dashboardId: 'main',
  theme: 'dark',   // matches init — button AND modal go dark
});

Gotcha — host and iframe must stay in sync. There's no postMessage bridge that keeps theme in sync at runtime. If the user toggles light/dark in your host UI, tear down the widget-builder iframe and re-init with the new value:

Re-init on host theme flipjavascript
// when host theme changes
container.innerHTML = '';
WidgetBuilder.init({ ...previousConfig, theme: newTheme });

Button + modal theming. The theme value on addButton controls three things in one go: the button's own colors (unless overridden by buttonStyle or the app's configured themeColor), the modal backdrop & close button, and the builder iframe that loads inside the modal.

Events — the full lifecycle

Every SDK surface lets you react to user actions via callbacks. The three lifecycle events you typically handle:

EventFires whenPayloadTypical handler
onSave` / `widget-savedUser saves a widget from the Add Widget flow{ _id, name, html, css, js, ... }Close your modal, refresh the dashboard iframe, show a toast
onClose` / `widget-closedUser cancels / closes the modal without saving{}Close your modal UI
`widget-builder:refresh` (parent → iframe)You want the dashboard to re-fetch widgets{ dashboardId }Send via `postMessage` after a save (SDK bridges this automatically when `dashboardId` matches)

Three ways to listen — pick whichever fits your usage:

1. SDK callbacks (addButton / openModal)javascript
WidgetBuilder.addButton({
  sessionId, baseUrl, container: '#my-btn',
  dashboardId: 'main',

  onSave: function (widget) {
    // widget = { _id, name, html, css, js, ... }
    console.log('Saved:', widget)
    // If init() was called with the SAME dashboardId,
    // the SDK auto-refreshes that iframe — no extra code needed.
  },

  onClose: function () {
    console.log('User cancelled')
  },
})
2. Web component (DOM events)javascript
const btn = document.querySelector('widget-builder-button')

btn.addEventListener('widget-saved', (e) => {
  console.log('Saved:', e.detail)    // { _id, name, ... }
})

btn.addEventListener('widget-closed', () => {
  console.log('Modal closed')
})
3. Raw postMessage (works for any surface, incl. direct iframes)javascript
const WB_ORIGIN = 'https://your-widget-builder.com'

window.addEventListener('message', (event) => {
  if (event.origin !== WB_ORIGIN) return       // verify origin!

  switch (event.data?.type) {
    case 'widget-builder:saved':
      console.log('Saved:', event.data.widget)
      break
    case 'widget-builder:close':
      console.log('Closed')
      break
  }
})

The dashboard auto-refresh bridge

When you call addButton AND init with the same dashboardId, the SDK automatically bridges save events — the dashboard iframe refreshes without any glue code on your side. If the dashboardIds differ (or either one is missing), the bridge is off and you must refresh manually via postMessage.

Switching tenants at runtime

Multi-tenant apps

If your host app lets users switch between tenants without a full page reload, call WidgetBuilder.setTenant once when the switch happens. Every active widget-builder iframe on the page reloads with the new tenant context — no re-renders, no re-mounts, no manual coordination on your side.

setTenantjavascript
// After your user switches tenants in your app:
WidgetBuilder.setTenant({ tenantId: 'new_tenant_id' })
// All iframes reload; the next x-tenant-id header sent to your API is the new one.

Single-tenant apps can skip this section entirely — pass a fixed tenantId to each SDK method and never call setTenant.

Troubleshooting8 common issues
SymptomLikely causeFix
Blank iframe`baseUrl` wrong or widget-builder server is downVerify the URL loads directly in a new tab; check `NEXT_PUBLIC_WIDGET_BUILDER_URL`
`Session invalid` / 401Session expired (24h TTL) or was minted for a different appRe-mint from your backend; if it still fails, confirm `appId`/`appSecret` match the app the session belongs to
WidgetBuilder is undefinedScript not yet loaded when you called the methodWait for `script.onload` before calling any method, or use the idempotent loader pattern above
`<widget-builder-button>` not renderingWeb component loaded BEFORE the script tagPut the `<script>` tag BEFORE any `<widget-builder-button>` element in the HTML
Widgets save but don't appear in the dashboard`dashboardId` mismatch — saved with one, reading with another (or missing)Pass the same `dashboardId` to both `addButton` and `init` (see the callout above)
Widgets load in list but 404 on renderSame `dashboardId` mismatch, but between list and per-widget fetchEnsure your SDK version propagates `dashboardId` to every iframe URL (fixed in latest `/embed/sdk.js`)
CORS errorsProxying embed endpoints through a non-widget-builder originCall widget-builder directly from the browser; embed endpoints already return permissive CORS headers
Data isn't scoped to the user's tenant`tenantId` not passed to SDK methodsAdd `tenantId: '...'` to every SDK call; your API receives it as `x-tenant-id`

API Reference

Complete request/response contracts for every embed endpoint. Widget endpoints authenticate with the x-session-id header; server-to-server provisioning endpoints use the x-app-secret header (copied from the admin portal when you registered your app). How you call them from your stack is up to you.

Sessions

POST/api/embed/sessions

Create a short-lived embed session tied to a specific user. Call this server-to-server from your backend (never from the browser — it requires your app secret).

Request Body

Request Bodyjson
{
  "appId": "6651b...",              // Required: Connected app ID (or use appName)
  "appName": "My App",              // Alt to appId: Connected app name
  "appSecret": "wbs_xxx...",        // Required: App secret from registration
  "userEmail": "user@company.com",  // Required: Session owner's email
  "embedToken": "emb_xxx..."        // Optional: Per-tenant token (auto-resolved if omitted)
}

Example Request

Example Requestbash
curl -X POST https://your-widget-builder.com/api/embed/sessions \
  -H "Content-Type: application/json" \
  -d '{
    "appId": "6651b...",
    "appSecret": "wbs_your_app_secret",
    "userEmail": "user@company.com",
    "embedToken": "emb_STORED_FOR_THIS_TENANT"
  }'

Response

Response (201 Created)json
{
  "success": true,
  "data": {
    "sessionId": "ess_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
    "expiresAt": "2025-01-02T12:00:00.000Z",
    "userEmail": "user@company.com",
    "organizationId": "6651a...",
    "appId": "6651b..."
  }
}

Error Responses

StatusWhen
401Invalid or missing `appSecret`
404User with `userEmail` does not exist in Widget Builder — create via `POST /api/embed/users` then retry
400Missing required fields
POST/api/embed/sessions/{sessionId}/refresh

Extend a session's TTL by another 24 hours. Safe to call any time before expiry.

Request Body

Request Bodyjson
{
  "appId": "6651b...",         // Required: Connected app ID
  "appSecret": "wbs_xxx..."    // Required: App secret from registration
}

Example Request

Example Requestbash
curl -X POST https://your-widget-builder.com/api/embed/sessions/ess_xxx/refresh \
  -H "Content-Type: application/json" \
  -d '{
    "appId": "6651b...",
    "appSecret": "wbs_your_app_secret"
  }'

Response

Response (200 OK)json
{
  "success": true,
  "data": {
    "sessionId": "ess_a1b2c3d4...",
    "expiresAt": "2025-01-03T12:00:00.000Z"
  }
}
GET/api/embed/verify

Check whether a session ID is still valid. No body — authenticate via x-session-id header.

Request Headers

Request Headershttp
x-session-id: ess_a1b2c3d4...

Example Request

Example Requestbash
curl https://your-widget-builder.com/api/embed/verify \
  -H "x-session-id: ess_a1b2c3d4..."

Response

Response (200 OK)json
{
  "success": true,
  "data": {
    "valid": true,
    "context": {
      "organizationId": "6651a...",
      "appId": "6651b...",
      "userEmail": "user@company.com"
    }
  }
}

Tenant Onboarding

Apps are created in the Widget Builder admin portal UI (see Step 2), which gives you an appId + appSecret. The endpoints below use that secret for authentication.
POST/api/embed/onboard

One-shot onboarding for a new host-application tenant: creates organization + user + owner membership + embed token atomically. This is the recommended path — see Step 3 above for the full walkthrough.

Request Headers

Request Headershttp
Content-Type: application/json
x-app-secret: wbs_your_app_secret      // or pass appSecret in the body

Request Body

Request Bodyjson
{
  "organizationName": "Acme Corp",          // Required: tenant/org display name
  "firstName": "Jane",                      // Required: owner first name
  "lastName": "Doe",                        // Required: owner last name
  "email": "jane@acme.com",                 // Required: owner email
  "appId": "673abc123def456789012345",      // Required: appId (or appName)
  "appName": "construction-portal",         // Alt to appId: exact connected app name
  "appSecret": "wbs_xxx...",                // Required if not sent as x-app-secret header
  "apiKeyName": "Acme Corp - Embed Token",  // Optional: default = "{orgName} - Embed Token"
  "allowedDomains": ["acme.com"],           // Optional: empty = allow all origins
  "hasExpiry": false,                       // Optional: set true for expiring token
  "expiryDays": 90                          // Optional: days until expiry (1-365)
}

Response

Response (201 Created)json
{
  "success": true,
  "data": {
    "message": "Successfully onboarded. Store the embed token securely — it won't be shown again.",
    "organization": { "id": "6651a...", "name": "Acme Corp", "slug": "..." },
    "user":         { "id": "65fa...", "email": "jane@acme.com", "role": "owner", "isExistingUser": false, ... },
    "embedToken":   { "id": "6651c...", "token": "emb_xxx...", "name": "Acme Corp - Embed Token", "allowedDomains": ["acme.com"], "expiresAt": null },
    "appId": "673abc..."
  }
}

Error Responses

StatusWhen
400Missing required fields, invalid email, invalid expiryDays, or connected app not registered
401Missing or invalid `appSecret` / `x-app-secret`
POST/api/embed/tokens/register/{organizationId}

Advanced: create an additional embed token for an organization that already exists (e.g. rotating tokens, or tokens scoped to another connected app). For first-time onboarding, use /api/embed/onboard above instead. Idempotent — returns the existing active token if one already exists for this org+app pair.

Request Headers

Request Headershttp
Content-Type: application/json
x-app-secret: wbs_your_app_secret

Request Body

Request Bodyjson
{
  "name": "Production Token",               // Required: Display name
  "appId": "6651b...",                      // Required: Connected app ID
  "allowedDomains": ["your-app.com"],       // Optional: Domain whitelist (empty = allow all)
  "expiresAt": null,                        // Optional: ISO date or null (never expires)
  "createdBy": "user_id_optional"           // Optional: User ID who created it
}

Example Request

Example Requestbash
curl -X POST https://your-widget-builder.com/api/embed/tokens/register/ORGANIZATION_ID \
  -H "Content-Type: application/json" \
  -H "x-app-secret: wbs_your_app_secret" \
  -d '{
    "name": "Production Token",
    "appId": "6651b...",
    "allowedDomains": ["your-app.com", "*.your-app.com"]
  }'

Response

Response (201 Created)json
{
  "success": true,
  "data": {
    "id": "6651c...",
    "token": "emb_aBcDeFgHiJk...",           // Store this server-side
    "name": "Production Token",
    "organizationId": "6651a...",
    "appId": "6651b...",
    "allowedDomains": ["your-app.com", "*.your-app.com"],
    "isActive": true,
    "expiresAt": null
  }
}

Users

POST/api/embed/users

Two modes: (a) Onboarding — no auth header, creates a new user + organization + owner membership; (b) Member — with x-embed-token, adds the user to that token's organization.

Mode A — Onboarding (no auth)

Creates user + organization + owner membership. Used once per tenant to onboard a brand-new workspace. See Step 3.1 for the full flow.

Request Headershttp
Content-Type: application/json
Request Bodyjson
{
  "email": "owner@tenant.com",   // Required
  "firstName": "Jane",           // Optional
  "lastName": "Doe"              // Optional
}
Response (201 Created — new tenant)json
{
  "success": true,
  "data": {
    "user":         { "id": "65fa...", "email": "owner@tenant.com", ... },
    "organization": { "id": "6651a...", "name": "Jane Doe's Workspace", "slug": "..." },
    "membership":   { "role": "owner", "organizationId": "6651a...", "createdAt": "..." },
    "existingUser": false,
    "message": "User and organization created successfully"
  }
}

If the email is already registered, returns 200 OK with that user's primary organization and existingUser: true. Use organization.id to register an embed token for that workspace.

Mode B — Add Member (with embed token)

Adds a new member to the organization identified by the embed token. Used for first-time users joining an already-onboarded tenant.

Request Headershttp
Content-Type: application/json
x-embed-token: emb_STORED_FOR_THIS_TENANT
Request Bodyjson
{
  "email": "newuser@tenant.com",   // Required
  "firstName": "Alex",             // Optional
  "lastName": "Singh",             // Optional
  "role": "user"                   // Optional: "owner" | "admin" | "user" (default: "user")
}
Example Requestbash
curl -X POST https://your-widget-builder.com/api/embed/users \
  -H "Content-Type: application/json" \
  -H "x-embed-token: emb_STORED_FOR_THIS_TENANT" \
  -d '{
    "email": "newuser@tenant.com",
    "firstName": "Alex",
    "lastName": "Singh",
    "role": "user"
  }'
Response (201 Created)json
{
  "success": true,
  "data": {
    "user":         { "id": "...", "email": "newuser@tenant.com", ... },
    "organization": { "id": "6651a...", "name": "..." },
    "membership":   { "role": "user", "createdAt": "..." },
    "isNewUser": true,
    "message": "User created and added to organization"
  }
}

Error Responses

StatusWhen
400Missing/invalid email, or invalid role value
401Invalid `x-embed-token` (Mode B only)
404Mode B: organization referenced by the token doesn't exist
409Mode B: user is already a member of this organization
POST/api/embed/users/email

Change an existing user's email address in place. The user keeps the same _id — every widget, membership and role stays intact.

Your host app owns the email address. Commit your own record first, then call this endpoint — that ordering makes a failed or retried call safe to replay.

Request Headers

Authenticate with either header — same as every other embed route. x-session-id takes priority if both are sent.

Request Headers — Option A: embed token (recommended here)http
Content-Type: application/json
x-embed-token: emb_STORED_FOR_THIS_TENANT
Request Headers — Option B: session idhttp
Content-Type: application/json
x-session-id: ess_xxx...

This is a server-to-server administrative call — your backend makes it right after committing its own email change, so there is usually no live user session to hand it anyway. x-embed-token is the natural fit, same as POST /api/embed/users (Mode B) above. x-session-id also works if you happen to have one.

Request Body

Request Bodyjson
{
  "currentEmail": "old@tenant.com",   // Required
  "newEmail": "new@tenant.com"        // Required
}
Example Request — Option A: embed tokenbash
curl -X POST https://your-widget-builder.com/api/embed/users/email \
  -H "Content-Type: application/json" \
  -H "x-embed-token: emb_STORED_FOR_THIS_TENANT" \
  -d '{
    "currentEmail": "old@tenant.com",
    "newEmail": "new@tenant.com"
  }'
Example Request — Option B: session idbash
curl -X POST https://your-widget-builder.com/api/embed/users/email \
  -H "Content-Type: application/json" \
  -H "x-session-id: ess_xxx..." \
  -d '{
    "currentEmail": "old@tenant.com",
    "newEmail": "new@tenant.com"
  }'

Response

Response (200 OK)json
{
  "success": true,
  "data": {
    "outcome": "updated",
    "user": { "id": "665f...", "email": "new@tenant.com", "previousEmail": "old@tenant.com" },
    "cascaded": { "embedSessions": 1, "aiChatThreads": 3, "globalAdmins": 0 }
  }
}

outcome is "updated", "already-applied" (a previous call already renamed this user — safe to treat as success), or "unchanged" (currentEmail === newEmail).

Error Responses

StatusWhen
400Missing `currentEmail`/`newEmail`, or `newEmail` is not a valid address
403User exists but is not a member of the calling organization
404No user found for `currentEmail`
409`newEmail` already belongs to a different user
GET/api/embed/member

Get the authenticated user's role and permissions within the organization.

Request Headers

Request Headershttp
x-session-id: ess_xxx...
x-user-email: user@company.com       // Alt: pass as ?userEmail= query param

Response

Response (200 OK)json
{
  "success": true,
  "data": {
    "role": "admin",
    "canManageWidgets": true,
    "joinedAt": "2025-01-01T00:00:00Z"
  }
}

Widgets

GET/api/embed/widgets

List all widgets for the authenticated session's organization. Optionally scope by dashboard via x-dashboard-id header.

Request Headers

Request Headershttp
x-session-id: ess_xxx...
x-dashboard-id: my-dashboard       // Optional: filter to one dashboard

Query Parameters

ParamTypeDescription
includestringSet to `content` to include `html`/`css`/`js` bodies in response

Response

Response (200 OK)json
{
  "success": true,
  "data": [
    {
      "_id": "6651d...",
      "name": "Revenue Chart",
      "description": "Monthly revenue",
      "layout": { "x": 0, "y": 0, "w": 6, "h": 4 },
      "createdAt": "2025-01-01T00:00:00Z",
      "updatedAt": "2025-01-02T00:00:00Z"
    }
  ]
}
POST/api/embed/widgets

Create a new custom widget with HTML, CSS, and JavaScript.

Request Body

Request Bodyjson
{
  "name": "Revenue Chart",                      // Required
  "html": "<canvas id='chart'></canvas>",       // Optional: widget markup
  "css": "canvas { width: 100%; }",             // Optional: widget styles
  "js": "const data = await fetchAPI('...');",  // Optional: widget script
  "sourceTemplateId": "6651e..."                // Optional: track origin template
}

Example Request

Example Requestbash
curl -X POST https://your-widget-builder.com/api/embed/widgets \
  -H "Content-Type: application/json" \
  -H "x-session-id: ess_xxx..." \
  -d '{
    "name": "Revenue Chart",
    "html": "<canvas id=\"chart\"></canvas>",
    "css": "canvas { width: 100%; height: 300px; }",
    "js": "/* fetch + render chart */"
  }'

Response

Response (201 Created)json
{
  "success": true,
  "data": {
    "_id": "6651d..."
  }
}
GET/api/embed/widgets/{id}

Fetch a single widget, including its HTML/CSS/JS bodies and theme color.

Response

Response (200 OK)json
{
  "success": true,
  "data": {
    "_id": "6651d...",
    "name": "Revenue Chart",
    "html": "<canvas id='chart'></canvas>",
    "css": "canvas { width: 100%; }",
    "js": "/* widget script */",
    "themeColor": "#e11d48",
    "layout": { "x": 0, "y": 0, "w": 6, "h": 4 },
    "createdAt": "2025-01-01T00:00:00Z",
    "updatedAt": "2025-01-02T00:00:00Z"
  }
}
PUT/api/embed/widgets/{id}

Update a widget's name, HTML, CSS, JS, or layout. Partial updates are supported — omit fields you don't want to change.

Request Body

Request Bodyjson
{
  "name": "Updated Name",                          // Optional
  "html": "<div>new html</div>",                   // Optional
  "css": "div { color: red; }",                    // Optional
  "js": "// new code",                             // Optional
  "layout": { "x": 0, "y": 0, "w": 12, "h": 6 }    // Optional
}

Response

Response (200 OK)json
{ "success": true }
DELETE/api/embed/widgets/{id}

Delete a widget permanently. Cannot be undone.

Response

Response (200 OK)json
{ "success": true }

Templates

GET/api/embed/widgets/templates

List all widget templates available to the authenticated session's app.

Response

Response (200 OK)json
{
  "success": true,
  "data": [
    {
      "_id": "6651e...",
      "name": "Line Chart",
      "html": "<canvas></canvas>",
      "css": "...",
      "js": "...",
      "templateCategory": "charts",
      "source": "system",
      "createdAt": "2025-01-01T00:00:00Z",
      "updatedAt": "2025-01-01T00:00:00Z"
    }
  ],
  "meta": {
    "appId": "6651b...",
    "appName": "My App"
  }
}
POST/api/embed/widgets/from-template

Clone a template into a new widget instance for the session's organization.

Request Body

Request Bodyjson
{
  "templateId": "6651e..."     // Required: ID from GET /api/embed/widgets/templates
}

Example Request

Example Requestbash
curl -X POST https://your-widget-builder.com/api/embed/widgets/from-template \
  -H "Content-Type: application/json" \
  -H "x-session-id: ess_xxx..." \
  -d '{ "templateId": "6651e..." }'

Response

Response (201 Created)json
{
  "success": true,
  "data": {
    "_id": "6651d...",
    "name": "Line Chart",
    "html": "...",
    "css": "...",
    "js": "..."
  }
}

API Discovery

GET/api/embed/apis

Return the host app's API catalog as read from the connected app's /api/config endpoint. Used by the widget editor for auto-complete and by the AI assistant to suggest widgets.

Each endpoint is returned verbatim from your /api/config, with every field that was present — including queryParameters, listArrayPath, and fieldSchema. The richer your config, the smarter the widget editor and AI suggestions become.

Response

Response (200 OK)json
{
  "success": true,
  "data": {
    "appName": "Construction Portal",
    "endpoints": [
      {
        "path": "/api/projects",
        "method": "GET",
        "module": "projects",
        "description": "List all projects",
        "queryParameters": [
          { "name": "page",      "type": "number", "required": false, "description": "Page number (default 1)" },
          { "name": "limit",     "type": "number", "required": false, "description": "Records per page (max 1000)" },
          { "name": "search",    "type": "string", "required": false, "description": "Search / filter query" },
          { "name": "sortField", "type": "string", "required": false, "description": "Field to sort by" },
          { "name": "sortOrder", "type": "string", "required": false, "description": "'asc' or 'desc'" },
          { "name": "status",    "type": "string", "required": false, "description": "Filter by lifecycle status" }
        ],
        "listArrayPath": "data.projects",
        "fieldSchema": [
          { "name": "_id",       "path": "_id",          "type": "objectId", "description": "Unique identifier",      "isIdentifier": true },
          { "name": "name",      "path": "name",         "type": "string",   "description": "Project name",           "isIdentifier": true },
          { "name": "status",    "path": "status",       "type": "string",   "description": "Lifecycle state",        "groupable": true, "enumValues": ["planning", "active", "completed", "cancelled"] },
          { "name": "budget",    "path": "budget.total", "type": "number",   "description": "Total project budget",   "numeric": true },
          { "name": "siteId",    "path": "siteId",       "type": "objectId", "description": "Site reference",         "lookupRef": "sites" },
          { "name": "startDate", "path": "startDate",    "type": "date",     "description": "Project start date",     "dateLike": true }
        ]
      },
      {
        "path": "/api/analytics/revenue",
        "method": "GET",
        "module": "analytics",
        "description": "Monthly revenue aggregates"
      }
    ]
  }
}

The first endpoint shows every supported field populated — richest auto-complete + chart suggestions. The second shows the minimum — only path and method are required. Field tags like groupable, numeric, dateLike, and lookupRef unlock different chart types in the AI assistant — see the section for the full schema.

Token Management (Server-Side)

Admin-facing endpoints for managing per-tenant embed tokens. Used by the admin portal, rarely by host apps directly.

MethodPathDescription
GET/api/embed/tokens?appId={id}List tokens for an app
PUT/api/embed/tokens/{id}Toggle token active/inactive
DELETE/api/embed/tokens/{id}Delete a token

Proxy System

Backend integrators only

Read this if your widgets fetch data from your host app's APIs (the common case). Skip it if your widgets are purely static or fetch only from external APIs.

Widgets don't call your APIs directly. All host-app data requests go through Widget Builder's proxy for security and observability.

GET/api/embed/proxy/{...path}

Forwards requests to your connected app's apiBaseUrl. Supports GET, POST, PUT, DELETE. Requires x-session-id header (sent automatically by the SDK).

Request Flow

Widget code: fetch('/api/projects')

↓

Proxy: GET /api/embed/proxy/projects

↓

Your app: GET https://your-app.com/api/projects

Headers: x-app-secret, x-tenant-id

↓

Response flows back through proxy → widget

Headers Added by Proxy

HeaderDescription
x-app-secretYour app's secret (copied from the admin portal when the app was registered) — authenticates the request
x-tenant-idTenant ID (only if multi-tenant scoping is used) — scope data to this tenant in your response
Content-Typeapplication/json

Handling Proxy Requests

Your API Routetypescript
export async function GET(request: Request) {
  // 1. Authenticate — reject anything without your app secret
  const appSecret = request.headers.get("x-app-secret");
  if (appSecret !== process.env.WIDGET_BUILDER_APP_SECRET) {
    return new Response("Unauthorized", { status: 401 });
  }

  // 2. Scope by tenant (only present if the widget was embedded with tenantId)
  const tenantId = request.headers.get("x-tenant-id");

  const projects = await db.projects.find({ tenantId }).toArray();
  return Response.json({ data: projects });
}

Admin Portal Helpers

Backend integrators only

Endpoints your host application must expose so the Widget Builder admin portal can power its UI. These are not called by widget iframes — they're called by Widget Builder itself, server-to-server, authenticated only with x-app-secret.

Tenant List (for template preview)

Powers the Tenant (for live preview) dropdown in the admin portal's template editor. When an admin picks a connected app while creating a template, the portal calls this endpoint on the connected host and renders the result so the admin can preview their template against real tenant data.

GET/api/external/tenants

Returns all tenants (workspaces / customers / sub-orgs) available in this connected host application. Authenticated with x-app-secret only — no per-tenant or per-customer scoping required, because the admin needs to be able to preview as any tenant.

Request Headers
HeaderRequiredDescription
x-app-secretYesYour app's secret. Validate against the value you stored when the app was registered.
Content-TypeYesapplication/json
x-wb-organization-idNo (ignored)Widget Builder may forward this for forward-compatibility, but you do NOT need to scope by it. Admin previews require the full tenant list.
Response Format

Return the wrapped shape below. Widget Builder also accepts a flat { data: [...] } array for backwards compatibility, but the wrapped shape is preferred — it mirrors the rest of your API and leaves room for pagination metadata.

200 OKjson
{
  "data": {
    "tenants": [
      { "_id": "string", "name": "string" }
    ],
    "count": 0
  },
  "error": null
}
Field Mapping

Each tenant entry needs at minimum an identifier and a display name. Widget Builder normalises the following alternative field names automatically, so you can return whichever shape matches your existing schema:

Frontend fieldAccepted host fieldsNotes
_id_id` or `idRequired. Used as the dropdown's selection value and stored on the saved template.
namename`, `companyName`, or `tenantNameRequired. Shown as the dropdown label.
logoUrllogoUrl` or `brandLogoUrlOptional. Future use for richer dropdown rendering.
Example Handler
Your API Routetypescript
export async function GET(request: Request) {
  // 1. Authenticate — only Widget Builder should be able to list tenants.
  const appSecret = request.headers.get("x-app-secret");
  if (appSecret !== process.env.WIDGET_BUILDER_APP_SECRET) {
    return Response.json(
      { data: null, error: "Unauthorized" },
      { status: 401 },
    );
  }

  // 2. Return every tenant in this host. The admin portal needs the full
  //    list because they may preview as any of them.
  const tenants = await db.tenants
    .find({ status: "Active" })
    .project({ _id: 1, name: "$companyName" })
    .toArray();

  return Response.json({
    data: { tenants, count: tenants.length },
    error: null,
  });
}

Why this endpoint is admin-only

Widget iframes call your APIs through the embed proxy, which validates customer-scoped embed tokens and an allowedDomains whitelist. The admin portal isn't on that whitelist — and shouldn't be — so admin-side reads (like populating the tenant preview dropdown) bypass the embed proxy and call /api/external/tenants directly with the app secret. Treat this endpoint accordingly: it returns more data than any single customer should see, so guard it on x-app-secret alone and never expose it to your own end users.


Multi-Tenant Support

If your app serves multiple tenants, pass tenantId when embedding. Widget Builder forwards it as x-tenant-id on every proxied API call.

Web Component with Tenanthtml
<widget-builder-button
  session-id="ess_YOUR_SESSION_ID"
  base-url="https://your-widget-builder.com"
  tenant-id="CURRENT_TENANT_ID"
></widget-builder-button>

Switching Tenants

Tenant Switchjavascript
// When the user switches tenants in your app:
WidgetBuilder.setTenant({ tenantId: 'new_tenant_id' });
// This reloads all active Widget Builder iframes with the new tenant context.

Response Format

All Widget Builder API responses follow a consistent format.

Success Response

Successjson
{
  "success": true,
  "data": {
    // Response data here
  }
}

Error Response

Errorjson
{
  "success": false,
  "error": "Human-readable error message"
}

List Response

Listjson
{
  "success": true,
  "data": [
    { "_id": "...", "name": "Widget 1", "createdAt": "2025-01-01T..." },
    { "_id": "...", "name": "Widget 2", "createdAt": "2025-01-02T..." }
  ]
}

Security Best Practices

Follow these guidelines to keep your integration secure in production.

✓

Keep appSecret and embedTokens strictly server-side — never return them to the browser. The browser only ever sees sessionIds (ess_…).

✓

Store appSecret securely — it's shown only once in the admin portal when you create the app

✓

Mint sessions from an authenticated backend endpoint so anonymous users can't acquire them

✓

Set allowedDomains on embed tokens to restrict where sessions minted from them can be used

✓

Validate x-app-secret on your API routes (including /api/config) to ensure requests come from Widget Builder

✓

Use x-tenant-id to scope data in your API responses when multi-tenant

✓

Review your /api/config endpoint to ensure it only exposes intended APIs

Need help? Visit the Admin Dashboard to manage your connected apps, per-tenant credentials, and widgets.