Call Halo from a Node backend
A small Node service that reads Halo on the server and serves your frontend, with the key kept private, a cache, retries and a cap on requests running at once.
When you're done, your frontend can call GET /api/revenue-by-region?days=30 on your own server, and never sees a Halo key. The service keeps answers for five minutes, retries when Halo says to, and shares one Halo request between callers asking the same thing.
It needs Node 20 or newer, and no packages.
Why a backend
A key reads as its person, so it must stay on a server. Your service decides which questions can be asked, and every visitor reads through those few questions rather than through whatever the key can reach.
Your service also carries a single person's access to every visitor. Use a key whose person should see all of what you serve. To show each reader their own rows, use a signed embed.
Make a key with only data.query, and put it and the dataset's id in the environment:
read -rs HALO_API_KEY && export HALO_API_KEY
export HALO_DATASET_ID="0195f3a2-…"The service
import { createServer } from "node:http";
const API = "https://api.usehalo.net/v1/developer";
const DATASET_ID = process.env.HALO_DATASET_ID;
const CACHE_MS = 5 * 60 * 1000;
// Codes worth another try. Everything else needs a change, not a retry.
const RETRY = new Set(["rate_limited", "query_busy", "source_error", "request_timeout", "internal"]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
class HaloError extends Error {
constructor(status, problem) {
super(`${problem.code ?? status}: ${problem.detail ?? "no detail"}`);
this.status = status;
this.code = problem.code;
this.requestId = problem.requestId;
}
}
// A key may have four requests running at once. The fifth would get a 429.
const MAX_RUNNING = 4;
let running = 0;
const waiting = [];
async function withSlot(work) {
while (running >= MAX_RUNNING) await new Promise((resolve) => waiting.push(resolve));
running++;
try {
return await work();
} finally {
running--;
waiting.shift()?.();
}
}
async function halo(path, body, attempts = 4) {
for (let attempt = 1; ; attempt++) {
const response = await withSlot(() =>
fetch(`${API}${path}`, {
method: body ? "POST" : "GET",
headers: {
Authorization: `Bearer ${process.env.HALO_API_KEY}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
}),
);
if (response.ok) return response.json();
const problem = await response.json().catch(() => ({}));
if (!RETRY.has(problem.code) || attempt === attempts) {
throw new HaloError(response.status, problem);
}
const retryAfter = Number(response.headers.get("Retry-After"));
await sleep(retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 500 + Math.random() * 250);
}
}
// Answers by key. A promise is stored while a request runs, so callers asking
// the same thing share it. A failed refresh serves the last good answer.
const cache = new Map();
function cached(key, load) {
const entry = cache.get(key);
if (entry && Date.now() < entry.expires) return entry.value;
const value = load()
.then((result) => {
cache.set(key, { value: Promise.resolve(result), expires: Date.now() + CACHE_MS, last: result });
return result;
})
.catch((error) => {
if (entry?.last) {
cache.set(key, entry);
return entry.last;
}
cache.delete(key);
throw error;
});
cache.set(key, { value, expires: Infinity, last: entry?.last });
return value;
}
function revenueByRegion(days) {
return cached(`revenue-by-region:${days}`, async () => {
const page = await halo(`/datasets/${DATASET_ID}/query`, {
dimensions: [{ field: "region" }],
measures: [{ field: "revenue" }],
filters: [
{
field: "order_date",
op: "date_range",
range: { kind: "relative", preset: "last_n_days", n: days },
},
],
sorts: [{ field: "revenue", direction: "desc" }],
limit: 50,
});
const keys = page.columns.map((column) => column.key);
return {
rows: page.rows.map((row) => Object.fromEntries(keys.map((key, i) => [key, row[i]]))),
asOf: page.stats.computedAt,
};
});
}
createServer(async (req, res) => {
const url = new URL(req.url, "http://localhost");
const send = (status, body) => {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(body));
};
if (url.pathname !== "/api/revenue-by-region") return send(404, { error: "Not found." });
const days = Number(url.searchParams.get("days") ?? 30);
if (!Number.isInteger(days) || days < 1 || days > 365) {
return send(400, { error: "days must be a whole number from 1 to 365." });
}
try {
send(200, await revenueByRegion(days));
} catch (error) {
// Keep Halo's words in your logs, not in your visitors' browsers.
console.error(error.message, error.requestId);
send(502, { error: "Couldn't load revenue. Try again in a moment." });
}
}).listen(3000);node server.mjs
curl "http://localhost:3000/api/revenue-by-region?days=30"{
"rows": [
{ "region": "North", "revenue": 182340.5 },
{ "region": "West", "revenue": 151220 }
],
"asOf": "2026-11-03T15:04:05Z"
}How it holds up
The key stays here. Browsers call your route, and your route takes only a whole number. The query itself is fixed in your code.
Cache. Each distinct
daysvalue keeps its answer for five minutes, and Halo has its own result cache behind that. Limitingdayskeeps a visitor from filling the cache, or your row budget, with many different requests.Retries. It waits for
Retry-Afterwhen Halo sends one, which is 60 seconds forrate_limited, and otherwise backs off. A visitor's request can wait that long, so put a timeout on your route if your frontend can't.No retry on
row_budget_exceeded. It's an hour's wait, so it fails over to the last good answer, or to your error.Requests at once. The slot limit holds this process to four. Run several processes with one key and each needs a smaller share.
In production
Alert on
invalid_api_key,forbiddenandapi_disabled. They won't fix themselves: see What stops a key.Log
requestIdfrom every problem, so you can find a request in the call log or quote it to Halo.Roll the key before it ends, with the 24-hour overlap. Reading it from the environment on each request, as here, means restarting your service is all it takes.