The first three lessons made each call cheaper or rarer. This one makes spending visible and bounded. The failure it prevents is familiar: a new feature ships, a retry path misbehaves, one customer imports a large file, and the first sign is an out-of-credits 429 on your signup form three weeks before renewal.
Four habits prevent it: know what each call costs, estimate a job before it starts, budget by feature and tenant, and watch the burn rate rather than the balance.
Price every call
On APIVerve the cost of a successful call is fixed per API and shown on each API's reference page. It varies by more than an order of magnitude, so "a call" is not a unit you can budget in:
| API | Credits per call |
|---|---|
| Number to Words, Hash Generator, Query String Builder | 1 |
| Country Lookup, Exchange Rate | 2 |
| DNS Lookup | 6 |
| IP Lookup, Phone Number Validator, Job Postings (per page) | 10 |
| Email Validator, Text Summarizer, HTML to PDF, Job Search | 20 |
| Website to Text | 25 |
Keep these in one config object that every estimator and ledger reads, and check it when you add an API. A cost table copied into three places is three places to forget.
curl "https://api.apiverve.com/v1/iplookup?ip=8.8.8.8" \
-H "x-api-key: YOUR_API_KEY"10 credits for this answer. The data for an IP changes slowly, so caching it for a day turns a per-request cost into a per-day one.
The billing rules from Fundamentals still hold and drive the estimates below: a successful call is billed at the API's cost, a successful but empty answer is billed, and a failed call (validation error, rejected key, rate-limited or blocked call) is not. In a batch, only successful items bill, and billed reports how many.
Estimate before you run
A job's cost is arithmetic you can do before the first call:
import { buildQuery } from './query.mjs';
export const COST = { numbertowords: 1, countrylookup: 2, iplookup: 10, jobpostings: 10, emailvalidator: 20 };
export function estimateJob(api, paramsList, { pagesPerInput = 1, retryAllowance = 0.02, ratePerMinute = null } = {}) {
const cost = COST[api];
if (!cost) throw new Error(`No cost configured for ${api}`);
const unique = new Set(paramsList.map((p) => buildQuery(p))).size;
const calls = unique * pagesPerInput;
const credits = Math.ceil(calls * cost * (1 + retryAllowance));
const minutes = ratePerMinute ? Math.ceil(calls / ratePerMinute) : null;
return { inputs: paramsList.length, unique, calls, credits, minutes };
}retryAllowance is the honest part. Failed calls are free, but some retries are not, as the next section shows. Two percent is a starting point; replace it with the ratio you measure from your own reconciliations.
Then compare with the balance. The Fundamentals course used a 1-credit probe call to read the headers. There is a better source: GET https://api.apiverve.com/v1/analytics returns the account's summary and is never billed, on any plan. It also still answers when the account is out of credits, which is exactly when you need it.
import { estimateJob } from './estimate.mjs';
export async function usage() {
const res = await fetch('https://api.apiverve.com/v1/analytics', {
headers: { 'x-api-key': process.env.APIVERVE_API_KEY },
signal: AbortSignal.timeout(10_000),
});
const body = await res.json().catch(() => null);
if (!res.ok || body?.status !== 'ok') throw new Error(body?.error || `analytics HTTP ${res.status}`);
return body.data; // { summary, detailed, ... }
}
export async function preflight(api, paramsList, { reserve = 0.2, ...opts } = {}) {
const { summary } = await usage();
const perMinute = parseInt(summary.rateLimit, 10) || null; // "60 requests/minute" or "unlimited"
const est = estimateJob(api, paramsList, { ...opts, ratePerMinute: perMinute });
const floor = Math.ceil(summary.tokensLimit * reserve); // keep 20% for live traffic
const fits = summary.tokensRemaining - est.credits >= floor;
return { ...est, remaining: summary.tokensRemaining, floor, fits, renewalDate: summary.renewalDate };
}Run preflight before every bulk job and refuse to start when fits is false. The reserve is a business decision: the job is the thing that should wait for renewal, never your production traffic. A refusal at minute zero with a clear number ("needs 412,000 credits, 380,000 available above the floor") is a planning conversation; a refusal at row 61,000 is an incident.
summary.rateLimit is a string, "60 requests/minute" or "unlimited", which is why it is parsed. The estimated minutes from lesson 3 come from the same call.
A job will validate 50,000 email rows, of which 31,000 are unique, at 20 credits each. retryAllowance is 0.02. Analytics reports tokensRemaining: 700000 and tokensLimit: 1000000, and preflight keeps a 20% floor. Does the job fit?
The hidden costs
Most overspend is not the job you estimated. It is calls you did not know you were making, or paying for twice.
Duplicates. The API does not deduplicate. Repeated inputs in a batch bill once per item, and the same lookup from two code paths bills twice. Dedupe before batching (lesson 2) and key caches canonically (lesson 1).
Uncached hot paths. A 10-credit IP Lookup on every page view, for an IP whose data changes slowly, is a cost that grows with traffic instead of with distinct visitors. Find these in the per-API breakdown below: one endpoint dominating usage is often one missing cache.
Retries of calls that succeeded. This is the expensive one. A failed call is free, so retrying a 429 or a 5xx costs rate limit, not credits. But a client timeout does not mean the call failed. If your deadline is shorter than the endpoint's real latency, the server finishes the work and bills it, your client has already given up, and the retry bills again. A 3-second timeout on an endpoint that sometimes takes 4 seconds pays roughly double for every slow call. Set deadlines from each endpoint's measured latency, and treat any retry after a timeout as a possible second charge in your reconciliation.
Repeated identical failures. They are not billed, but they consume rate-limit slots and get the exact call throttled, which delays the calls that would have succeeded.
Page size and over-fetching. Every page is a full-price call. A plan with smaller pages pays more calls for the same listing, and paging through everything to keep the newest ten wastes most of them. Filter on the server and stop as soon as you have what you need.
Empty successes. A lookup that runs and finds nothing on an API that answers with an empty success is billed. Negative caching (lesson 1) stops you paying for the same nothing twice.
Budgets per feature and per tenant
Sub-keys are the obvious unit for separating workloads, and they do separate a lot: each has its own rate-limit window, its own restrictions, and can be revoked alone. What they do not have is a credit allowance of their own. Every sub-key spends the account's shared credits, so a sub-key cannot stop one tenant spending another tenant's share. If you resell or embed API access, or simply want the search feature not to starve the signup feature, the budget is yours to enforce.
A ledger with three operations does it. Reserve the call's cost before calling, commit it when the call succeeds, release it when the call fails (failed calls were not billed):
import { COST } from './estimate.mjs';
export class Ledger {
constructor(limits) { // e.g. { 'acme:search': 50000, 'acme:signup': 20000 }
this.limits = limits;
this.spent = new Map(); // committed
this.held = new Map(); // reserved, not yet settled
}
used(bucket) { return (this.spent.get(bucket) ?? 0) + (this.held.get(bucket) ?? 0); }
reserve(bucket, api, n = 1) {
const cost = COST[api] * n;
const limit = this.limits[bucket] ?? 0;
if (this.used(bucket) + cost > limit) {
throw Object.assign(new Error(`Budget exceeded for ${bucket}`), { code: 'BUDGET', bucket });
}
this.held.set(bucket, (this.held.get(bucket) ?? 0) + cost);
let settled = false;
const settle = (billedItems) => {
if (settled) return;
settled = true;
this.held.set(bucket, this.held.get(bucket) - cost);
this.spent.set(bucket, (this.spent.get(bucket) ?? 0) + COST[api] * billedItems);
};
return { commit: (billedItems = n) => settle(billedItems), release: () => settle(0) };
}
}
// Usage around any call:
// const hold = ledger.reserve('acme:search', 'jobpostings');
// try { const data = await callApi(...); hold.commit(); return data; }
// catch (err) { hold.release(); throw err; }For a batch, reserve n items up front and call commit(body.billed) afterwards: the ledger then charges exactly what the API billed, and a batch where half the items were rate-limited releases the other half.
Design notes:
- Reserve before calling, not after. Checking spent-so-far after each call lets twenty concurrent requests all pass the check and overshoot together. Holding the cost first makes the limit hard.
- A timeout is a commit, not a release. If you cannot tell whether the call ran, assume it was billed. The ledger should err toward the account's real balance, not away from it.
- Bucket names carry the dimensions you report on.
tenant:featureis usually enough. Reset buckets on the account's renewal date, fromsummary.renewalDate, so your periods line up with the allowance. - One process is not a ledger. With several instances, keep the counters in a shared store and update them atomically: a Redis
INCRBYchecked against the limit inside a Lua script, or a single conditionalUPDATE ... WHERE spent + :cost <= limitin SQL. An in-memoryMapper instance multiplies every limit by the instance count.
What happens on BUDGET is product design: a clear message to that tenant, degraded results from cache, or a queue that waits for the next period. What must not happen is the call going out anyway.
Read usage, and alert on the slope
Two sources tell you where the account stands.
Every response's headers. x-api-remaining-credits, x-api-credits-used and x-api-max-credits arrive on calls you were making anyway, along with x-api-renewal (a Unix timestamp). Read them defensively: Number(res.headers.get('x-api-remaining-credits')) is 0 when the header is missing, because Number(null) === 0. Check for null first, or a missing header looks like an empty account.
GET /v1/analytics, never billed. Every plan gets the summary: plan, tokensUsed, tokensRemaining, tokensLimit, bonusTokens, usagePercent, renewalDate (YYYY-MM-DD) and rateLimit. On Pro and Mega, detailed is true and the response adds daily (calls per day for the last 30 days), byApi (calls per endpoint) and lastActivity. Branch on detailed, not on whether daily exists. Note that byApi counts calls, not credits: multiply by each API's cost before comparing endpoints, or a 1-credit endpoint with many calls will look more expensive than a 20-credit one with fewer.
The dashboard already emails at 80% and 100% of the cycle's credits, and the 80% warning is worth keeping on. But a threshold on the balance fires late when spend accelerates: crossing 80% on day 27 and on day 9 send the same email. Alert on the projection instead:
import { usage } from './preflight.mjs';
const DAY = 86_400_000;
export async function checkBurn({ warnAt = 0.9 } = {}) {
const { summary } = await usage();
const renewal = new Date(`${summary.renewalDate}T00:00:00Z`);
const start = new Date(renewal);
start.setUTCMonth(start.getUTCMonth() - 1); // monthly cycle
const cycleDays = (renewal - start) / DAY;
const elapsed = Math.max(1, (Date.now() - start) / DAY);
const projected = Math.round((summary.tokensUsed / elapsed) * cycleDays);
const ratio = projected / summary.tokensLimit;
if (ratio >= warnAt) {
return { alert: true, message: `Projected ${projected} of ${summary.tokensLimit} credits this cycle (${Math.round(ratio * 100)}%)` };
}
return { alert: false, projected, ratio };
}Run it on a schedule (hourly is plenty, and it costs nothing) and send the alert wherever your team actually looks. A straight-line projection is crude, and that is fine: its job is to fire on day 9 when a change doubled the daily spend, while there are still three weeks to add a cache, throttle a job or change plan. With daily available, project from the last seven days instead of the whole cycle so a recent step change is not averaged away.
Two other checks cost nothing and catch configuration mistakes: at startup, read summary.plan and summary.rateLimit and fail the deploy if an environment holds the wrong key; and after each bulk job, compare the change in tokensUsed with your ledger's commits for that job.
On day 10 of a 30-day cycle, analytics shows tokensUsed: 400000 and tokensLimit: 1000000. The 80% usage email has not fired. What does a straight-line projection say, and what should you do?
The course in one sentence
Pay once per distinct question (canonical keys, caches, dedupe), send what you must in the fewest round trips the window allows (batches sized to the limit), make long work restartable and complete (checkpoints, merged pages), and know the bill before it arrives (estimates, ledgers, burn alerts).
That is cost and scale. The other half of running an integration in production is keeping it safe: keys, scopes, logs and the data that passes through them. Continue with Operate integrations securely.