Skip to navigation

Checks and holds

Decide before work starts, and reserve what it will cost.

check() answers one question before an agent starts work: can this customer pay for it? With hold: true, it also reserves the cost, so other work can’t spend the same balance while this work runs.

Describe the work

Pass exactly one of these, and Runledger prices it from the customer’s plan:

FieldUse it whenExample
event_name and quantityThe work is usage you’ll track"agent.task", "1"
outcome_definitionThe work may produce an outcome you charge for"chat_resolution"
amountYou’ve already priced it"40" (credits, or minor units on a postpaid plan)

A denial is a normal response with allowed: false and a reason:

reasonMeaning
insufficient_balanceA prepaid balance can’t cover it, after other holds.
credit_limit_reachedA postpaid customer’s unbilled charges and holds would pass the credit limit.
no_active_subscriptionThe customer isn’t on a plan.
subscription_past_dueAn invoice is unpaid.
no_rate_for_eventThe plan has no rate for this event or outcome.
allowance_exhaustedA plan usage window with on_exhausted: block has no room. denied_by names it and says when it resets.
spend_limit_reachedA spend limit with action: block has no room left in its period. denied_by names it and says when it resets.
margin_below_floorA margin guardrail with action: block found the work would sell below its floor.

The response also returns what the work costs (required, which is "0" for no_active_subscription and no_rate_for_event, where there is no plan or rate to price it from) and what was available before this call, so you can show the customer how far short they are and offer a top-up. The affordability block gives the same figures for the balance, pools, allowances and spend limits. When an allowance or a spend limit denies the work, denied_by names which one and when it lifts.

A denial carries one reason. If more than one test would deny the work, which reason you get isn’t specified, so handle every reason in the table.

How far the work fits

Every check() response carries an affordability block, allowed or denied, with or without hold: true. It says what the work would do to the balance, the pools, every allowance that applies and the spend limits, so your product can show a customer “this uses about 40 of your 1,260 credits” before the work starts. The block is always there, and so is every field in it: a value that doesn’t apply is null, and a list with nothing in it is []. POST /v1/events/preview prices an event and doesn’t return it.

Response: CheckResult
{
"object": "check",
"allowed": true,
"customer": "cus_9sF2kq81LmQz",
"credit_type": "ai_credits",
"required": "40",
"available": "1260",
"affordability": {
"balance": { "available_before": "1260", "available_after": "1220", "shortfall": "0" },
"pools": [],
"allowances": [],
"spend_limits": [],
"would_exceed": false,
"reason": null
},
"livemode": false
}

A customer with no pools, allowances or limits gets empty lists. When a plan has them, each is listed. Here a spend limit with action: warn would be taken 15 past its room: the limit lists its shortfall of "15", the work is allowed, and would_exceed is false, because a warn-only limit doesn’t block anything.

Response: CheckResult
{
"object": "check",
"allowed": true,
"customer": "cus_9sF2kq81LmQz",
"credit_type": "ai_credits",
"required": "40",
"available": "1260",
"affordability": {
"balance": { "available_before": "1260", "available_after": "1220", "shortfall": "0" },
"pools": [
{ "pool": "purchased", "available_before": "1260", "would_draw": "40", "available_after": "1220" }
],
"allowances": [],
"spend_limits": [
{
"id": "sl_4kP7wD2nRt",
"key": "team_monthly_cap",
"action": "warn",
"counts": "paid_usage",
"currency": "usd",
"counted": "40",
"remaining_before": "25",
"remaining_after": "-15",
"shortfall": "15",
"resets_at": "2026-12-01T00:00:00Z"
}
],
"would_exceed": false,
"reason": null
},
"livemode": false
}

Past an allowance: overage, or a stop

Every allowance that applies is listed, and on_exhausted says what happens to the part of the work that goes past it. The same numbers read two ways. A plan gives 100 credits a month, 90 are used, and the job needs 30, so the window has remaining_before of "10", remaining_after of "-20" and a shortfall of "20".

When the allowance bills overage, the 20 past it are billed as overage. The work is allowed, and would_exceed is false. This postpaid plan includes 100 credits a month of the credits event and bills overage at 5 minor units a credit, so required is 20 × 5 = 100:

