A 402 with error_ means money would not cover the request: the prepaid balance, a spending ceiling on the key, or a daily or monthly ceiling on the account. Nothing is charged. GET / shows how much can still be spent and which ceiling applies; then top up, raise the key’s ceiling, or wait for the window to roll over.
- Three stops answer with one status: the balance, the key’s own ceiling, and the account’s daily and monthly ceilings.
- The refusal comes before anything is generated, so it costs nothing.
- It is not a rate limit: a
402carries noRetry-After, and repeating the request on a timer gets the same answer. There is no published per-key request rate; what stops you is money (402) or a temporary refusal (429/503). limit_onremaining GET /is the smallest of all three. Read it before a batch, not after the first refusal.api/ v1/ key
What a 402 means here
Every request is paid from a prepaid balance, and money is checked twice before anything is generated: a cheap look at the account when the request arrives, then an atomic hold for the price of what was asked. Either check can refuse with 402 payment_, and three things can make it refuse:
- The balance. A request is served while the balance covers it. When it does not, the answer is
402. - The ceiling on the key. Each key can carry its own cap and its own window — daily, weekly, monthly, or one that covers the whole life of the key — set on the API keys screen in your account area. Only that key stops; the other keys keep working.
- The account ceilings. A daily and a monthly cap in dollars on the whole account, across every key. They are a safety catch against a key that got loose, not a cap on your work, and an account starts with none.
A few neighbouring refusals look similar and are not this one. A suspended account is refused earlier, with 403 permission_. A model that is temporarily unavailable answers 503 with a Retry-After you can wait out. And there is no free allowance to fall back to: is_ on the key route is always false.
A 402 is decided before the first token exists, so it never touches the balance. The one case that costs money without a complete answer is a streamed answer that started and then broke — it is billed for what the model produced — and that is never a 402.
Read the refusal
On the OpenAI-shaped routes the refusal arrives in the usual error body, with the status repeated in error.. With stream: true it is still a real HTTP status, not an event inside the stream: everything decided before the first byte arrives that way.
{
"error": {
"code": 402,
"message": "daily limit reached",
"metadata": {
"error_type": "payment_required"
}
}
}Branch on metadata.. The message is there for a person: it reads insufficient balance when the balance is the stop, and daily limit reached or monthly limit reached when an account ceiling is. Log it, but do not match on its text in code — it is meant to be shown, and it changes when it can be worded better.
import openai
from openai import OpenAI
client = OpenAI(
base_url="https://api.gtokens.io/api/v1",
api_key="sk-sl-your-key",
)
class OutOfBudget(Exception):
"""A 402: nothing was charged, and a repeat is refused the same way."""
def ask(prompt: str) -> str:
try:
completion = client.chat.completions.create(
model="anthropic/claude-sonnet-5",
max_tokens=1024, # the hold is sized by this
messages=[{"role": "user", "content": prompt}],
)
except openai.APIStatusError as e:
error = e.response.json().get("error", {})
if error.get("metadata", {}).get("error_type") == "payment_required":
# The message names the cause for a person: log it, do not parse it.
raise OutOfBudget(error.get("message")) from e
raise
return completion.choices[0].message.contentimport OpenAI from 'openai'
const client = new OpenAI({
baseURL: 'https://api.gtokens.io/api/v1',
apiKey: 'sk-sl-your-key',
})
type GatewayError = {
code: number
message: string
metadata: { error_type: string; reason?: string }
}
/** A 402: nothing was charged, and a repeat is refused the same way. */
export class OutOfBudget extends Error {}
export async function ask(prompt: string): Promise<string | null> {
try {
const completion = await client.chat.completions.create({
model: 'anthropic/claude-sonnet-5',
max_tokens: 1024, // the hold is sized by this
messages: [{ role: 'user', content: prompt }],
})
return completion.choices[0].message.content
} catch (err) {
// err.error is the "error" object of the response body
const body = err instanceof OpenAI.APIError ? (err.error as GatewayError | undefined) : undefined
if (body?.metadata?.error_type === 'payment_required') {
// The message names the cause for a person: log it, do not parse it.
throw new OutOfBudget(body.message)
}
throw err
}
}import anthropic
client = anthropic.Anthropic(
base_url="https://api.gtokens.io",
auth_token="sk-sl-your-key",
)
try:
message = client.messages.create(
model="anthropic/claude-haiku-4-5",
max_tokens=1024,
messages=[{"role": "user", "content": "Summarise this ticket in two lines."}],
)
except anthropic.APIStatusError as e:
# Native envelope on this route: the typed code sits in error.error_type.
error = (e.body or {}).get("error", {})
if error.get("error_type") != "payment_required":
raise
print("refused, not charged:", error.get("message"))On the Messages API routes the same refusal comes in the native envelope that client library expects, and the typed code rides inside error. — the third tab. Whichever library you use, treat the 402 as a signal to stop sending: the same request is refused the same way until money is added, a ceiling is raised, or its window rolls over.
Tell the three stops apart
GET / describes the key that made the call. It costs nothing and needs no balance, so it answers even while every other call comes back 402. Three of its fields carry the answer:
| Field | What it tells you |
|---|---|
limit | This key’s own ceiling in dollars, or the account ceiling when the key has none of its own — and null when there is neither. |
limit_ | The window that ceiling counts over: daily, weekly or monthly. null means the ceiling covers the whole life of the key, or that there is no ceiling at all. |
limit_ | How much can be spent right now: the smallest of the balance, what is left of this key’s ceiling, and what is left of the account’s daily and monthly ones. Always a number, never null. |
Scroll the table sideways for what each field tells you.
The balance on its own is total_ from GET /. Compare the two: when limit_ equals the balance, the balance is what stops you; when it is lower, a ceiling is closer.
import requests
BASE = "https://api.gtokens.io/api/v1"
HEADERS = {"Authorization": "Bearer sk-sl-your-key"}
def get(path: str) -> dict:
r = requests.get(f"{BASE}/{path}", headers=HEADERS, timeout=30)
r.raise_for_status()
return r.json()["data"]
key, credits = get("key"), get("credits")
balance = credits["total_credits"] - credits["total_usage"]
# limit_remaining is the smallest of the balance and every ceiling.
# Equal to the balance: the balance is the stop. Lower: a ceiling is closer.
if round(key["limit_remaining"], 6) >= round(balance, 6):
print(f"balance: {balance:.2f} USD left")
else:
print(f"a ceiling: {key['limit_remaining']:.2f} USD left under it")
print("ceiling on this key:", key["limit"], key["limit_reset"])Both figures are snapshots. Every key on the account draws on the same balance, so another key can move them between two reads, and a request in flight has already put its estimate aside, so both read low while it runs.
What to do in each case
| Cause | How it shows | What to do |
|---|---|---|
| The balance | insufficient balance; limit_ equals the balance. | Top up from the balance page in your account area and send the same request again. Nothing else about the account changes. |
| The key’s ceiling | A ceiling you set on this key; limit_ is below the balance; other keys of the account still work. | Raise it on the API keys screen, or wait until its window rolls over. A ceiling with no window covers the whole life of the key: nothing rolls over, so only raising it helps. |
| An account ceiling | daily limit reached or monthly limit reached; every key on the account is refused. | Wait for the window to turn over. The amounts are set on request: write to us with the ones you want. |
| The hold | A thin balance and a large max_. | Send the max_ you actually need, or top up. The next section explains why. |
Scroll the table sideways for the fix.
Windows are calendar windows in the account’s own time zone: the day starts at midnight, the week on Monday, the month on the first — not the last 24 hours. The ceilings are enforced over those same windows, so limit_ and the refusal agree with each other, at a boundary as well as in the middle of a day.
Another key is not a way round any of this. The balance and the account ceilings belong to the account, so every key draws on the same ones, and a second key divides what you have rather than adding to it.
Why a cheap request can come back 402
What a request will cost is not known until it finishes, so an estimate is held from the balance before it goes out and settled against the real counters when it comes back. The difference is returned, and a request that fails before its reply starts returns the whole hold. For text the estimate is your max_ — 4,096 when you do not set it — plus the input, at that model’s rates.
The practical consequence: a thin balance with a very large max_ can be refused even though the answer would have been three words long. The same applies to parallel work. Each request in flight holds its own estimate, and limit_ drops when a call starts while the usage counters move only when it finishes — so ten parallel requests need room for ten holds at once.
Check what is left before a batch
A batch that starts with two dollars left does not stop politely. It stops in the middle, with part of the work done and paid for and the rest coming back 402. Reading limit_ before the run turns that into a decision made in advance. The upper bound for the run is the number of requests times one hold, with the rates taken from GET / — they are dollars per single token, not per million:
import requests
BASE = "https://api.gtokens.io/api/v1"
HEADERS = {"Authorization": "Bearer sk-sl-your-key"}
MODEL = "anthropic/claude-haiku-4-5"
JOBS = 400 # requests in the batch
INPUT_TOKENS = 2_000 # your own estimate of one prompt
MAX_TOKENS = 512 # the max_tokens every request will send
catalogue = requests.get(f"{BASE}/models", timeout=30).json()["data"]
rates = next(m["pricing"] for m in catalogue if m["id"] == MODEL)
# What one request holds before it runs: the input plus max_tokens,
# at the model's rates. Rates are dollars per single token.
hold = INPUT_TOKENS * float(rates["prompt"]) + MAX_TOKENS * float(rates["completion"])
r = requests.get(f"{BASE}/key", headers=HEADERS, timeout=30)
r.raise_for_status()
left = r.json()["data"]["limit_remaining"]
if left < JOBS * hold:
raise SystemExit(f"{left:.2f} USD left; the batch can cost up to {JOBS * hold:.2f}")const BASE = 'https://api.gtokens.io/api/v1'
const headers = { Authorization: 'Bearer sk-sl-your-key' }
const MODEL = 'anthropic/claude-haiku-4-5'
const JOBS = 400 // requests in the batch
const INPUT_TOKENS = 2_000 // your own estimate of one prompt
const MAX_TOKENS = 512 // the max_tokens every request will send
const catalogue = await (await fetch(`${BASE}/models`)).json()
const { pricing } = catalogue.data.find((m: { id: string }) => m.id === MODEL)
// What one request holds before it runs: the input plus max_tokens,
// at the model's rates. Rates are dollars per single token.
const hold = INPUT_TOKENS * Number(pricing.prompt) + MAX_TOKENS * Number(pricing.completion)
const r = await fetch(`${BASE}/key`, { headers })
if (!r.ok) throw new Error(`key check failed: ${r.status}`)
const { data } = await r.json()
if (data.limit_remaining < JOBS * hold) {
throw new Error(
`${data.limit_remaining.toFixed(2)} USD left; the batch can cost up to ${(JOBS * hold).toFixed(2)}`,
)
}Poll the key route on a schedule rather than before every request: the figures move only when a request settles. And keep the 402 handler from the start of this post even with the check in place — another key on the account can spend the same money first.



