I Built an API Cost Tracker and Then Couldn't Ship It
BurnRate was supposed to be a unified dashboard for Anthropic and OpenAI spend. It worked great, until I realized the keys it needed could do a lot more than just read.
I opened my Anthropic dashboard one morning and genuinely flinched. I’d been building with Claude Code pretty heavily that week and my bill was way higher than expected. Not catastrophic, but enough to make me think: why am I always surprised by this number?
The problem got worse when I started using OpenAI’s APIs too. Now I had two dashboards, two billing pages, two sets of numbers I needed to mentally combine to understand what I was actually spending. So I did what any developer does when mildly annoyed. I built something.
What BurnRate Does
BurnRate is a unified API cost dashboard. You paste in your Anthropic and OpenAI admin keys, and it gives you one view of everything: daily spend trends, per-model breakdowns (how much went to Opus vs. Sonnet, GPT-4o vs. o3), projected monthly costs based on your actual burn rate, and budget warnings when you’re getting close to a limit you set.
The whole thing runs on Next.js 16 with Cloudflare Workers, and the core design principle was “zero persistence.” No database, no accounts, no server-side key storage. Keys live in your browser and get sent to edge functions only when fetching data.
The Provider Pattern
The most satisfying part of the architecture was the provider abstraction. Anthropic and OpenAI have completely different billing APIs, different pagination schemes, different data shapes. I needed a clean way to normalize all of that.
export interface UsageProvider {
id: "anthropic" | "openai";
name: string;
keyPrefix: string;
validateKey(key: string): Promise<{ valid: boolean; error?: string }>;
fetchUsage(key: string, startDate: Date, endDate: Date): Promise<ProviderUsage>;
}
Each provider implements this interface, and the rest of the app doesn’t care which one it’s talking to. Adding a new provider (Google AI, Mistral, whatever) would just mean creating a new file and registering it. The dashboard code stays untouched.
Dealing with Two Very Different APIs
Anthropic uses cursor-based pagination with a next_page token. OpenAI uses page numbers. Their cost data comes back in different formats, different field names, different granularities. I wrote a generic pagination helper to smooth this over:
async function fetchAllPages<T>(
baseUrl: string,
params: URLSearchParams,
headers: HeadersInit,
extractData: (json: { data: T[]; has_more: boolean; next_page: string | null }) => T[]
): Promise<T[]> {
const allData: T[] = [];
let page: string | null = null;
do {
const urlParams = new URLSearchParams(params);
if (page) urlParams.set("page", page);
const res = await fetch(`${baseUrl}?${urlParams.toString()}`, { headers });
if (!res.ok) throw new Error(`API error (${res.status}): ${await res.text()}`);
const json = await res.json();
allData.push(...extractData(json));
page = json.has_more ? json.next_page : null;
} while (page);
return allData;
}
Then both providers fetch their cost and usage reports in parallel with Promise.all, which cuts the latency roughly in half since we’re hitting two endpoints per provider.
The Key Security Problem
This is where the whole thing fell apart. And honestly, it’s not a bug or a technical limitation. It’s a fundamental design problem.
Both Anthropic and OpenAI require admin-level API keys to access billing and usage data. These aren’t read-only keys. They’re the same keys that can create API requests, manage organization settings, and do basically anything on your account.
I designed BurnRate to be “zero trust” from the start. Keys stored in sessionStorage by default, cleared when you close the tab. Optional localStorage if you check “Remember me.” Keys never persisted server-side.
const saveKeys = useCallback(
(newKeys: ProviderKeys, shouldRemember: boolean) => {
// Clear from both storages first
localStorage.removeItem(STORAGE_KEY);
sessionStorage.removeItem(STORAGE_KEY);
// Save to the appropriate storage
const storage = shouldRemember ? localStorage : sessionStorage;
storage.setItem(STORAGE_KEY, JSON.stringify(newKeys));
localStorage.setItem(REMEMBER_KEY, shouldRemember.toString());
},
[]
);
But here’s the thing. Even with all that care, the keys still have to travel through my edge function to reach the provider APIs. That means they pass through infrastructure I control. And even though I’d never log them or store them, users have no way to verify that. I’m asking people to trust me with keys that have full admin access to their AI accounts.
I couldn’t ship that in good conscience.
What the APIs Didn’t Give Me
Beyond the key problem, the provider APIs had their own limitations. Neither Anthropic nor OpenAI gives you everything you’d want for a proper cost dashboard out of the box. Some data is bucketed in ways that make fine-grained analysis tricky. Token counts and cost amounts come from separate endpoints, so you have to merge them yourself and hope the model names match up:
export function mergeDashboardData(providers: ProviderUsage[]): DashboardData {
const totalCost = providers.reduce((sum, p) => sum + p.totalCost, 0);
const dailyMap = new Map<string, number>();
for (const provider of providers) {
for (const dc of provider.dailyCosts) {
dailyMap.set(dc.date, (dailyMap.get(dc.date) || 0) + dc.cost);
}
}
// Burn rate only counts days with actual usage
const daysWithData = dailyCosts.filter((d) => d.cost > 0).length;
const burnRate = daysWithData > 0 ? totalCost / daysWithData : 0;
const projectedMonthlyCost = burnRate * 30;
return { providers, totalCost, dailyCosts, burnRate, projectedMonthlyCost, modelBreakdown };
}
The burn rate calculation was a small decision that mattered. If you only use the API on weekdays, averaging across all 30 days would undercount your actual daily spend. So I only count days where there’s real usage, then project from there. It’s a small thing, but it’s the difference between a number you trust and one you don’t.
Graceful Degradation
One pattern I’m glad I built in: partial failure handling. If your Anthropic key works but your OpenAI key is expired, you still see your Anthropic data. The dashboard renders what it can and shows a warning for what failed.
const results = await Promise.allSettled(fetches);
results.forEach((result, index) => {
if (result.status === "fulfilled") {
providers.push(result.value);
} else {
errors.push({
provider: providerName,
error: result.reason?.message || "Unknown error",
});
}
});
Promise.allSettled instead of Promise.all was the key choice here. One bad key shouldn’t nuke the whole dashboard.
Where It Stands Now
BurnRate is on the back burner. The code works. The dashboard looks good. The architecture is clean. But I can’t ask people to hand over admin keys to a web app, and the providers don’t offer read-only billing scopes that would make this safe.
If Anthropic or OpenAI ever ship scoped API keys with read-only billing access, I’ll dust this off in a heartbeat. The provider pattern means I could add that support in an afternoon.
What I’d Do Differently
The honest answer is that I’d build a real backend with proper encrypted key storage, server-side sessions, and all the infrastructure that comes with handling sensitive credentials. I avoided that because I wanted to keep things simple and minimize risk. No database, no encryption keys to manage, no breach surface.
But it turns out that’s the only real way to accomplish what BurnRate is trying to do. You can’t build a tool that needs admin API keys and also avoid the responsibility of securing them. The “keys stay in your browser” approach felt clever, but the keys still transit through server infrastructure on every request. The risk doesn’t disappear just because you don’t persist it to disk.
For now, I don’t want to take on that responsibility for other people’s keys. Maybe that changes. Maybe the providers give us better scoping. Either way, BurnRate taught me that sometimes the hardest part of shipping isn’t the code. It’s deciding whether you should.
Share this post
Stay in the loop
Get notified when I publish new posts. No spam, unsubscribe anytime.