Integration Dashboard
Pull data from external REST APIs via Informer integrations and combine it with internal dataset data.
What You'll Build
A dashboard that:
- Queries an external API via an Informer integration
- Combines external data with Informer dataset data
- Handles integration auth (tokens, headers) via
informer.yaml - Shows error handling for upstream API failures
Prerequisites
- Completed Multi-Dataset Dashboard
- An integration configured in Informer (e.g., REST API, Salesforce, QuickBooks)
Example Prompts for Claude Code
Add integration data:
"Declare a dependency slot named
partnerfor our partner API integration and a slot namedordersfor our orders dataset. In a server route, query the partner API's /v1/sales endpoint and combine the revenue with our internal orders. Read both through the server route so no resource IDs are hardcoded in the frontend. Show them as separate metric cards plus a pie chart showing the revenue split."
Salesforce example:
"Declare a dependency slot named
salesforcefor our Salesforce integration. In a server route, query the Opportunity object for all Closed Won deals in the current date range and combine the total value and count with our orders data."
Error handling:
"Add defensive error handling for the integration request. If the external API is down, show a graceful fallback message instead of breaking the whole dashboard."
What is an Integration?
Integrations are authenticated connections to external APIs. Informer acts as a secure proxy:
- Your app declares an
integrationdependency slot ininformer.yaml - A server route calls
context.<slot>.request({ method, url, params, data }) - Informer adds auth headers (tokens, OAuth, etc.) and forwards the request to the external API
- The response is returned to your handler, which shapes the JSON for the frontend
Benefits:
- Secure - API credentials never exposed to client-side JavaScript
- Centralized - One integration serves multiple apps
- Row-level security - Inject user-specific tokens via
informer.yaml - Portable - Slots survive bundle export/import and resource renames; the frontend never sees an integration ID
Data Access Configuration
Declare a typed dependency slot for each resource your app reads — one for the orders dataset and one for the partner integration. The installer (or your defaultBinding) points each slot at a real resource, and your server route refers to it by slot name. No dataset or integration IDs ever appear in your frontend code.
dependencies:
orders:
target: dataset
# UUID of your dataset — look it up with `GET /api/datasets-list`.
defaultBinding: 7d5a9b1e-0c83-4bde-9e2a-3a4b5c6d7e8f
partner:
target: integration
# UUID of your integration — look it up with `GET /api/integrations-list`.
defaultBinding: 5a6b7c8d-9e0f-1234-5678-9abcdef01234
options:
headers:
Authorization: Bearer $user.custom.partnerToken
params:
tenant_id: $tenant.id
The slot names (orders, partner) are how your server route refers to each resource via context.orders and context.partner. See the informer.yaml reference for the full slot model, including dataset filters and integration credential injection.
Variable Expansion
Variables are expanded server-side, keeping credentials secure:
| Variable | Description |
|---|---|
$user.username | Login name |
$user.email | Email address |
$user.custom.xxx | Custom user field value |
$tenant.id | Tenant identifier |
$report.id | Report UUID |
In this example:
$user.custom.partnerToken- Each user has their own API token stored in a custom field$tenant.id- The tenant ID is sent as a query parameter
What Claude Will Generate
When you ask Claude to add integration support, it will handle the API proxy pattern, error handling, and data combination. The integration call lives in a server route that reads the partner slot — the frontend just calls /api/_server/... and renders the JSON.
Integration Request Pattern
Inside a server handler, call request() on the integration slot. The slot resolves to the bound integration and Informer injects the auth headers declared in informer.yaml:
// server/sales.js
export async function GET({ context }) {
// request() returns the upstream response body (parsed JSON) directly.
// It throws if the upstream responds 4xx/5xx — there is no error flag.
const sales = await context.partner.request({
method: 'GET', // HTTP method
url: '/v1/sales', // URL, relative to the integration's base URL
params: {
start_date: '2024-01-01',
end_date: '2024-12-31'
}
});
return sales; // e.g. { sales: [...], total_revenue: 123456, count: 789 }
}
Server Route
The data access lives in a server route. It reads both the orders dataset slot (context.orders.search()) and the partner integration slot (context.partner.request()), combines them, and returns the shaped result. The frontend never sees a dataset or integration ID:
// server/sales.js
export async function GET({ context, request }) {
const dateFilter = buildDateFilter(request.query.dateRange);
// Query both sources in parallel
const [ordersResult, partnerResult] = await Promise.all([
queryOrders(context, dateFilter),
queryPartnerAPI(context, dateFilter)
]);
const ordersAggs = ordersResult.aggregations;
return {
internalRevenue: ordersAggs.totalRevenue.value,
internalOrders: ordersAggs.orderCount.value,
partnerRevenue: partnerResult.totalRevenue,
partnerOrders: partnerResult.orderCount
};
}
function buildDateFilter(dateRange) {
if (!dateRange || dateRange === 'all') {
return null;
}
const days = parseInt(dateRange);
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
return {
start: startDate.toISOString().split('T')[0],
end: endDate.toISOString().split('T')[0]
};
}
async function queryOrders(context, dateFilter) {
const filters = [];
if (dateFilter) {
filters.push({
range: {
orderDate: {
gte: dateFilter.start,
lte: dateFilter.end
}
}
});
}
// dataset slot → search(esQuery)
return await context.orders.search({
query: { bool: { filter: filters } },
size: 0,
aggs: {
totalRevenue: { sum: { field: 'amount' } },
orderCount: { value_count: { field: 'id' } }
}
});
}
async function queryPartnerAPI(context, dateFilter) {
const params = {};
if (dateFilter) {
params.start_date = dateFilter.start;
params.end_date = dateFilter.end;
}
// integration slot → request({ method, url, params, data })
// Returns the upstream JSON directly; throws on a 4xx/5xx upstream.
try {
const sales = await context.partner.request({
method: 'GET',
url: '/v1/sales',
params
});
// Response format (example): { sales: [...], total_revenue: 123456, count: 789 }
return {
totalRevenue: sales.total_revenue || 0,
orderCount: sales.count || 0,
sales: sales.sales || []
};
} catch (err) {
log.error('Partner API error', { message: err.message });
return { totalRevenue: 0, orderCount: 0, sales: [] };
}
}
The auth headers and tenant param from informer.yaml are injected server-side by the partner slot — they never touch client JavaScript.
Frontend
The frontend calls the server route and renders the JSON. No dataset IDs, no integration IDs, no Elasticsearch query in the browser:
window.informerReady = false;
const theme = window.__INFORMER__?.theme || 'light';
document.documentElement.setAttribute('data-theme', theme);
let currentFilters = { dateRange: '30' };
async function init() {
try {
await loadData();
document.getElementById('dateRange').addEventListener('change', (e) => {
currentFilters.dateRange = e.target.value;
});
document.getElementById('refresh').addEventListener('click', loadData);
window.informerReady = true;
} catch (err) {
console.error('Failed to load data:', err);
showError(err.message);
}
}
async function loadData() {
// The server route reads the `orders` and `partner` slots, combines
// them, and returns the shaped totals — no resource IDs in the frontend.
const response = await fetch(`/api/_server/sales?dateRange=${currentFilters.dateRange}`);
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
updateMetrics(data);
renderChart(data);
}
function updateMetrics(data) {
const combinedRevenue = data.internalRevenue + data.partnerRevenue;
const combinedOrders = data.internalOrders + data.partnerOrders;
document.getElementById('totalRevenue').textContent = formatCurrency(combinedRevenue);
document.getElementById('orderCount').textContent = combinedOrders.toLocaleString();
document.getElementById('internalRevenue').textContent = formatCurrency(data.internalRevenue);
document.getElementById('partnerRevenue').textContent = formatCurrency(data.partnerRevenue);
}
function renderChart(data) {
const chart = echarts.init(document.getElementById('chart'), theme === 'dark' ? 'dark' : null);
chart.setOption({
tooltip: {
trigger: 'item',
formatter: (params) => {
return `${params.name}<br/>${formatCurrency(params.value)} (${params.percent}%)`;
}
},
legend: {
orient: 'vertical',
left: 'left'
},
series: [{
name: 'Revenue',
type: 'pie',
radius: '50%',
data: [
{ value: data.internalRevenue, name: 'Internal Orders' },
{ value: data.partnerRevenue, name: 'Partner Sales' }
],
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}]
});
window.addEventListener('resize', () => chart.resize());
}
function formatCurrency(val) {
if (isNaN(val)) return '--';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0
}).format(val);
}
function showError(message) {
document.querySelector('.dashboard').innerHTML =
`<div style="padding: 40px; text-align: center; color: #ef4444;">
Error: ${message}
</div>`;
}
init();
HTML for Combined Metrics
Claude will add metric cards for both internal and partner data:
<div class="metrics">
<div class="metric-card">
<span class="metric-value" id="totalRevenue">--</span>
<span class="metric-label">Total Revenue</span>
</div>
<div class="metric-card">
<span class="metric-value" id="orderCount">--</span>
<span class="metric-label">Total Orders</span>
</div>
<div class="metric-card">
<span class="metric-value" id="internalRevenue">--</span>
<span class="metric-label">Internal Revenue</span>
</div>
<div class="metric-card">
<span class="metric-value" id="partnerRevenue">--</span>
<span class="metric-label">Partner Revenue</span>
</div>
</div>
<div class="content">
<h2>Revenue Sources</h2>
<div id="chart" style="width: 100%; height: 400px;"></div>
</div>
Error Handling
Integration requests can fail in multiple ways. Handle them in the server route, where the context.partner.request() call lives:
Unbound or Broken Slot
If the installer hasn't bound the partner slot yet (or the bound integration was deleted), the proxy throws a boom 422 with a structured errorCode:
try {
const sales = await context.partner.request({ method: 'GET', url: '/v1/sales' });
} catch (err) {
// err.output.payload.data.errorCode is 'dependency_unbound' or 'dependency_broken'
if (err.output?.payload?.data?.errorCode === 'dependency_unbound') {
return { status: 503, body: { error: 'This app needs setup — bind the partner integration.' } };
}
throw err;
}
See Accessing dependencies for the full error model.
Upstream API Errors (External Service Down)
request() throws when the upstream returns 4xx/5xx (the error carries err.output.payload.data.upstreamStatus). There is no success/error flag on the return value — catch it:
try {
const sales = await context.partner.request({ method: 'GET', url: '/v1/sales' });
// ...use sales
} catch (err) {
// Upstream returned 4xx/5xx (or the slot is unbound/broken)
log.error('Upstream error', { status: err.output?.payload?.data?.upstreamStatus, message: err.message });
}
Defensive Pattern
async function queryPartnerAPI(context, dateFilter) {
try {
const sales = await context.partner.request({
method: 'GET',
url: '/v1/sales',
params: dateFilter ? { start_date: dateFilter.start, end_date: dateFilter.end } : {}
});
return parsePartnerResponse(sales);
} catch (err) {
log.error('Partner request failed', { message: err.message });
return fallbackData();
}
}
function fallbackData() {
return { totalRevenue: 0, orderCount: 0, sales: [] };
}
The frontend then only has to handle a non-200 from /api/_server/sales and degrade gracefully.
Salesforce Example
For a real-world Salesforce integration, declare a salesforce slot in informer.yaml:
dependencies:
salesforce:
target: integration
# UUID of your Salesforce integration — look it up with `GET /api/integrations-list`.
defaultBinding: 5a6b7c8d-9e0f-1234-5678-9abcdef01234
Then call context.salesforce.request() from a server route — the slot resolves to the bound integration, no integration ID in the handler:
// server/salesforce.js
export async function GET({ context, request }) {
const start = request.query.start;
const end = request.query.end;
const soql = `
SELECT Id, Name, Amount, StageName, CloseDate
FROM Opportunity
WHERE StageName = 'Closed Won'
AND CloseDate >= ${start}
AND CloseDate <= ${end}
`;
// integration slot → request({ method, url, params, data })
// Returns the upstream JSON directly; throws on a 4xx/5xx upstream.
const sf = await context.salesforce.request({
method: 'GET',
url: '/data/v59.0/query',
params: { q: soql }
});
// sf.records = [{ Id, Name, Amount, ... }, ...]
const records = sf.records;
const totalRevenue = records.reduce((sum, r) => sum + (r.Amount || 0), 0);
return { totalRevenue, records };
}
The frontend reads it with fetch('/api/_server/salesforce?start=...&end=...').
Test Locally
npm run dev
Verify:
- Integration request succeeds (check network tab)
- Error handling gracefully degrades if API fails
- Combined metrics display correctly
Deploy
npm run deploy
Next Steps
Continue to API Mashup to learn how to combine datasets, integrations, and saved queries in one app.