Billing API
An agent holding a Rove API key can buy credits for its account, within limits the account owner sets. There are two ways a purchase is paid:
- Saved card, off-session. The owner saved a card and allowed agents to charge it. The agent's purchase is charged at once, with nobody present.
- Payment link for a person. Otherwise the purchase returns a Stripe Checkout link (
next_action.url). The agent gives it to a person, who pays in their own browser.
Both go through one endpoint, POST /v1/billing/purchases. Rove checks the owner's limits before any money moves, and the card number never reaches Rove or the agent.
Machine-readable reference: OpenAPI document. Base URL: https://api.roveapi.com.
Owner setup
Agent purchasing is off by default. Nothing below is possible with an API key; the owner signs in to Billing.
- Turn on agent purchasing (Billing → Agent purchasing). Set the most per purchase, a period (calendar day, week from Monday, or month, in UTC) and the most per period. The form starts at 25 dollars per purchase and 100 dollars a month; the highest you can set without contacting Rove is 500 dollars per purchase and 2,000 dollars per period. Purchases you make yourself on the billing page do not count toward this limit.
- Allow a key to buy (API keys, "Agent may buy credits"). Each key needs its own grant. A grant may carry lower limits of its own; they can never exceed the account's. Test keys are granted the same way.
- Save a card (optional, Billing → Payment method). The card is entered on a Stripe-hosted page. Then choose Allow charging to let agents charge it without asking you. Without a saved card, or without that permission, every agent purchase becomes a payment link for you.
- Auto top-up (optional, Billing → Auto top-up). When the balance falls below a threshold, Rove buys the pack you chose on the saved card. It needs a saved card with charging allowed, spends within the same period limit as agents, and pauses (and emails you) when the bank asks you to confirm a payment, the card is declined or removed, or a limit no longer fits the pack.
You are emailed when an agent creates a payment link (with the link), when auto top-up pauses or is blocked by the period limit, and when any purchase is paid. Every change to these settings is recorded and shown under Agent purchasing.
Owner routes use the signed-in browser session, not an API key, and accept changes only from the Rove web app (30 changes per account per hour):
| Route | What it does |
|---|---|
GET, PUT /v1/account/billing/agent-purchasing | Read or set state (off, on), per_purchase_max_cents, period, period_max_cents, off_session_allowed |
PUT, DELETE /v1/account/billing/keys/{keyId}/purchase-grant | Allow a key to buy, with optional per_purchase_max_cents and period_max_cents; or revoke it |
POST /v1/account/billing/payment-method/setup-sessions | Returns a Stripe-hosted url for saving a card |
DELETE /v1/account/billing/payment-method | Remove the saved card (pauses auto top-up) |
GET, PUT /v1/account/billing/auto-topup | Read, arm (state: "armed", threshold_credits, item_id) or turn off auto top-up |
GET /v1/account/billing/balance | The account's balance and limits |
Prices
Credits never expire. The packs on sale:
| Pack | Price | Credits |
|---|---|---|
| 1,000 credits | $10 | 1,000 |
| 5,000 credits | $49 | 5,000 |
| 10,000 credits | $89 | 10,000 |
GET /v1/billing/catalogue (public, no key) returns the same packs with their id (for example pack_1k), the subscription tiers, what each operation costs and the supervised-minute rate. See Credits & Pricing.
Endpoints
Apart from the public catalogue, all take Authorization: Bearer rvp_live_... (or rvp_test_...), cost no credits, and answer with Cache-Control: no-store.
| Method and path | Returns | Notes |
|---|---|---|
GET /v1/billing/catalogue | packs, tiers, costs | public; no key needed |
GET /v1/account/balance | BalanceView | the balance, exact to the millicredit, and whether this key may buy (purchasing) |
POST /v1/billing/purchases | 201 PurchaseView; replay 200 | body item_id, optional payment; Idempotency-Key header required |
GET /v1/billing/purchases | data, next_cursor | purchases this key made, newest first; ?limit=1..100 (default 20) &cursor= |
GET /v1/billing/purchases/{id} | PurchaseView | only purchases this key made |
POST /v1/billing/purchases/{id}/cancel | PurchaseView | cancels an unpaid purchase; canceling a canceled one returns it |
POST /v1/billing/purchases/{id}/simulate | PurchaseView | test keys only, see Test purchases |
POST /v1/billing/purchases is limited to 10 purchases per key and 20 per account per hour, counted separately for live and test keys.
Check what this key may buy
GET /v1/account/balance answers, for this key:
credits_remaining(whole credits) andcredits_remaining_millicredits(exact; 1 credit = 1,000 millicredits).purchasing.allowed, andpurchasing.reasonwhen not:agent_purchasing_offorkey_not_granted, withowner_action_urlfor the owner.purchasing.packs_within_limits: the pack ids this key may buy right now without exceeding any limit.purchasing.payment_modesandpurchasing.limits(per purchase, the period, what is already committed, andperiod_resets_at).supervised_millicredits_per_minute: the rate a supervised session started now would use.auto_topup: its state, ornull.
Buy a pack
curl -X POST https://api.roveapi.com/v1/billing/purchases \
-H "Authorization: Bearer rvp_live_YOUR_KEY" \
-H "Idempotency-Key: 6f1c2b7e-buy-pack-1k" \
-H "Content-Type: application/json" \
-d '{ "item_id": "pack_1k", "payment": "auto" }'
payment chooses how it is paid:
payment | Behaviour |
|---|---|
auto (default) | Charge the saved card when the owner allowed it; if there is none, or the bank asks for confirmation, or the card is declined, return a payment link instead |
checkout | Always return a payment link |
saved_method | Only the saved card. Refused with 403 purchase-not-permitted (suggested_payment: "auto") when there is no card or charging is not allowed; a decline is 402 payment-failed |
A purchase that needs a person comes back open with a payment link:
{
"id": "0b9b5b8e-6a3b-4f4e-9d8c-2f1e4c3b2a10",
"object": "purchase",
"status": "open",
"livemode": true,
"actor": "api_key",
"api_key_id": "6d0f3c1e-2a4b-4c5d-8e9f-0a1b2c3d4e5f",
"api_key_name": "ci-runner",
"replayed": false,
"channel": "checkout",
"item": { "id": "pack_1k", "version": 1, "name": "1,000 credits" },
"amount_cents": 1000,
"currency": "usd",
"credits": 1000,
"amount_refunded_cents": 0,
"millicredits_reversed": 0,
"millicredits_held": 0,
"next_action": {
"type": "open_checkout_url",
"url": "https://checkout.stripe.com/c/pay/cs_live_example",
"expires_at": "2026-10-10T15:40:00.000Z",
"reason": "no_saved_method",
"instruction": "Give this link to the account owner to pay $10.00 before 15:40 UTC."
},
"retry": null,
"failure_code": null,
"created_at": "2026-10-10T14:40:00.000Z",
"paid_at": null
}
What each status means for the agent:
status | Next step |
|---|---|
paid | Credits were added. |
processing | The card payment is going through. Poll GET /v1/billing/purchases/{id} every 5 seconds or slower. |
open, requires_action | Give next_action.url to a person (see The payment link), then poll. next_action.reason says why: checkout_requested, no_saved_method, off_session_not_allowed, authentication_required or card_declined. |
created | Billing has not confirmed it yet. Repeat the same request with the same Idempotency-Key after 30 seconds; it is never charged twice. |
failed, expired, canceled | Nothing was charged. A new attempt needs a new Idempotency-Key. |
refunded, disputed, reversed | Changed after payment by the owner or the bank. |
Idempotency and retries
Idempotency-Key is required on POST /v1/billing/purchases: 1 to 128 characters of A-Z a-z 0-9 _ . : -, unique within the account. Use a new key for each purchase you mean to make, and the same key when repeating a request whose answer you did not get. A repeat with the same key and body returns the purchase as it is now, with status 200, replayed: true and the header Idempotent-Replayed: true; it never charges again. The same key with a different body is 422 idempotency-key-reused.
Every billing problem carries retry, which says whether to repeat the request and with which key:
same_key: repeat it with the same Idempotency-Key, afterRetry-Afterwhen present.new_key: this attempt is over; a new attempt needs a new key.none: repeating it fails again until something changes (a limit, a grant, the request).
| Outcome | Purchase row | retry | What to do |
|---|---|---|---|
400, 401, 403 purchase-not-permitted, 403 spend-limit-exceeded, 404 pack-not-found | none | none (but same_key for purchase-not-permitted with suggested_payment) | Fix the cause, or give a person owner_action_url. The key is unused. |
429 purchase-rate-limited | none | same_key | Wait Retry-After, repeat. |
503 billing-unavailable | none, or kept created (purchase_id present) | same_key | Wait Retry-After, repeat with the same key. A kept purchase is reconciled with Stripe and never charged twice. |
409 idempotency-in-progress | being created | same_key | Wait Retry-After: 1, repeat. |
201, or 200 replay | live | null on the purchase | Follow next_action, or poll the purchase. |
402 payment-failed | failed | new_key | Try again with a new key, for example with payment: "checkout". |
replay of a failed, expired or canceled purchase | terminal | new_key | Start again with a new key. |
422 idempotency-key-reused | the other request's | new_key | Use a new key for a different request. |
Both SDKs generate a key when you give none, reuse it on their own retries (network errors, 409 idempotency-in-progress, 429, 503; at most 3 attempts, honouring Retry-After), and attach it to every error they raise, so you can retry at your level with the same key.
Problems
Errors are RFC 7807 application/problem+json. Match a problem by the last path segment of type (insufficient-credits, payment-failed), never by host: older problems use https://api.noncelogic.com/errors/... and billing problems use https://api.roveapi.com/errors/.... Status 402 has two types, insufficient-credits (your balance is too low) and payment-failed (a purchase's payment failed).
| Type | Status | Members | retry |
|---|---|---|---|
idempotency-key-required | 400 | none | |
invalid-params | 400 | field errors | none |
invalid-cursor | 400 | none | |
purchase-not-permitted | 403 | reason: agent_purchasing_off or key_not_granted, with owner_action_url | none |
purchase-not-permitted | 403 | reason: no_saved_method or off_session_not_allowed (only for payment: "saved_method"), suggested_payment: "auto" | same_key |
spend-limit-exceeded | 403 | limit (per_purchase, key_period, account_period), limit_cents, committed_cents, requested_cents, resets_at, packs_within_limits, owner_action_url | none |
test-key-required | 403 | none | |
pack-not-found | 404 | item_id | none |
purchase-not-found | 404 | none | |
payment-failed | 402 | failure_code, purchase_id | new_key |
purchase-not-cancelable | 409 | purchase_status | none |
invalid-transition | 409 | purchase_status | none |
idempotency-in-progress | 409 | Retry-After | same_key |
idempotency-key-reused | 422 | new_key | |
purchase-rate-limited | 429 | Retry-After | same_key |
billing-unavailable | 503 | Retry-After, purchase_id when a purchase was kept | same_key |
Problems on a purchase request also carry idempotency_key. The full list, including the owner routes, is in the Error Reference.
Out of credits
When credits run out an agent meets one of two answers. Both keep every member they always had and add what the agent needs to act:
402 insufficient-creditson session create,/v1/browser/action,/v1/browser/screenshot,/v1/browser/extractand a supervised start.410 session-expiredwithreason: "credit_exhausted"on/v1/browser/actionandGET /v1/browser/session/{session_id}, when a supervised session ended because the account ran out. The browser profile is kept; after adding credits, create a new session.
| Member | Meaning |
|---|---|
credits_remaining, credits_remaining_millicredits | The balance, whole and exact |
required_millicredits | 402 only: what the refused operation needed |
packs_within_limits | Pack ids this key may buy now within every limit; empty when it may not buy |
purchasing | allowed, and reason (agent_purchasing_off, key_not_granted) when not |
next_step | buy_credits: buy a pack from packs_within_limits. ask_owner: give a person owner_action_url |
links | balance, purchase, catalogue and docs |
owner_action_url | The billing page where the owner adds credits |
retry | Always none: the same request fails until credits are added |
next_step is buy_credits only when the key may buy and at least one pack fits every limit. The extensions are left out when the balance cannot be read, so treat them as optional. Test keys never run out of credits, so they never get these answers; rehearse with the bodies below. They are the fixtures the Node and Python SDK tests use.
A key that may buy:
{
"type": "https://api.noncelogic.com/errors/insufficient-credits",
"title": "Insufficient credits",
"status": 402,
"detail": "Not enough credits for requested operation.",
"instance": "/v1/browser/action",
"credits_remaining": 0,
"credits_remaining_millicredits": 250,
"required_millicredits": 1000,
"packs_within_limits": ["pack_1k", "pack_5k"],
"purchasing": { "allowed": true, "reason": null },
"next_step": "buy_credits",
"links": {
"balance": "/v1/account/balance",
"purchase": "/v1/billing/purchases",
"catalogue": "/v1/billing/catalogue",
"docs": "https://roveapi.com/docs/billing-api"
},
"owner_action_url": "https://roveapi.com/dashboard/billing",
"retry": "none"
}
A supervised session that ran out, on a key the owner has not allowed to buy:
{
"type": "https://api.noncelogic.com/errors/session-expired",
"title": "Session expired",
"status": 410,
"detail": "This supervised session ended because the account ran out of credits. Add credits, then create a new session; the browser profile is kept.",
"instance": "/v1/browser/action",
"reason": "credit_exhausted",
"controller": "agent",
"credits_remaining": 0,
"credits_remaining_millicredits": 0,
"packs_within_limits": [],
"purchasing": { "allowed": false, "reason": "key_not_granted" },
"next_step": "ask_owner",
"links": {
"balance": "/v1/account/balance",
"purchase": "/v1/billing/purchases",
"catalogue": "/v1/billing/catalogue",
"docs": "https://roveapi.com/docs/billing-api"
},
"owner_action_url": "https://roveapi.com/dashboard/billing",
"retry": "none"
}
The payment link
When a purchase has next_action.type: "open_checkout_url", give next_action.url to a person; next_action.instruction is a sentence you can pass on as it is. The link is a Stripe Checkout page for the account's Stripe customer:
- It expires 60 minutes after it was created (
next_action.expires_at). Unpaid, the purchase becomesexpiredand nothing is charged. - It names the key that asked ("Requested by API key ci-runner ..."), so the person can see who wants to buy.
- The owner is emailed the same link.
POST /v1/billing/purchases/{id}/cancelmakes it stop working.
Have the person open the link in their own browser. Do not open it inside a Rove supervised session: the card would be typed into a browser Rove streams and the agent can read.
After paying, poll GET /v1/billing/purchases/{id} until it is paid. If a supervised session ended for lack of credits, create a new one; the browser profile is kept.
Test purchases
A test key (rvp_test_...) buys through the same rules as a live key: agent purchasing must be on, the key granted, and the pack within the limits. Nothing reaches Stripe, no card is charged and no credits are added. Test purchases have livemode: false and their own hourly rate window.
- If the account has a saved card with charging allowed and
paymentis notcheckout, the purchase startsprocessing, as an off-session charge would. - Otherwise it starts
openwith anext_action.urlthat points to this section, since there is no payment page to open.
Then drive it with POST /v1/billing/purchases/{id}/simulate and an outcome:
outcome | From | To |
|---|---|---|
paid | open, requires_action, processing | paid (no credits are added) |
expired | open, requires_action | expired |
failed | processing | failed, failure_code: "card_declined" |
authentication_required | processing | requires_action, as when the bank asks the owner to confirm |
Repeating an outcome the purchase already has returns it unchanged. Any other move is 409 invalid-transition with purchase_status. A live key gets 403 test-key-required.
SDK examples
Node (@roveapi/browser 1.3.0 or later). This example is type-checked against the SDK in CI:
import { BrowserClient, InsufficientCreditsError, PaymentFailedError, SpendLimitExceededError } from '@roveapi/browser'
const rove = new BrowserClient({ apiKey: process.env.ROVE_API_KEY! })
/** Buys the cheapest pack this key may buy, or tells a person what to do. */
async function topUp(): Promise<void> {
const balance = await rove.balance()
const itemId = balance.purchasing.packs_within_limits[0]
if (!balance.purchasing.allowed || !itemId) {
console.log(`Ask the account owner to add credits: ${balance.purchasing.owner_action_url}`)
return
}
try {
const purchase = await rove.purchaseCredits({ itemId })
if (purchase.next_action) console.log(purchase.next_action.instruction, purchase.next_action.url)
let current = await rove.getPurchase(purchase.id)
while (current.status === 'open' || current.status === 'requires_action' || current.status === 'processing') {
await new Promise((resolve) => setTimeout(resolve, 5_000))
current = await rove.getPurchase(purchase.id)
}
console.log(`Purchase ${current.id} is ${current.status}`)
} catch (err) {
if (err instanceof SpendLimitExceededError) console.log(`Limit reached until ${err.resetsAt}; packs that fit: ${err.packsWithinLimits?.join(', ')}`)
else if (err instanceof PaymentFailedError) console.log(`Declined (${err.failureCode}); retry with a new key and payment 'checkout'`)
else throw err
}
}
try {
const session = await rove.session()
await session.navigate('https://example.com')
} catch (err) {
if (err instanceof InsufficientCreditsError && err.nextStep === 'buy_credits') await topUp()
else if (err instanceof InsufficientCreditsError) console.log(`Ask the account owner to add credits: ${err.ownerActionUrl}`)
else throw err
}
With a test key, finish a test purchase:
import { BrowserClient } from '@roveapi/browser'
const rove = new BrowserClient({ apiKey: process.env.ROVE_TEST_KEY! })
const purchase = await rove.purchaseCredits({ itemId: 'pack_1k', payment: 'checkout' })
const paid = await rove.simulatePurchase(purchase.id, 'paid')
console.log(paid.status, paid.livemode)
Python (roveapi 1.3.0 or later), type-checked with mypy in CI:
import os
import time
from roveapi import BrowserClient, InsufficientCreditsError, PaymentFailedError, SpendLimitExceededError
rove = BrowserClient(api_key=os.environ["ROVE_API_KEY"])
def top_up() -> None:
"""Buys the cheapest pack this key may buy, or tells a person what to do."""
purchasing = rove.balance()["purchasing"]
packs = purchasing["packs_within_limits"]
if not purchasing["allowed"] or not packs:
print("Ask the account owner to add credits:", purchasing["owner_action_url"])
return
try:
purchase = rove.purchase_credits(packs[0])
except SpendLimitExceededError as err:
print("Limit reached until", err.resets_at, "; packs that fit:", err.packs_within_limits)
return
except PaymentFailedError as err:
print("Declined", err.failure_code, "; retry with a new key and payment='checkout'")
return
if purchase["next_action"]:
print(purchase["next_action"]["instruction"], purchase["next_action"]["url"])
while purchase["status"] in ("open", "requires_action", "processing"):
time.sleep(5)
purchase = rove.get_purchase(purchase["id"])
print("Purchase", purchase["id"], "is", purchase["status"])
try:
session = rove.session()
session.navigate("https://example.com")
except InsufficientCreditsError as err:
if err.next_step == "buy_credits":
top_up()
else:
print("Ask the account owner to add credits:", err.owner_action_url)
finally:
rove.close()
MCP examples
The Rove MCP server (@roveapi/mcp 1.10.0 or later, and the hosted server) has seven credit tools; see MCP Setup. These calls are checked against the tools' input schemas in CI.
See prices and whether this key may buy (never buys):
{ "name": "buy_credits", "arguments": {} }
Buy a pack. Without idempotency_key the server derives one, so the same call again in the same MCP session returns the first purchase for 10 to 20 minutes; the result carries the idempotency_key to reuse:
{ "name": "buy_credits", "arguments": { "item_id": "pack_1k", "payment": "auto" } }
Follow it, and with a test key, finish it:
{ "name": "get_purchase", "arguments": { "purchase_id": "0b9b5b8e-6a3b-4f4e-9d8c-2f1e4c3b2a10" } }
{ "name": "simulate_purchase", "arguments": { "purchase_id": "0b9b5b8e-6a3b-4f4e-9d8c-2f1e4c3b2a10", "outcome": "paid" } }
A tool that hits an out-of-credits answer returns it as a tool error carrying the members above and ending with the next step: "Call get_credit_balance, then buy_credits with an item_id from top_ups." or "Ask the account owner to add credits at ...".