docs / reference

Per-Customer Budgets

Set, change and remove each customer's AI spend cap from your backend with an SDK key, monthly or as a total pool that never resets.

If you resell AI to your own customers, their limits change all the time: on signup, on upgrade, on a top-up, on cancel. Per-customer budgets let your backend write those limits with the SDK key, so nobody types them into the dashboard.

A cap is the same per-user cap you can set in the dashboard. check() enforces it before the LLM call fires, and a change applies on that customer's very next check().

Start with plan quotas

If every customer on a plan gets the same allowance, you don't need this API. Add a plan quota per plan (for example trial $1, small $5, large $20 a month) and pass the customer's plan on every check() as context.plan. A plan change in your billing system then takes effect with no API call.

Use per-customer budgets for the limits a plan can't express:

  • a cap sized to a customer's wallet or contract;
  • a one-off raise for one customer;
  • a total pool that never resets, such as a trial or a prepaid credit pack;
  • blocking one customer outright (a cap of 0).

A per-customer cap always wins over the customer's plan quota.

Turn on "Can manage budgets"

A key that can write caps can also raise them, so the permission is off by default. Open your project, find SDK key, and turn on Can manage budgets. Rotating the key keeps the setting.

Any key for the project can read caps. Writes with a key that doesn't have the permission get 403 KEY_CANNOT_MANAGE_BUDGETS. Only use this key on your server, never in a browser or a mobile app.

Set a customer's cap

from noburn import NoburnGuard, NoburnApiError

guard = NoburnGuard(api_key=NOBURN_KEY, project_id=PROJECT_ID)

# $20 a month, resets on the 1st (UTC)
guard.set_end_user_budget(workspace.id, 20)

# 500 requests a day
guard.set_end_user_budget(workspace.id, 500, limit_type="requests", window="day")

# A $5 trial pool that never resets
guard.set_end_user_budget(workspace.id, 5, window="total")

# Remove the cap: the customer falls back to their plan quota, if any
guard.delete_end_user_budget(workspace.id)
import { NoburnGuard, NoburnApiError } from '@noburn/sdk';

const guard = new NoburnGuard({ apiKey: NOBURN_KEY, projectId: PROJECT_ID });

// $20 a month, resets on the 1st (UTC)
await guard.setEndUserBudget({ endUserId: workspace.id, limit: 20 });

// 500 requests a day
await guard.setEndUserBudget({ endUserId: workspace.id, limit: 500, limitType: 'requests', window: 'day' });

// A $5 trial pool that never resets
await guard.setEndUserBudget({ endUserId: workspace.id, limit: 5, window: 'total' });

// Remove the cap: the customer falls back to their plan quota, if any
await guard.deleteEndUserBudget(workspace.id);

The end_user_id must be the same id you pass to check() and record().

These methods throw, unlike check(), which fails open. A cap you meant to set must not quietly go missing. Catch NoburnApiError (with status, code and, for a batch, errors), NoburnAuthError and NoburnTimeoutError.

Monthly or total

WindowCounts usage fromResets
day00:00 UTCthe next 00:00 UTC
weekMonday 00:00 UTCthe next Monday
month (default)the 1st, 00:00 UTCthe 1st of the next month
totalthe cap's period_startnever

A total cap starts its pool when you create it. Setting it again with the same or a new limit keeps the pool, so retries and limit changes are safe. To give the customer a fresh pool (after an upgrade or a top-up), pass reset_spend / resetSpend: true. That moves period_start to now. reset_spend does nothing for the other windows.

A USD-per-month cap blocks with user_budget_exceeded, as per-user caps always have. Every other type or window, total included, blocks with quota_exceeded. The quota object on check() shows window: "total" and resets_at: null.

Lowering a cap below what the customer has already used blocks their next call. A cap of 0 blocks every call.

Read a cap and its usage

b = guard.get_end_user_budget(workspace.id)   # None when there is no cap
if b:
    print(f"${b.used:.2f} of ${b.limit:.2f} used since {b.window_start}")
const b = await guard.getEndUserBudget(workspace.id);   // null when there is no cap
if (b) console.log(`$${b.used.toFixed(2)} of $${b.limit.toFixed(2)} used since ${b.windowStart}`);

Backfill every customer

setEndUserBudgets (set_end_user_budgets) writes up to 500 caps in one call. It is all or nothing: if one item is invalid, nothing is written and the error lists each bad item by index. Send larger sets in batches of 500.

for batch in chunks(workspaces, 500):
    guard.set_end_user_budgets([
        {"end_user_id": w.id, "limit": cap_for(w.plan)} for w in batch
    ])
for (const batch of chunks(workspaces, 500)) {
  await guard.setEndUserBudgets(batch.map((w) => ({ endUserId: w.id, limit: capFor(w.plan) })));
}

listEndUserBudgets (list_end_user_budgets) pages through every cap in the project, 50 at a time by default and at most 100.

HTTP API

All routes take the SDK key as Authorization: Bearer <key>, and project_id must be the key's project. Writes need Can manage budgets and are limited to 120 a minute per key; a bulk call counts as one write. Over the limit you get 429 with Retry-After.

MethodRouteDoes
PUT/api/v1/end-user-budgets/{end_user_id}Create or replace one cap
GET/api/v1/end-user-budgets/{end_user_id}?project_id=The cap and its usage; 404 when there is none
DELETE/api/v1/end-user-budgets/{end_user_id}?project_id=Remove the cap; 204, also when there was none
GET/api/v1/end-user-budgets?project_id=&cursor=&limit=List caps, ordered by end_user_id
POST/api/v1/end-user-budgetsCreate or replace 1 to 500 caps

URL-encode the end_user_id in the path.

PUT body:

{
  "project_id": "your-project-id",
  "limit": 12.5,
  "limit_type": "usd",
  "window": "total",
  "reset_spend": false
}
FieldTypeDefaultNotes
limitnumberrequired0 to 1000000. USD for usd, a whole number for requests
limit_type"usd" | "requests""usd"What the cap counts
window"day" | "week" | "month" | "total""month"See above
reset_spendbooleanfalsetotal only: start a new pool now

PUT and single GET return:

{
  "budget": {
    "end_user_id": "ws_123",
    "limit_type": "usd",
    "limit": 12.5,
    "window": "total",
    "period_start": "2026-10-28T10:00:00.000Z",
    "source": "api",
    "created_at": "2026-10-28T10:00:00.000Z",
    "updated_at": "2026-10-28T10:00:00.000Z",
    "window_start": "2026-10-28T10:00:00.000Z",
    "resets_at": null,
    "used": 3.41,
    "remaining": 9.09
  }
}

source is "api" or "dashboard": whoever wrote the cap last. List and bulk responses return { "budgets": [...] } without the usage fields; the list also returns next_cursor, which is null on the last page.

Bulk body: { "project_id": "...", "items": [{ "end_user_id": "ws_1", "limit": 5, "window": "month" }] }. Each end_user_id may appear once. A rejected batch returns 400 with errors: [{ "index": 1, "error": "..." }].

Every write is recorded in your audit log with the key as the actor, along with the old and new cap.

On this page