Response: CheckResult
{
"object": "check",
"allowed": true,
"customer": "cus_9sF2kq81LmQz",
"currency": "usd",
"required": "100",
"available": "50000",
"affordability": {
"balance": { "available_before": "50000", "available_after": "49900", "shortfall": "0" },
"pools": [],
"allowances": [
{
"key": "credits",
"kind": "included",
"event_name": "credits",
"pool": null,
"on_exhausted": "bill_overage",
"member_id": null,
"counted": "30",
"remaining_before": "10",
"remaining_after": "-20",
"shortfall": "20",
"resets_at": "2026-12-01T00:00:00Z"
}
],
"spend_limits": [],
"would_exceed": false,
"reason": null
},
"livemode": false
}

When the allowance stops at its cap, the same 20 are over a limit. The work is denied, and would_exceed is true. This prepaid plan caps use at 100 credits a month, and the work is 30 credits, so the figures are the same. The balance has plenty, so it isn’t short:

Response: CheckResult
{
"object": "check",
"allowed": false,
"reason": "allowance_exhausted",
"customer": "cus_9sF2kq81LmQz",
"credit_type": "ai_credits",
"required": "30",
"available": "1260",
"denied_by": {
"type": "allowance",
"id": "monthly_cap",
"resets_at": "2026-12-01T00:00:00Z",
"remaining": "10"
},
"affordability": {
"balance": { "available_before": "1260", "available_after": "1230", "shortfall": "0" },
"pools": [],
"allowances": [
{
"key": "monthly_cap",
"kind": "cap",
"event_name": null,
"pool": null,
"on_exhausted": "block",
"member_id": null,
"counted": "30",
"remaining_before": "10",
"remaining_after": "-20",
"shortfall": "20",
"resets_at": "2026-12-01T00:00:00Z"
}
],
"spend_limits": [],
"would_exceed": true,
"reason": "allowance_exhausted"
},
"livemode": false
}

An allowance with on_exhausted: draw_next_pool lists the same kind of shortfall, the amount that moves on to the next pool, and leaves would_exceed false unless the balance can’t cover it.

What the figures mean

Units. Balance and pool amounts are decimal strings in the customer’s balance unit: credits of the credit_type on a prepaid plan, and minor units of the plan’s currency on a postpaid plan. Allowance amounts are credits, or units of the event for an included quantity. Spend limit amounts are minor units of that limit’s own currency. Currencies are never converted or added together.

Before and after. A _before figure is read before this call’s own hold, and counts every other active hold. It is the same figure as available on the response. An _after figure is the _before figure minus what the work uses. Without hold: true nothing is reserved, and _after is what would be left if the work ran. With hold: true on an allowed check the hold is for required, so _after is what the next caller sees. A denied check places no hold, and its _after figures are worked out the same way.

