◉CLAUDE LABJP
●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…
Articles/API & SDK
⬡ API & SDK/2026-03-18Advanced

Claude Code Analytics API: Building a Team Productivity Dashboard

A hands-on guide to building a custom productivity dashboard using the Claude Code Analytics Admin API—covering authentication, full pagination, aggregation, cost allocation, and production security.

claude-code132analytics3api39dashboardproductivity19admin-apitypescript11

Why the Analytics API Matters

When you roll out Claude Code across an organization, leadership will inevitably ask: "Is productivity actually improving? Who's using it? Are we spending wisely?" Answering those questions with real data is where the Claude Code Analytics Admin API comes in.

By the end of this tutorial you'll be able to:

  • Issue an Admin API key and authenticate against the Analytics API
  • Fetch multiple days of data reliably with cursor-based pagination in TypeScript
  • Aggregate per-user records into team-level summaries (lines of code, acceptance rates, cost)
  • Build an auto-refreshing React dashboard backed by an Express API
  • Deploy securely so that sensitive email and cost data never leaks to the browser

Prerequisites: Claude Team or Enterprise plan, Admin role in your organization, Node.js 20+, familiarity with TypeScript and React.


Environment Setup

Create a fresh project and install dependencies:

mkdir claude-analytics-dashboard && cd claude-analytics-dashboard
npm init -y
npm install typescript tsx @types/node dotenv
npm install express cors
npm install react react-dom @vitejs/plugin-react vite
npx tsc --init

Create a .env file with your Admin API key (starts with sk-ant-admin...):

# .env — never commit this file
ADMIN_API_KEY=sk-ant-admin-YOUR_KEY_HERE
PORT=3001

How to get an Admin API key: In the Claude Console, navigate to Settings → Admin Keys. Only org members with the Admin role can create these keys. They are separate from regular sk-ant-api... inference keys.

Project layout:

claude-analytics-dashboard/
├── src/
│   ├── api/
│   │   ├── analytics.ts       # Analytics API client
│   │   ├── aggregation.ts     # Aggregation logic
│   │   ├── cache.ts           # Simple TTL cache
│   │   └── server.ts          # Express server
│   └── types/
│       └── analytics.ts       # TypeScript types
└── .env

Core Concepts

The Data Model

The Analytics API follows a simple rule: one record = one user × one day. If ten developers used Claude Code on a given day, the response for that date contains ten records.

Each record is divided into three categories:

Core productivity metrics — sessions, lines of code added/removed, commits, and pull requests created via Claude Code.

Tool action acceptance rates — how often developers accepted (accepted) vs. rejected (rejected) proposals from the Edit, Write, and NotebookEdit tools. A healthy edit acceptance rate is typically above 75%.

Per-model cost breakdown — token counts (input, output, cache read/write) and estimated cost in cents (USD) for each Claude model used.

Why Cursor-Based Pagination Matters

For organizations with hundreds of developers, a single day can produce more records than the default page size. The API supports cursor-based pagination: each response includes a next_page opaque token and a has_more boolean. Unlike offset pagination, cursors remain stable even as new data arrives during your iteration—so you won't miss or duplicate records.


Step-by-Step Implementation

Step 1: TypeScript Types and API Client

// src/types/analytics.ts
 
export interface Actor {
  type: "user_actor" | "api_actor";
  email_address?: string;  // present for user_actor
  api_key_name?: string;   // present for api_actor
}
 
export interface ToolActions {
  edit_tool: { accepted: number; rejected: number };
  multi_edit_tool: { accepted: number; rejected: number };
  write_tool: { accepted: number; rejected: number };
  notebook_edit_tool: { accepted: number; rejected: number };
}
 
export interface ModelBreakdown {
  model: string;
  tokens: { input: number; output: number; cache_read: number; cache_creation: number };
  estimated_cost: { currency: string; amount: number }; // cents, USD
}
 
export interface AnalyticsRecord {
  date: string;
  actor: Actor;
  organization_id: string;
  customer_type: "api" | "subscription";
  terminal_type: string;
  core_metrics: {
    num_sessions: number;
    lines_of_code: { added: number; removed: number };
    commits_by_claude_code: number;
    pull_requests_by_claude_code: number;
  };
  tool_actions: ToolActions;
  model_breakdown: ModelBreakdown[];
}
 
export interface AnalyticsResponse {
  data: AnalyticsRecord[];
  has_more: boolean;
  next_page: string | null;
}
// src/api/analytics.ts
 
import "dotenv/config";
import { AnalyticsRecord, AnalyticsResponse } from "../types/analytics";
 
