docketrouter
DocumentationBrowse
API reference

Usage and billing

Every request through the gateway is logged against the key that made it. A key sees only its own rows; a signed-in session sees every key on the account. The log is the audit trail for cost, latency, grounding and fabrication, and it is where the verification report lives after the response is gone.

GET/usagekey or sessionRows, filters, totals, CSV.
GET/usage/dailykey or sessionPer-day rollups, by model or app.
GET/usage/:request_idkey or sessionOne request in full.
GET/generation?id=key or sessionThe same, OpenRouter-shaped.
GET/creditskey or sessionAPI credits behind the account.
POST/usage/synckey or sessionReconcile against the provider record.

GET /usage

ParameterValuesNotes
fromISO timestamp or dateInclusive lower bound on created_at.
toISO timestamp or dateUpper bound.
modelmodel idExact match.
appapp nameExact match on app_name, the value set by X-DocketRouter-App, X-Title or the referrer's hostname. See Apps and attribution.
keykey idRestrict to one key. An API key may only pass its own id (403 otherwise); a signed-in session may pass any key on its account (404 if the id belongs to no key there).
statusok or errorErrors here are upstream failures, not rejected requests. See the note below.
juiced1 or 0Whether the grounding layer ran.
limitintegerDefaults to 100.
offsetintegerPage with next_offset until it is null.
formatcsvReturns text/csv with a filename attachment header. Up to 5,000 rows, and it ignores limit and offset.

The envelope

FieldMeaning
totalsAggregates over everything in scope, not just this page: requests, cost, prompt_tokens, completion_tokens, latency (mean ms), juiced and fakes_caught.
by_modelPer model: requests, cost, tokens.
by_keyPer key rollup. Present only for a signed-in session, absent for an API key.
by_appPer attributed app, over the whole scope: app_name, app_url (the most recently seen URL for that name), requests, cost, prompt_tokens, completion_tokens, last_used. One row has app_name: null for unattributed requests. Like totals and by_model, it honours from and to but not the other filters.
dataThe rows, newest first.
total, limit, offset, next_offsetPaging. next_offset is null on the last page.
filtersEchoes the filters applied to data: from, to, model, app, key, status, juiced. Fields you did not pass are omitted.
200 OK, abridged
{
  "totals": { "requests": 7, "cost": 0.00135336, "prompt_tokens": 8696,
              "completion_tokens": 1891, "latency": 15312.71,
              "juiced": 7, "fakes_caught": 0 },
  "by_model": [ { "model": "deepseek/deepseek-v4-flash", "requests": 7,
                  "cost": 0.00135336, "tokens": 10587 } ],
  "by_app": [ { "app_name": "Intake Bot", "app_url": "https://intake.example.com",
                "requests": 5, "cost": 0.00101, "prompt_tokens": 6200,
                "completion_tokens": 1300, "last_used": "2026-08-27T18:25:19.695Z" },
              { "app_name": null, "app_url": null, "requests": 2, "cost": 0.00034336,
                "prompt_tokens": 2496, "completion_tokens": 591,
                "last_used": "2026-08-26T09:11:02.410Z" } ],
  "data": [ … ],
  "total": 7, "limit": 5, "offset": 0, "next_offset": 5,
  "filters": { "from": "2026-08-01", "status": "ok" }
}
Rejected requests are not logged

A request rejected before the model call (400, 401, 403, 413, 422, 429) writes no row at all. The log covers requests that reached a provider, whether they succeeded (status: "ok") or failed there (status: "error"). If you need a count of client errors, count them in your own client.

Row fields

