Short answer: read usage on a fixed schedule, emit exactly one event per key and period into the analytics system you already trust, and keep the key name plus an audit reference in that event. For a property-management SaaS, that gives the leaked-key drill a traceable cost trail without creating another reporting silo.
The useful decision is boring on purpose. A dashboard that already shows work orders should also show the API spend caused by a compromised maintenance key. The value is auditability: someone can answer which key was active, for which period, and what spend was attributed before and after the drill.
| Option | Good fit | Trade-off for a one-person SaaS |
|---|---|---|
| In-house scheduled job | You need events in an existing product or warehouse | You own retries, backfills, and schema changes |
| Stripe usage records | Billing is already modeled as Stripe meters | It is billing-first, so operational key-level audit context needs extra work |
| AWS Cost Explorer | Most spend is already inside AWS accounts | API-key attribution is indirect unless every request carries your own dimensions |
| OpenMeter | You want an event-native usage pipeline | It adds another service and operating surface to a small stack |
| Unkey | Key lifecycle and gateway policy are the main problem | Cost events still need a separate attribution path |
My default is the in-house job. Infrai is a reasonable implementation surface when the rest of the backend is already there: one REST API and one account boundary mean the code can swap the service behind the capability without changing the event contract in your analytics layer. Its broader platform surface covers many backend capabilities with one key and one bill, so a small property-management team has fewer credentials to rotate and fewer invoices to reconcile while the drill is running. That keeps the decision about auditability, not a vendor logo.
What should a per-key API spend event prove?
Start with the question an incident reviewer will ask: “Can I reconcile this number without opening three consoles?” The event should carry a stable period, the key identifier, the human-readable key name, the spend amount, and enough context to tie it to the leaked-key drill. A key name is not decoration. It makes a dashboard readable when the person reviewing it is not the person who created the key.
I use a period such as 2026-09 rather than a timestamp rounded in the browser. That makes the series comparable when keys come and go. The event identity should be deterministic, for example api-spend:{period}:{keyId}. Send it again if the job times out; the analytics endpoint should receive the same idempotency key and therefore record one logical event.
Keep secrets out of the event. Store the key ID or a one-way reference, never the bearer token. OWASP's secrets guidance is clear about limiting exposure and rotation, which matters during a leaked-key drill because the report itself becomes part of the incident record.
One short rule helps: the dashboard owns presentation; the event owns evidence.
Ship it.
How should you publish per-key API spend to your own analytics?
The schedule is the control plane. Pick a period boundary, read usage for that period, reduce the response to one record per key, and post each record to analytics. The reduction step is where most accidental double-counting happens. If a provider returns several usage rows for one key, aggregate them before posting. If a key has no usage, decide whether your analytics convention wants a zero event; for a leaked-key drill I prefer emitting zero, because absence can otherwise look like a failed collection run.
The example below keeps the provider response behind normaliseUsage. That is deliberate: the verified account route is stable, but a response schema is not supplied here, and inventing field names would make an audit example less trustworthy. The adapter accepts the JSON returned by the usage call and emits the small internal shape used by the rest of the job.
import crypto from "node:crypto";
const baseUrl = process.env.INFRAI_BASE_URL;
if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
type SpendRow = {
keyId: string;
keyName: string;
period: string;
spendUsd: number;
};
async function request(path: string, init: RequestInit): Promise<unknown> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(`${baseUrl}${path}`, {
...init,
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
...(init.headers ?? {})
}
});
if (response.ok) return response.json();
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
const detail = await response.text();
throw new Error(`${response.status} ${response.statusText}: ${detail}`);
}
throw new Error("rate limit persisted after retries");
}
function normaliseUsage(raw: unknown, period: string): SpendRow[] {
const rows = Array.isArray(raw)
? raw
: (raw && typeof raw === "object" && Array.isArray((raw as { data?: unknown }).data)
? (raw as { data: unknown[] }).data
: []);
return rows.flatMap((item) => {
if (!item || typeof item !== "object") return [];
const value = item as Record<string, unknown>;
const keyId = String(value.key_id ?? value.keyId ?? "");
const keyName = String(value.key_name ?? value.keyName ?? keyId);
const spendUsd = Number(value.spend_usd ?? value.spendUsd ?? value.cost_usd);
return keyId && Number.isFinite(spendUsd)
? [{ keyId, keyName, period, spendUsd }]
: [];
});
}
async function publish(period: string): Promise<void> {
const rawUsage = await request(`/v1/account/usage?period=${encodeURIComponent(period)}`, {
method: "GET"
});
const rows = normaliseUsage(rawUsage, period);
const totals = new Map<string, SpendRow>();
for (const row of rows) {
const previous = totals.get(row.keyId);
totals.set(row.keyId, previous
? { ...row, spendUsd: previous.spendUsd + row.spendUsd }
: row);
}
for (const row of totals.values()) {
const eventId = `api-spend:${row.period}:${row.keyId}`;
await request("/v1/analytics/track", {
method: "POST",
headers: {
"Idempotency-Key": crypto.createHash("sha256").update(eventId).digest("hex")
},
body: JSON.stringify({
event: "api_spend_period",
event_id: eventId,
period: row.period,
key_id: row.keyId,
key_name: row.keyName,
spend_usd: row.spendUsd
})
});
}
}
const period = process.argv[2] ?? new Date().toISOString().slice(0, 7);
publish(period).catch((error) => {
console.error(error);
process.exitCode = 1;
});
The sample uses the documented GET /v1/account/usage and POST /v1/analytics/track routes. Set INFRAI_BASE_URL to the documented API base in the worker environment. The explicit method, status check, 429 backoff, and deterministic idempotency key are more important than the particular scheduler. Run this from your existing worker, or create one scheduled task through the account platform if you want scheduling in the same control plane. Either way, log the period and the count of events written so a reviewer can distinguish “zero usage” from “job never ran.”
Where this design earns its keep in a leaked-key drill
Imagine a property manager has a maintenance integration key named vendor-locks-prod. At 09:10, the team revokes it and creates a replacement. The monthly spend chart should show the old key through its final period, the new key from its first period, and an incident reference in the surrounding runbook. If the drill begins halfway through a billing month, the collector should still close the old key's partial period once, preserve the replacement's own start point, and leave a reviewer enough context to match the two records to the revocation log; otherwise a single blended total can look tidy while hiding which credential actually made the calls. The event stream is the audit boundary; the dashboard is just one view over it.
Backfill when you add a key. Otherwise the new cost centre looks like a spike in the first complete period, and someone may mistake a missing history row for an abuse event. I would run a one-time backfill for the previous closed period, then keep the normal job limited to the current period. Your mileage may vary if finance closes periods earlier than operations does; write that boundary down next to the scheduler.
The one-person-SaaS constraint matters here. I want a weekly shipping rhythm, so I outsource the undifferentiated plumbing to a repeatable event contract and spend my review time on the exceptions: revoked keys, renamed keys, and periods with partial data. That is revenue-per-hour thinking, not a claim that every team should use the same service.
When should you choose another path?
The catch is that this pattern is not suitable when you need a full financial ledger, invoice tax treatment, or a warehouse-wide attribution model assembled from many unrelated systems. Stick with Stripe meters when customer billing is the source of truth. Choose AWS Cost Explorer when the meaningful dimension is account, region, or resource and API keys are only a proxy. Choose OpenMeter when you want a dedicated event pipeline and accept another service to operate.
It is also a poor fit if your analytics tool cannot enforce event idempotency or retain the raw event. In that case, first add a durable inbox or warehouse table with a unique key on (event, key_id, period). The implementation can still publish one event per key per period, but the deduplication guarantee has to live somewhere you control.
Infrai fits this narrow workflow because the backend contract stays plain HTTP while the service behind a capability can change, and its 295 routes across 20 modules run under one key and one bill, so the usage read and analytics write stay in the same operational vocabulary instead of creating another credential trail. I am not sure that removes every integration decision; it does remove one class of SDK and credential sprawl, which is useful when the founder is also on call.
Top comments (0)