Skip to main content

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 external service to read from. You can bind to an Integration someone already set up in Informer (the approach this guide uses), or have the app bring its own Integration by declaring it in informer.yaml. See Bring your own Integration.

Example Prompts for Claude Code​

Add integration data:

"Declare a dependency slot named partner for our partner API integration and a slot named orders for 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 salesforce for 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:

  1. Your app declares an integration dependency slot in informer.yaml
  2. A server route calls context.<slot>.request({ method, url, params, data })
  3. Informer adds auth headers (tokens, OAuth, etc.) and forwards the request to the external API
  4. 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

Bring your own Integration​

This guide binds a slot to an Integration that already exists. That works well when an admin has set the connection up for you. But every external service an app needs is one more thing to configure by hand before the app can run, which gets painful as an app is shared or published.

Instead, an app can declare the Integration it needs and let deploy create it. Add an integrations: block to informer.yaml:

integrations:
partner:
name: Partner API
authType: apiKey
apiBaseUri: https://api.partner.example.com/v1
apiKey: $env.PARTNER_API_KEY

env:
PARTNER_API_KEY:
description: Partner API key

Deploy creates a real Informer Integration owned by the app and binds a partner slot to it, so the same context.partner.request(...) code below works unchanged. The installer fills PARTNER_API_KEY once per tenant on the app's Environment tab; no secret lives in the manifest. For an OAuth service like Salesforce, users connect their own account in-app. The full field reference, auth types, and OAuth details are in the integrations: section of the manifest reference.

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:

VariableDescription
$user.usernameLogin name
$user.emailEmail address
$user.custom.xxxCustom user field value, or the field's Default Value when the user has none
$tenant.idTenant identifier
$tenant.nameTenant display name
$app.idApp UUID
$app.nameApp display name

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 });
}

The message tells you who returned the error. An error the external service produced is prefixed upstream returned <status>: and carries the service's own message verbatim:

Dependency "partner" (integration): upstream returned 404: Repository not found

An error Informer itself produced — the slot resolving to nothing, a path the binding does not allow — has no such prefix:

Dependency "partner" (integration): Not Found

A service that never answers at all — DNS failure, refused connection, TLS error, timeout — is also the provider's, and arrives as a 502 carrying the network error code:

Dependency "partner" (integration): upstream returned 502: The integration's upstream could not be reached (ECONNREFUSED)

The same attribution is available as err.output.payload.data.source, which is 'upstream' or 'route'. Treat it as diagnostic detail for logs and support, not as a branching contract — branch on errorCode for that, since it is the field the local dev proxy mirrors.

source can only be 'upstream' for slots whose target is outside Informer: an integration (the third-party service answered) or an app (the target app's own handler answered). A dataset, query, or datasource slot is served by Informer itself, so its failures are always 'route'.

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.