Budget Alerts
Define spending thresholds in cents. Get email or webhook notifications when project spend crosses each one. A threshold fires at most once per billing period; the monthly period reset clears notified_thresholds so each threshold can fire again next month.
Overview
Budget alerts monitor your project's monetary spend (computed from
per-request cost on usage events, in cents) against
configurable thresholds. Each threshold triggers only once per
billing period.
All /billing/* routes are mounted on the dashboard router
group and require an authenticated session plus a CSRF token. Obtain
the token from the session bootstrap endpoint and supply it via the
X-CSRF-Token header on every mutating request (POST,
PUT, DELETE). Read-only GET requests still require the session
cookie.
Threshold Lifecycle
A threshold transitions through three states each billing period: armed, fired, then reset on the month boundary.
flowchart LR
Armed["Armed
threshold not in
notified_thresholds"]
Fired["Fired
added to
notified_thresholds"]
History["Alert history row
+ email / webhook"]
Reset["Period reset
(monthly)"]
Armed -->|spend crosses threshold| Fired
Fired --> History
History --> Reset
Reset -->|clears notified_thresholds| Armed
API Endpoints
| Method | Endpoint | Description |
|---|---|---|
| GET | /billing/budgets | List all budget alerts for the project. |
| POST | /billing/budgets | Create a new budget alert. |
| GET | /billing/budgets/:budgetId | Get budget alert details. |
| PUT | /billing/budgets/:budgetId | Update a budget alert. |
| DELETE | /billing/budgets/:budgetId | Delete a budget alert and its history. Returns 204 No Content with an empty body on success. |
| GET | /billing/budgets/:budgetId/history | Get alert history (query: ?limit=, default 50, max 100). Cursor pagination is not supported on this endpoint. |
| POST | /billing/budgets/check | Manually trigger a budget check for the project. See Budget Check. |
Create a Budget Alert
| Parameter | Type | Description |
|---|---|---|
| namerequired | string | Human-readable name for this budget alert. |
| budget_amountrequired | integer | Monthly budget limit in cents (e.g., 10000 = $100.00). |
| thresholdsoptional | array of integer | Percentage thresholds that trigger notifications (1-100). Defaults to [50, 75, 90, 100]. |
| alert_channelsoptional | array of string | Notification channels: "email" and/or "webhook". Defaults to ["email"]. |
| webhook_urloptional | string | HTTPS URL for webhook notifications. Required when alert_channels includes "webhook". |
curl -X POST https://xerotier.ai/billing/budgets \
-H "Authorization: Bearer xero_my-project_abc123" \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: your-csrf-token" \
-d '{
"name": "Monthly API Budget",
"budget_amount": 10000,
"thresholds": [50, 75, 90, 100],
"alert_channels": ["email", "webhook"],
"webhook_url": "https://hooks.example.com/budget-alert"
}'
The budget_amount is specified in cents.
A value of 10000 represents $100.00.
import requests
headers = {
"Authorization": "Bearer xero_my-project_abc123",
"Content-Type": "application/json",
"X-CSRF-Token": "your-csrf-token"
}
response = requests.post(
"https://xerotier.ai/billing/budgets",
headers=headers,
json={
"name": "Monthly API Budget",
"budget_amount": 10000,
"thresholds": [50, 75, 90, 100],
"alert_channels": ["email", "webhook"],
"webhook_url": "https://hooks.example.com/budget-alert"
}
)
print(response.json())
const response = await fetch(
"https://xerotier.ai/billing/budgets",
{
method: "POST",
headers: {
"Authorization": "Bearer xero_my-project_abc123",
"Content-Type": "application/json",
"X-CSRF-Token": "your-csrf-token"
},
body: JSON.stringify({
name: "Monthly API Budget",
budget_amount: 10000,
thresholds: [50, 75, 90, 100],
alert_channels: ["email", "webhook"],
webhook_url: "https://hooks.example.com/budget-alert"
})
}
);
const data = await response.json();
console.log(data);
Response Example
All response bodies use snake_case field names. The
same shape is returned by list, get, create, and update endpoints.
{
"id": "00000000-1111-0000-1111-000000000000",
"project_id": "00000000-2222-0000-2222-000000000000",
"name": "Monthly API Budget",
"budget_amount": 10000,
"budget_amount_formatted": "$100.00",
"thresholds": [50, 75, 90, 100],
"alert_channels": ["email", "webhook"],
"notified_thresholds": [],
"current_spend": 0,
"current_spend_formatted": "$0.00",
"spend_percentage": 0.0,
"remaining_budget": 10000,
"remaining_budget_formatted": "$100.00",
"period_start": "2026-04-01T00:00:00Z",
"period_end": "2026-05-01T00:00:00Z",
"is_enabled": true,
"webhook_url": "https://hooks.example.com/budget-alert",
"next_threshold": 50,
"created_at": "2026-04-09T10:00:00Z",
"updated_at": null
}
Once every configured threshold has fired in the current period,
next_threshold becomes null and
notified_thresholds contains the full set:
{
"thresholds": [50, 75, 90, 100],
"notified_thresholds": [50, 75, 90, 100],
"next_threshold": null,
"spend_percentage": 104.2
}
Response Fields
| Field | Type | Description |
|---|---|---|
id |
string (uuid) | Budget alert identifier. |
project_id |
string (uuid) | Owning project identifier. |
budget_amount |
integer | Monthly budget limit in cents. |
current_spend |
integer | Spend for the current billing period in cents. |
spend_percentage |
number | Current spend as a percentage of the budget. |
notified_thresholds |
array of integer | Thresholds that have already fired in the current period. |
next_threshold |
integer or null | The next un-fired threshold, or null when all thresholds have fired. |
period_start / period_end |
string (ISO 8601) | Start (inclusive) and end (exclusive) of the current billing period. |
is_enabled |
boolean | Whether the alert is evaluated during budget checks. |
webhook_url |
string or null | Configured webhook URL, or null when webhook delivery is not in use. |
created_at / updated_at |
string (ISO 8601) or null | Timestamps; updated_at is null until the first update. |
Update a Budget Alert
All fields are optional in an update request. Only provided fields are changed.
| Parameter | Type | Description |
|---|---|---|
| nameoptional | string | Updated name. |
| budget_amountoptional | integer | Updated monthly budget limit in cents. |
| thresholdsoptional | array of integer | Updated threshold percentages. |
| alert_channelsoptional | array of string | Updated notification channels. |
| webhook_urloptional | string | Updated webhook URL (null to remove). |
| is_enabledoptional | boolean | Enable or disable the budget alert. |
curl -X PUT https://xerotier.ai/billing/budgets/budget-uuid \
-H "Authorization: Bearer xero_my-project_abc123" \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: your-csrf-token" \
-d '{
"budget_amount": 20000,
"is_enabled": true
}'
Thresholds
A threshold is an integer percentage (1..100) of
the budget. When spend crosses a threshold for the first time
this billing period, the alert fires on every configured channel.
Three common ladders:
| Configuration | Thresholds | Use Case |
|---|---|---|
| Standard | [50, 75, 90, 100] | Gradual warnings as spending increases. |
| Conservative | [25, 50, 75, 90, 100] | Early warning for tight budgets. |
| Simple | [80, 100] | Alert only when nearing or at the limit. |
A threshold fires once per billing period. After the 75%
threshold fires it will not fire again until the next period
resets notified_thresholds, even if spend drops
below and re-crosses the line.
Notifications
Budget alerts support two notification channels:
| Channel | Description |
|---|---|
email |
Email notification fanned out to every user with the owner role on the project (one or more recipients). |
webhook |
HTTP POST to a configured URL. Requires HTTPS and a publicly routable host. |
Webhook Payload
Budget-alert deliveries use a legacy non-enveloped payload
shape distinct from the canonical event envelope used by
other webhook events.
All fields are snake_case.
When a threshold is triggered, the configured URL receives a POST request with this body:
{
"event_type": "budget.threshold_reached",
"alert_id": "uuid",
"alert_name": "Monthly API Budget",
"project_id": "uuid",
"project_name": "My Project",
"threshold": 75,
"current_spend_cents": 7500,
"budget_amount_cents": 10000,
"spend_percentage": 75.0,
"timestamp": "2026-01-15T10:30:00Z"
}
Webhook URL Rules
Webhook URLs are validated server-side. A URL that fails any
rule below is rejected with HTTP 400
Webhook URL must not point to a private or loopback address
(or Webhook URL must use HTTPS for the scheme
check). Validation happens at create and update time, so a
rejected URL never reaches the delivery worker.
| Rule | Rejected Values |
|---|---|
| Scheme | Anything other than https. |
| Loopback | 127.0.0.0/8, ::1. |
| Link-local | 169.254.0.0/16. |
| RFC1918 private | 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16. |
| Reserved hostnames | localhost, localhost.localdomain, metadata.google.internal, 169.254.169.254. |
Delivery Semantics
Delivery timeout is 30 seconds. Failed deliveries are retried by
the background job queue; each attempt writes a row to alert
history, so delivery_success = false entries may
accumulate across retries until the delivery succeeds or the job
is given up.
Alert History
Each triggered alert is recorded in the alert history with delivery status:
curl https://xerotier.ai/billing/budgets/budget-uuid/history?limit=20 \
-H "Authorization: Bearer xero_my-project_abc123"
import requests
headers = {"Authorization": "Bearer xero_my-project_abc123"}
response = requests.get(
"https://xerotier.ai/billing/budgets/budget-uuid/history",
headers=headers,
params={"limit": 20}
)
for record in response.json():
print(f"Threshold {record['threshold']}% at {record['notified_at']}")
const response = await fetch(
"https://xerotier.ai/billing/budgets/budget-uuid/history?limit=20",
{
headers: { "Authorization": "Bearer xero_my-project_abc123" }
}
);
const history = await response.json();
history.forEach(record => {
console.log(`Threshold ${record.threshold}% at ${record.notified_at}`);
});
History Record Fields
| Field | Description |
|---|---|
threshold |
The percentage threshold that triggered (e.g., 75). |
spend_at_alert |
Spend in cents when the alert triggered. |
budget_at_alert |
Budget amount in cents when the alert triggered. |
notified_at |
Timestamp of the notification (ISO 8601). |
notification_channel |
Channel used (email or webhook). |
delivery_success |
Whether the notification was delivered successfully. |
error_message |
Error details if delivery failed (null on success). |
Budget Check
POST /billing/budgets/check evaluates every enabled
budget alert in the project against the current spend and fires any
thresholds that are newly crossed. The route takes no request body.
{
"budgets_checked": 3,
"alerts_triggered": 1,
"triggered_alerts": [
{
"budget_alert_id": "00000000-1111-0000-1111-000000000000",
"name": "Monthly API Budget",
"threshold": 75,
"spend_percentage": 76.4,
"notification_channels": ["email", "webhook"]
}
]
}
triggered_alerts is empty when no new threshold was
crossed. Disabled alerts (is_enabled = false) are
skipped; re-enabling an alert within the same period leaves any
previously fired thresholds in notified_thresholds, so
they will not fire again until the period resets.
Errors and Authentication
All /billing/budgets routes return the standard error
envelope used by other dashboard APIs. See
Error Handling for the
envelope shape and the full type taxonomy.
{
"error": {
"message": "Thresholds must be between 1 and 100",
"type": "invalid_request_error"
}
}
| Status | Condition |
|---|---|
| 400 | Validation failure: thresholds outside 1..100, missing required fields, non-HTTPS webhook URL, or webhook URL pointing to a private or loopback address. |
| 401 | No active session, or the CSRF token in X-CSRF-Token is missing or stale on a mutating request. |
| 403 | The requested budget alert exists but belongs to a different project than the current request context (Budget alert does not belong to this project). |
| 404 | No budget alert with the given id exists. |
Sample Error Bodies
{
"error": {
"message": "Thresholds must be between 1 and 100",
"type": "invalid_request_error"
}
}
{
"error": {
"message": "Webhook URL must not point to a private or loopback address",
"type": "invalid_request_error"
}
}
{
"error": {
"message": "Budget alert does not belong to this project",
"type": "forbidden"
}
}
Billing Periods
Budget alerts operate on monthly billing periods. A background job
resets each alert when its period_start is strictly
older than the start of the new month, so resets are best-effort
background work rather than a real-time event at 00:00 UTC. At
reset the system clears:
notified_thresholds, Cleared so all thresholds can fire again.current_spend, Recomputed from usage events for the new period.period_start/period_end, Updated to the new month boundary.
Current spend is calculated from the cost field on
usage events for the current billing period. Use
POST /billing/budgets/check to force an immediate
recalculation between scheduled runs.
See Also
- Billing and Subscriptions, project billing context that funds the budget under audit.
- Webhooks, canonical event envelope used by every webhook other than budget alerts.
- Error Handling, envelope shape and the
typetaxonomy used by every4xxbody on this page. - Usage, where the per-request
coston usage events comes from.