Skip to content

Stop a Task at Its Budget

Use this pattern when one task has a fixed budget, such as 5 USD. The task can make multiple model and tool calls, but must stop after its rated usage reaches that budget.

Keep one durable subject route (for example user -> billing account) and create one non-reusable Budget Lease per task. A Lease has a fixed lifetime amount window that never resets, but it does not create a new subject route.

  • Every rated usage that leaves the task at or above its cap creates a budget_lease.exhausted event. Replays of the same usage version are deduplicated.
  • The webhook lets your runtime cancel an active stream and prevent later model, tool, and child-task steps.
  • Usage already accepted by Meterry remains an accounting fact. A 202 Accepted ingest response does not mean the task has been rated or stopped yet.

This is a realtime stop-loss mechanism, not a synchronous authorization or reservation API. The remaining overspend is bounded by work already in flight, reporting granularity, and queue/delivery latency.

Task budget stop sequenceControl planeTask runtimeModel / toolMeterryRating and limitWebhook and run stateCreate Budget Lease (5 USD)Start callActual usageIngest usage202 AcceptedExtract, rate, advance limitFirst-crossing webhookPersist blocked and cancelAbort stream when supported

Generate a task ID that is never reused, for example run_01J.... Before the task starts, create a Lease. Its subject must already have a stable route to the same billing account. The account and subject route can be shared by many tasks; only the Lease is task-specific.

POST /v1/projects/:project_id/budget-leases
{
"billing_account_id": "acct_customer_001",
"run_id": "run_01J...",
"subject_type": "user",
"subject_id": "user_123",
"currency": "USD",
"limit_amount": "5.00",
"action": "block",
"webhook_event": "usage.task_budget_exhausted"
}

The returned budget_lease_id has a fixed lifetime window (window_id: "lifetime"). Do not reuse run_id for another task: its budget intentionally never resets.

For the complete request contract, see Create Budget Lease.

Your published extractor rule set should keep extracting the stable subject that owns the payer route. The task runtime also attaches the Lease returned for this run:

{
"x-usage-control": {
"budget_lease_id": "ble_..."
}
}

Billing snapshots the ID on the normalized usage event. Rated charge items must contain an amount and currency for the Lease to advance. Use attached pricing, x-billing.pricing_hints, or fallback pricing so the completed call has a rated amount.

3. Report actual usage after every billable step

Section titled “3. Report actual usage after every billable step”

Report the provider’s actual usage as soon as each model or tool call completes. Use a stable external_event_id and idempotency_key for that provider call so a retry cannot charge the task twice.

Do not wait until the whole task completes to send a combined usage event. Smaller reporting units reduce the cost that can accrue before the runtime sees the stop signal.

See Ingest Usage Event for the event contract and LLM Gateway for provider usage normalization.

Create a webhook endpoint in the same project. Subscribe to either the standard event type or the configured business event name:

{
"url": "https://tasks.example.com/internal/billing-webhooks",
"event_types": ["usage.task_budget_exhausted"]
}

The delivered envelope uses event_type: "budget_lease.exhausted". The payload identifies the Lease, action, and fixed lifetime window:

{
"id": "wh_evt_...",
"event_type": "budget_lease.exhausted",
"data": {
"budget_lease": { "id": "ble_...", "run_id": "run_01J..." },
"subject": { "type": "user", "id": "user_123" },
"limit": {
"window_id": "lifetime",
"used_amount": "5.24",
"limit_amount": "5.00",
"remaining_amount": "-0.24",
"action": "block"
}
}
}

Keep the endpoint signing secret in your backend. Verify the raw request body, timestamp, and HMAC signature before parsing or acting on the event. See Webhooks for the signing protocol.

5. Make the webhook change execution state, not just send an alert

Section titled “5. Make the webhook change execution state, not just send an alert”

Treat the receiver as an idempotent command handler. In one durable transaction, deduplicate by webhook ID, change the task from active to blocked, and write an outbox message or publish a cancellation signal to active workers. Return a 2xx only after that state transition is durable.

if (
event.event_type === "budget_lease.exhausted" &&
event.data.limit.window_id === "lifetime" &&
event.data.limit.action === "block"
) {
await db.transaction(async (tx) => {
const changed = await tx.taskRuns.blockOnce({
runID: event.data.budget_lease.run_id,
webhookID: event.id,
reason: "budget_exhausted",
})
if (changed) {
await tx.runCancellationOutbox.enqueue(event.data.subject.id)
}
})
}

Every worker must read this shared state before scheduling another model, tool, or child-task step. Workers that own an active streaming request should also listen for the cancellation signal and call the provider’s abort API when available.

  • Use a Budget Lease to stop one task. It never creates a one-off subject route. A shared wallet-insufficient event is account-wide and should not be interpreted as only one task being blocked.
  • Query the realtime wallet amount before expensive steps when you need an additional guard against webhook delivery delay.
  • Keep the Lease active long enough to accept delayed provider usage. After that retention window, transition it to settling and archive it; retain usage, webhook, and accounting records for audit.
  • Monitor webhook delivery and task cancellation lag. A webhook is the signal to stop future work, not proof that an in-flight call was free or reversible.