FieldTypePresentHow it is worked out
balance.available_beforedecimal string, or nullnull for a postpaid plan with no credit limit, and for no_active_subscription and no_rate_for_eventThe available balance: posted balance minus active holds on a prepaid plan, credit limit minus unbilled charges minus active holds on a postpaid plan. Negative when overdrawn
balance.available_afterdecimal string, or nullnull when available_before is nullavailable_before minus required. Negative when the work doesn’t fit
balance.shortfalldecimal string, or null"0" when the work fits, and when there is no credit limit to pass. null for no_active_subscription and no_rate_for_eventrequired minus available_before when that is above zero
poolslist[] on a postpaid plan, and for a prepaid customer whose credits sit in one pool and whose plan has no poolsOne entry for each pool the customer holds credits in, in drawdown order
pools[].poolstringAlwaysThe pool key, default when the plan has none
pools[].available_beforedecimal stringAlwaysThe pool’s posted credits minus active holds on it
pools[].would_drawdecimal stringAlwaysThe credits of required the pool would cover: the lesser of its available_before and what the earlier pools left uncovered. A pool whose cap has on_exhausted: draw_next_pool covers no more than the room left in the cap, so it is skipped when there is none. Across all pools it adds up to required minus balance.shortfall
pools[].available_afterdecimal stringAlwaysavailable_before minus would_draw. Never below zero
allowanceslist[] when no window appliesOne entry for every allowance window that applies to the work, not only caps: the plan’s refill and cap allowances, and the included quantity of a postpaid rate. The same windows the usage meters list
allowances[].key, .kindstringAlwaysThe allowance key (the event name for an included quantity), and refill, cap or included
allowances[].event_namestring, or nullnull for a refill or capThe event an included quantity counts
allowances[].on_exhaustedstringAlwaysWhat happens to the part of the work past the window: block stops it (a cap’s default), draw_next_pool moves it to the next pool (a refill, or a cap set to it), bill_overage bills it as overage (an included quantity)
allowances[].poolstring, or nullnull when the window isn’t tied to a poolThe pool a refill fills or a cap limits. null for a cap that counts every pool, and for an included quantity
allowances[].member_idstring, or nullnull for a customer-wide windowThe member, for a per-member window
allowances[].counteddecimal stringAlwaysWhat the work counts in the window: all of required for a refill, a cap with no pool and an included quantity (its quantity, in units of the event), and the share the pool would cover for a cap with one. Credits, except units for an included quantity
allowances[].remaining_beforedecimal stringAlwaysCredits (units for an included quantity) left in the window, after every other active hold
allowances[].remaining_afterdecimal stringAlwaysremaining_before minus counted. Negative when the work goes past the window
allowances[].shortfalldecimal stringAlwaysHow far the work is past the window: counted minus remaining_before when that is above zero, otherwise "0". It is the amount on_exhausted applies to: blocked, moved to the next pool, or billed as overage
allowances[].resets_atdate-timeAlwaysWhen the window resets
spend_limitslist[] when no enabled limit appliesOne entry for each enabled spend limit that applies to the customer and the work, whether its action is block or warn
spend_limits[].id, .action, .countsstringAlwaysThe limit’s ID, warn or block, and paid_usage or all_usage
spend_limits[].keystring, or nullnull when the limit has no keyThe limit’s key
spend_limits[].currencystringAlwaysThe limit’s currency. Every amount in the entry is in its minor units
spend_limits[].counteddecimal string, minor unitsAlwaysThe money value of the work that counts toward the limit: the part drawn from paid credits and overage for paid_usage, all of it for all_usage
spend_limits[].remaining_beforedecimal string, minor unitsAlwaysThe limit’s amount minus what is spent and held this period, after every other active hold
spend_limits[].remaining_afterdecimal string, minor unitsAlwaysremaining_before minus counted. Negative when the limit is overrun
spend_limits[].shortfalldecimal string, minor unitsAlwayscounted minus remaining_before when that is above zero, otherwise "0". A warn limit lists it and still allows the work
spend_limits[].resets_atdate-timeAlwaysWhen the period resets
would_exceedbooleanAlwaystrue only when something would block the work: balance.shortfall is above "0", or an allowances entry with on_exhausted: block, or a spend_limits entry with action: block, has a shortfall above "0". A shortfall that doesn’t block doesn’t set it: a warn spend limit, an allowance that bills overage and an allowance that draws from the next pool all list their shortfall and leave it false. It is true only when the check is denied for lack of room, and false on an allowed check, for no_active_subscription, no_rate_for_event and denials that aren’t about room, such as subscription_past_due and margin_below_floor
reasonreason value, or nullnull when the check allowed the workThe same as reason on the response

A denial still carries reason and denied_by, and affordability shows the figures behind them. For no_active_subscription and no_rate_for_event there is no balance or price to measure against, so required is "0", the balance figures are null, pools, allowances and spend_limits are [], and would_exceed is false:

Response: CheckResult
{
"object": "check",
"allowed": false,
"reason": "no_rate_for_event",
"customer": "cus_9sF2kq81LmQz",
"credit_type": "ai_credits",
"required": "0",
"affordability": {
"balance": { "available_before": null, "available_after": null, "shortfall": null },
"pools": [],
"allowances": [],
"spend_limits": [],
"would_exceed": false,
"reason": "no_rate_for_event"
},
"livemode": false
}

