← All guides

Anthropic Admin API Tutorial: Pull Usage, Costs, and Workspaces (2026)

Complete tutorial on the Anthropic Admin API — sk-ant-admin keys, usage_report endpoint, organizations, workspaces, and a working cost-tracker script.

The Anthropic Admin API gives you read-only programmatic access to organization-level data: token usage, workspaces, members, and audit logs. It does not create messages — that is the regular Messages API. Admin keys carry the prefix sk-ant-admin-... (distinct from the message-sending sk-ant-api03-... keys) and authenticate to a separate surface area used by cost-tracking dashboards, finance integrations, and provisioning scripts. The three endpoints you will use 90% of the time are GET /v1/organizations, GET /v1/organizations/{id}/usage_report/messages, and GET /v1/organizations/{id}/workspaces. Rate limits on the Admin API are gentler than the Messages API because the calls are infrequent — a nightly cron is the typical pattern.

Admin API vs regular API

These are two different products with two different keys. Mixing them up is the most common first-day mistake.

Capability Messages API (sk-ant-api03-...) Admin API (sk-ant-admin-...)
Send messages to Claude Yes No
Stream completions Yes No
Use tools / files / batch Yes No
List organizations No Yes
Pull usage_report (token + cost data) No Yes
List/create workspaces No Yes
List/invite organization members No Yes
Read audit logs No Yes
Counts toward your message rate limits Yes No
Required anthropic-version header Yes Yes

If your code calls client.messages.create(...), you need a regular key. If your code is building a Grafana panel of "tokens spent yesterday by workspace," you need an admin key. Most production setups have both, stored as separate environment variables (ANTHROPIC_API_KEY and ANTHROPIC_ADMIN_KEY).

Generate an Admin API key

Admin keys are not visible from the regular API Keys page — they live one level up.

  1. Sign in at console.anthropic.com as an organization owner (admin keys cannot be created by member-tier users).
  2. Open Settings → Admin Keys.
  3. Click Create Admin Key, give it a descriptive name (e.g. cost-tracker-prod), and copy the value once. You cannot view it again.
  4. Store it as ANTHROPIC_ADMIN_KEY in your secret manager.

Admin keys are scoped to the organization that created them. They cannot be limited to a single workspace from the console — you scope by workspace at query time using the workspace_ids[] parameter on the usage endpoint.

Authentication

Every Admin API request needs two headers:

curl https://api.anthropic.com/v1/organizations \
  -H "x-api-key: $ANTHROPIC_ADMIN_KEY" \
  -H "anthropic-version: 2023-06-01"

The anthropic-version value is the same one used by the Messages API — 2023-06-01 is the current GA value as of May 2026. If you omit it, requests return 400 invalid_request_error.

GET /v1/organizations

Returns the organizations the key can see. For most accounts this is a single org, but enterprise setups with multiple billing entities will see more than one.

{
  "data": [
    {
      "id": "org_01ABCxyz...",
      "name": "Acme Corp",
      "created_at": "2024-08-12T18:42:11Z"
    }
  ]
}

Cache the id — every other admin call needs it in the path.

GET /v1/organizations/{id}/usage_report/messages

This is the heavy hitter. It returns token consumption bucketed by time, model, and (optionally) workspace.

Query parameters:

Param Required Notes
starting_at yes RFC 3339 timestamp, inclusive. Max lookback ~13 months.
ending_at no Defaults to "now". Exclusive.
bucket_width no 1d (default) or 1h. 1m is not supported.
workspace_ids[] no Filter to one or more workspaces. Repeat the param.
models[] no Filter to specific models, e.g. claude-opus-4-7.

Response shape:

{
  "data": [
    {
      "starting_at": "2026-05-08T00:00:00Z",
      "ending_at": "2026-05-09T00:00:00Z",
      "results": [
        {
          "model": "claude-sonnet-4-7",
          "workspace_id": "wrkspc_01XYZ...",
          "input_tokens": 1842110,
          "output_tokens": 312044,
          "cache_creation_input_tokens": 88210,
          "cache_read_input_tokens": 1488221,
          "service_tier": "standard"
        }
      ]
    }
  ],
  "has_more": false
}

Pagination: as of May 2026 the usage endpoint returns the entire window in a single response — has_more is reserved for future use but currently always false. That said, write your loop assuming pagination will land; you do not want to rewrite the integration when it does.

The four token fields matter independently because they price differently:

Sum them with the right multipliers or your "total cost" number will be off by 30%+ on cache-heavy workloads.

GET /v1/organizations/{id}/workspaces

curl "https://api.anthropic.com/v1/organizations/$ORG_ID/workspaces" \
  -H "x-api-key: $ANTHROPIC_ADMIN_KEY" \
  -H "anthropic-version: 2023-06-01"

