Enforcing a spend ceiling in your dispatcher
Budget enforcement belongs in your dispatcher. Price planned work against live balance and a ceiling you define, then refuse to submit past it.
Versely will not stop a key at N credits. A credit_limit field exists on the key row and is echoed when you create or list a key. Nothing in the request path reads it. Per-key RPM caps how often that key may call, not how many credits those calls spend. The account-level fuse is Insufficient credits at zero or at charge time. If you need "this worker may spend 2,000 credits this week and then stop," that number lives in your dispatcher.
The pattern is short. Price the planned work. Read the live balance. Compare both against a ceiling you store. Refuse to POST /generate when either check fails. Pricing and the credits page are the units. The agent already does a version of this with estimate_cost and check_credits; estimating before you dispatch a batch and checking credits before generating cover that surface. This post is the REST version you run when the caller is a worker, not a chat.
Why the key cannot be the ceiling
Three controls people confuse:
| Control | What it actually does | What it does not do |
|---|---|---|
rate_limit_rpm on the key |
Caps requests per 60-second window (default 60, 1–1000) | Caps credits. Five expensive videos per minute still spend. |
credit_limit on the key |
Stored, returned in create/list | Enforced. Never read at auth or at charge. |
| Profile balance | 403 when credits <= 0; 402 when this job does not fit |
Implement your budget. A 50,000-credit wallet will happily run one worker down to empty. |
The 403/402 pair is a safety net for the account, not a job budget. A generate worker, a poster, and a human in the pipeline share one balance. Without a dispatcher ceiling, the worker can spend the poster's week. Tracking credits per client is how you explain it after the fact. The ceiling is how you prevent it.
There is no customer webhook to tell you a job finished, and no Idempotency-Key to make a retry safe. You poll GET /api/v1/status/:requestId. Treat a successful submit as spend against the ceiling. Do not increment on a 429, 401, or 403 that never accepted the work. Do not decrement on failed unless the product credit history shows the charge did not stick. Guessing is how a "2,000 credit" cap becomes 3,100.
The pre-flight
You need two numbers from Versely and one from yourself.
Live balance. GET /api/v1/user/me with the worker's key (read scope, because /user is a read prefix). The body includes user.credits. That is the account, not the job.
Planned cost. POST /api/v1/ai-models/calculate-credits with { models, contentType, duration, resolution, count } for the selection you are about to send. Send the same Authorization header you will send to /generate, so the estimate uses the same pricing function as the charge. models is a non-empty array of names; contentType is required. Pass the real duration, resolution, and count you will generate with. Video and speech move with duration. Images are a per-generation charge. If you omit duration on a 20-second clip, you are not pricing the clip you will submit.
The quote is a credit total for that selection, not a second, softer price list. If the body also includes a higher worst-case figure (full price when a discounted path is not used), gate the ceiling on the higher number. A dispatcher that only looks at the happy-path total will go through the ceiling on the path the charge already planned for.
Your ceiling. An integer you store next to the job: 2_000 credits this week for nightly-hooks, 400 this run for a batch, 150 for a client's first draft. It is not a Versely setting. A spreadsheet is enough. A Redis key named spend:nightly-hooks:2026-W34 is better if more than one process submits.
Worked numbers for a 30-second ad, if you want a sense of scale before you pick the integer, are on what a finished 30-second ad costs. Do not copy that figure in as the ceiling. Copy the method: price the actual model and duration.
Refuse, then submit, then count
Pseudocode for one worker:
ceiling = 2000 # this job, this period
spent = load(spent) # what this dispatcher has already submitted
balance = GET /api/v1/user/me -> user.credits
quote = POST /api/v1/ai-models/calculate-credits { models, contentType, duration, resolution, count }
need = worst-case credits from quote # the higher number if two are returned
if spent + need > ceiling:
refuse("job ceiling") # your error, not Versely's
stop
if need > balance:
refuse("account balance") # would 402/403 anyway
stop
res = POST /api/v1/generate/video { model, prompt, duration, ... }
if res accepted with request_id:
spent += need
save(spent)
poll GET /api/v1/status/:requestId
else if 429 / 401 / 403 scope:
do not increment spent # request was not accepted
else if 402 / 403 insufficient credits:
do not increment spent
stop the run
Rules that keep the arithmetic honest:
Count on accept, not on complete. A
generatingjob has already been accepted and will charge. If you wait forcompletedto incrementspent, two overlapping submits will both pass the ceiling check.Do not double-count a retry. There is no idempotency key. A second POST is a second job. If you retry a generate, add it again or do not retry.
Poll; do not guess completion.
GET /api/v1/status/:requestIdreturnsgenerating|completed|failed. Afailedwithtransient: trueis the only status-shaped hint that a resubmit might be warranted. Resubmit only if the ceiling still has room.Reconcile against credit history in the product.
/billingis the ledger group; status payloads do not include a receipt. Yourspentis an estimate of what this job submitted. The history is what the account actually moved. Drift is either a refund you did not notice or a submit you did not record. Fix the ledger; do not raise the ceiling to match the drift.One ceiling per key, one key per job. A shared
"all"key with one ceiling is an account cap you built yourself, badly. Nightly hooks get a hooks ceiling. The poster gets a poster ceiling. The human in the web app is not in this dispatcher, so leave them headroom on the live balance check.
The agent equivalent, if the "dispatcher" is a chat turn rather than a worker:
estimate_costtakesitems(image, video, music, speech, or sound effect, with optional model, count, andduration_sec) and returns per-item credits, a total, and the current balance. Same pricing brain as a real charge.check_creditstakesoperationandestimated_creditsand is the yes/no balance gate before a multi-step run.
Those tools are lookups. They do not enforce a ceiling either. The refuse step is still yours: if the estimate plus the period spend exceeds the number you chose, or if check_credits says the balance cannot cover the job, you do not call generate.
FAQ
Can I set credit_limit on create and have Versely stop the key?
You can send it. It is stored and returned. No middleware and no charge path reads req.apiKey.creditLimit. A key with credit_limit: 100 will keep generating until the account cannot pay. Put the 100 in your dispatcher.
Should I gate on the discounted total or the higher figure?
The higher figure, when the quote includes both. That is the worst case if a discounted path is not used. Gating on the happy-path number is how a ceiling of 2,000 is breached without any bug in the increment.
Does calculate-credits cost credits?
No. It is a pricing lookup. Calling it before every submit is the correct default. Sending the same Bearer token you will generate with keeps the quote in the same context as the charge.
What if two processes share the ceiling?
Then spent cannot live in a process-local variable. Use a single store both writers increment atomically before POST, and decrement only if the POST was rejected before accept. Two workers that both read spent = 1,900 and both submit a 200-credit job will go through a 2,000 ceiling. That is a locking bug in your dispatcher, not a missing Versely flag.