How Emban Works
Emban lets you build one analytics dashboard and show it to every customer — each seeing only their own data. No per-viewer fees, no hosted BI platform, no Metabase. Just a self-hosted Go binary talking to your ClickHouse.
tenant_id. The same dashboard shows different data per customer — enforced server-side, not by filters the user can remove.
Architecture at a glance
Events flow from your backend into ClickHouse. Your customers see their slice through signed embed sessions.
Your Backend Emban Your Customer's Browser
─────────── ───── ──────────────────────
POST /v1/events ──────────► ClickHouse (analytics)
tenant_id: "acme" │
event_name: "api.call" │
numeric_props: {tokens: 150} │
▼
POST /v1/embed-sessions ──► Signed JWT token ──────────► <iframe src="embed_url">
tenant_id: "acme" │ │
dashboard_id: "dash_..." │ ▼
expires_in: 3600 │ Dashboard renders
│ (only acme's data)
│
PostgreSQL (config)
dashboards, users, orgs
The full flow, step by step
1. You sign up and get an API key
Register at /app/register. An organization and a default admin API key are created automatically. You can also create scoped keys later:
| Scope | Can do | Use for |
|---|---|---|
admin | Everything: dashboards, embeds, ingestion, settings | Your trusted backend |
ingest | Only POST /v1/events | Data pipeline, edge workers |
read | Read-only dashboard/query access via user sessions | Viewer/editor admin-console sessions |
2. You send events from your backend
Events are the raw data your dashboards visualize. Each event has a tenant_id (your customer), an event_name, and optional properties:
POST /v1/events
Authorization: Bearer YOUR_INGEST_KEY
{
"events": [{
"tenant_id": "acme",
"event_name": "api.call",
"timestamp": "2026-04-14T10:00:00Z",
"user_id": "user_42",
"string_props": {
"endpoint": "/chat",
"model": "gpt-4",
"status": "success"
},
"numeric_props": {
"tokens": 150,
"latency_ms": 230
}
}]
}
Events go straight to ClickHouse. You can send up to 1,000 events per batch. The tenant_id is what isolates data between your customers.
string_props are dimensions you filter or group by (model, endpoint, region).numeric_props are values you aggregate: sum, average, p95, p99 (tokens, latency, cost).
3. You build a dashboard in the visual editor
Open the Builder and create a dashboard. Or let Emban auto-detect your event schema and generate one:
POST /v1/discover/auto-create
Authorization: Bearer YOUR_ADMIN_KEY
The builder lets you:
- Add widgets: KPI cards, time series charts, breakdowns, tables
- Configure each widget's query: which event, which metric, which grouping
- Set up filters: period selector, dimension filters (dropdown, multi-select, numeric range)
- Customize the theme: colors, fonts, logo, "powered by" badge
- Drag and drop layout on a 12-column grid
Dashboards start as drafts. Editing a draft never affects what your customers see.
4. You publish the dashboard
Publishing creates an immutable snapshot of the dashboard configuration. This is what your customers see in embeds.
POST /v1/dashboards/YOUR_DASHBOARD_ID/publish
Authorization: Bearer YOUR_ADMIN_KEY
You can keep editing the draft after publishing. When you're ready, publish again to update. You can also restore the draft to the last published version if you want to undo changes.
5. Your backend creates a signed embed session
This is the key security boundary. When a customer loads their dashboard page in your app, your backend creates a short-lived, tenant-scoped session:
POST /v1/embed-sessions
Authorization: Bearer YOUR_ADMIN_KEY
{
"tenant_id": "acme",
"dashboard_id": "YOUR_DASHBOARD_ID",
"expires_in": 3600
}
Response:
{
"token": "eyJhbGc...",
"embed_url": "https://emban.sidelabs.dev/embed/dash_...?token=eyJhbGc...",
"expires_at": "2026-04-14T14:00:00Z"
}
The embed_url is what you pass to the frontend. It's a signed JWT that encodes:
tenant_id— which customer's data to showdashboard_id— which published dashboard to renderpermissions— optional: locked filters, hidden widgets, allowed periodsexp— when the session expires (default: 1 hour)
embed_url to the frontend. The customer never sees your API key.
6. Your frontend embeds the dashboard
Three options, from simplest to most control:
Option A: Plain iframe
<iframe
src="EMBED_URL"
width="100%"
height="600"
style="border: none; border-radius: 8px"
></iframe>
Option B: Browser helper — adds auto-resize, event listeners, and runtime control
<div id="analytics"></div>
<script src="https://emban.sidelabs.dev/sdk/emban-embed.js"></script>
<script>
const dash = Emban.create({
container: '#analytics',
embedUrl: 'EMBED_URL'
});
// React to customer interactions
dash.on('ready', () => console.log('Dashboard loaded'));
dash.on('drill', (e) => console.log('Clicked:', e.dimension, e.value));
dash.on('filter', (e) => console.log('Filtered:', e.filters));
// Control the dashboard from your app
dash.setFilters({ period: '7d', dimensions: { model: 'gpt-4' } });
dash.setTheme({ primaryColor: '#0f9d58' });
</script>
Option C: React wrapper
import { EmbanEmbedFrame } from '@emban/embed-helper/react';
function CustomerDashboard({ embedUrl }) {
return (
<EmbanEmbedFrame
embedUrl={embedUrl}
onReady={() => console.log('Loaded')}
onDrill={(e) => console.log(e.dimension, e.value)}
containerStyle={{ minHeight: 600, borderRadius: 8 }}
/>
);
}
Option D: Native React components (no iframe) — for full control over layout and styling
import { EmbanProvider, EmbanDashboard } from '@emban/react';
function CustomerDashboard({ token, dashboardId }) {
return (
<EmbanProvider
host="https://emban.sidelabs.dev"
token={token}
dashboardId={dashboardId}
>
<EmbanDashboard />
</EmbanProvider>
);
}
Controlling what customers see
When creating an embed session, you can optionally restrict what the customer can do:
POST /v1/embed-sessions
{
"tenant_id": "acme",
"dashboard_id": "dash_...",
"expires_in": 3600,
"permissions": {
"locked_filters": {
"model": "gpt-4"
},
"hidden_widgets": ["w_internal_costs"],
"allowed_periods": ["24h", "7d", "30d"],
"allow_drill_down": true,
"max_date_range_days": 90
}
}
| Permission | What it does |
|---|---|
locked_filters | Pre-set filter values the customer cannot change. Useful for restricting to a specific plan, region, or product tier. |
hidden_widgets | Widget IDs that won't render for this customer. Use for internal-only metrics. |
allowed_periods | Which time periods the customer can select. Others are hidden. |
allow_drill_down | Whether clicking a chart data point opens a detail view. |
max_date_range_days | Maximum lookback window. Prevents expensive queries on large date ranges. |
Browser runtime: commands and events
Once the dashboard is mounted, the host page and embed communicate via postMessage. The browser helper wraps this into a clean API:
| Direction | Event / Command | When |
|---|---|---|
| embed → host | ready | Dashboard finished loading |
| embed → host | resize | Content height changed (for auto-sizing) |
| embed → host | drill | Customer clicked a chart data point |
| embed → host | filter | Customer changed a filter or period |
| embed → host | error | Something went wrong |
| host → embed | setFilters | Programmatically set filters from your app |
| host → embed | setTheme | Change colors/fonts at runtime |
| host → embed | reload | Refresh data without reloading the page |
Typical integration (Node.js / Next.js)
Backend route — creates a session when your customer visits their dashboard page:
// app/api/analytics/session/route.ts (Next.js)
import Emban from '@emban/sdk';
const emban = new Emban({
host: 'https://emban.sidelabs.dev',
apiKey: process.env.EMBAN_API_KEY // admin scope
});
export async function GET(request) {
const tenantId = getTenantFromSession(request); // your auth logic
const session = await emban.createEmbedSession({
tenantId,
dashboardId: process.env.EMBAN_DASHBOARD_ID,
expiresIn: 3600
});
return Response.json({ embedUrl: session.embedUrl });
}
Frontend component — fetches the session and renders the dashboard:
// components/CustomerDashboard.tsx
'use client';
import { EmbanEmbedFrame } from '@emban/embed-helper/react';
import { useEffect, useState } from 'react';
export function CustomerDashboard() {
const [embedUrl, setEmbedUrl] = useState('');
useEffect(() => {
fetch('/api/analytics/session')
.then(r => r.json())
.then(d => setEmbedUrl(d.embedUrl));
}, []);
if (!embedUrl) return <div>Loading analytics...</div>;
return (
<EmbanEmbedFrame
embedUrl={embedUrl}
containerStyle={{ minHeight: 600 }}
/>
);
}
Widget types
| Kind | Chart options | Best for |
|---|---|---|
kpi | stat, progress | Single number with trend: total calls, avg latency, error rate |
timeseries | area, line, bar | Metric over time: daily API calls, weekly tokens consumed |
breakdown | bar, list | Top N by dimension: calls per model, errors per endpoint |
table | table | Tabular data with sorting |
Each widget queries one event type with one metric:
| Metric | What it computes | Requires numeric_prop? |
|---|---|---|
count | Total events | No |
count_unique | Distinct values of a property | No (uses user_id by default) |
sum | Sum of a numeric property | Yes |
avg | Average of a numeric property | Yes |
p95 | 95th percentile | Yes |
p99 | 99th percentile | Yes |
Alerts
Set up alerts to get notified when metrics cross a threshold. Alerts evaluate every 60 seconds.
POST /v1/alerts
Authorization: Bearer YOUR_ADMIN_KEY
{
"name": "High error rate",
"dashboard_id": "dash_...",
"widget_id": "w_errors",
"condition_type": "threshold",
"operator": "gt",
"threshold_value": 100,
"webhook_url": "https://your-app.com/webhooks/emban",
"email_to": ["ops@your-company.com"],
"enabled": true
}
Alerts can deliver via:
- Webhook — JSON POST to any URL (retries 3x with backoff)
- Email — requires SMTP configuration
Team management
Invite team members with different roles:
| Role | Can do |
|---|---|
owner | Everything + delete org + change roles |
admin | Create/edit/publish dashboards, manage API keys, create embeds, manage alerts |
member | View dashboards and usage (read-only) |
POST /v1/members/invite
Authorization: Bearer YOUR_ADMIN_KEY
{
"email": "teammate@your-company.com",
"role": "admin"
}
Key concepts glossary
| Term | Meaning |
|---|---|
| Tenant | Your customer. Identified by tenant_id in events and embed sessions. One dashboard, many tenants. |
| Event | A data point your backend sends: an API call, a page view, a transaction. Has string and numeric properties. |
| Dashboard | A collection of widgets with filters and a theme. Has draft and published states. |
| Widget | A single chart or KPI card on a dashboard. Each widget has its own query configuration. |
| Embed session | A short-lived signed JWT that grants a specific tenant access to a specific published dashboard. |
| Published config | An immutable snapshot of the dashboard. What embeds actually render. Draft changes don't affect it. |
| Scope | Credential permission level. API keys are admin or ingest; user sessions can also resolve to read-only access for viewer/editor roles. |
| Dimension | A string property you can filter or group by (model, endpoint, status, region). |
| Metric | The aggregation applied to events: count, sum, avg, p95, p99, count_unique. |
What's next
- Quickstart — send your first event and embed a dashboard in 10 minutes
- Next.js guide — full server-side session creation with App Router
- React guide — iframe helper vs native React components
- Tenants — deep dive into tenant isolation model
- Permissions — locked filters, hidden widgets, drill bounds
- API Reference — every endpoint, every parameter