Returns workspace IDs and display names. Use this to build a workspace_id → label lookup before you render any usage chart, otherwise your dashboard shows opaque wrkspc_01... strings.

Worked example: Python script for 30-day CSV

The script below pulls 30 days of usage, joins it with model pricing, and writes a CSV. Drop it on a cron and you have a finance feed.

#!/usr/bin/env python3
"""Pull 30-day Anthropic usage and emit cost CSV."""
import csv
import os
from datetime import datetime, timedelta, timezone

import requests

ADMIN_KEY = os.environ["ANTHROPIC_ADMIN_KEY"]
BASE = "https://api.anthropic.com/v1"
HEADERS = {"x-api-key": ADMIN_KEY, "anthropic-version": "2023-06-01"}

# Pricing per million tokens — refresh from claude-api-pricing-2026 quarterly.
PRICING = {
    "claude-opus-4-7":   {"in": 15.00, "out": 75.00, "cw": 18.75, "cr": 1.50},
    "claude-sonnet-4-7": {"in":  3.00, "out": 15.00, "cw":  3.75, "cr": 0.30},
    "claude-haiku-4-5":  {"in":  0.80, "out":  4.00, "cw":  1.00, "cr": 0.08},
}

def org_id() -> str:
    r = requests.get(f"{BASE}/organizations", headers=HEADERS, timeout=30)
    r.raise_for_status()
    return r.json()["data"][0]["id"]

def workspaces(org: str) -> dict[str, str]:
    r = requests.get(f"{BASE}/organizations/{org}/workspaces",
                     headers=HEADERS, timeout=30)
    r.raise_for_status()
    return {w["id"]: w["name"] for w in r.json()["data"]}

def usage(org: str, days: int = 30) -> list[dict]:
    end = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
    start = end - timedelta(days=days)
    params = {
        "starting_at": start.isoformat().replace("+00:00", "Z"),
        "ending_at": end.isoformat().replace("+00:00", "Z"),
        "bucket_width": "1d",
    }
    r = requests.get(f"{BASE}/organizations/{org}/usage_report/messages",
                     headers=HEADERS, params=params, timeout=60)
    r.raise_for_status()
    return r.json()["data"]

def cost(row: dict) -> float:
    p = PRICING.get(row["model"])
    if not p:
        return 0.0
    return (
        row["input_tokens"]                  * p["in"]  +
        row["output_tokens"]                 * p["out"] +
        row["cache_creation_input_tokens"]   * p["cw"]  +
        row["cache_read_input_tokens"]       * p["cr"]
    ) / 1_000_000

def main() -> None:
    org = org_id()
    wmap = workspaces(org)
    rows = []
    for bucket in usage(org):
        for r in bucket["results"]:
            rows.append({
                "date": bucket["starting_at"][:10],
                "workspace": wmap.get(r["workspace_id"], r["workspace_id"]),
                "model": r["model"],
                "input": r["input_tokens"],
                "output": r["output_tokens"],
                "cache_write": r["cache_creation_input_tokens"],
                "cache_read":  r["cache_read_input_tokens"],
                "usd": round(cost(r), 4),
            })
    with open("anthropic_usage.csv", "w", newline="") as f:
        w = csv.DictWriter(f, fieldnames=rows[0].keys())
        w.writeheader()
        w.writerows(rows)
    print(f"wrote {len(rows)} rows, total ${sum(r['usd'] for r in rows):.2f}")

if __name__ == "__main__":
    main()

Want this without writing a single line of code? claudecosts.app is a free dashboard built on top of this exact API — paste your admin key, get daily usage, per-workspace breakdowns, and budget alerts. Use it as your reference implementation if you decide to roll your own.

Worked example: TypeScript fetch with retry + budget gate

For Node/edge runtimes, here is a typed version with exponential backoff and a hard budget cutoff that makes the script return non-zero if you blow your monthly cap.

// usage-guard.ts — fail CI if month-to-date spend exceeds budget
const ADMIN_KEY = process.env.ANTHROPIC_ADMIN_KEY!;
const BUDGET_USD = Number(process.env.MONTHLY_BUDGET_USD ?? "500");
const BASE = "https://api.anthropic.com/v1";

const HEADERS = {
  "x-api-key": ADMIN_KEY,
  "anthropic-version": "2023-06-01",
};

const PRICE = {
  "claude-opus-4-7":   [15, 75, 18.75, 1.5],
  "claude-sonnet-4-7": [3, 15, 3.75, 0.3],
  "claude-haiku-4-5":  [0.8, 4, 1.0, 0.08],
} as const;