const ADMIN_API_KEY = process.env.ADMIN_API_KEY!;
const BASE_URL = "https://api.anthropic.com/v1/organizations";
 
/**
 * Fetch ALL records for a single UTC date, handling pagination automatically.
 * @param date - YYYY-MM-DD format (UTC)
 */
export async function fetchDailyAnalytics(date: string): Promise<AnalyticsRecord[]> {
  const allRecords: AnalyticsRecord[] = [];
  let page: string | undefined;
  let hasMore = true;
 
  while (hasMore) {
    const params = new URLSearchParams({
      starting_at: date,
      limit: "1000",
      ...(page ? { page } : {}),
    });
 
    const response = await fetch(`${BASE_URL}/usage_report/claude_code?${params}`, {
      headers: {
        "anthropic-version": "2023-06-01",
        "x-api-key": ADMIN_API_KEY,
        "User-Agent": "ClaudeAnalyticsDashboard/1.0.0 (https://yourapp.example.com)",
      },
    });
 
    if (!response.ok) {
      throw new Error(`Analytics API ${response.status}: ${await response.text()}`);
    }
 
    const data: AnalyticsResponse = await response.json();
    allRecords.push(...data.data);
    hasMore = data.has_more;
    page = data.next_page ?? undefined;
  }
 
  return allRecords;
}
 
/**
 * Fetch multiple days in parallel batches of 5 to respect rate limits.
 */
export async function fetchMultiDayAnalytics(
  dates: string[]
): Promise<Map<string, AnalyticsRecord[]>> {
  const results = new Map<string, AnalyticsRecord[]>();
  const BATCH = 5;
 
  for (let i = 0; i < dates.length; i += BATCH) {
    const batch = dates.slice(i, i + BATCH);
    const batchResults = await Promise.all(
      batch.map(async (date) => ({ date, records: await fetchDailyAnalytics(date) }))
    );
    for (const { date, records } of batchResults) results.set(date, records);
 
    // Brief pause between batches
    if (i + BATCH < dates.length) await new Promise((r) => setTimeout(r, 500));
  }
 
  return results;
}
 
/** Build a date array for the past N days (excluding today). */
export function getDateRange(days: number): string[] {
  return Array.from({ length: days }, (_, i) => {
    const d = new Date(Date.now() - (i + 1) * 86_400_000);
    return d.toISOString().split("T")[0];
  });
}

Step 2: Aggregation Logic

// src/api/aggregation.ts
 
import { AnalyticsRecord } from "../types/analytics";
 
export interface UserSummary {
  email: string;
  totalSessions: number;
  linesAdded: number;
  linesRemoved: number;
  commits: number;
  pullRequests: number;
  editAcceptRate: number;   // 0–100
  writeAcceptRate: number;  // 0–100
  totalCostUSD: number;
  activeDays: number;
}
 
export interface TeamSummary {
  activeUsers: number;
  totalSessions: number;
  totalLinesAdded: number;
  totalCommits: number;
  totalPullRequests: number;
  avgEditAcceptRate: number;
  totalCostUSD: number;
  topUsers: UserSummary[];
}
 
export function aggregateByUser(
  recordsByDate: Map<string, AnalyticsRecord[]>
): Map<string, UserSummary> {
  // Internal accumulator type
  type Acc = UserSummary & {
    _editAccepted: number; _editTotal: number;
    _writeAccepted: number; _writeTotal: number;
  };
 
  const map = new Map<string, Acc>();
 
  for (const records of recordsByDate.values()) {
    for (const r of records) {
      const key = r.actor.type === "user_actor"
        ? r.actor.email_address!
        : `api:${r.actor.api_key_name}`;
 
      if (!map.has(key)) {
        map.set(key, {
          email: key, totalSessions: 0, linesAdded: 0, linesRemoved: 0,
          commits: 0, pullRequests: 0, editAcceptRate: 0, writeAcceptRate: 0,
          totalCostUSD: 0, activeDays: 0,
          _editAccepted: 0, _editTotal: 0, _writeAccepted: 0, _writeTotal: 0,
        });
      }
 
      const acc = map.get(key)!;
      const m = r.core_metrics;
      acc.totalSessions += m.num_sessions;
      acc.linesAdded += m.lines_of_code.added;
      acc.linesRemoved += m.lines_of_code.removed;
      acc.commits += m.commits_by_claude_code;
      acc.pullRequests += m.pull_requests_by_claude_code;
      acc.activeDays += 1;
 
      const { edit_tool: et, write_tool: wt } = r.tool_actions;
      acc._editAccepted += et.accepted;
      acc._editTotal += et.accepted + et.rejected;
      acc._writeAccepted += wt.accepted;
      acc._writeTotal += wt.accepted + wt.rejected;
 
      for (const mb of r.model_breakdown) {
        acc.totalCostUSD += mb.estimated_cost.amount / 100; // cents → dollars
      }
    }
  }
 
  // Finalize acceptance rates
  for (const acc of map.values()) {
    acc.editAcceptRate  = acc._editTotal  > 0 ? Math.round(acc._editAccepted  / acc._editTotal  * 100) : 0;
    acc.writeAcceptRate = acc._writeTotal > 0 ? Math.round(acc._writeAccepted / acc._writeTotal * 100) : 0;
  }
 
  return map as Map<string, UserSummary>;
}
 
