Spending controls
Spending controls are guardrails layered on your prepaid credit wallet. You set a per-period spend limit and a set of alert thresholds for your organization, and BatchRouter enforces them at batch-submit time. Controls are configured once per org and apply to every batch your keys submit.
Read and write controls with GET / PUT /v1/billing/controls, and review every change through the audit log at GET /v1/billing/controls/audit. You can also manage all of this from the dashboard at batchrouter.com/app/billing.
Read your controls
Section titled “Read your controls”GET /v1/billing/controls returns the org’s current configuration under a controls object. Until you save controls for the first time, configured is false and the fields hold their defaults (limit disabled, soft mode, no thresholds).
curl https://api.batchrouter.com/v1/billing/controls \ -H "Authorization: Bearer $BATCHROUTER_API_KEY"const res = await fetch("https://api.batchrouter.com/v1/billing/controls", { headers: { Authorization: `Bearer ${process.env.BATCHROUTER_API_KEY}` },});const { controls } = await res.json();console.log(controls.limit_enabled, controls.limit_amount);import os, requests
res = requests.get( "https://api.batchrouter.com/v1/billing/controls", headers={"Authorization": f"Bearer {os.environ['BATCHROUTER_API_KEY']}"},)controls = res.json()["controls"]print(controls["limit_enabled"], controls["limit_amount"])A configured response looks like this:
{ "controls": { "org_id": "org_123", "configured": true, "limit_enabled": true, "limit_period": "monthly", "limit_amount": { "currency": "usd", "amount": "500.00" }, "limit_mode": "hard", "limit_timezone": "America/New_York", "low_balance_amount": { "currency": "usd", "amount": "25.00" }, "alert_thresholds": [ { "type": "pct_of_limit", "value": 80 }, { "type": "balance_below", "value": 25 } ], "alert_email_enabled": true, "alert_webhook_enabled": false, "created_at": "2026-06-01T12:00:00Z", "updated_at": "2026-06-15T09:30:00Z" }}Field reference
Section titled “Field reference”| Field | Type | Meaning |
|---|---|---|
configured | boolean | false until you first save controls. |
limit_enabled | boolean | Whether a spend limit is active. |
limit_period | daily | monthly | null | The window the limit applies over. |
limit_amount | Money | null | Spend cap for the period. |
limit_mode | soft | hard | soft alerts only; hard blocks new spend at the cap. |
limit_timezone | string | IANA timezone used to compute period boundaries. |
low_balance_amount | Money | null | Balance level that triggers the low-balance alert. |
alert_thresholds | array | Threshold rules (see Alert thresholds). |
alert_email_enabled | boolean | Email alerts on/off (default on). |
alert_webhook_enabled | boolean | Also deliver alerts to your org delivery webhook (default off). |
Update your controls
Section titled “Update your controls”PUT /v1/billing/controls accepts a partial body — supply only the fields you want to change, and they are merged over the current configuration. Send null to clear a nullable field. Money fields in the request body are plain USD dollar numbers (for example 500 for $500), not Money objects.
This example turns on a hard monthly limit of $500 and adds two alert thresholds:
curl -X PUT https://api.batchrouter.com/v1/billing/controls \ -H "Authorization: Bearer $BATCHROUTER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "limit_enabled": true, "limit_period": "monthly", "limit_amount": 500, "limit_mode": "hard", "limit_timezone": "America/New_York", "low_balance_amount": 25, "alert_thresholds": [ { "type": "pct_of_limit", "value": 80 }, { "type": "balance_below", "value": 25 } ], "alert_email_enabled": true }'const res = await fetch("https://api.batchrouter.com/v1/billing/controls", { method: "PUT", headers: { Authorization: `Bearer ${process.env.BATCHROUTER_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ limit_enabled: true, limit_period: "monthly", limit_amount: 500, limit_mode: "hard", limit_timezone: "America/New_York", low_balance_amount: 25, alert_thresholds: [ { type: "pct_of_limit", value: 80 }, { type: "balance_below", value: 25 }, ], alert_email_enabled: true, }),});const { controls } = await res.json();import os, requests
res = requests.put( "https://api.batchrouter.com/v1/billing/controls", headers={ "Authorization": f"Bearer {os.environ['BATCHROUTER_API_KEY']}", "Content-Type": "application/json", }, json={ "limit_enabled": True, "limit_period": "monthly", "limit_amount": 500, "limit_mode": "hard", "limit_timezone": "America/New_York", "low_balance_amount": 25, "alert_thresholds": [ {"type": "pct_of_limit", "value": 80}, {"type": "balance_below", "value": 25}, ], "alert_email_enabled": True, },)controls = res.json()["controls"]The response echoes the full, updated controls object — the same shape as GET.
To remove the limit later, send { "limit_enabled": false }, or clear an amount with { "limit_amount": null }.
Soft vs. hard limits
Section titled “Soft vs. hard limits”soft— the safe default for any newly created limit. It never blocks a batch; it only drives alerts so you can watch spend without risking a hard stop. Reaching a soft limit emits abilling.limit_reachedevent.hard— blocks new batches once spend would exceed the cap. See How limits affect batch creation.
Alert thresholds
Section titled “Alert thresholds”alert_thresholds is an array (max 20 entries) of { type, value } rules. Each entry uses one of:
type | value meaning | Fires when |
|---|---|---|
balance_below | USD dollars | Available balance drops below value. |
spend_above | USD dollars | Period spend rises above value. |
pct_of_limit | whole percent (0–100) | Period spend reaches that percent of the limit. |
When a threshold fires, BatchRouter emits a billing.* event and, best-effort, sends a customer email (when alert_email_enabled, default on) and a signed delivery webhook (when alert_webhook_enabled, default off). Webhook alerts carry the billing.* event and are HMAC-SHA256 signed with the same X-BatchRouter-Signature scheme as batch delivery webhooks. Alerts are debounced per period so a sweep never spams you.
How limits affect batch creation
Section titled “How limits affect batch creation”A spend limit in hard mode is enforced at the authoritative submit gate. When you call POST /v1/batches, BatchRouter computes period spend + in-flight reservations + this batch's estimate. If that projected total would exceed limit_amount for the current period, the batch is rejected at submit with 402 (the same payment-required gate used when credits are insufficient). The error uses the standard error envelope, with limit context in details:
{ "error": { "code": "insufficient_credits", "message": "This batch would exceed your monthly spending limit.", "details": { "limit": "500.00", "period": "monthly", "spent": "480.00", "projected": "512.00" } }}Key behaviors to design around:
-
Quotes never throw on a limit.
POST /v1/quotes/modelis free and always succeeds for a valid request — it never fails just because the estimate would land near or over your limit. Quote first to check headroom before submitting. -
Soft limits never block. In
softmode a batch is always accepted; the limit only drives alerts. -
Completed batches are never clawed back. Settlement is a backstop — if a finished batch pushes period spend over a hard limit, it still completes, but an overrun emits
billing.limit_reachedso the next submit is blocked. -
Period boundaries use
limit_timezone. Daily and monthly windows are computed from the current time in that timezone, so there is no stored counter that can drift.
Audit log
Section titled “Audit log”Every controls change — yours or a system sweep — writes an entry to the billing-config audit log. Retrieve recent entries, newest first, with GET /v1/billing/controls/audit. The optional limit query parameter (1–200, default 50) caps the page size.
curl "https://api.batchrouter.com/v1/billing/controls/audit?limit=20" \ -H "Authorization: Bearer $BATCHROUTER_API_KEY"const res = await fetch( "https://api.batchrouter.com/v1/billing/controls/audit?limit=20", { headers: { Authorization: `Bearer ${process.env.BATCHROUTER_API_KEY}` } },);const { data } = await res.json();import os, requests
res = requests.get( "https://api.batchrouter.com/v1/billing/controls/audit", params={"limit": 20}, headers={"Authorization": f"Bearer {os.environ['BATCHROUTER_API_KEY']}"},)data = res.json()["data"]Each entry records the actor, the action, and the before/after configuration:
{ "data": [ { "id": "aud_123", "org_id": "org_123", "actor_user_id": "usr_456", "actor_role": "owner", "action": "controls.update", "before": { "limit_enabled": false }, "after": { "limit_enabled": true, "limit_mode": "hard" }, "ip_address": "203.0.113.10", "created_at": "2026-06-15T09:30:00Z" } ]}System or sweep-initiated changes have a null actor_user_id and actor_role. The before and after objects hold the changed configuration; see the API reference for the full entry shape.