Your own dashboard on our API
A page with four analytics blocks on your site: a server intermediary calls the API, the browser gets only the finished data.
The scheme
Browser --GET /analytics-api/...--> your server (proxy) --Bearer key--> API
Browser <--finished JSON data------- your server (proxy) <--API response--
The API key lives only in the server environment. The key scopes are analytics:read and dashboards:read; creation and rotation are in Authentication and Permissions. The read limit is 600 requests a minute per key: give the blocks the proxy cached response (the example sets Cache-Control: private, max-age=60).
The server proxy
The proxy lets in only your authenticated session and forwards only the whitelisted routes and known parameters. Until you replace the sign-in check it is closed to everyone.
Node.js
// server.mjs - A dependency-free proxy (Node 18+). The key and API address come from the server environment and never reach the browser.
import http from 'node:http';
const API_BASE = process.env.API_BASE;
const API_KEY = process.env.API_KEY;
// A whitelist of routes and parameters: the proxy forwards nothing extra
const ROUTES = {
'/analytics-api/timeseries': '/analytics/timeseries',
'/analytics-api/traffic-sources': '/analytics/traffic-sources',
'/analytics-api/funnel': '/analytics/funnel',
'/analytics-api/visibility': '/analytics/visibility',
'/analytics-api/overview': '/dashboards/overview',
'/analytics-api/pulse': '/dashboards/pulse',
};
const PARAMS = ['metric', 'from', 'to', 'granularity', 'engine', 'blocks', 'include'];
function allowed(req) {
return false; // REPLACE: let only your own authenticated session in. While it returns false the proxy is closed to everyone.
}
http.createServer(async (req, res) => {
const parts = (req.url || '').split('?');
const target = ROUTES[parts[0]];
if (req.method !== 'GET' || !target) { res.writeHead(404).end(); return; }
if (!allowed(req)) { res.writeHead(403).end(); return; }
const incoming = new URLSearchParams(parts[1] || '');
const query = new URLSearchParams();
PARAMS.forEach((k) => { if (incoming.has(k)) query.set(k, incoming.get(k)); });
try {
const upstream = await fetch(API_BASE + target + '?' + query, { headers: { Authorization: 'Bearer ' + API_KEY } });
res.writeHead(upstream.status, { 'Content-Type': 'application/json', 'Cache-Control': 'private, max-age=60' });
res.end(await upstream.text());
} catch (err) {
res.writeHead(502, { 'Content-Type': 'application/json' });
res.end('{"error":"Could not reach the API"}');
}
}).listen(3000);PHP
<?php
// analytics-api.php - The same proxy in PHP (curl). The page calls analytics-api.php?r=<name>&...
session_start();
if (empty($_SESSION['user_id'])) { http_response_code(403); exit; } // REPLACE: your own sign-in check
// A whitelist of routes and parameters: the proxy forwards nothing extra
$routes = [
'timeseries' => '/analytics/timeseries',
'traffic-sources' => '/analytics/traffic-sources',
'funnel' => '/analytics/funnel',
'visibility' => '/analytics/visibility',
'overview' => '/dashboards/overview',
'pulse' => '/dashboards/pulse',
];
$params = ['metric', 'from', 'to', 'granularity', 'engine', 'blocks', 'include'];
$name = $_GET['r'] ?? '';
if (!isset($routes[$name])) { http_response_code(404); exit; }
$query = array_intersect_key($_GET, array_flip($params));
$ch = curl_init(getenv('API_BASE') . $routes[$name] . '?' . http_build_query($query));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('API_KEY')],
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
header('Content-Type: application/json');
header('Cache-Control: private, max-age=60');
http_response_code($body === false ? 502 : $status);
echo $body === false ? '{"error":"Could not reach the API"}' : $body;The page
Site analytics
Drawn from example responses; the figures are made up. On a phone the blocks stack in one column.
HTML
<main class="dash" data-dash>
<h1 class="dash-title">Site analytics</h1>
<section class="dash-wide ab" data-block="overview" aria-label="Key figures" aria-busy="true">Loading</section>
<div class="dash-grid">
<section class="ab" data-block="timeseries" aria-label="Search clicks" aria-busy="true">Loading</section>
<section class="ab" data-block="traffic-sources" aria-label="Sources" aria-busy="true">Loading</section>
<section class="ab" data-block="funnel" aria-label="Funnel" aria-busy="true">Loading</section>
</div>
</main>CSS
.dash { max-width: 1100px; margin: 0 auto; padding: 16px; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.dash-title { margin: 0 0 16px; font-size: 22px; font-weight: 700; }
.dash-wide { margin-bottom: 16px; }
.dash-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); gap: 16px; }
/* + the blocks CSS (the .ab class and --ab-* variables) */
.ab {
--ab-bg: #ffffff; --ab-fg: #1f2d26; --ab-muted: #4f6458; --ab-line: #e4ddcf; --ab-track: #eef1ec;
--ab-accent: #2d6a4f; --ab-soft: #e8f5e9; --ab-up: #17703f; --ab-down: #b3261e; --ab-radius: 16px;
--ab-d1: #1a3c2a; --ab-d2: #2d6a4f; --ab-d3: #7fb89b; --ab-d4: #cfd8d2;
--ab-direct: #6b7a72; --ab-ai: #a9741f; --ab-social: #7a3db8; --ab-ads: #0b6bcb;
background: var(--ab-bg); color: var(--ab-fg); border: 1px solid var(--ab-line); border-radius: var(--ab-radius);
padding: 16px; font: 14px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; min-width: 0;
}
@media (prefers-color-scheme: dark) {
.ab:not([data-theme="light"]) {
--ab-bg: #14201a; --ab-fg: #e6eee9; --ab-muted: #a3b9ab; --ab-line: #2b3d32; --ab-track: #1f2f26;
--ab-accent: #6ee7b7; --ab-soft: #1b2f24; --ab-up: #6ee7b7; --ab-down: #ff8a80;
--ab-d1: #6ee7b7; --ab-d2: #3fae85; --ab-d3: #2a7a5c; --ab-d4: #3a4b41;
--ab-direct: #9fb1a6; --ab-ai: #e0b266; --ab-social: #c9a0f5; --ab-ads: #6cb2ff;
}
}
.ab[data-theme="dark"] {
--ab-bg: #14201a; --ab-fg: #e6eee9; --ab-muted: #a3b9ab; --ab-line: #2b3d32; --ab-track: #1f2f26;
--ab-accent: #6ee7b7; --ab-soft: #1b2f24; --ab-up: #6ee7b7; --ab-down: #ff8a80;
--ab-d1: #6ee7b7; --ab-d2: #3fae85; --ab-d3: #2a7a5c; --ab-d4: #3a4b41;
--ab-direct: #9fb1a6; --ab-ai: #e0b266; --ab-social: #c9a0f5; --ab-ads: #6cb2ff;
}
.ab-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }
.ab-tile { background: var(--ab-soft); border-radius: 12px; padding: 12px 14px; min-width: 0; }
.ab-label, .ab-sub, .ab-axis { font-size: 12px; color: var(--ab-muted); }
.ab-value { font-size: 24px; font-weight: 700; line-height: 1.25; overflow-wrap: anywhere; }
.ab-delta { font-size: 12px; font-weight: 600; color: var(--ab-muted); }
.ab-up { color: var(--ab-up); }
.ab-down { color: var(--ab-down); }
.ab-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 12px; }
.ab-big { font-size: 26px; font-weight: 700; }
.ab-plot { height: 160px; margin-top: 10px; }
.ab-plot svg, .ab-bar svg, .ab-stack svg { display: block; width: 100%; height: 100%; }
.ab-grid { stroke: var(--ab-line); stroke-width: 1; vector-effect: non-scaling-stroke; }
.ab-line { fill: none; stroke: var(--ab-accent); stroke-width: 2.5; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
.ab-area { fill: var(--ab-accent); opacity: 0.14; }
.ab-axis { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 2px 8px; margin-top: 6px; }
.ab-rows { display: grid; gap: 12px; }
.ab-row { display: grid; grid-template-columns: minmax(90px, 150px) minmax(0, 1fr) minmax(96px, auto); gap: 4px 12px; align-items: center; }
.ab-name { font-weight: 600; overflow-wrap: anywhere; }
.ab-bar { height: 10px; border-radius: 5px; overflow: hidden; }
.ab-bar-none { background: var(--ab-track); opacity: 0.5; }
.ab-num { display: flex; flex-direction: column; align-items: flex-end; text-align: right; line-height: 1.3; }
.ab-track { fill: var(--ab-track); }
.ab-fill { fill: var(--ab-accent); }
.ab-k-direct { fill: var(--ab-direct); }
.ab-k-ai_assistant { fill: var(--ab-ai); }
.ab-k-social { fill: var(--ab-social); }
.ab-k-ads { fill: var(--ab-ads); }
.ab-sub-title { margin: 16px 0 8px; font-family: inherit; font-size: 13px; font-weight: 600; line-height: 1.4; color: var(--ab-fg); }
p.ab-sub { margin: 0 0 8px; }
.ab-stack { height: 14px; border-radius: 7px; overflow: hidden; }
.ab-d1 { fill: var(--ab-d1); } .ab-d2 { fill: var(--ab-d2); } .ab-d3 { fill: var(--ab-d3); } .ab-d4 { fill: var(--ab-d4); }
.ab-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; list-style: none; margin: 10px 0 0; padding: 0; }
.ab-legend li { display: flex; gap: 6px; align-items: baseline; }
.ab-legend li::before { content: ""; width: 10px; height: 10px; border-radius: 3px; background: var(--ab-d1); align-self: center; }
.ab-legend .ab-d2-key::before { background: var(--ab-d2); }
.ab-legend .ab-d3-key::before { background: var(--ab-d3); }
.ab-legend .ab-d4-key::before { background: var(--ab-d4); }
.ab-list { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 8px; }
.ab-item { display: flex; justify-content: space-between; gap: 12px; align-items: center; padding: 10px 12px; border: 1px solid var(--ab-line); border-radius: 12px; }
.ab-item-text { display: flex; flex-direction: column; min-width: 0; }
.ab-chip { background: var(--ab-soft); color: var(--ab-up); font-weight: 700; border-radius: 999px; padding: 2px 10px; white-space: nowrap; }
.ab-note { margin: 0 0 8px; padding: 12px; border-radius: 12px; background: var(--ab-soft); color: var(--ab-muted); }
@media (max-width: 480px) {
.ab-row { grid-template-columns: minmax(0, 1fr) auto; }
.ab-bar { grid-column: 1 / -1; grid-row: 2; }
.ab-num { grid-column: 2; grid-row: 1; }
}dashboard.js
// The blocks are drawn by the functions from the blocks code (renderTiles, renderTimeseries, renderSources, renderFunnel): paste it above.
function endpoint(name, query) {
return '/analytics-api/' + name + '?' + query; // For the PHP proxy: return '/analytics-api.php?r=' + name + '&' + query;
}
function iso(d) { return d.toISOString().slice(0, 10); }
function period(days) { // to is yesterday (UTC): the last full day
var to = new Date(Date.now() - 86400000), from = new Date(to.getTime() - (days - 1) * 86400000);
return 'from=' + iso(from) + '&to=' + iso(to);
}
var BLOCKS = {
'overview': { query: 'blocks=kpi&' + period(28), draw: function (root, body) { renderTiles(root, body.data.blocks.kpi.tiles); } },
'timeseries': { query: 'metric=clicks&' + period(28), draw: function (root, body) { renderTimeseries(root, body.data); } },
'traffic-sources': { query: period(28), draw: function (root, body) { renderSources(root, body.data); } },
'funnel': { query: period(28), draw: function (root, body) { renderFunnel(root, body.data); } }
};
Array.prototype.forEach.call(document.querySelectorAll('[data-block]'), function (root) {
var name = root.getAttribute('data-block'), block = BLOCKS[name];
fetch(endpoint(name, block.query))
.then(function (res) { if (!res.ok) throw new Error(String(res.status)); return res.json(); })
.then(function (body) { root.textContent = ''; block.draw(root, body); })
.catch(function () { root.textContent = 'Could not load the block'; }) // each block fails on its own
.then(function () { root.removeAttribute('aria-busy'); });
});blocks.js
Include it before dashboard.js. Each block is described on the "Analytics blocks" page.
// Labels: edit for your site. The keys (clicks, google, ...) come from the API.
var LOCALE = 'en-GB';
var T = {
impressions: 'Impressions', clicks: 'Clicks', sessions: 'Sessions', ai_people: 'People from AI assistants', revenue: 'Revenue',
leads: 'Leads', deals_created: 'Deals created', deals_won: 'Deals won',
google: 'Google', yandex: 'Yandex', bing: 'Bing', chatgpt: 'ChatGPT', perplexity: 'Perplexity', ai_other: 'Other AI',
social: 'Social', ads: 'Ads', direct: 'Direct', other: 'Other'
};
var M = {
dash: '—', noBase: 'no base', no_data: 'No data for the period', no_source: 'Source not connected',
not_connected: 'Section not connected', through: 'data through', total: 'total', leads: 'leads', prevStep: 'of previous step',
share: 'Visibility share', spread: 'Query positions', top3: 'Top 3', top10: 'Top 10', top30: 'Top 30', outside: 'Below',
aeo: 'Citations in AI answers', volume: 'demand', new7: 'New in 7 days'
};
function esc(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
function el(tag, cls, text) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (text != null) n.textContent = text; // textContent, not innerHTML: the text never becomes markup
return n;
}
// An API value: a number, money {amount, currency} or null. null means "no data", not 0.
function num(v) { return v == null ? null : (typeof v === 'object' ? Number(v.amount) : v); }
function fmt(v) {
if (v == null) return M.dash;
if (typeof v === 'object') return new Intl.NumberFormat(LOCALE, { style: 'currency', currency: v.currency, maximumFractionDigits: 0 }).format(Number(v.amount));
return new Intl.NumberFormat(LOCALE, { maximumFractionDigits: 1 }).format(v);
}
function delta(v) {
if (v == null) return el('span', 'ab-delta', M.noBase);
var sign = v > 0 ? '▲ +' : (v < 0 ? '▼ ' : '');
return el('span', 'ab-delta' + (v > 0 ? ' ab-up' : (v < 0 ? ' ab-down' : '')), sign + fmt(v) + '%');
}
function note(root, availability) { root.appendChild(el('p', 'ab-note', M[availability] || M.no_data)); }
function bar(percent, cls) {
var b = el('div', 'ab-bar');
b.innerHTML = '<svg viewBox="0 0 100 10" preserveAspectRatio="none" aria-hidden="true"><rect class="ab-track" width="100" height="10"/><rect class="' + cls + '" width="' + Math.max(0.8, Math.min(100, percent)).toFixed(1) + '" height="10"/></svg>';
return b;
}
// Metric tiles: GET /dashboards/overview?blocks=kpi -> data.blocks.kpi.tiles
function renderTiles(root, tiles) {
var grid = el('div', 'ab-tiles');
tiles.forEach(function (t) {
var tile = el('div', 'ab-tile');
tile.appendChild(el('div', 'ab-label', T[t.key] || t.key));
tile.appendChild(el('div', 'ab-value', fmt(t.current)));
tile.appendChild(delta(t.delta_pct));
grid.appendChild(tile);
});
root.appendChild(grid);
}
// Daily line: GET /analytics/timeseries (one metric) -> data
function renderTimeseries(root, item) {
if (item.availability !== 'ok') { note(root, item.availability); return; }
var vals = item.series.map(function (p) { return num(p.value); });
var present = vals.filter(function (v) { return v != null; });
var max = Math.max.apply(null, present.concat([1]));
var W = 640, H = 200, step = vals.length > 1 ? W / (vals.length - 1) : 0, d = '', open = false, grid = '';
vals.forEach(function (v, i) {
if (v == null) { open = false; return; } // a gap in the data: do not draw the line across it
d += (open ? 'L' : 'M') + (i * step).toFixed(1) + ' ' + (H - 8 - (v / max) * (H - 16)).toFixed(1);
open = true;
});
for (var g = 0; g <= 4; g++) { var y = (8 + g * (H - 16) / 4).toFixed(1); grid += '<line class="ab-grid" x1="0" x2="' + W + '" y1="' + y + '" y2="' + y + '"/>'; }
var area = present.length === vals.length ? '<path class="ab-area" d="' + d + 'L' + W + ' ' + H + 'L0 ' + H + 'Z"/>' : '';
var head = el('div', 'ab-head');
head.appendChild(el('strong', 'ab-title', T[item.metric] || item.metric));
head.appendChild(el('span', 'ab-big', fmt(item.summary ? item.summary.total : null)));
head.appendChild(delta(item.comparison.delta_pct));
var plot = el('div', 'ab-plot');
plot.setAttribute('role', 'img');
plot.setAttribute('aria-label', (T[item.metric] || item.metric) + ', ' + item.from + ' – ' + item.to + ', ' + M.total + ' ' + fmt(item.summary ? item.summary.total : null));
plot.innerHTML = '<svg viewBox="0 0 ' + W + ' ' + H + '" preserveAspectRatio="none" aria-hidden="true">' + grid + area + '<path class="ab-line" d="' + d + '"/></svg>';
var axis = el('div', 'ab-axis');
axis.appendChild(el('span', '', item.series[0].date));
axis.appendChild(el('span', '', M.through + ' ' + item.comparison.data_through));
axis.appendChild(el('span', '', item.series[item.series.length - 1].date));
root.appendChild(head); root.appendChild(plot); root.appendChild(axis);
}
// Source bars: GET /analytics/traffic-sources -> data (an array)
function renderSources(root, rows) {
if (!rows.length) { note(root, 'no_data'); return; }
var max = Math.max.apply(null, rows.map(function (r) { return r.sessions; }));
var list = el('div', 'ab-rows');
rows.forEach(function (r) {
var row = el('div', 'ab-row');
row.appendChild(el('span', 'ab-name', T[r.source] || r.source));
row.appendChild(bar(r.sessions / max * 100, 'ab-fill ab-k-' + esc(r.kind)));
var nums = el('span', 'ab-num');
nums.appendChild(el('strong', '', fmt(r.sessions)));
nums.appendChild(el('span', 'ab-sub', M.leads + ': ' + fmt(r.leads)));
row.appendChild(nums);
list.appendChild(row);
});
root.appendChild(list);
}
// Funnel: GET /analytics/funnel -> data. The step shares are computed by the client.
function renderFunnel(root, data) {
var list = el('div', 'ab-rows'), prev = null;
data.steps.forEach(function (s) {
var row = el('div', 'ab-row');
var ok = s.availability === 'ok';
row.appendChild(el('span', 'ab-name', T[s.key] || s.key));
var cur = ok ? num(s.current) : null;
if (ok && s.unit === 'count') {
var rate = prev ? cur / prev * 100 : 100; // share of the previous step that has data
row.appendChild(bar(rate, 'ab-fill ab-k-search'));
} else {
row.appendChild(el('div', 'ab-bar ab-bar-none'));
}
var nums = el('span', 'ab-num');
nums.appendChild(el('strong', '', ok ? fmt(s.current) : M.dash));
if (!ok) nums.appendChild(el('span', 'ab-sub', M[s.availability] || M.no_data));
else if (s.unit === 'count' && prev) nums.appendChild(el('span', 'ab-sub', fmt(cur / prev * 100) + '% ' + M.prevStep));
else nums.appendChild(delta(s.delta_pct));
row.appendChild(nums);
list.appendChild(row);
if (ok && s.unit === 'count' && cur > 0) prev = cur;
});
root.appendChild(list);
}
// Visibility: GET /analytics/visibility -> data. Five sections, each with its own availability.
function renderVisibility(root, v) {
var s = v.share, dist = v.distribution, aeo = v.aeo;
var box = el('div', 'ab-tiles');
var a = el('div', 'ab-tile');
a.appendChild(el('div', 'ab-label', M.share));
if (s.availability === 'ok' && s.data.value != null) {
a.appendChild(el('div', 'ab-value', fmt(s.data.value) + '%'));
a.appendChild(delta(s.data.comparison ? s.data.comparison.delta_pct : null));
} else { a.appendChild(el('div', 'ab-value', M.dash)); a.appendChild(el('span', 'ab-delta', M[s.availability] || M.no_data)); }
box.appendChild(a);
var b = el('div', 'ab-tile');
b.appendChild(el('div', 'ab-label', M.aeo));
if (aeo.availability === 'ok') {
b.appendChild(el('div', 'ab-value', fmt(aeo.data.rate) + '%'));
b.appendChild(delta(aeo.data.comparison ? aeo.data.comparison.delta_pct : null));
} else { b.appendChild(el('div', 'ab-value', M.dash)); b.appendChild(el('span', 'ab-delta', M[aeo.availability] || M.no_data)); }
box.appendChild(b);
root.appendChild(box);
root.appendChild(el('h4', 'ab-sub-title', M.spread));
if (dist.availability !== 'ok') { note(root, dist.availability); return; }
var parts = ['top3', 'top10', 'top30', 'outside'], x = 0, rects = '';
parts.forEach(function (k, i) {
var w = dist.data[k] / dist.data.total * 100;
rects += '<rect class="ab-d' + (i + 1) + '" x="' + x.toFixed(1) + '" width="' + w.toFixed(1) + '" height="14"/>';
x += w;
});
var stack = el('div', 'ab-stack');
stack.innerHTML = '<svg viewBox="0 0 100 14" preserveAspectRatio="none" aria-hidden="true">' + rects + '</svg>';
root.appendChild(stack);
var legend = el('ul', 'ab-legend');
parts.forEach(function (k, i) {
var li = el('li', 'ab-d' + (i + 1) + '-key');
li.appendChild(el('span', '', M[k]));
li.appendChild(el('strong', '', fmt(dist.data[k])));
legend.appendChild(li);
});
root.appendChild(legend);
}
// "Pulse" findings: GET /dashboards/pulse -> data
function renderPulse(root, data) {
if (!data.alerts.length) { note(root, 'no_data'); return; }
root.appendChild(el('p', 'ab-sub', M.new7 + ': ' + fmt(data.new_7d)));
var list = el('ul', 'ab-list');
data.alerts.forEach(function (a) {
var li = el('li', 'ab-item');
var text = el('span', 'ab-item-text');
text.appendChild(el('strong', '', a.topic_name || a.keyword));
text.appendChild(el('span', 'ab-sub', a.keyword + ' · ' + M.volume + ' ' + fmt(a.volume)));
li.appendChild(text);
li.appendChild(el('span', 'ab-chip', a.growth == null ? M.dash : '×' + fmt(a.growth)));
list.appendChild(li);
});
root.appendChild(list);
}Security
- The API key lives only in the server environment. Do not put it in HTML, JS, links or the page repository.
- Replace
allowed()(or the PHP session check) with your real authorization: the analytics is visible only to signed-in users. - Keep the route and parameter whitelist in the proxy: it stops the proxy from being used for foreign API requests.
- The proxy returns the response as is; the blocks write text with textContent, so foreign strings never become markup.
- Revenue and leads are tenant data. Do not publish the page without sign-in and keep it out of a shared cache (Cache-Control: private).