Getting Started as a Host Application
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):
- 0 — POST
/api/setuponce withADMIN_SETUP_SECRETto create the first admin. Subsequent admins are invited from inside the portal. - 1 — so Widget Builder can discover your APIs
- 2 — get
appId+appSecret - 3 — one short-lived session (
ess_...) per logged-in user - 4 — pass
sessionIdto 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
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
<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
<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. Browser → Your backend. Frontend hits your own authenticated endpoint (e.g.
/api/widget-builder-session) to request a session. - 2. Your backend → Widget Builder. Call
POST /api/embed/sessionswithappId + appSecret + userEmail + embedToken. Cache the returnedsessionIdin your DB keyed by (tenantId, userId) — sessions live 24h, re-minting on every page load wastes a round trip. - 3. Your backend → Browser. Return the
sessionId. - 4. Browser → SDK. Pass the
sessionIdto the SDK (WidgetBuilder.init,<widget-builder-button>, etc.).
One-time setup (the numbered steps below)
- Register your app in the admin portal UI — you receive
appId+appSecret. Store both server-side. Done once per product. - Onboard each tenant in one call —
POST /api/embed/onboard(authenticated withx-app-secret) creates the org, user, owner membership, and embed token in one atomic call. StoreembedToken.tokenin your DB keyed by tenant. - Add additional users (optional) — if a tenant has multiple host-app users, add them with
POST /api/embed/users(withx-embed-token) so they can mint sessions.
Edge cases worth handling
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.
/api/setupCreate the very first admin in the Widget Builder admin portal. One-shot — refuses if any admin already exists.
Request Headers
Content-Type: application/json
Authorization: Bearer <SETUP_SECRET>Request Body
{
"email": "you@your-company.com", // Required: admin's email
"name": "Your Name" // Required: admin's display name
}Example Request
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
{
"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:
{
"data": null,
"error": "Setup already completed. Admin user already exists."
}Check the setup status any time with a GET request (same Authorization header):
/api/setupCheck whether setup is still required and how many admins exist.
curl {WIDGET_BUILDER_URL}/api/setup \
-H "Authorization: Bearer <SETUP_SECRET>"{
"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.
/api/configReturns 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.
{
"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).
{
"appName": "My App",
"version": "1.0.0",
"endpoints": [
{ "path": "/api/analytics/revenue", "method": "GET", "description": "Monthly revenue aggregates" }
]
}Endpoints Array
| Field | Type | Required | Description |
|---|---|---|---|
path | string | Yes | The API route (e.g. /api/projects) |
method | string | Yes | GET, POST, PUT, or DELETE |
description | string | No | Human-readable description shown in the widget editor |
module | string | No | Logical grouping (e.g. projects, analytics) |
queryParameters | array | No | Supported query params (name, type, required, description) — used by the AI to generate pagination/filter/sort URLs |
listArrayPath | string | No | Dot-path to the array of items in list responses (e.g. `data.projects`) |
fieldSchema | array | No | Per-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.
| Tag | Type | Meaning |
|---|---|---|
name | string | Human-readable field name shown in the editor |
path | string | Dot-path to the value inside each item (e.g. `budget.total`) |
type | string | One of `string` / `number` / `boolean` / `date` / `objectId` / `array` / `object` |
description | string | One-line description used by the AI to match user intent |
groupable | boolean | Good choice for groupBy / category axis in charts |
numeric | boolean | Can be summed/averaged/used as a y-axis value |
dateLike | boolean | Can drive timeseries (over-time) charts |
isIdentifier | boolean | Title/label/id field — use as row title, never as group key |
enumValues | string[] | Closed list of allowed values (e.g. statuses) |
lookupRef | string | Name 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't | Do |
|---|---|
"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
| Mistake | Why it breaks things | Fix |
|---|---|---|
| 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 fields | AI generates invalid filter URLs and can't colour-code charts. | List every allowed value: `"enumValues": ["planning","active","completed"]`. |
| Grouping/pie-charting an `isIdentifier` field | Each 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 have | AI 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 routes | Widget 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 auth | Leaks 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` tags | AI 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 route | Widget 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
/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.
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. Log into the Widget Builder admin portal.
- 2. Go to Connected Apps > Add App.
- 3. Enter your app name,
apiBaseUrl, andapiConfigEndpoint(defaults to/api/config). - 4. Save. The portal shows an
appIdandappSecret. - 5. Copy both into your host app's environment. The secret is shown only once — store it securely.
What you walk away with
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
# 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.
/api/embed/onboardAuthenticated with x-app-secret. If any step fails, all prior inserts are rolled back.
Request Headers
Content-Type: application/json
x-app-secret: wbs_your_app_secret // or pass appSecret in the bodyRequest Body
{
"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
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
{
"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.isExistingUseristrue. 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
| Status | When |
|---|---|
400 | Missing required field (organizationName, firstName, lastName, email), invalid email, invalid expiryDays, or connected app not registered |
401 | Missing 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
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
/api/embed/sessionsServer-to-server: create a short-lived embed session tied to a specific user. Call this from your backend only.
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
| Field | Type | Required | Description |
|---|---|---|---|
appId | string | Yes* | Connected app ID (use appId or appName) |
appName | string | Yes* | Connected app name (alternative to appId) |
appSecret | string | Yes | Your app secret (from app registration) |
userEmail | string | Yes | Email of the user creating the session |
embedToken | string | No | Per-tenant embed token (auto-resolved from the user's org if omitted) |
Response
{
"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.
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:
| Method | Example |
|---|---|
| `x-session-id` header | headers: { 'x-session-id': 'ess_...' } |
| `Authorization` header | headers: { 'Authorization': 'Bearer ess_...' } |
| Query parameter | ?sessionId=ess_... |
Verify a Session
/api/embed/verifyCheck if a session is valid. Returns the organization, app, and user context.
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.
/api/embed/sessions/{sessionId}/refreshExtend a session's TTL by another 24 hours. Requires appId + appSecret.
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 timestampWidget 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
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
{
"create": true,
"edit": "ALL",
"delete": "OWN",
"drag": false
}| Field | Type | Controls |
|---|---|---|
| create | boolean | Creating widgets — blank, from a template, or with AI |
| edit | NONE | OWN | ALL | Changing a widget's name or content |
| delete | NONE | OWN | ALL | Deleting a widget |
| drag | boolean | Moving 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.
const role = await getRoleForUser(userId, tenantId);
const permissions = role.isAdmin
? { create: true, edit: "ALL", delete: "ALL", drag: true }
: role.widgetPermissions ?? UNRESTRICTED; // absent ⇒ unrestrictedStep 2 — Send them with the session
Add two fields to the POST /api/embed/sessions call you already make. No new endpoint.
{
"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"
}| Field | Required | Description |
|---|---|---|
| permissions | No | The resolved answer. Omit for unrestricted. Partial objects allowed — missing keys fall back to unrestricted. |
| roleKey | No | Your 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.
| Request | Requires |
|---|---|
| POST /api/embed/widgets | create |
| POST /api/embed/widgets/from-template | create |
| POST /api/embed/ai/widget/code | create or edit ≠ NONE |
| PUT /api/embed/widgets/:id — body contains only layout | drag |
| PUT /api/embed/widgets/:id — any other body | edit (+ ownership when OWN) |
| DELETE /api/embed/widgets/:id | delete (+ 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.
/api/embed/sessions/permissionsUpdate widget permissions on live sessions. Authenticated with appId + appSecret.
{
"appId": "6651b...",
"appSecret": "wbs_your_app_secret",
"organizationId": "6651a...",
"roleKey": "site-manager",
"permissions": { "create": false, "edit": "OWN", "delete": "NONE", "drag": false }
}| Target | Field | Effect |
|---|---|---|
| A whole role | roleKey | Every live session for that role — one request whether 5 or 500 users hold it |
| Specific people | userEmails (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
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 app | Action |
|---|---|
| A role's widget permissions edited | Push by roleKey |
| A user moved to a different role | Push by userEmails, and drop your cached session id so the next one carries the new roleKey |
| A user deactivated or removed | Push by userEmails with revoke: true |
| A new user added to a role | Nothing — 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
WidgetBuilder.getPermissions({ sessionId: 'ess_...' }).then((p) => {
addWidgetButton.hidden = !p.create;
});/api/embed/permissionsThe caller's own widget permissions. Reports rather than rejects — a user with no rights gets a 200 describing that, not a 401.
{
"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
Rollout checklist
- Ship Steps 1–2. Nothing changes yet for roles you leave unconfigured.
- Add the two level controls and two switches to your role editor.
- Add Step 4's push so changes land immediately instead of within 24 hours.
- 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:
- Backend mints a session. Your server calls
POST /api/embed/sessions(see ) and returns only thesessionIdto the browser. Never exposeappSecretor embed tokens to the client. - Frontend loads the SDK. Add a
<script src="{BASE_URL}/embed/sdk.js">tag. Once loaded,window.WidgetBuilderis available with all SDK methods. - Call SDK methods with the session. Any combination of
init(full builder),addButton("+ Add Widget"),renderAll(widget grid), orrender(single widget). - SDK creates iframes / buttons in your DOM. Widgets render inside sandboxed iframes; no CSS or JS leakage into your page.
- React to lifecycle events. Wire
onSave/onClosecallbacks to refresh your UI when a user creates or cancels a widget. Use the samedashboardIdacrossaddButton+initto let the SDK auto-refresh the dashboard on save.
╔══════════════════╗ (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
- 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.
<!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
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
| Attribute | Required | Default | Description |
|---|---|---|---|
session-id | Yes | — | Session ID (ess_...) minted by your backend |
base-url | Yes | — | Your Widget Builder host URL |
tenant-id | No | — | Tenant ID for multi-tenant data scoping |
app-id | No | — | Scope to a specific connected app |
dashboard-id | No | — | Scope widgets to a named dashboard group |
label | No | + Add Widget | Button text |
theme | No | light | `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:
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');
});React / SPA integration (complete example)
A production-ready component that handles every step: fetching the session from your backend, loading the SDK script idempotently, calling init + addButton, and recovering from errors. Drop it onto any page — pass a dashboardId to scope widgets per page.
- Idempotent SDK loading (works across re-mounts)
- Session fetched from your own backend
- Loading / error / retry states
- Per-page
dashboardIdscoping
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
// 1. Tell TypeScript that sdk.js will add this global
declare global {
interface Window {
WidgetBuilder?: {
init: (cfg: SdkConfig) => HTMLIFrameElement | undefined
addButton: (cfg: SdkConfig & { onSave?: (w: unknown) => void; onClose?: () => void }) => HTMLButtonElement | undefined
openModal: (cfg: Omit<SdkConfig, 'container'> & { path?: string; onSave?: (w: unknown) => void; onClose?: () => void }) => unknown
}
}
}
type SdkConfig = {
sessionId: string
container: string
baseUrl: string
dashboardId?: string
height?: string
label?: string
}
const WB_URL = process.env.NEXT_PUBLIC_WIDGET_BUILDER_URL!
export function WidgetBuilderDashboard({
dashboardId,
height = '680px',
showAddButton = true,
}: { dashboardId: string; height?: string; showAddButton?: boolean }) {
const [sessionId, setSessionId] = useState<string | null>(null)
const [sdkReady, setSdkReady] = useState(
() => typeof window !== 'undefined' && !!window.WidgetBuilder,
)
const [error, setError] = useState<string | null>(null)
const inited = useRef(false)
const dashContainerId = `wb-dashboard-${dashboardId}`
const btnContainerId = `wb-add-btn-${dashboardId}`
// 2. Fetch session from YOUR backend (never call widget-builder directly)
useEffect(() => {
fetch('/api/embed/widget-builder-session', { method: 'POST' })
.then(r => r.ok ? r.json() : Promise.reject(r))
.then(({ data }) => setSessionId(data.sessionId))
.catch(() => setError('Failed to create Widget Builder session'))
}, [])
// 3. Load sdk.js once (idempotent — works across re-mounts)
useEffect(() => {
if (sdkReady) return
const existing = document.querySelector<HTMLScriptElement>('script[data-wb-sdk]')
if (existing) {
existing.addEventListener('load', () => setSdkReady(true))
return
}
const s = document.createElement('script')
s.src = `${WB_URL}/embed/sdk.js`
s.async = true
s.dataset.wbSdk = 'true'
s.onload = () => setSdkReady(true)
s.onerror = () => setError('Failed to load Widget Builder SDK')
document.head.appendChild(s)
}, [sdkReady])
// 4. Initialize dashboard + button once both session + SDK are ready
useEffect(() => {
if (!sessionId || !sdkReady || !window.WidgetBuilder || inited.current) return
inited.current = true
window.WidgetBuilder.init({
sessionId,
baseUrl: WB_URL,
container: `#${dashContainerId}`,
dashboardId,
height,
})
if (showAddButton) {
window.WidgetBuilder.addButton({
sessionId,
baseUrl: WB_URL,
container: `#${btnContainerId}`,
dashboardId,
label: '+ Add Widget',
onSave: (w) => console.log('saved:', w), // dashboard auto-refreshes
onClose: () => console.log('closed'),
})
}
}, [sessionId, sdkReady, dashboardId, height, showAddButton, dashContainerId, btnContainerId])
// 5. UI states: loading, error, ready
if (error) return <div className="error">{error}</div>
if (!sessionId || !sdkReady) return <div>Loading Widget Builder…</div>
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{showAddButton && (
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: 8 }}>
<div id={btnContainerId} />
</div>
)}
<div id={dashContainerId} style={{ flex: 1 }} />
</div>
)
}Usage on 5 different pages:
// Sales page
<WidgetBuilderDashboard dashboardId="sales" />
// Reports page
<WidgetBuilderDashboard dashboardId="reports" />
// Analytics page
<WidgetBuilderDashboard dashboardId="analytics" />
// Operations page
<WidgetBuilderDashboard dashboardId="operations" />
// Custom page — without the Add Widget button
<WidgetBuilderDashboard dashboardId="custom" showAddButton={false} />Session caching is your backend's job, not the SDK's
sessionId you pass. Sessions last 24h, so your backend should cache them (keyed by tenantId + userId) and mint new ones only when the cached one is close to expiry. See the reference pattern in .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:
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)
})
}<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:
<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.
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.
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:
| Value | What loads | Use case |
|---|---|---|
'/embed/builder' (default) | Bare builder (HTML/CSS/JS editor) | Edit a custom widget from scratch |
'/embed/add' | Template selector → builder | Same 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).
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.
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.
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.
| Value | Effect |
|---|---|
| `'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 |
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:
// 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:
| Event | Fires when | Payload | Typical handler |
|---|---|---|---|
onSave` / `widget-saved | User 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-closed | User 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:
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')
},
})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')
})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
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 appsIf 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.
// 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
| Symptom | Likely cause | Fix |
|---|---|---|
| Blank iframe | `baseUrl` wrong or widget-builder server is down | Verify the URL loads directly in a new tab; check `NEXT_PUBLIC_WIDGET_BUILDER_URL` |
| `Session invalid` / 401 | Session expired (24h TTL) or was minted for a different app | Re-mint from your backend; if it still fails, confirm `appId`/`appSecret` match the app the session belongs to |
WidgetBuilder is undefined | Script not yet loaded when you called the method | Wait for `script.onload` before calling any method, or use the idempotent loader pattern above |
| `<widget-builder-button>` not rendering | Web component loaded BEFORE the script tag | Put 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 render | Same `dashboardId` mismatch, but between list and per-widget fetch | Ensure your SDK version propagates `dashboardId` to every iframe URL (fixed in latest `/embed/sdk.js`) |
| CORS errors | Proxying embed endpoints through a non-widget-builder origin | Call 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 methods | Add `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
/api/embed/sessionsCreate 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
{
"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
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
{
"success": true,
"data": {
"sessionId": "ess_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"expiresAt": "2025-01-02T12:00:00.000Z",
"userEmail": "user@company.com",
"organizationId": "6651a...",
"appId": "6651b..."
}
}Error Responses
| Status | When |
|---|---|
401 | Invalid or missing `appSecret` |
404 | User with `userEmail` does not exist in Widget Builder — create via `POST /api/embed/users` then retry |
400 | Missing required fields |
/api/embed/sessions/{sessionId}/refreshExtend a session's TTL by another 24 hours. Safe to call any time before expiry.
Request Body
{
"appId": "6651b...", // Required: Connected app ID
"appSecret": "wbs_xxx..." // Required: App secret from registration
}Example Request
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
{
"success": true,
"data": {
"sessionId": "ess_a1b2c3d4...",
"expiresAt": "2025-01-03T12:00:00.000Z"
}
}/api/embed/verifyCheck whether a session ID is still valid. No body — authenticate via x-session-id header.
Request Headers
x-session-id: ess_a1b2c3d4...Example Request
curl https://your-widget-builder.com/api/embed/verify \
-H "x-session-id: ess_a1b2c3d4..."Response
{
"success": true,
"data": {
"valid": true,
"context": {
"organizationId": "6651a...",
"appId": "6651b...",
"userEmail": "user@company.com"
}
}
}Tenant Onboarding
appId + appSecret. The endpoints below use that secret for authentication./api/embed/onboardOne-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
Content-Type: application/json
x-app-secret: wbs_your_app_secret // or pass appSecret in the bodyRequest Body
{
"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
{
"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
| Status | When |
|---|---|
400 | Missing required fields, invalid email, invalid expiryDays, or connected app not registered |
401 | Missing or invalid `appSecret` / `x-app-secret` |
/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
Content-Type: application/json
x-app-secret: wbs_your_app_secretRequest Body
{
"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
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
{
"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
/api/embed/usersTwo 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.
Content-Type: application/json{
"email": "owner@tenant.com", // Required
"firstName": "Jane", // Optional
"lastName": "Doe" // Optional
}{
"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.
Content-Type: application/json
x-embed-token: emb_STORED_FOR_THIS_TENANT{
"email": "newuser@tenant.com", // Required
"firstName": "Alex", // Optional
"lastName": "Singh", // Optional
"role": "user" // Optional: "owner" | "admin" | "user" (default: "user")
}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"
}'{
"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
| Status | When |
|---|---|
400 | Missing/invalid email, or invalid role value |
401 | Invalid `x-embed-token` (Mode B only) |
404 | Mode B: organization referenced by the token doesn't exist |
409 | Mode B: user is already a member of this organization |
/api/embed/users/emailChange 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.
Content-Type: application/json
x-embed-token: emb_STORED_FOR_THIS_TENANTContent-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
{
"currentEmail": "old@tenant.com", // Required
"newEmail": "new@tenant.com" // Required
}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"
}'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
{
"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
| Status | When |
|---|---|
400 | Missing `currentEmail`/`newEmail`, or `newEmail` is not a valid address |
403 | User exists but is not a member of the calling organization |
404 | No user found for `currentEmail` |
409 | `newEmail` already belongs to a different user |
/api/embed/memberGet the authenticated user's role and permissions within the organization.
Request Headers
x-session-id: ess_xxx...
x-user-email: user@company.com // Alt: pass as ?userEmail= query paramResponse
{
"success": true,
"data": {
"role": "admin",
"canManageWidgets": true,
"joinedAt": "2025-01-01T00:00:00Z"
}
}Widgets
/api/embed/widgetsList all widgets for the authenticated session's organization. Optionally scope by dashboard via x-dashboard-id header.
Request Headers
x-session-id: ess_xxx...
x-dashboard-id: my-dashboard // Optional: filter to one dashboardQuery Parameters
| Param | Type | Description |
|---|---|---|
include | string | Set to `content` to include `html`/`css`/`js` bodies in response |
Response
{
"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"
}
]
}/api/embed/widgetsCreate a new custom widget with HTML, CSS, and JavaScript.
Request Body
{
"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
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
{
"success": true,
"data": {
"_id": "6651d..."
}
}/api/embed/widgets/{id}Fetch a single widget, including its HTML/CSS/JS bodies and theme color.
Response
{
"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"
}
}/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
{
"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
{ "success": true }/api/embed/widgets/{id}Delete a widget permanently. Cannot be undone.
Response
{ "success": true }Templates
/api/embed/widgets/templatesList all widget templates available to the authenticated session's app.
Response
{
"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"
}
}/api/embed/widgets/from-templateClone a template into a new widget instance for the session's organization.
Request Body
{
"templateId": "6651e..." // Required: ID from GET /api/embed/widgets/templates
}Example Request
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
{
"success": true,
"data": {
"_id": "6651d...",
"name": "Line Chart",
"html": "...",
"css": "...",
"js": "..."
}
}API Discovery
/api/embed/apisReturn 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
{
"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.
| Method | Path | Description |
|---|---|---|
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
Proxy System
Backend integrators onlyRead 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.
/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
| Header | Description |
|---|---|
x-app-secret | Your app's secret (copied from the admin portal when the app was registered) — authenticates the request |
x-tenant-id | Tenant ID (only if multi-tenant scoping is used) — scope data to this tenant in your response |
Content-Type | application/json |
Handling Proxy Requests
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
Admin Portal Helpers
Backend integrators onlyEndpoints 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.
/api/external/tenantsReturns 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
| Header | Required | Description |
|---|---|---|
x-app-secret | Yes | Your app's secret. Validate against the value you stored when the app was registered. |
Content-Type | Yes | application/json |
x-wb-organization-id | No (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.
{
"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 field | Accepted host fields | Notes |
|---|---|---|
_id | _id` or `id | Required. Used as the dropdown's selection value and stored on the saved template. |
name | name`, `companyName`, or `tenantName | Required. Shown as the dropdown label. |
logoUrl | logoUrl` or `brandLogoUrl | Optional. Future use for richer dropdown rendering. |
Example Handler
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.
<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
// 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
{
"success": true,
"data": {
// Response data here
}
}Error Response
{
"success": false,
"error": "Human-readable error message"
}List Response
{
"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.