one row
{
  "id": 75,
  "request_id": "req_f60437cb80a541d8912c",
  "created_at": "2026-08-27T18:25:19.695Z",
  "key_id": "672f0e35-…", "key_name": "docs-v2",
  "app_name": "Intake Bot", "app_url": "https://intake.example.com",
  "model": "deepseek/deepseek-v4-flash",
  "provider": "Baidu",
  "juiced": 1, "status": "ok",
  "prompt_tokens": 514, "completion_tokens": 120, "cached_tokens": 0,
  "cost_usd": 0.00007482, "upstream_cost_usd": 0.00005985252,
  "latency_ms": 2784, "retrieval_ms": 952,
  "sources_n": 3, "citations_checked": 0, "citations_not_found": 0,
  "or_generation_id": "gen-1787855117-9x3UcZvNvaKglZLhzoyD",
  "synced_at": "2026-08-27T18:25:19.695Z",
  "error": null,
  "verification": { "checked": [], "fabricated": [], "unverified": [], "revised": false },
  "has_content": false
}
FieldMeaning
created_atWhen the row was written, which is when the request finished.
request_idThe req_… handle also returned in the response header.
key_id, key_nameWhich key made the request.
app_name, app_urlWhich app made the request, from the attribution headers. Both null when none were sent. See Apps and attribution.
modelThe model you asked for.
providerThe upstream provider that actually served it, once reconciled. Two identical requests can differ.
juiced1 if the grounding layer ran, 0 if you sent juice: false.
statusok or error.
prompt_tokens, completion_tokensNative token counts. Prompt tokens include the injected authorities.
cached_tokensPrompt tokens served from the provider prompt cache.
cost_usdWhat you are billed for this request.
upstream_cost_usdWhat the provider charged. The ratio is the markup, currently 1.25, so you can audit it.
latency_msWall clock for the whole request, retrieval and verification included.
retrieval_msTime spent in the grounding layer before the model call.
sources_nRetrieved rules plus cases injected. 0 on a degraded request, which makes degradation countable after the fact.
citations_checkedReporter citations found in your prompt and checked.
citations_not_foundOf those, how many came back not found. This is the fabrication counter.
verificationThe full answer-side report: checked, fabricated, unverified, revised.
or_generation_idThe upstream generation id, used for reconciliation.
synced_atWhen this row was last reconciled against the provider record.
errorThe upstream failure message, kept here rather than returned in the 502.
has_contentWhether the prompt and answer were stored. False unless log_content is on for the key. The list endpoint never returns the content itself.

GET /usage/:request_id and GET /generation?id=

Everything about one request, including what the response no longer has.

Same fields as a list row, plus two more and a set of grouped views of the same values. This is where a request id from a support ticket turns into an answer. GET /generation?id=req_… is the same endpoint with the id in the query string, matching OpenRouter's GET /generation so a client written against it works unchanged; the two bodies are byte for byte identical, and GET /generation without an id is a 400.

FieldMeaning
contentThe last 6 messages and the answer, or null. Only present when the key had log_content on at request time.
openrouter_generationThe provider's own record for this generation: native token counts, cache hits, moderation and generation latency, finish reason, data region, and billed_usd. Fetched live, and null when it is not available.

Additive fields

Nothing was renamed or removed; the flat fields stay exactly as they are on a list row. These objects group the same values so a client can read the shape it wants.

FieldContents
app{ name, url }, the attributed app or two nulls.
price_usdThe DocketRouter price for this request. Same value as cost_usd.
tokens{ prompt, completion, cached }.
latency{ total_ms, retrieval_ms }.
grounding{ sources_n, citations_checked, citations_not_found, reranked, verification }.
key{ id, name }, the key that made the request.
timestamps{ created_at, synced_at }.
GET /generation?id=req_f60437cb80a541d8912c, abridged
{
  "request_id": "req_f60437cb80a541d8912c",
  "model": "deepseek/deepseek-v4-flash", "status": "ok",
  "prompt_tokens": 514, "completion_tokens": 120, "cost_usd": 0.00007482,
  …
  "app": { "name": "Intake Bot", "url": "https://intake.example.com" },
  "price_usd": 0.00007482,
  "tokens": { "prompt": 514, "completion": 120, "cached": 0 },
  "latency": { "total_ms": 2784, "retrieval_ms": 952 },
  "grounding": { "sources_n": 3, "citations_checked": 0, "citations_not_found": 0,
                 "reranked": null, "verification": { … } },
  "key": { "id": "672f0e35-…", "name": "docs-v2" },
  "timestamps": { "created_at": "2026-08-27T18:25:19.695Z", "synced_at": "2026-08-27T18:25:19.695Z" }
}
content, when logging is on
{
  "messages": [{ "role": "user", "content": "One line: what does Rule 12(b)(6) test?" }],
  "answer": "Rule 12(b)(6) tests whether the complaint states a claim upon which relief can be granted."
}

Fetching a request also opportunistically fills in a missing cost from the provider record, so a row that had no cost at write time will have one after you read it.

POST /usage/sync

Reconcile recent rows against the provider's per-generation truth.

request and response
curl -X POST "https://docketrouter.ai/api/v1/usage/sync?limit=50" -H "Authorization: Bearer dr-…"

{ "synced": 12, "unresolved": 0, "remaining": 0 }
  • Only rows that have not been reconciled are touched, so the call is safe to repeat and cheap when there is nothing to do.
  • limit defaults to 50 and is capped at 200.
  • It fills in exact upstream cost, the provider that served the request, native token counts and cache hits.
  • unresolved counts rows whose provider record could not be fetched. They stay eligible for the next run.
  • Run it on a schedule (hourly is plenty) if you invoice from this data. Treat the synced value as the billable one.

GET /usage/daily

One bucket per UTC calendar day, for charts and invoices.

GET/usage/dailykey or session
ParameterValuesNotes
fromISO timestamp or dateDefaults to 30 days ago at 00:00Z.
toISO timestamp or dateDefaults to now.
modelmodel idExact match.
appapp nameExact match on app_name.
keykey idSame rules as on GET /usage: a key may only name itself, a session any key on its account.
groupmodel or appSplits each day into one row per model or per app. Anything else is a 400. Omit it for one row per day.
formatcsvReturns text/csv with a filename attachment header and the columns below, in that order.
200 OK, group=app
{
  "from": "2026-07-28T00:00:00.000Z", "to": "2026-08-27T18:40:02.113Z", "group": "app",
  "data": [
    { "day": "2026-08-26", "app_name": "Intake Bot", "requests": 41,
      "prompt_tokens": 50210, "completion_tokens": 8830, "cost_usd": 0.0031, "upstream_cost_usd": 0.00248 },
    { "day": "2026-08-26", "app_name": null, "requests": 2,
      "prompt_tokens": 2496, "completion_tokens": 591, "cost_usd": 0.00034, "upstream_cost_usd": 0.00027 },
    { "day": "2026-08-27", "app_name": "Intake Bot", "requests": 37, … }
  ],
  "totals": { "requests": 80, "prompt_tokens": 98006, "completion_tokens": 17102,
              "cost_usd": 0.00612, "upstream_cost_usd": 0.0049 }
}
  • day is the UTC date the row was written, YYYY-MM-DD. Days with no traffic are absent, not zero; fill them in on your side if a chart needs a continuous axis.
  • model is present only with group=model, app_name only with group=app. Both are empty columns in the CSV when not grouped.
  • totals sums data and is the same however you group.
CSV columns
day,model,app_name,requests,prompt_tokens,completion_tokens,cost_usd,upstream_cost_usd

GET /credits

The API credits behind the account, in cents and in dollars.

GET/creditskey or session

A key reports on the account it is attached to; a signed-in session reports on its own account; the admin token must name an account with ?owner= and gets a 400 without it. The call reads the ledger and never touches a model, so it costs nothing.

