# Cost Tracking

> What the balance is, what one request cost, and what the same call costs elsewhere.

Section: Guides. Canonical page: https://routehook.ai/docs/cost-tracking

Four places report what you spent, in the order they become available: usage.cost on the response body, the X-Routehook-Cost header beside it, GET /v1/generation for one request after the fact, and GET /v1/credits for the account. They agree, because they are one ledger read from four ends.

## usage.cost, on the response you already have

Chat responses carry `usage.cost` as an OpenRouter-compatible JSON number and `usage.cost_decimal` as the exact decimal string. A streamed response puts the same fields in the final usage frame only when `stream_options.include_usage` is true; otherwise look the request up afterwards.

## Headers on every gateway response

| HEADER | VALUE |
| --- | --- |
| X-Routehook-Request-Id | req_…. The id to look the request up by, and the id to quote. |
| X-Routehook-Cost | USD charged, as a decimal string. |
| X-Routehook-Attempts | How many targets were tried. |
| X-Routehook-Fallback | true when the target that answered was not the first one. |
| X-Routehook-Upstream-Latency-Ms | How long the upstream leg took, excluding our own overhead. |

A streamed response has no cost header. Headers go out before the first token and the charge is not known until after the last one, so a stream's total arrives in the final usage frame or from GET /v1/generation. The request id is set before the request is routed, which is why it is on every response including the failures.

## GET /v1/generation

Read what one request cost and how long it took.

Status: Available. Authentication: any live API key on the account.

GET /v1/generation?id=req_… is the record of one request: usage is what you were charged, attempts and fallback_used say whether the first attempt worked, generation_time and latency are milliseconds, and status with error_code covers the requests that failed. It is scoped to the calling account, another account's id is a 404, not a 403, because a 403 would confirm the id exists.

## One call, then its cost

**cURL**

```bash
# 1: make the call, keeping the response headers.
curl -sS https://api.routehook.ai/v1/chat/completions \
  -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \
  -H "Content-Type: application/json" \
  -D headers.txt \
  -d '{
    "model": "openai/gpt-5.4-mini",
    "messages": [{ "role": "user", "content": "Hello" }]
  }' | jq '.usage'

# 2: the id it was recorded under.
REQUEST_ID=$(awk 'tolower($1) == "x-routehook-request-id:" { print $2 }' \
  headers.txt | tr -d '\r')

# 3: the same request, read back.
curl -sS "https://api.routehook.ai/v1/generation?id=$REQUEST_ID" \
  -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \
  | jq '.data | {usage, total_cost, attempts, fallback_used}'
```

**JavaScript**

```javascript
const auth = { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` };

const res = await fetch("https://api.routehook.ai/v1/chat/completions", {
  method: "POST",
  headers: { ...auth, "Content-Type": "application/json" },
  body: JSON.stringify({
    model: "openai/gpt-5.4-mini",
    messages: [{ role: "user", content: "Hello" }],
  }),
});

const completion = await res.json();
const requestId = res.headers.get("x-routehook-request-id");

// Charged, in the response you already hold.
console.log(completion.usage.cost, res.headers.get("x-routehook-cost"));

// The same request, read back from the log.
const record = await fetch(
  `https://api.routehook.ai/v1/generation?id=${requestId}`,
  { headers: auth },
).then((r) => r.json());

console.log(record.data.usage, record.data.total_cost);
```

**Python**

```python
import os, requests

auth = {"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}

res = requests.post(
    "https://api.routehook.ai/v1/chat/completions",
    headers=auth,
    json={
        "model": "openai/gpt-5.4-mini",
        "messages": [{"role": "user", "content": "Hello"}],
    },
)

completion = res.json()
request_id = res.headers["X-Routehook-Request-Id"]

# Charged, in the response you already hold.
print(completion["usage"]["cost"], res.headers.get("X-Routehook-Cost"))

# The same request, read back from the log.
record = requests.get(
    "https://api.routehook.ai/v1/generation",
    headers=auth,
    params={"id": request_id},
).json()

print(record["data"]["usage"], record["data"]["total_cost"])
```

> **Note: The record lands as the response leaves**
>
> The usage row is written as the last byte goes out, so a lookup fired in the same instant can miss it. Retry once before treating a 404 as real. The id itself is valid from the moment you receive it.

## GET /v1/credits

Read the account balance, the amount held by requests in flight and what is left to spend.

Status: Available. Authentication: any live API key on the account.

GET /v1/credits describes the account, not one key. total_credits is lifetime deposits and total_usage lifetime spend, both OpenRouter's names; balance is what is settled, held is what requests in flight have reserved, and available is balance minus held plus any credit limit. available is the figure to check before starting a batch. Balance on its own will happily tell you that you can afford work you have already committed to. These are numbers rounded to two places, not strings: per-token rates are strings, balances are not.

## Why held exists

Money is reserved before it is spent and settled at the real cost afterwards. A request holds the most it could possibly cost, the actual charge replaces that hold when the response is delivered, and the hold is released if the request fails. So held is non-zero while work is in flight, a 5xx costs nothing, and a queue job's money is committed at submit rather than at completion, which is the answer to why the balance moved before a video existed.

## GET /v1/key

Describe the key making the call: its label, spend cap, lifetime usage and rate limit.

Status: Available. Authentication: any live API key on the account.

GET /v1/key describes the key that made the call: usage is lifetime USD spent on that key, limit and limit_remaining are its own spend cap where one is set, and rate_limit is the requests-per-interval it is subject to. Spend is attributed per key, so one key per project turns the account balance into a per-project bill with no tagging of your own.

## A workable setup

- One key per project or environment, so usage on GET /v1/key is already the breakdown.
- Log X-Routehook-Request-Id on every call, successful or not.
- Log X-Routehook-Cost beside it, and X-Routehook-Fallback to catch the days something behind a model is struggling.
- Reconcile against GET /v1/generation when a number looks wrong, rather than re-adding your own logs.
- Poll GET /v1/credits on a schedule, and alert on available rather than balance.

## Quote the request id

Every response carries the request id on X-Routehook-Request-Id (the same header on every route, images included), and every error body repeats it inside the error object. A support report carrying that id is a lookup. The attempts, the underlying error and the charge are all on one row. Without it, the same report is an investigation.

## Related

- [Pricing](/docs/pricing): The rate table, and the hold-settle-release cycle in full.
- [Routing](/docs/routing): Why a fallback shows up in what you paid.
- [Support](/docs/support): What to send with a request id when a number looks wrong.
