Balances
Check before an action, track after it. Both answer from a cache in milliseconds.
balances.check
Also at /v1/check.
{ "customer_id": "user_123", "feature_id": "videos", "required_balance": 1, "entity_id": "ws_1" }{
"allowed": true,
"customer_id": "user_123",
"entity_id": "ws_1",
"feature_id": "videos",
"required_balance": 1,
"code": "feature_found",
"credit_feature_id": null,
"balance": {
"feature_id": "videos",
"type": "metered",
"unlimited": false,
"granted": 30,
"used": 12,
"remaining": 18,
"overage_allowed": false,
"next_reset_at": "2026-11-10T18:00:00.000Z",
"interval": "month",
"breakdown": [],
"usage_limits": [{ "limit": 10, "interval": "day", "usage": 4, "source": "customer" }]
}
}code is one of:
| Code | Meaning |
|---|---|
feature_found | Allowed |
insufficient_balance | Not enough left |
spend_limit_reached, max_purchase_reached | Pay as you go, but past the cap |
usage_limit_reached | Balance is left, but the daily, weekly, monthly or yearly cap is used up |
no_access | No plan grants it |
feature_not_found | Unknown feature id |
When a credit system prices the feature and the customer has no direct balance, credit_feature_id names it and balance is the credit balance. With an entity_id, the entity's own balance is used first and the customer's second.
check never writes, so it is safe to call on page load to grey out a button. Pass "send_event": true to check and track in one call when the action is instant and cannot fail. Pass "with_preview": true and a blocked answer includes preview.upgrade_options, the plans that would unlock it.
Reserve, then settle: locks
For work whose cost you only know at the end, such as an AI completion, reserve the most it could use and settle afterwards:
{ "customer_id": "user_123", "feature_id": "credits", "required_balance": 4000, "lock": { "enabled": true, "lock_id": "completion_42", "expires_at": 1760200000000 } }The amount is held so no other request can spend it. When the work finishes:
{ "lock_id": "completion_42", "action": "confirm", "override_value": 743 }confirm keeps the hold, or the override_value if you pass one, and gives the rest back. release gives it all back. A lock that is never finalized is released when it expires, at most 24 hours after it was made.
balances.track
Also at /v1/track.
{ "customer_id": "user_123", "feature_id": "videos", "value": 1, "idempotency_key": "render:vid_42", "properties": { "video_id": "vid_42" } }{
"code": "tracked",
"customer_id": "user_123",
"feature_id": "videos",
"value": 1,
"applied": 1,
"credits": 0,
"capped": false,
"limit_hit": null,
"credit_feature_id": null,
"balance": { "feature_id": "videos", "remaining": 17 }
}| Field | Meaning |
|---|---|
value | Units used. Negative values give units back, such as when a seat is removed. Defaults to 1. |
event_name | Instead of feature_id: drains every feature that listens to this event name |
entity_id | Track against a seat or workspace |
overflow | cap (default) stops at the limit and sets capped; reject refuses with a 402; allow goes past it. Pay as you go items go below zero on their own up to their spend limit |
applied | Units actually taken |
credits | Credits spent when a credit system paid for it |
limit_hit | included, spend_limit, max_purchase or usage_limit when this call ran into one |
timestamp | Optional ISO time when the usage happened |
Always send an idempotency key built from your own record id, so a retried job never counts twice. Tracking can fire balances.limit_reached, balances.usage_alert_triggered and an auto top up; see Limits and alerts.
balances.batch_track
{ "events": [{ "customer_id", "feature_id" | "event_name", "value", "properties", "idempotency_key" }, ...] }, up to 1,000. Answers { "accepted": 998, "failed_count": 2, "failed": [{ "index": 4, "code": "customer_not_found", "message": "..." }] }.
balances.track_tokens
Turns AI tokens into credits by the model's price, and records the provider cost for margin in the same call.
{ "customer_id": "user_123", "model_id": "anthropic/claude-haiku-4-5", "input_tokens": 1200, "output_tokens": 400, "cache_read_tokens": 8000 }model_id is provider/model as listed on Models.dev. Revenue prices the tokens, adds the credit system's markup, divides by its credit value and tracks the credits. Send cost_usd instead when you already know the cost, such as from OpenRouter. With more than one AI credit system, send feature_id. record_cost: false skips the cost record.
balances.create, balances.update, balances.delete
| Operation | Body | Effect |
|---|---|---|
balances.create | { "customer_id", "feature_id", "granted": 500, "expires_at"?: "2026-12-31T00:00:00Z", "interval"?: "month", "balance_id"?: "launch_gift" } | A grant outside any plan. With interval it resets; with expires_at it disappears then, used before grants that never expire |
balances.update | { "customer_id", "feature_id", "usage": 3 } or { ..., "remaining": 40 } | Sets the count directly, for counts your app owns such as seats |
balances.update | { "customer_id", "feature_id", "balance_id": "balance:launch_gift", "granted": 800 } | Changes one grant |
balances.delete | { "customer_id", "feature_id", "balance_id" } | Removes a grant that is not from a plan |
balances.grant ({ "customer_id", "feature_id", "amount", "reason" }) still works and adds a grant that never expires.
balances.list
{ "customer_id": "user_123", "entity_id"? } returns { "customer_id": "user_123", "balances": { "<feature_id>": balance } }.