200 OK
{
  "data": {
    "owner": "user_2p…",
    "balance_cents": 800, "balance_usd": 8,          // purchased minus refunded
    "total_purchased_cents": 1000, "total_purchased_usd": 10,
    "total_refunded_cents": 200,
    "total_used_usd": 0.5,                            // metered spend across every key on the account
    "pending_cents": 0, "pending_usd": 0,             // unexpired Monero invoices not yet confirmed
    "trial_allowance_usd": 10,
    "billing_enabled": true,
    "monero": false
  }
}
  • A key minted by the operator with no account attached gets owner: null, zeros everywhere except total_used_usd (that key's own spend), and a note saying the key limit alone caps it. monero is omitted on that branch.
  • balance_cents is what was paid in and not refunded; it does not subtract usage. Read total_used_usd next to it, or use available_cents on GET /billing/balance, which does.
  • How credits are bought, and how a top-up raises every key's cap, is on Keys and identity.

Export and client billing

format=csv returns a flat file with a stable column order and a filename attachment header. It deliberately omits verification and any content, so it is safe to hand to a finance system.

CSV columns
created_at,request_id,key_name,app_name,app_url,model,provider,juiced,status,
prompt_tokens,completion_tokens,cached_tokens,cost_usd,upstream_cost_usd,latency_ms,
retrieval_ms,sources_n,citations_checked,citations_not_found,or_generation_id,error

2026-08-27T18:25:19.695Z,req_f60437cb80a541d8912c,docs-v2,Intake Bot,https://intake.example.com,
deepseek/deepseek-v4-flash,Baidu,1,ok,514,120,0,0.00007482,0.00005985252,2784,952,3,0,0,gen-1787855117-…,
# recent requests, newest first
curl "https://docketrouter.ai/api/v1/usage?limit=100" -H "Authorization: Bearer dr-…"

# filters
curl "https://docketrouter.ai/api/v1/usage?from=2026-08-01&status=ok&juiced=1&limit=100&offset=0" \
  -H "Authorization: Bearer dr-…"

# rows for one app, or one key on the account
curl "https://docketrouter.ai/api/v1/usage?app=Intake%20Bot" -H "Authorization: Bearer dr-…"
curl "https://docketrouter.ai/api/v1/usage?key=672f0e35-…" -H "Authorization: Bearer dr-…"

# CSV export (up to 5000 rows)
curl "https://docketrouter.ai/api/v1/usage?format=csv" -H "Authorization: Bearer dr-…" -o usage.csv

# per-day rollups, split by app
curl "https://docketrouter.ai/api/v1/usage/daily?group=app&from=2026-08-01" -H "Authorization: Bearer dr-…"

# one request in full, either spelling
curl "https://docketrouter.ai/api/v1/usage/req_f60437cb80a541d8912c" -H "Authorization: Bearer dr-…"
curl "https://docketrouter.ai/api/v1/generation?id=req_f60437cb80a541d8912c" -H "Authorization: Bearer dr-…"

# API credits behind the account
curl "https://docketrouter.ai/api/v1/credits" -H "Authorization: Bearer dr-…"

# reconcile (safe to repeat)
curl -X POST "https://docketrouter.ai/api/v1/usage/sync?limit=50" -H "Authorization: Bearer dr-…"

A worked per-matter billing export, keyed on session_id, is in Recipes.

Pricing

Per token, no minimums, no subscription: the upstream provider price times 1.25. That same multiple is applied to the prices in the model catalog and to usage.cost on each response, so the number you were quoted, the number in the response and the number in the log all agree. You can check it on any row: cost_usd / upstream_cost_usd is the markup.

  • Prompt tokens include the injected authorities, so a grounded request costs more input tokens than the text you sent. Input tokens are the cheap ones.
  • The revise pass, when it runs, is billed in the same request.
  • Each key carries a hard spend cap enforced upstream, so a runaway loop can cost at most that key's cap. A key on a limit_reset schedule is additionally capped per day, week or month, and answers 402 before any model call once the period is spent.
  • cost_usd is written from provider metadata at request time and refined by POST /usage/sync.

Something here wrong or missing? Mail hello@docketrouter.ai with the request_id and we will fix the docs or the API, whichever is broken.