async function get<T>(path: string, params?: Record<string, string>): Promise<T> {
  const url = new URL(BASE + path);
  if (params) Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
  for (let attempt = 0; attempt < 4; attempt++) {
    const r = await fetch(url, { headers: HEADERS });
    if (r.ok) return r.json() as Promise<T>;
    if (r.status >= 500 || r.status === 429) {
      await new Promise(res => setTimeout(res, 2 ** attempt * 500));
      continue;
    }
    throw new Error(`${r.status} ${await r.text()}`);
  }
  throw new Error("retries exhausted");
}

const orgs = await get<{ data: { id: string }[] }>("/organizations");
const orgId = orgs.data[0].id;

const start = new Date();
start.setUTCDate(1); start.setUTCHours(0, 0, 0, 0);

const usage = await get<{ data: { results: any[] }[] }>(
  `/organizations/${orgId}/usage_report/messages`,
  { starting_at: start.toISOString(), bucket_width: "1d" }
);

let mtd = 0;
for (const bucket of usage.data) for (const r of bucket.results) {
  const p = PRICE[r.model as keyof typeof PRICE];
  if (!p) continue;
  mtd += (r.input_tokens*p[0] + r.output_tokens*p[1]
        + r.cache_creation_input_tokens*p[2]
        + r.cache_read_input_tokens*p[3]) / 1_000_000;
}

console.log(`MTD spend: $${mtd.toFixed(2)} / budget $${BUDGET_USD}`);
if (mtd > BUDGET_USD) {
  console.error("BUDGET EXCEEDED");
  process.exit(1);
}

Hook this into a CI step that runs hourly and you have a hard cost ceiling.

Common gotchas

A short list of things that have burned at least one engineer on this codebase:

Self-hosted dashboard recipe

If you want internal-only and own all the data, the smallest viable stack:

  1. Cronlaunchd on macOS or a Render/Fly cron worker. Runs the Python script above every six hours.
  2. Postgres — one table, anthropic_usage(date, workspace, model, input, output, cache_write, cache_read, usd). Upsert by (date, workspace, model) so re-runs are idempotent.
  3. Recharts or Tremor — Next.js page that queries Postgres and renders a stacked area chart of daily USD per workspace plus a per-model breakdown.
  4. Slack alert — a second cron that compares week-over-week and posts to Slack if spend climbs more than 25%.

This is roughly the architecture behind claudecosts.app, except they ship it as a service so you don't have to run the Postgres. If you'd rather not build it, claudecosts.app does this for free. For the optimization side — actually getting your bill down once you can see it — the Cost Optimization Masterclass covers the same patterns this article hints at: cache shaping, model routing, and batch consolidation.

For deeper background on what the cost numbers mean, see Claude API cost monitoring guide, the current Claude API pricing 2026, and the prompt caching cost benchmark which shows where cache reads start paying for cache writes.

Frequently Asked Questions

Can the Admin API send messages?

No. The Admin API surface is read/management only — organizations, workspaces, members, invites, API keys, audit logs, and usage_report. To call messages.create you need a regular sk-ant-api03-... key. The two key types are not interchangeable: an admin key sent to /v1/messages returns 401 authentication_error and a regular key sent to /v1/organizations returns the same.

How recent is the usage data?

Expect a 1–2 hour lag at the daily bucket and slightly more for hourly. The usage_report is designed for finance and reporting, not for real-time rate limiting. If you need to cut off requests the moment a budget is hit, count tokens client-side from the Messages API response (usage.input_tokens etc.) and store running totals in Redis. Then reconcile against the Admin API daily.

Can I rotate Admin keys without downtime?

Yes. Create a second admin key in the console, deploy it to your secret store under a new name, restart consumers, then revoke the old key. Admin keys do not have a built-in "next" slot like some providers offer, so you maintain rotation by always running with two valid keys overlapping for a few minutes.

How do I scope by workspace?

Pass workspace_ids[] on the usage_report/messages call — repeat the param to list multiple. Example: ?workspace_ids[]=wrkspc_A&workspace_ids[]=wrkspc_B. The response only includes results from those workspaces. There is no per-API-key filter, so the standard pattern is "one workspace per service" if you need fine-grained attribution.

Are there official SDKs?

The standard anthropic Python and @anthropic-ai/sdk Node packages cover the Messages API but Admin API support has lagged — as of May 2026 most teams hit Admin endpoints with plain requests / fetch as shown above. Community wrappers exist on GitHub but the surface is small enough (≈12 endpoints) that hand-rolled typed fetchers are usually less hassle than another dependency.

AI Disclosure: Drafted with Claude Code; tested against Anthropic Admin API endpoints documented at docs.anthropic.com/en/api/admin-api as of May 2026.

Tools and references