export function calculateTeamSummary(users: Map<string, UserSummary>): TeamSummary {
  const arr = [...users.values()];
  const rates = arr.filter((u) => u.editAcceptRate > 0).map((u) => u.editAcceptRate);
 
  return {
    activeUsers: arr.length,
    totalSessions: arr.reduce((s, u) => s + u.totalSessions, 0),
    totalLinesAdded: arr.reduce((s, u) => s + u.linesAdded, 0),
    totalCommits: arr.reduce((s, u) => s + u.commits, 0),
    totalPullRequests: arr.reduce((s, u) => s + u.pullRequests, 0),
    avgEditAcceptRate: rates.length ? Math.round(rates.reduce((a, b) => a + b, 0) / rates.length) : 0,
    totalCostUSD: arr.reduce((s, u) => s + u.totalCostUSD, 0),
    topUsers: [...arr].sort((a, b) => b.linesAdded - a.linesAdded).slice(0, 10),
  };
}

Step 3: Express API Server

// src/api/server.ts
 
import express from "express";
import cors from "cors";
import { fetchMultiDayAnalytics, getDateRange } from "./analytics";
import { aggregateByUser, calculateTeamSummary } from "./aggregation";
 
const app = express();
app.use(cors({ origin: process.env.FRONTEND_ORIGIN ?? "http://localhost:5173" }));
 
app.get("/api/team-summary", async (req, res) => {
  try {
    const days = Math.min(Number(req.query.days ?? 7), 30);
    const dates = getDateRange(days);
 
    const recordsByDate = await fetchMultiDayAnalytics(dates);
    const userSummaries = aggregateByUser(recordsByDate);
    const team = calculateTeamSummary(userSummaries);
 
    res.json({
      period: { start: dates.at(-1), end: dates[0] },
      team,
      users: [...userSummaries.values()],
    });
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: String(err) });
  }
});
 
app.listen(Number(process.env.PORT ?? 3001), () =>
  console.log(`✅ Server running on port ${process.env.PORT ?? 3001}`)
);

Advanced Patterns

Weekly Slack Digest

Combine this with a scheduled job to push a weekly summary to Slack every Monday, reducing the need for anyone to log into the dashboard manually. Pair it with the tool use guide for advanced automation patterns.

// src/jobs/weekly-slack-report.ts
 
async function sendSlackDigest() {
  const records = await fetchMultiDayAnalytics(getDateRange(7));
  const summary = calculateTeamSummary(aggregateByUser(records));
 
  await fetch(process.env.SLACK_WEBHOOK_URL!, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      text: [
        "*📊 Claude Code — Weekly Summary*",
        `👥 Active developers: *${summary.activeUsers}*`,
        `💻 Total sessions: *${summary.totalSessions.toLocaleString()}*`,
        `📝 Lines added: *${summary.totalLinesAdded.toLocaleString()}*`,
        `✅ Edit acceptance rate: *${summary.avgEditAcceptRate}%*`,
        `💰 Total cost: *$${summary.totalCostUSD.toFixed(2)}*`,
      ].join("\n"),
    }),
  });
}

FinOps: Cost Allocation by Team

For larger organizations, map email prefixes to team names and generate cost-per-team breakdowns that integrate directly into your internal FinOps tooling:

// Team config: prefix → team name
const TEAM_CONFIG: Record<string, string[]> = {
  "Platform": ["alice@", "bob@", "carol@"],
  "Mobile":   ["dave@", "eve@"],
  "Data":     ["frank@"],
};
 
function buildCostBreakdown(users: Map<string, UserSummary>, config: typeof TEAM_CONFIG) {
  const total = [...users.values()].reduce((s, u) => s + u.totalCostUSD, 0);
 
  return Object.entries(config).map(([team, prefixes]) => {
    const teamUsers = [...users.values()].filter((u) =>
      prefixes.some((p) => u.email.startsWith(p))
    );
    const cost = teamUsers.reduce((s, u) => s + u.totalCostUSD, 0);
    return {
      team,
      members: teamUsers.length,
      costUSD: cost,
      share: total > 0 ? `${Math.round((cost / total) * 100)}%` : "0%",
    };
  });
}

