Documentation
Everything you need to add preflight billing to your agents.
Quick Start, 2 minutes
Step 1, Get your API key
Sign up at agentbill.dev/register with Google, GitHub or a one-time link by email, free, no credit card. Signing in opens the console, and its start screen makes your key: it starts with agb_, it is shown once and never emailed, and the screen hands you a filled-in export AGENTBILL_API_KEY= line to copy. No key is issued before your address is verified.
Step 2, Give the job a ceiling
Name the job and say what it is worth, on the console's task budgets view or with PUT /tasks/:task_ref/ceiling. No terminal yet, and that order is the point: a preflight for a job that has no ceiling is a 422 task_ceiling_required, because there is nothing to check the call against. Every call carrying that name is checked against that one ceiling, whatever agent or tool made it. It changes only through the console or the endpoint; a task_ceiling on a later preflight is not applied.
Keep the first ceiling small. Use 3. The sample below makes one call more than the ceiling allows, so its last call is refused, and that refusal is the thing worth seeing.
Step 3, Install, then ask
python3 -m venv .venv source .venv/bin/activate pip install agentbill-sdk
Python 3.9 or newer. The last line on its own is enough wherever pip is on your path and the interpreter is not externally managed; all three work everywhere, which is why all three are printed here. On Windows the activate line is .venv\Scripts\activate.
Save this as first_run.py and run it with python3 first_run.py. It reads the key from the environment, so the export line from step 1 has to have run in the same shell.
import os
from agentbill import AgentBillClient, TaskCeilingExceededError
key = os.environ["AGENTBILL_API_KEY"]
client = AgentBillClient(api_key=key)
try:
# one call more than the ceiling of 3
for _ in range(4):
result = client.preflight(
agent_id="researcher",
task_ref="job-1",
)
print("approved:", result.approved,
"units left:", result.task_remaining_units)
# your model call runs here
client.record(
agent_id="researcher",
task_ref="job-1",
units=1,
)
except TaskCeilingExceededError as refused:
print(refused)Three lines approve and count down, and the fourth is the answer this page exists for:
approved: True units left: 2 approved: True units left: 1 approved: True units left: 0 Refused (task_ceiling_exceeded): task 'job-1' is at 3/3 units and 0 remaining is not enough for this call.
Nothing of ours reached into the run. The SDK raised, your code caught it, and what happens next is yours. The refusal is also written down: the console's refusals view carries the literal body your code received.
That is the whole integration. agent_id is a label the console groups by; the ceiling is on the task, not on the agent. Run it a second time and the first call is refused, because the job spent 3 of 3 and a job ceiling has no clock to reset it: raise the ceiling or use a new name. New to it, or something did not run? Every setup failure, and its fix. The free tier is 1,000 preflight calls per month, per account.
A call that passes no customer_id draws on a customer named default. A customer starts with no limit, so until you set one the task ceiling is the only thing that refuses, and remaining_units in the preflight body is null. PUT /budget gives a customer a ceiling in units; past it, preflight refuses with budget_exhausted and the SDK raises BudgetExhaustedError. That balance never resets on its own.
Core Concepts
Preflight
Consulted before your provider call goes out. If the units already used plus this call's estimate would cross the ceiling, preflight answers approved: false and the SDK raises. It answers on the units your code sends or, under wrap(), on tokens; it does not read your provider bill and cannot know what the refused call would have cost.
Record
Logs actual usage after a successful run. Idempotent per idempotency_key: /events dedupes on it. Both SDKs send a fresh key for each call unless you pass idempotency_key, so a retried record() without one is a second event: pass the same key on the retry. wrap() passes the provider's response id.
Per-task ceiling
One job, one ceiling, held across every call and every tool that passes the same task_ref. This is the one that is not bound to a calendar month and not to an identity, and that is the whole point: a budget that resets tomorrow refuses nothing the loop is doing tonight.
Give the job its ceiling before the code runs, on the console's task budgets view or with PUT /tasks/:task_ref/ceiling; after that every call carries task_ref and nothing about the budget. Opening it from code also works, as the alternate: pass task_ceiling on the first preflight of a new task_ref. A preflight for a job that does not exist yet, sent without a ceiling, is rejected with task_ceiling_required. Once the job exists, a task_ceiling on preflight is not applied, so a retry cannot raise the ceiling it was supposed to respect; the last save through the console or the endpoint is the ceiling in force. An approved answer and a task_ceiling_exceeded refusal carry it as task_ceiling in the HTTP body (the other refusals are decided before the job's row is consulted; the SDK result objects expose it in a later SDK release). When the units already used plus this call's estimate would cross it, preflight answers approved: false with task_ceiling_exceeded and the SDK raises.
from agentbill import AgentBillClient, TaskCeilingExceededError client = AgentBillClient(api_key="agb_your_key") # job-142 has a ceiling of 500, set in the console. Researcher takes 300 of it. client.preflight(agent_id="researcher", task_ref="job-142", estimated_units=300) try: # A different agent, same job: 300 + 250 > 500, refused before it runs. client.preflight(agent_id="writer", task_ref="job-142", estimated_units=250) except TaskCeilingExceededError as e: print(f"task {e.task_ref} hit its ceiling")
Note which identifier is doing the work there. agent_id is a label for attribution and carries no budget of its own; two different agents that share a task_ref share one ceiling. The job is what costs money, not the agent.
How the suggestion is computed
Not sure what a job needs? The task budgets view suggests a ceiling from your own history. For at most 5 agents, those whose latest finished jobs are the most recent, it shows the p50, p90 and max used_units of each one's 20 most recently updated finished jobs, so every figure is one real job's total. Pick one and it goes in the ceiling field with that agent's label, still editable, and nothing is saved until you press Set ceiling. Any other agent, including one with no finished job, gets no suggestion.
Finished means the job has spent units and holds no reservation: used_units above 0 and reserved_units 0. No event marks a job as done, so a job resting between two calls counts, and a call still in flight keeps its job out until it records, or, if it never does, until its reservation expires after 60 minutes and a sweep releases it. A job refused at its ceiling counts at what it spent. Jobs with the placeholder label console, the one a job carries until an approved call names an agent, are left out. When every call of those jobs was priced, the figures are the jobs’ totals in dollars at public list price instead, and a pick opens the next job in dollars; one unpriced job and the agent is suggested in its own unit.
Per-request ceiling
Refuse any single call that would consume more than a set number of units. Set ceiling=N on the client; if estimated_units exceeds it, the call is refused before it goes out and CeilingExceededError is raised. This one caps a call, not a job: it is a sanity check on a bad estimate, not the cross-call ceiling above.
# No single call may cost more than 20 units. client = AgentBillClient(api_key="agb_your_key", ceiling=20) client.preflight( agent_id="researcher", customer_id="user_123", estimated_units=50, # 50 > 20: raises CeilingExceededError, nothing runs )
The reservation
Preflight does not read your balance and then decide. It takes the budget in the same statement that checks it:
UPDATE customers
SET reserved_units = reserved_units + :units
WHERE account_id = :account
AND customer_ref = :customer
AND (limit_units IS NULL
OR used_units + reserved_units + :units <= limit_units)Zero rows back means the budget is gone, and nothing was taken. The task ceiling reserves the same way against task_budgets, scoped to your task_ref. Both happen in one transaction with the monthly quota, so a rejected call reserves nothing and burns no quota.
This matters for one reason. Read the balance, compare it in your process, then act, and ten parallel calls read the same remaining number and all ten pass. The budget is already spent by the time the eleventh is refused. Putting the condition in the WHERE clause is what makes that impossible.
So the counter cannot go below zero. Not "usually", not "we alert you". The database refuses the write.
A ceiling that can go negative is not a ceiling. It is an invoice line you read afterwards. On the revenue side an overage is billable, so letting a balance overshoot is a reasonable design. On your side the overage is money already spent, and there is nobody to bill it to.
Reservations expire
Each reservation is a row with a TTL, returned to you as reservation_expires_at on every approved preflight. Default is 60 minutes, set RESERVATION_TTL_MINUTES to change it. If record() never arrives, a sweeper reclaims the units. Every approved preflight also returns reservation_id: a record over HTTP that passes it back closes that reservation whole and releases what it held beyond the units recorded. Without it, settling closes reservation rows FIFO by the units passed. Both decrement by what the closed rows actually held, not by what you passed, so a late settle after a sweep cannot release the same units twice.
Note which way this fails. An abandoned reservation makes your ceiling tighter, never looser. The gate does not open by accident. Full walkthrough of the concurrency design.
What the reservation is not
It is not a measurement. The number reserved is an estimate made before the call: the estimated_units you passed or, under wrap(), the job's running average in tokens. record() settles with what the call used: the number you pass or, under wrap(), the token count your provider reported on the response your process received. AgentBill's server never calls your provider and holds no credential for it, and it never sees your GPU or your tool calls: those count only if your code records them. In a job counted in units, a unit is an integer you define.
What you get is ordering and arithmetic that hold under concurrent load: the ceiling is consulted before the work starts, and what is reserved across every call sharing a task_ref cannot exceed it. A call that used more than it reserved still records what it used, so a job can land past its ceiling by that call, and the record says so with task_exceeded. What you do not get is an opinion about what a call was worth. That number is yours, or your provider's.
Automatic metering with wrap()
Wrap your model client once, and every call it makes through the methods below is measured from the usage your provider already returned on the response: before the call it asks the job whether it still has room, after it records the tokens. You pass no estimate and record no number. The call still goes from your process straight to your provider: no proxy, no base URL to change, and AgentBill never sees your prompt, the answer or your provider account. What it receives is the provider's name, the model, the token counts, how long the call took and the step you named.
Python
pip install -U agentbill-sdk openai
from openai import OpenAI from agentbill import wrap, Refusal # Reads AGENTBILL_API_KEY; OpenAI() reads OPENAI_API_KEY. The job is counted # in tokens, and task_ceiling opens it on the call that creates it. llm = wrap(OpenAI(), task_ref="tokens-1", agent_id="researcher", task_ceiling=50_000) questions = ["What is a job ceiling?", "Name one use for it.", "And one limit."] for q in questions: reply = llm.chat.completions.create( model="gpt-4o-mini", max_tokens=300, messages=[{"role": "user", "content": q}], ) if isinstance(reply, Refusal): # The call that would have passed the ceiling was not sent. Nothing # is raised: reply.reason, reply.used, reply.ceiling, reply.remaining. print(reply) break print(reply.choices[0].message.content)
The same job across providers, one step each. wrap() on a wrapped client gives another view of it with a different step, sharing the job's running average:
from anthropic import Anthropic
from google import genai
from agentbill import wrap
claude = wrap(Anthropic(), task_ref="tokens-1", agent_id="writer", step="draft")
gemini = wrap(genai.Client(), task_ref="tokens-1", agent_id="checker", step="check")
draft = claude.messages.create(model="claude-sonnet-4-5", max_tokens=500,
messages=[{"role": "user", "content": "One line on job ceilings."}])
check = gemini.models.generate_content(model="gemini-2.5-flash",
contents="Is this clear? " + draft.content[0].text)
print(check.text)
Node
npm install agentbill openai
import OpenAI from 'openai'
import { wrap, isRefusal } from 'agentbill'
// Reads AGENTBILL_API_KEY; new OpenAI() reads OPENAI_API_KEY.
const llm = wrap(new OpenAI(), { taskRef: 'tokens-1', agentId: 'researcher', taskCeiling: 50_000 })
const stream = await llm.chat.completions.create({
model: 'gpt-4o-mini', max_tokens: 300, stream: true,
messages: [{ role: 'user', content: 'What is a job ceiling?' }],
})
if (isRefusal(stream)) {
// The call was not sent, and nothing is thrown: stream.reason, stream.used,
// stream.ceiling, stream.remaining. A refused stream iterates to nothing.
console.log(String(stream))
} else {
for await (const chunk of stream) process.stdout.write(chunk.choices[0]?.delta?.content ?? '')
}
What is measured, and what is not
| Provider | Measured methods | Usage read from the response |
|---|---|---|
| OpenAI | chat.completions.create, responses.create | input (cache reads and cache writes apart), output, reasoning. A streamed Chat Completions call gets stream_options.include_usage turned on, and the usage-only chunk that adds is kept out of your loop. One you set yourself is left as you set it. |
| Anthropic | messages.create | input, cache reads, cache writes (five-minute and one-hour apart), output. |
| Gemini (google-genai) | models.generate_content, models.generate_content_stream; in Python also aio.models | prompt (cached apart), candidates plus thoughts as output: Gemini reports thinking outside candidates. With automatic function calling (a Python function or a CallableTool in tools) one call sends a model request per round and returns only the last round's usage, so each round is measured as its own call, with its own preflight and record. |
- Only wrapped calls are measured. A call through a client you did not wrap, or through a method not in the table (a stream helper, a raw-response call), is not counted. A tool call, a GPU run or a vector search counts only if your code records it with the same task_ref.
- The estimate. Before the first measured call of the job in your process, preflight reserves default_estimate (2,000 tokens unless you set it); after, the job's running average per call, and never more than the average prompt plus the call's own max_tokens when it sets one. The prompt is not counted before the call, and no request is added. The record then charges what the provider reported, so one call can pass the ceiling when the estimate was low: at most that one call for each caller running at the same moment, and the next preflight is refused. That bound holds while preflight checks the ceiling; see the quota below for the one state where it does not.
- Missing usage is recorded as missing, never as 0. The call is charged at least what it reserved, and counted in usage_missing_calls.
- The job is counted in tokens. Open it with task_ceiling on wrap(), or with PUT /tasks/:task_ref/ceiling and "unit": "token". A job opened in units answers 422 task_unit_mismatch, and the call is not sent. A customer limit set with PUT /budget counts whatever that customer's calls send, so under wrap() it counts tokens.
- A refusal is returned, and your code decides. The measured call's value is a Refusal (Python: isinstance(reply, Refusal), Node: isRefusal(reply)) with approved false, reason, task_ref, asked, used, ceiling, remaining and answer, the preflight answer whole. It is not shaped like a provider response: no choices, content, candidates or usage. A refused streaming call returns the same Refusal, which iterates to nothing; the one stream that can be refused after it started, a Gemini automatic-function-calling stream whose later round is refused, ends after the earlier round's chunks and sets its refusal. Nothing is raised for a refusal. An exception out of a wrapped call is a failure: the provider's own error, or from AgentBill a network error, a 401 or a 5xx. A provider error releases the call's reservation. A record that fails after the provider answered never loses the answer. preflight() and record() on the plain client are not changed by this: preflight() still raises TaskCeilingExceededError on a ceiling refusal and returns on the quota.
- Each measured call is one preflight, so it uses one preflight of your account's monthly quota. Once that quota is spent, preflight answers before it looks at the job and no ceiling can be checked. So by default the wrapped call returns a Refusal with reason free_tier_exceeded or plan_limit_exceeded and the upgrade link, and is not sent. With on_quota="send" (Node: onQuota: 'send') it is sent unchecked and recorded, with a warning once per job, and nothing bounds the job until the quota resets or you upgrade.
- A client pointed at another host (Azure, a proxy, an OpenAI-compatible server) is recorded as openai-compatible and gets no list price. Its records are keyed by a random key, not the response id, because such a server's ids need not be unique. In Node the wrapped create returns a plain Promise, without the SDK's withResponse().
What the job cost, by model and by step
GET /tasks/:task_ref carries a breakdown beside the job's numbers (the SDKs' get_task and getTask return it as it is):
{
"calls": 4, "units": 4100, "unattributed_units": 0,
"list_price_usd_estimate": 0.00066,
"priced_calls": 3, "unpriced_calls": 1,
"price_versions": ["litellm-ccee9e7-2026-09-23"],
"by_model": [
{ "provider": "openai", "model": "gpt-4o-mini-2024-07-18", "calls": 3, "units": 2900,
"tokens": { "input": 2400, "cache_read": 0, "cache_write": 0, "cache_write_1h": 0, "output": 500, "reasoning": 0 },
"list_price_usd_estimate": 0.00066, "unpriced_calls": 0, "unpriced_reasons": [] },
{ "provider": "openai", "model": "my-finetune", "calls": 1, "units": 1200,
"list_price_usd_estimate": null, "unpriced_calls": 1,
"unpriced_reasons": ["no list price for my-finetune"] }
],
"by_step": [ { "step": "draft", "calls": 4, "units": 4100, "list_price_usd_estimate": 0.00066,
"unpriced_calls": 1 } ]
}
list_price_usd_estimate is an estimate at public list price, from a dated snapshot of the LiteLLM price table that price_versions names, priced per token type, with the long-context and service-tier rates where the table has them. List price, your invoice may differ: contract discounts, batch and regional pricing, and server-side tool fees are not in it. A call with no list price says why in unpriced_reasons and is left out of the estimate; it is never counted as $0, and a row where nothing is priced shows null. The ceiling itself is in tokens. unattributed_units is what the job spent through records made before events carried the job's name.
API Reference
Base URL: https://agentbill.dev. Every request carries the header Authorization: Bearer <your key>. The tables below are the SDKs' parameters; preflight() is POST /preflight with the same body, and record() is POST /events, whose raw body has three names of its own: see POST /events.
preflight()
| Parameter | Type | Description |
|---|---|---|
| agent_id | string | A label for attribution, not a budget. Every task and every refusal in the console carries it, and nothing is capped by it. |
| customer_id | string optional | Your internal customer ID. Defaults to "default". A customer starts with no limit; set one with PUT /budget. |
| estimated_units | int optional | Expected units for this run. Used for ceiling check. Default: 1. |
| ceiling | int optional, on AgentBillClient(...) | Set on the client, not per call: every preflight is refused if estimated_units exceeds it. |
| task_ref | string optional | Groups many calls under one cross-call budget. Pass the same task_ref on every call in the job. See task budgets. |
| task_ceiling | int optional | The alternate way to open a job: a first preflight of a new task_ref that carries task_ceiling opens it with that ceiling. The default is to open the job first in the console or with PUT /tasks/:task_ref/ceiling, after which the call needs task_ref only. Not applied once the job exists; an approved answer and a task_ceiling_exceeded refusal carry the ceiling in force as task_ceiling. |
Every identifier above (agent_id, customer_id, task_ref, and idempotency_key) is 1 to 128 characters and may not contain control characters. A value that breaks either rule is a 422 with validation_error, never a 500.
Returns, for a call on a job that has a ceiling:
{
"approved": true,
"reason": null,
"estimated_units": 12,
"remaining_units": null,
"reservation_expires_at": "2026-09-11T12:00:00.000Z",
"reservation_id": "3f1c2b7a-8d4e-4b1a-9c2d-5e6f7a8b9c0d",
"task_ref": "job-142",
"task_ceiling": 500,
"task_remaining_units": 488
}
Two scopes, two names. remaining_units is the customer's balance, and it is null while the customer has no limit, which is the default. task_remaining_units is the job's, and it is the one that counts down under a task ceiling. On GET /tasks the job's own balance is called remaining_units, because that endpoint has only the one scope, and exceeded there is true only when a record landed past the ceiling after the fact: a job at its ceiling with nothing leaked reads remaining_units: 0 and exceeded: false, and its next preflight is refused.
When refused:
{
"approved": false,
# plan_limit_exceeded on a paid plan. budget_exhausted and the
# ceiling refusals carry no upgrade_url.
"reason": "free_tier_exceeded",
"plan": "free",
"monthly_calls": 1000,
"plan_limit": 1000,
"upgrade_url": "https://agentbill.dev/pricing"
}
That is the raw HTTP shape. Both SDKs then apply one rule to it, and it is the same rule in Python and Node: they raise when your own spend rule refused the call (ceiling_exceeded, task_ceiling_exceeded, budget_exhausted) and return the result when the refusal is AgentBill's own quota (free_tier_exceeded, plan_limit_exceeded), with upgrade_url set. Our quota running out must never crash your agent. Under wrap() nothing is raised for a refusal: every one, the quota included, comes back as a returned Refusal, because once the quota is spent no ceiling can be checked; on_quota="send" sends the call unchecked instead. preflight() itself is unchanged.
record()
| Parameter | Type | Description |
|---|---|---|
| agent_id | string | The same attribution label you passed to preflight. |
| units | int optional | Units consumed by this run. Default: 1. 0 is allowed: a call that ran and cost nothing. |
| customer_id | string optional | Your internal customer ID. Defaults to "default". A customer starts with no limit; set one with PUT /budget. |
| task_ref | string optional | Settles against that task's ceiling. Pass the same one you preflighted with, or the units stay reserved until the reservation expires. |
| success | bool optional | false releases the preflight reservation without billing. Default: true. |
| reservation_id | string optional | The one preflight returned. That reservation closes whole: units is what was spent, and the rest it held is released now. Without it, the record closes the oldest reservations of this customer and task FIFO by units. result.record(...) passes it for you. |
| idempotency_key | string optional | Same key, one event. A fresh one is sent when you leave it out. |
| metadata | object optional | Stored on the event, never counted. At most 8 KB as JSON and 32 keys; more is a 422. provider, model and tokens in the shape wrap() writes are priced at list price for the job's breakdown. |
| usage_missing | bool optional | The provider reported no usage. Not read as 0: the call is charged at least the reservation it settles, and counted in usage_missing_calls. |
POST /events, the raw request
For n8n, Make, curl or anything else that is not the SDK. record() fills three fields in for you that a raw request sends itself:
| Field | Type | Description |
|---|---|---|
| customer_id | string required | The client this call is for. "default" if you have no customers. |
| event_type | string required | The agent's label: the value preflight calls agent_id. |
| idempotency_key | string required | Any string unique to this call. Same key, one event. |
| reservation_id, units, task_ref, success, metadata, usage_missing | optional | As in the record() table above. |
curl -X POST https://agentbill.dev/events \
-H "Authorization: Bearer $AGENTBILL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"customer_id": "test-client", "event_type": "campaign-writer",
"idempotency_key": "test-call-1",
"metadata": {"provider": "openai", "model": "gpt-4o-mini",
"tokens": {"input": 1200, "output": 300}}}'
A body that misses one of them is a 422 that names every missing field.
In n8n: the real model and tokens
The values in the body above are samples. These replace them:
- Header: in the HTTP Request node, Authentication: Generic Credential Type, then Header Auth, with Name Authorization and Value Bearer, a space and your key.
- When preflight says no: "approved": false comes back as HTTP 200, so the HTTP Request node succeeds and the next node runs anyway. Put an IF node after Preflight on {{ $json.approved }} is true: the true branch goes on to the model call, the false branch to a Stop and Error node, or to a message to you, with {{ $('Preflight').item.json.reason }}. Without it, a refused call still reaches OpenAI.
- One event per model call. A run that calls the model three times sends three, each with its own idempotency_key. {{ $execution.id }}-writer-{{ $runIndex }} is unique to each run and each pass of a loop; use a different word in place of writer for each call.
- reservation_id from the preflight answer: {{ $('Preflight').item.json.reservation_id }} when that node is named Preflight.
- Model and tokens from the OpenAI node (Message a model): turn its Simplify Output off, then use {{ $json.model }}, {{ $json.usage.input_tokens }} and {{ $json.usage.output_tokens }} in the node right after it, or $('Message a model').item.json in place of $json further on. With Simplify Output on, the usage is not in its output.
- An HTTP Request straight to OpenAI returns usage.prompt_tokens and usage.completion_tokens; one to Anthropic returns usage.input_tokens and usage.output_tokens.
- The Basic LLM Chain node passes on only its text, and a node after it cannot read the chat model under it, so there are no tokens to map. For a chain with no memory and no tools, the OpenAI node does the same job and passes them on.
- When the model call fails: set that node's On Error to Continue (using error output) and send the same body from the error branch with "success": false and the reservation_id. That releases the hold and bills nothing.
PUT /tasks/:task_ref/ceiling
Opens a job with a ceiling, or changes the ceiling of one that exists. The same write the console's task budgets view makes. The last save through the endpoint or the console is the ceiling in force; a task_ceiling sent on a later preflight is not applied. An approved answer and a task_ceiling_exceeded refusal carry the ceiling in force as task_ceiling.
curl -X PUT https://agentbill.dev/tasks/job-142/ceiling \
-H "Authorization: Bearer agb_your_key" \
-H "Content-Type: application/json" \
-d '{"ceiling_units":500,"agent_id":"researcher"}'
# {"task_ref":"job-142","agent_id":"researcher","ceiling_units":500,"used_units":0,
# "reserved_units":0,"remaining_units":500,"exceeded":false,...,"task_created":true}Send ceiling_units, a positive integer, or ceiling_usd, a ceiling in dollars (below); exactly one of the two. agent_id is optional and is read only when this call opens the job; an existing job keeps the agent that opened it.
A ceiling in dollars
{"ceiling_usd": 5} opens a job counted in dollars, unit usd (so does task_ceiling_usd on a first preflight, and the console's task budgets view). On such a job every *_units number is micro-dollars (1,000,000 is $1.00) and each answer carries the same figures in dollars beside them. What it counts is an estimate at public list price, not your invoice: before a call, preflight reserves your own estimate when you send one (estimated_usd), otherwise the median of the job's last 20 priced calls (their largest once the job is at 80% of its ceiling, counting calls still in flight), or $0.10 before its first, and the answer's estimate_source says which. After it, the record is charged the list price of the tokens it reports (provider, model and tokens in its metadata, the shape wrap() writes), rounded up to the micro-dollar, and the reservation is settled to that. A call that cannot be priced (no model named, a model with no list price, usage missing) is never counted as $0: it is charged its reservation, or with none open the job's estimate, and counted in unpriced_calls. The reservation is exact, the estimate is not: a call bigger than its estimate can land past the ceiling by the difference, and the next preflight is refused. A dollar job never draws on a customer's balance: that balance, its limit and what is left of it count the units and tokens your code reports, and a customer's unit limit does not refuse its dollar job's calls. What a customer's calls cost is reported beside the balance, at list price, not in it.
A ceiling cannot go under what the job has already spent plus what is reserved by calls in flight. That answers 409 ceiling_below_committed with minimum_ceiling_units, the smallest value that would be accepted. Nothing is clamped and nothing in flight is rewritten: each reservation settles through record() or expires, and the next preflight reads the new ceiling.
GET /tasks
Lists this account's jobs, each with the fields GET /tasks/:task_ref returns: ceiling_units, used_units, reserved_units, remaining_units, exceeded, created_at and updated_at.
| Parameter | Type | Description |
|---|---|---|
| agent_id | string optional | Only the jobs that carry this agent label. |
| limit | int optional | 1 to 200. Default: 50. |
| sort | string optional | created (the default): newest job first. used: most used first, and never one unit's number against another's. Jobs with a dollar figure come first, dearest first: a job in dollars by its own ledger, any other job by the list price of its priced calls. Then the jobs with none, tokens before units, each by its own used_units, ties newest first. An account whose jobs are all in units gets the order it always had. Any other value is a 422. |
curl "https://agentbill.dev/tasks?sort=used&limit=5" \ -H "Authorization: Bearer agb_your_key"
GET /usage
The units your code recorded over a window, split by the event_type each record carried, heaviest first. It counts the units your code reported and nothing else. record() in both SDKs sends its agent_id as the event_type, so for those calls this is a split by agent; meter() and a direct POST /events carry the event name your code passed. The console's activity view shows the same split.
| Parameter | Type | Description |
|---|---|---|
| by | string | Required. event_type, the one split there is. |
| days | int optional | 1 to 90 calendar days, today included. Default: 30. |
| limit | int optional | 1 to 200 groups. Default: 50. The totals always cover the whole window. |
curl "https://agentbill.dev/usage?by=event_type&days=7" \ -H "Authorization: Bearer agb_your_key" # {"by":"event_type","days":7,"since":"2026-09-17", # "total_units":1200,"total_events":41,"group_count":3,"groups":[ # {"event_type":"crawler","units":600,"events":12,"share":0.5}, # {"event_type":"researcher","units":360,"events":9,"share":0.3}, # {"event_type":"summarizer","units":240,"events":20,"share":0.2}]}
share is a fraction of total_units, to four places. group_count is how many event_types the window holds, so a response with fewer groups than that was cut by limit.
PUT /budget
Sets one customer's ceiling, and creates that customer if it has never been seen. The per-request ceiling is an argument to preflight() and has no endpoint.
curl -X PUT https://agentbill.dev/budget \
-H "Authorization: Bearer agb_your_key" \
-H "Content-Type: application/json" \
-d '{"customer_id":"user_123","limit_units":5000}'
# {"customer_id":"user_123","customer_created":false,"limit":5000,
# "used":1200,"reserved":40,"remaining":3760,"is_blocked":false}limit_units is required and may be null, which means no limit. It is never optional: a body that left it out would have to mean "change nothing", and a write that silently does nothing is how a typo looks like a success.
It is a ceiling, not a balance, so it may be set below what that customer has already used and reserved. Nothing is rewritten to fit, no counter goes negative, and the customer is simply refused with budget_exhausted until the open reservations settle or expire. Raising it again releases them on the next call, with no repair step.
POST /webhook-config
Sets the one URL this account's anomaly alerts go to. POST /step flags a step whose units are more than twice the average of that agent_id and step_name, and posts an anomaly.detected payload to this URL.
curl -X POST https://agentbill.dev/webhook-config \
-H "Authorization: Bearer agb_your_key" \
-H "Content-Type: application/json" \
-d '{"url":"https://hooks.example.com/agentbill"}'
# {"webhook_url":"https://hooks.example.com/agentbill",
# "signing_secret":"whsec_...","signature_header":"X-AgentBill-Signature"}The URL must be https on a public address. It is refused with 422 webhook_url_refused when its host is, or resolves to, a loopback, private, link-local, carrier-grade NAT or unique-local address, or is a name like localhost or one ending in .internal or .local. The same check runs again on the address actually dialled at send time. A delivery is one POST with a five-second timeout; a redirect is not followed.
signing_secret is shown once, in this response. Saving the URL again issues a new one. Every delivery carries X-AgentBill-Signature: t=<unix seconds>,v1=<hex>, where the hex is the HMAC-SHA256 of <t>.<raw body> keyed by that secret. Check it against the raw body before you parse it, and refuse an old t:
import { createHmac, timingSafeEqual } from 'node:crypto'
// header: the X-AgentBill-Signature value. body: the raw request body, as a string.
export function verifyAgentBill(header: string, body: string, secret: string, maxAgeSeconds = 300): boolean {
const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header)
if (!m) return false
if (Math.abs(Date.now() / 1000 - Number(m[1])) > maxAgeSeconds) return false
const expected = createHmac('sha256', secret).update(m[1] + '.' + body).digest('hex')
return timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]))
}A URL saved before signing existed has no secret, and its deliveries go out unsigned until it is saved again.
Security
How to report a vulnerability, what the SDKs send and what they never send, and how to revoke a key that leaked: agentbill.dev/security.
Node.js
npm install agentbill
The package is ESM and the sample below uses a top-level await, which is the caller's file, not the dependency: saved as .js in a project with no module type it fails with Cannot use import statement outside a module before any of it runs. Save it as first-run.mjs, or put "type": "module" in your package.json. The key is read from AGENTBILL_API_KEY and there is no argument for it: the Node SDK exports functions rather than a client object, so on a host with no shell, set the variable in the platform's own config or call the endpoint with an Authorization: Bearer header.
// Reads AGENTBILL_API_KEY from the environment. Units are yours to define. import { preflight, record, TaskCeilingExceededError } from 'agentbill' // job-142 already has its ceiling, set in the console. Before each call, // name the job and what this call is worth, and nothing about the budget. // A refused call throws TaskCeilingExceededError, so the expensive work never starts. await preflight({ agentId: 'researcher', taskRef: 'job-142', estimatedUnits: 12 }) // ... your LLM or tool call ... // After the call: record what it actually cost await record({ agentId: 'researcher', taskRef: 'job-142', units: 12 })
What it does NOT do
AgentBill does not replace your payment processor: it does not move money, hold a card or charge your end customers, and it is not positioned between you and one that does. Multi-step workflows with state machines or reversal logic are out of scope.