Without hold: true the block is a point-in-time read, not a reservation. Another caller can use the balance, an allowance’s room or a spend limit’s room right after you read it, so a figure that said the work fits can be out of date by the time the work starts. Work that must not lose its place needs hold: true. Checks against one balance are serialized, so a hold can’t double-spend it. That guarantee is stated for balances, and the block doesn’t promise more for caps or spend limits.

Holds

A hold counts against the available balance until one of these happens:

  • track() captures it. Pass the hold’s ID as hold on the event. The event’s price is captured and the rest released, unless you set release_remainder: false to capture again later, for example once per step of a long run.
  • You release it with POST /v1/holds/{id}/release, when the work is canceled or fails.
  • It expires. The default lifetime is 15 minutes, set by hold_expires_in (any ISO 8601 duration, up to 30 days). Expired holds release automatically and send hold.expired.

Long-running agents can extend a hold with POST /v1/holds/{id}/extend, passing a new expires_in and, if the work grew, a new total amount. expires_in on an extend is in whole days only: P1D to P30D, counted from now, such as P3D. A duration in hours or minutes, such as PT2H, returns 400 with parameter_invalid. That differs from hold_expires_in on check(), which can be minutes (the default is 15 minutes), so an extend always moves the expiry at least a day out. If you only need a little more time, place the hold with a longer hold_expires_in to begin with. A larger total must fit the available balance or the request fails with insufficient_balance, and the hold keeps its current amount.

When the captured event is an outcome, the reservation moves to the pending outcome charge and stays held until the charge confirms or is reversed.

Margin

check() can also compare a price with the cost you expect and deny work that would sell below a margin floor. A guardrail’s action decides what you see: block denies the work with the reason margin_below_floor, and warn allows it and adds the warning margin_below_guardrail. track() only ever warns, with margin_below_guardrail. They are one guardrail seen two ways: margin_below_floor is a reason for a denial, and margin_below_guardrail is a code on a warning. It takes optional properties and estimated_usage. See Pricing from cost.

Two agents, one balance

This is the failure holds exist to prevent. A customer has 100 credits. Two agents start at the same moment, and each run will cost 60.

Without holdsWith holds
Agent A checks100 available: allowed100 available: allowed, 60 held
Agent B checks100 available: allowed40 available: denied, insufficient_balance
Both finish120 spent from 100: overdrawn by 2060 spent, 40 left

Checks against one balance are serialized, so two concurrent check() calls can never both reserve the same credits. The sandbox scenarios include this race as a script you can run.

Holds and changing balances

A hold reserves an amount of a credit type, not particular grants. Credits are drawn from grants only at capture, in the published burn-down order. So a hold placed before the monthly reset survives it, and a hold backed by credits that expire before capture draws from whatever is left. If the balance can no longer cover an active hold, for example after a grant is voided, it shows as overdrawn and the next check is denied.

Work that already happened

track() never fails for lack of balance, because the work is done. If an event costs more than its hold, or there was no hold, the difference is drawn from the available balance. A prepaid balance may go below zero by the plan’s overdraft_limit (default "0"); past it, the balance is overdrawn, track() warns balance_overdrawn, balance.depleted fires, and checks are denied until the customer tops up.

Postpaid customers

On a postpaid plan, checks and holds work against the credit limit: available = credit limit - unbilled charges - held. Without a credit limit, checks are allowed unless the subscription is past_due. Holds are still worth placing, because they show what is in flight.

Limits and windows

A balance says what a customer has. Two more tests say how fast it may be used, and by whom:

  • Usage windows refill a plan’s credits on a schedule and cap use per week, per five-hour session or per hour. A cap that is full denies with allowance_exhausted.
  • Spend limits cap the money value of what a customer, team, member or agent consumes in a period. A limit that is full denies with spend_limit_reached.

Active holds count against both, so two agents can’t reserve the same room. track() never blocks. Work that already happened is recorded, and a window or limit it takes past its amount adds the warning allowance_exhausted or spend_limit_exceeded. Holds, events and costs record agent_id, member_id and team_id, so you can see and cap spend by any of them. See Members, teams and agents.

Alerts at thresholds you choose are webhooks: usage.threshold_reached, spend_limit.threshold_reached and balance.low, besides balance.depleted. See Auto top-up and alerts.

Next steps