Pairing with OpenTelemetry

The Analytics API provides daily aggregates—ideal for trend reports and cost tracking. For real-time session-level monitoring, combine it with Claude Code's OpenTelemetry integration. You can feed both into Grafana: OTel for live dashboards and the Analytics API for historical trend panels. See also the hooks and automation guide for triggering workflows based on Claude Code events.


Troubleshooting

403 Forbidden — You're using a standard inference key instead of an Admin API key. Admin keys begin with sk-ant-admin... and can only be issued by org admins in Console → Settings → Admin Keys.

400 Bad Request: starting_at is required — Always pass a YYYY-MM-DD UTC date string. Data has up to a 1-hour delay, so requests for "today" often return empty results. Use yesterday's date or earlier.

Empty data: [] response — Confirm Claude Code is actively being used by org members on the specified date, that you're on a Team or Enterprise plan, and that the date is at least a few hours in the past.

429 Too Many Requests — Implement exponential back-off. See the rate limits guide for full guidance:

async function fetchWithBackoff(url: string, headers: HeadersInit, retries = 3): Promise<Response> {
  for (let attempt = 0; attempt < retries; attempt++) {
    const res = await fetch(url, { headers });
    if (res.status !== 429) return res;
    const wait = Number(res.headers.get("retry-after") ?? 5) * 1000 * 2 ** attempt;
    await new Promise((r) => setTimeout(r, wait));
  }
  throw new Error("Max retries exceeded");
}

Performance and Security

Response Caching

Analytics data refreshes at most once an hour. Cache API responses in Redis or with an in-memory TTL store to avoid redundant network calls and stay well within rate limits:

// Simple in-memory cache with TTL
const _cache = new Map<string, { value: unknown; exp: number }>();
 
export async function cached<T>(key: string, ttlSec: number, fn: () => Promise<T>): Promise<T> {
  const hit = _cache.get(key);
  if (hit && Date.now() < hit.exp) return hit.value as T;
  const value = await fn();
  _cache.set(key, { value, exp: Date.now() + ttlSec * 1000 });
  return value;
}
 
// Usage: cache daily data for 1 hour
const records = await cached(`analytics:${date}`, 3600, () => fetchDailyAnalytics(date));

Security Checklist

Admin API responses contain every user's email address and cost data. Treat them as sensitive PII:

  1. Never expose the Admin API key to the browser. All Analytics API calls must happen server-side.
  2. Restrict dashboard access with session-based auth or OAuth so only authorized managers can view the data.
  3. Minimize logging. Avoid writing full API responses to log files.
  4. Rotate keys periodically. Treat Admin API keys like passwords—rotate them quarterly and revoke any that are no longer needed.
  5. Use environment-specific keys. Never use a production Admin key in a development or staging environment.

Summary and Next Steps

You've built a production-ready Claude Code analytics system from scratch: a typed API client with full pagination, an aggregation layer that computes team-level summaries and tool acceptance rates, an Express backend that safely exposes metrics to a React frontend, and an in-memory cache that keeps response times fast.

The metrics this system surfaces—cost per developer, edit acceptance rate, commits shipped via Claude Code—are the building blocks of a compelling ROI story for leadership and a diagnostic tool for continuously improving your team's Claude Code experience.

Where to go from here:

  • Explore streaming responses to add live Claude API interactions to your dashboard.
  • Combine with the Usage and Cost API to track all Anthropic API spend—not just Claude Code—in a single view.
Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⬡ API & SDK2026-03-27
Claude API Production Resilience Patterns — Model Routing, Circuit Breakers, and Fallback Strategies for Indie Teams
Production resilience patterns for Claude API: circuit breakers, intelligent model routing, fallback chains, exponential backoff with jitter, and disaster recovery — with TypeScript implementations and operational notes from an indie developer.
⬡ API & SDK2026-05-16
Automating Multilingual App Review Replies with Claude API — Living with the App Store's Undocumented Limits
An indie developer running a portfolio of wallpaper and healing apps shares the full implementation of Claude API-powered multilingual review reply automation — including App Store's undocumented 8-second rule, session limits, and the three traps that can get you banned.
⬡ API & SDK2026-05-01
Claude API Telemetry on ClickHouse: A Production Guide to Cost, Latency, and Error Analytics
Stream per-request Claude API telemetry into ClickHouse, build sub-second dashboards with materialized views, and detect cost spikes, retry loops, and silent failures with practical SQL recipes.