Capping spend so a runaway agent loop can't drain the account
A hard cap with an alert threshold below it, plus the webhook that tells you before the ceiling lands. What the cap does and does not protect.
An agent that retries a paid call in a loop is the modern version of a runaway cron job, and the only defence that works while you’re asleep is a ceiling the platform enforces. On Infrai that’s PUT /v1/account/budget/set: a hard_cap_usd, a period of daily or monthly, and an alert_threshold_usd below the cap so you hear about it before it bites.
Set it before you ship the agent, not after the first surprise.
Set the cap
curl -sS -X PUT "https://api.infrai.cc/v1/account/budget/set" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"hard_cap_usd": 250, "period": "monthly", "alert_threshold_usd": 200}'
{
"ok": true,
"data": {
"hard_cap_usd": 250.0,
"period": "monthly",
"alert_threshold_usd": 200.0,
"spent_this_period_usd": 17.25214845,
"configured": true,
"updated_at": "2026-09-20T23:06:18Z"
}
}
spent_this_period_usd comes back with it, so the same call that sets the ceiling tells you how close you already are. period_resets_at appears on the read and is the field to show in a dashboard — a monthly cap that resets mid-month is a support ticket waiting to happen.
Reading it back is a free GET:
curl -sS "https://api.infrai.cc/v1/account/budget/get" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Pick the numbers from your own runway
Don’t guess. GET /v1/account/balance returns what you’re actually spending per day:
curl -sS "https://api.infrai.cc/v1/account/balance" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"balance": 24.03029009,
"cap": 500.0,
"available_room": 475.96970991,
"runway_days": 41.79,
"daily_avg_spend": 0.57507162,
"tier": "standard",
"currency": "USD",
"is_in_grace_period": false
}
}
daily_avg_spend times the days in your period is your baseline. A cap at two or three times that leaves room for growth while still stopping a loop; a cap at twenty times it is decoration.
runway_days is the number to alert on. It’s derived from your own average rather than a fixed threshold, so it moves as soon as consumption changes.
Hear about it before the ceiling
The alert threshold is only useful if something is listening. Register a webhook for the wallet events:
curl -sS -X POST "https://api.infrai.cc/v1/account/webhooks/register" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"url": "https://ops.example.com/hooks/infrai",
"events": ["wallet.low_balance", "wallet.expiring_soon", "autorecharge.failed"],
"description": "spend guardrails",
"secret": "a-long-random-string-you-generate"
}'
Those event names are from a fixed catalogue, and ["*"] subscribes to everything if you’d rather filter on your side. The delivery carries X-Infrai-Event and an X-Infrai-Signature header of the form sha256=<hex>, computed as an HMAC-SHA256 of the exact request body with your secret.
The whole guardrail as one setup script
import os
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
def current_burn() -> dict:
resp = SESSION.get(f"{API}/v1/account/balance", timeout=20)
resp.raise_for_status()
return resp.json()["data"]
def set_cap_from_burn(multiple: float = 3.0, period: str = "monthly") -> dict:
"""Derive the ceiling from measured spend instead of a number someone
remembered. A cap you picked out of the air is either useless or an outage."""
burn = current_burn()
days = 30 if period == "monthly" else 1
baseline = max(1.0, burn["daily_avg_spend"] * days)
cap = round(baseline * multiple, 2)
resp = SESSION.put(
f"{API}/v1/account/budget/set",
json={"hard_cap_usd": cap, "period": period,
"alert_threshold_usd": round(cap * 0.8, 2)},
timeout=20,
)
resp.raise_for_status()
return resp.json()["data"]
if __name__ == "__main__":
print(set_cap_from_burn())
A cap derived from measured burn survives your product growing. A hardcoded one becomes either an outage or a fiction within a quarter.
What the cap protects, and what it doesn’t
| Failure | Does the cap help? |
|---|---|
| Agent retrying a paid call in a loop | yes — spend stops at the ceiling |
| A tenant abusing a free tier you resell | yes, indirectly — your total is bounded |
| One expensive call (a long video render) | no — a single call under the cap still lands |
| Storage rent accruing on forgotten objects | partly — it counts toward spend, but the objects stay |
| A leaked key | no — use POST /v1/account/keys/suspected_compromise/{id} |
That third row is the caveat worth internalising. A cap is a total, not a per-call limit, so it won’t save you from one deliberately enormous request. For that you want to check the price before you call: GET /v1/discovery/{capability} returns the live billing block, and GET /v1/account/balance returns an affordable_uses_hint telling you how many calls of each kind your remaining credit buys.
When the ceiling lands
Calls that would exceed the cap are refused with a typed, non-retryable INSUFFICIENT_CREDIT rather than silently degrading. Handle it as a business condition: pause the worker, tell someone, and don’t wrap it in a retry loop — that’s the same loop you were trying to stop.
Nothing here reaches into your own infrastructure, which is the honest limitation. The cap bounds what you spend on the platform; a runaway loop still burns your own compute, your own database connections and your own logs, and the platform can’t see any of that.
What it does give you is one place for the whole guardrail. The cap, the alert threshold, the webhook that fires, the email that goes out via POST /v1/email/send, the scheduled review job on POST /v1/cron/create and the spend it’s all protecting are the same account and the same key — so “are we protected” is one read rather than four vendor consoles. All the account routes here report billing_class: free in discovery, so the guardrails cost nothing to run; the rates they guard are live in GET /v1/account/balance (verified 2026-09-21) and drift downward over time, so read them there.