If your product calls Claude on behalf of your customers, one heavy account can spend a month's margin in a weekend. Anthropic's console limits protect your organization. They don't know which of your customers sent the traffic, so they can't stop one of them without stopping everyone.
This post builds a per-customer monthly cap for the Anthropic Messages API in TypeScript:
- Price each call correctly, including prompt-cache reads and writes.
- Check the budget before the call, in a way that holds up under concurrent requests.
- Switch to a cheaper model near the cap, and refuse at the cap.
It uses @anthropic-ai/sdk and Redis (ioredis). The ideas carry over to any store with atomic operations.
1. Pricing a Messages API call
A Claude response's usage has four token counts that matter for cost:
| Field | What it is | Billed at |
|---|---|---|
input_tokens | input after the last cache breakpoint | base input price |
cache_read_input_tokens | prefix read from the prompt cache | 0.1x base for most models (0.05x for Opus 5.5 and Sonnet 5.5) |
cache_creation_input_tokens | prefix written to the cache this call | 1.25x base (5-minute TTL), 2x (1-hour TTL) |
output_tokens | generated tokens, including thinking | output price |
The trap is that input_tokens is not the total input. Total input is input_tokens + cache_read_input_tokens + cache_creation_input_tokens. If you only price input_tokens, a cached chat app looks almost free. And if you ignore cache writes, you under-count the first call of every conversation by 25% or more on its prefix.
When the 1-hour TTL is in play, usage.cache_creation splits writes into ephemeral_5m_input_tokens and ephemeral_1h_input_tokens, so you can price them separately.
// prices.ts: USD per 1M tokens, standard tier. // From https://docs.anthropic.com/en/docs/about-claude/pricing, read 2026-11-02. Check before relying on these. type Price = { input: number; output: number; cacheRead: number; cacheWrite5m: number; cacheWrite1h: number }; export const PRICES: Record<string, Price> = { "claude-sonnet-5-5": { input: 2.0, output: 10.0, cacheRead: 0.1, cacheWrite5m: 2.5, cacheWrite1h: 4.0 }, // Haiku 5.5 has a higher price for prompts over 100K tokens; this is the up-to-100K tier. "claude-haiku-5-5": { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite5m: 0.125, cacheWrite1h: 0.2 }, }; export interface ClaudeUsage { input_tokens: number; output_tokens: number; cache_read_input_tokens?: number | null; cache_creation_input_tokens?: number | null; cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number } | null; } export function costUsd(model: string, u: ClaudeUsage): number { const p = PRICES[model]; if (!p) throw new Error(`No price for ${model}; add it to PRICES`); const write1h = u.cache_creation?.ephemeral_1h_input_tokens ?? 0; const write5m = u.cache_creation?.ephemeral_5m_input_tokens ?? (u.cache_creation_input_tokens ?? 0) - write1h; return ( u.input_tokens * p.input + (u.cache_read_input_tokens ?? 0) * p.cacheRead + write5m * p.cacheWrite5m + write1h * p.cacheWrite1h + u.output_tokens * p.output ) / 1_000_000; } Not modeled here: batch discounts, long-context tiers, data-residency uplifts and server-tool fees (web search and the like). If you use them, add them; otherwise your cap is a floor, not a ceiling.
Throwing on an unknown model is deliberate. Silently pricing a new model at $0 is how caps stop working without anyone noticing.
2. Why "check, then record" isn't enough
The simple version reads the customer's spend, compares it to the budget, makes the call, and adds the cost afterwards. That's fine for one request at a time. But an agent loop or a user with three tabs open can have ten requests in flight. All ten read the same "under budget" total, all ten go through, and the customer ends up well past their cap.
The fix is to reserve an estimate before the call, atomically with the check, then settle with the real cost afterwards:
before: if spent + reserved + estimate > budget → refuse else reserved += estimate (one atomic step) call Claude after: reserved -= estimate; spent += actual Parallel requests now see each other's holds, so the overshoot is bounded by how wrong one estimate is, instead of by everything in flight.
Estimating a call before you make it
The upper bound on a call's cost is its input at full price plus max_tokens of output. You can get the input size from the token-counting endpoint:
const { input_tokens } = await anthropic.messages.countTokens({ model, system, messages }); const estimate = (input_tokens * price.input + maxTokens * price.output) / 1_000_000; That's an extra round trip per call. If it's too slow for you, JSON.stringify(messages).length / 4 is a rough stand-in. Either way, pricing all input at the full rate over-estimates cached prompts, which is the safe direction for a hold.
Atomic reserve and settle in Redis
Two keys per customer per month: spent and held. A Lua script makes the check and the hold one step:
// budget.ts import Redis from "ioredis"; const redis = new Redis(process.env.REDIS_URL!); const TTL = 60 * 60 * 24 * 40; // let old months expire on their own const period = () => new Date().toISOString().slice(0, 7); // "2026-11", UTC const keys = (customerId: string) => [ `budget:${period()}:${customerId}:spent`, `budget:${period()}:${customerId}:held`, ]; const RESERVE = ` local spent = tonumber(redis.call('GET', KEYS[1]) or '0') local held = tonumber(redis.call('GET', KEYS[2]) or '0') if spent + held + tonumber(ARGV[1]) > tonumber(ARGV[2]) then return 0 end redis.call('INCRBYFLOAT', KEYS[2], ARGV[1]) redis.call('EXPIRE', KEYS[2], ARGV[3]) return 1`; const SETTLE = ` redis.call('INCRBYFLOAT', KEYS[2], ARGV[1]) redis.call('INCRBYFLOAT', KEYS[1], ARGV[2]) redis.call('EXPIRE', KEYS[1], ARGV[3]) redis.call('EXPIRE', KEYS[2], ARGV[3]) return 1`; export async function usedFraction(customerId: string, budgetUsd: number): Promise<number> { const [spent, held] = await redis.mget(...keys(customerId)); return (Number(spent ?? 0) + Number(held ?? 0)) / budgetUsd; } export async function reserve(customerId: string, estimateUsd: number, budgetUsd: number): Promise<boolean> { const ok = await redis.eval(RESERVE, 2, ...keys(customerId), estimateUsd, budgetUsd, TTL); return ok === 1; } export async function settle(customerId: string, estimateUsd: number, actualUsd: number): Promise<void> { await redis.eval(SETTLE, 2, ...keys(customerId), -estimateUsd, actualUsd, TTL); } (keys() is computed per call, so a request that starts at 23:59 UTC on the 31st and settles after midnight will settle into the new month. If that bothers you, compute the keys once and pass them through.)
3. Putting it around the call
// claude.ts import Anthropic from "@anthropic-ai/sdk"; import { PRICES, costUsd } from "./prices"; import { reserve, settle, usedFraction } from "./budget"; const anthropic = new Anthropic(); export class BudgetExceededError extends Error { constructor(public customerId: string) { super(`AI budget reached for ${customerId}`); } } const CHEAPER: Record<string, string> = { "claude-sonnet-5-5": "claude-haiku-5-5" }; export async function createForCustomer( customerId: string, budgetUsd: number, params: Anthropic.MessageCreateParamsNonStreaming, ): Promise<Anthropic.Message> { // Near the cap: same request, cheaper model. let model = params.model; if (CHEAPER[model] && (await usedFraction(customerId, budgetUsd)) >= 0.8) model = CHEAPER[model]; const price = PRICES[model]; if (!price) throw new Error(`No price for ${model}`); const { input_tokens } = await anthropic.messages.countTokens({ model, system: params.system, messages: params.messages, tools: params.tools, }); const estimate = (input_tokens * price.input + params.max_tokens * price.output) / 1_000_000; if (!(await reserve(customerId, estimate, budgetUsd))) throw new BudgetExceededError(customerId); let actual = 0; try { const msg = await anthropic.messages.create({ ...params, model }); actual = costUsd(msg.model, msg.usage); return msg; } finally { // Failed calls release their hold at $0. await settle(customerId, estimate, actual); } } And in a route handler:
try { const msg = await createForCustomer(user.id, user.plan === "pro" ? 20 : 2, { model: "claude-sonnet-5-5", max_tokens: 1024, messages: [{ role: "user", content: prompt }], }); res.json(msg); } catch (e) { if (e instanceof BudgetExceededError) { return res.status(402).json({ error: "monthly_ai_limit", message: "You've reached this month's AI limit." }); } throw e; } A specific error code lets your UI show "upgrade or wait until the 1st" instead of a generic failure. That's the difference between a cap and an outage.
Streaming
With anthropic.messages.stream(...), input and cache counts arrive in the message_start event and the final output count in message_delta. The SDK's await stream.finalMessage() gives you the assembled usage, so price it the same way and call settle in a finally. If the client disconnects and you abort the stream, you may not get final usage; settling at the estimate in that case errs on the side of the cap.
Decisions you still have to make
- Fail open or closed? If Redis is down, do you let calls through (unmetered spend) or refuse them (feature outage)? Most products should fail open and alert. Wrap
reservein a try/catch and make it a conscious choice. - Leaked holds. If a process dies between
reserveandsettle, its hold stays until the month's keys expire. For most apps that's a few cents. If it isn't for you, store holds individually with a timestamp and sweep old ones. - Estimate size. A large
max_tokensmakes big holds, which can refuse a customer who has a little budget left. Setmax_tokensto what the feature really needs. - What "cheaper model" means. Test the fallback model on the feature before wiring it in. For extraction and short answers it's usually fine; for long reasoning it may not be.
If you'd rather not write this
Disclosure: I maintain llmbrake, an MIT-licensed TypeScript library for this pattern. It wraps OpenAI-compatible chat.completions calls, so with Claude it works through Anthropic's OpenAI-compatible endpoint rather than the native Messages API shown above, and its default price table doesn't price cache writes separately. If you use the native SDK and prompt caching heavily, the code in this post is the more accurate option.