HTTP 402 payment_required: balance, spend limits and fixes

Why an API call comes back 402 payment_required: the prepaid balance, a key spend limit or an account ceiling. How to tell which, and what to do.

GTokens teamUpdated

The short answer

A 402 with error_type: payment_required 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 /api/v1/key 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 402 carries no Retry-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_remaining on GET /api/v1/key is the smallest of all three. Read it before a batch, not after the first refusal.

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_required, 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_denied. 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_free_tier on the key route is always false.

A refusal costs nothing

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.code. 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.

402 application/json
{
  "error": {
    "code": 402,
    "message": "daily limit reached",
    "metadata": {
      "error_type": "payment_required"
    }
  }
}

Branch on metadata.error_type. 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.

ask.py
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.content
ask.ts
import 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
  }
}
ask.py
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.error_type — 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 /api/v1/key 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:

The fields of GET /api/v1/key that explain a 402
FieldWhat it tells you
limitThis 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_resetThe 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_remainingHow 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_credits - total_usage from GET /api/v1/credits. Compare the two: when limit_remaining equals the balance, the balance is what stops you; when it is lower, a ceiling is closer.

which.py
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

Each cause of a 402 and its fix
CauseHow it showsWhat to do
The balanceinsufficient balance; limit_remaining 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 ceilingA ceiling you set on this key; limit_remaining 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 ceilingdaily 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 holdA thin balance and a large max_tokens.Send the max_tokens 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_remaining 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_tokens — 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_tokens 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_remaining 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_remaining 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 /api/v1/models — they are dollars per single token, not per million:

preflight.py
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}")
preflight.ts
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.

More in API

All in API →