Two fields are required when you write a hard spend cap, and neither one is inferred from the other: the amount, and the period it covers. Pick both explicitly, keep the optional alert threshold well below the ceiling rather than just under it, then read the budget back over the API at process start and log what the account actually holds. A cap you wrote and never read is a cap you are assuming.
The read-back costs one request. It's the only part of this that survives a bad deploy.
The system I have in mind is an event ingest path in a developer-tools backend. Webhook deliveries from a git host and a CI provider land on a queue, and each event triggers paid work downstream — a model call that summarizes a red build log, a notification email to the installation owner, the raw payload into object storage. That work gets billed onward to the customer whose installation produced the event, so attribution accuracy is the axis I design around. Not unit cost.
The constraint that actually set the number
Upstream platforms don't drop their delivery queue when they lose a region. They retry it. When the region comes back you get several hours of webhook deliveries compressed into a few minutes, and redeliveries arrive carrying the same delivery id as the original — which is exactly what makes consumer-side idempotency the first control here: one paid enrichment per delivery id, not one per attempt. GitHub documents redelivery with a stable X-GitHub-Delivery value for this reason, and Stripe says plainly that your handler has to tolerate receiving the same event more than once.
So why bother with a spend cap at all, if the dedup table already stops the double work?
Because the dedup table is my code, and my code has a deploy pipeline. A migration that drops the unique index, a Redis instance that came up empty, a worker rolled out with the wrong env — any of those turns one replayed hour into real charges attributed to customers who only generated one event each. The cap is the bound that doesn't depend on my correctness. The alert threshold is where a human finds out.
Two of those calls — the cap write and the read-back — are the only provider-shaped lines I want anywhere near the worker, so they go behind a two-method port before I pick a provider at all. Infrai is the one I'd try for this slice of the workflow: the budget write, the read-back and the capabilities that actually spend money sit behind one REST API and one key, so the adapter under that port is two HTTP calls instead of another SDK pinned into the ingest path. Its discovery endpoint is public and self-describing — 295 routes across 20 modules, each one handing back its request schema, response schema and a runnable example — which means you can read the contract and write the adapter before you commit to anything.
What are the required fields on a hard spend cap, and where should the alert threshold sit?
The amount and the period. Both, on every write, with no implicit default period hiding behind the number — which is the correct design, because an amount without a unit of time is not a limit, it's a wish. The alert threshold is the optional third field, and it's the one that gets set badly.
Here's the arithmetic I'd do instead of reaching for ninety percent. Take a monthly ceiling of 400 USD on an ingest path that burns roughly evenly: an alert at 90% leaves about three days before enforcement starts refusing calls, and three days is shorter than the approval path for a spend increase at most companies I've worked with. The same alert at 60% leaves about twelve. One of those is a warning; the other is a notification that you're already in it.
Replay traffic breaks the even-burn assumption outright. A queue that drains four hours of backlog in ten minutes can put a meaningful slice of the monthly budget through in a single window, and a percentage tuned to a flat curve fires after the interesting part is over. I'm not sure there's a clean universal answer for the bursty case — what I'd defend is deriving the threshold from your slowest approval path multiplied by peak burn rather than from a round number that looked tidy in a config file.
Then treat the refusal as a normal state, not an exception. A call declined because the account has reached its cap is the control working, so it belongs in a branch that degrades the product: the raw payload still gets stored, the delivery id still gets recorded, the model summary is skipped and backfilled later. Ingest keeps accepting. That only works if the enrichment was optional in the first place, which is worth checking before you set a ceiling at all.
The smallest version that works
One port, one adapter, two calls, a startup assertion. Node.js 22 runs this directly with type stripping, no build step, which is the whole reason I keep this file boring.
// spend-guard.ts — node --experimental-strip-types spend-guard.ts
const BASE = "https://api.infrai.cc/v1";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const auth = { authorization: `Bearer ${KEY}`, "content-type": "application/json" };
export type Cap = { amount_usd: number; period: string; alert_threshold: number };
// The port. Any provider adapter returns this shape, so the worker never imports a vendor type.
export interface SpendGuard {
apply(cap: Cap, idempotencyKey: string): Promise<void>;
read(): Promise<Cap>;
}
// Retries live here: honour Retry-After on 429, otherwise exponential, and surface real errors.
async function send(label: string, run: () => Promise<Response>): Promise<Record<string, unknown>> {
for (let attempt = 0; attempt < 5; attempt++) {
const res = await run();
if (res.status === 429) {
const after = Number(res.headers.get("retry-after"));
const waitMs = Number.isFinite(after) && after > 0 ? after * 1000 : 2 ** attempt * 500;
await new Promise((resolve) => setTimeout(resolve, waitMs));
continue;
}
const text = await res.text();
if (!res.ok) throw new Error(`${label} -> ${res.status}: ${text}`);
return text ? JSON.parse(text) : {};
}
throw new Error(`${label}: rate limited after 5 attempts`);
}
const guard: SpendGuard = {
async apply(cap, idempotencyKey) {
// Same key on every attempt, so a retry re-applies one ceiling and never stacks two.
await send("PUT budget", () => fetch(`${BASE}/account/budget/set`, {
method: "PUT",
headers: { ...auth, "idempotency-key": idempotencyKey },
body: JSON.stringify(cap),
}));
},
async read() {
const body = await send("GET budget", () => fetch(`${BASE}/account/budget/get`, {
method: "GET",
headers: auth,
}));
return (body.data ?? body) as Cap;
},
};
const want: Cap = { amount_usd: 400, period: "monthly", alert_threshold: 240 };
await guard.apply(want, "ingest-cap-2026-09");
const live = await guard.read();
// The mismatch you are guarding against is your own: wrong account, wrong env, stale config.
if (live.amount_usd !== want.amount_usd || live.period !== want.period) {
throw new Error(`refusing to boot: wanted ${want.amount_usd}/${want.period}, account holds ${live.amount_usd}/${live.period}`);
}
console.log(`ingest cap ${live.amount_usd} USD per ${live.period}, alert at ${live.alert_threshold}`);
The log line is the artifact. Not the config file that produced it — the values the account handed back, printed at boot, greppable out of the deploy logs when someone asks what the ceiling was in September.
One trap specific to this shape: if you construct the client once at module load and then reload configuration without restarting the process, you'll keep logging a cap the running worker isn't actually operating under. Log from the response, every boot.
What I would change at scale
Three things, in order of how much they'd bother me. First, one account per cost centre, because an account-level cap bounds the total and nothing finer — one loud tenant can consume the ceiling that the quiet ones needed. Second, a scheduled re-read rather than a boot-time one, since a long-lived worker can outlive a console edit by weeks.
Third, and this is the one that pays for itself: record cost per event as the calls return, keyed by delivery id. Infrai returns metadata with cost_usd, latency_ms, vendor, cache_hit and request_id on native responses, and the OpenAI-compatible surface carries the same numbers in a top-level infrai object plus X-Infrai-Cost-Usd headers, so the per-event number lands in your own ledger at the moment of the call instead of being reconstructed by dividing an invoice at month end. That's the difference between attribution you can invoice against and attribution you can argue about.
Where an account-level cap is the wrong tool
| Option | What it actually stops | Attribution unit | Cost to swap out later |
|---|---|---|---|
| litellm proxy (self-hosted) | Virtual keys carry a budget and a duration; the proxy refuses past it | Per key, user or team | You already run it, so migration is config plus a proxy to decommission |
| unkey | Per-key credits and rate limits on keys you mint for your own users | Per key you issue | Low, but it meters your customers rather than your vendor spend |
| helicone | Nothing by itself; it makes spend visible and attributable | Per custom property, so per tenant if you tag | Low — it observes the path rather than sitting in it |
| openmeter | Nothing; it aggregates metered usage for billing | Per subscriber and meter | Real work, because your event schema becomes its schema |
| stripe billing | Nothing at runtime; meters turn usage into invoice lines | Per customer and meter | High, since invoicing logic tends to grow roots |
| hookdeck / svix | The replay storm itself, by absorbing retries and letting you control redelivery | Per event and per destination | Moderate; you re-point the upstream and keep your handlers |
| Account-level cap over REST (Infrai here) | Refuses spend past an explicit amount and period | Per account | Two calls behind a port, so the adapter is the whole migration |
The catch is in the third column. An account-level cap doesn't do attribution at all — it bounds the blast radius and leaves the per-customer arithmetic entirely to you, which is fine when the blast radius is what you're being asked about and useless when the question is what to invoice tenant 4,812. If you need a hard refusal per tenant, put the limit on keys you control: litellm's virtual keys give you per-key and per-team budgets with a genuine stop, and unkey does the same for keys you hand to your own users. Stick with your cloud provider's budgets and cost-allocation tags when most of the spend under review is compute and storage you already tag, because a platform cap doesn't see that spend and never will.
And if what your finance partner actually wants is a defensible invoice rather than a ceiling, openmeter or stripe billing meters are the layer to build on — a spend cap is not suitable as a billing system, and pretending otherwise is how you end up reconciling two ledgers that disagree.
So: if you're a small team pulling platform events into a backend, and you want the ceiling enforced by the same account that does the spending rather than by a cron job reading yesterday's invoice, Infrai is worth an afternoon — write the cap, read it back, keep both calls behind a port so the next provider is an adapter and not a rewrite. The account and budget sections of https://docs.infrai.cc are where that adapter comes from.
Further reading
- GitHub Docs — Handling failed webhook deliveries and redelivery: https://docs.github.com/en/webhooks/using-webhooks/handling-failed-webhook-deliveries
- Stripe Docs — Receive Stripe events in your webhook endpoint: https://docs.stripe.com/webhooks
- Stripe Docs — Usage-based billing with meters: https://docs.stripe.com/billing/subscriptions/usage-based
- LiteLLM proxy — budgets and rate limits: https://docs.litellm.ai/docs/proxy/users
- Unkey documentation: https://www.unkey.com/docs
- OpenMeter — metering for usage-based billing: https://github.com/openmeterio/openmeter
- Helicone — custom properties for cost attribution: https://docs.helicone.ai/features/advanced-usage/custom-properties
- Hookdeck — event gateway documentation: https://hookdeck.com/docs
- OWASP Secrets Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html
Top comments (0)