CLAUDE LABJP
TEACHERS — Anthropic launches Claude for Teachers, giving verified US K-12 educators free premium access, teaching skills, and curriculum links aligned to standards in all 50 statesADMIN — The Admin API is now in beta for every Claude Enterprise organization: member and invite calls need no beta header, while group and custom-role calls doM365 — The Microsoft 365 connector gains write tools — draft, send, and organize email, manage calendar events and mailbox settings, and create or update files in OneDrive and SharePointMCP — Fixed servers from --mcp-config or .mcp.json ignoring a per-server request_timeout_ms, which left long-running tool calls timing out at the 60s default in fresh sessionsSUBAGENT — A new --forward-subagent-text flag and CLAUDE_CODE_FORWARD_SUBAGENT_TEXT variable include subagent text and thinking in stream-json outputDEADLINE — Opus 4.7 fast mode is removed on July 24, seven days out. speed: "fast" will error, so confirm your move to Opus 4.8TEACHERS — Anthropic launches Claude for Teachers, giving verified US K-12 educators free premium access, teaching skills, and curriculum links aligned to standards in all 50 statesADMIN — The Admin API is now in beta for every Claude Enterprise organization: member and invite calls need no beta header, while group and custom-role calls doM365 — The Microsoft 365 connector gains write tools — draft, send, and organize email, manage calendar events and mailbox settings, and create or update files in OneDrive and SharePointMCP — Fixed servers from --mcp-config or .mcp.json ignoring a per-server request_timeout_ms, which left long-running tool calls timing out at the 60s default in fresh sessionsSUBAGENT — A new --forward-subagent-text flag and CLAUDE_CODE_FORWARD_SUBAGENT_TEXT variable include subagent text and thinking in stream-json outputDEADLINE — Opus 4.7 fast mode is removed on July 24, seven days out. speed: "fast" will error, so confirm your move to Opus 4.8
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-code129analytics3api38dashboard2productivity18admin-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 lessons from running Dolice Labs across four sites as an indie developer.
API & SDK2026-03-24
Claude Files API Guide — Upload Once, Reference Anywhere in Your API Calls
Learn how to use the Claude Files API to upload PDFs, images, and text once and reference them across calls. Includes Python and TypeScript examples, a production-grade retry helper, real token cost estimates, and hard-won operational tips.
Claude Code2026-04-21
Resolving tRPC Type Errors in 15 Minutes with Claude Code — A Practical Workflow
When tRPC type errors cascade across files, how can Claude Code help you fix them fast? Router design, client integration, and the pitfalls I hit in real projects — distilled into a practical workflow.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →