On a Saturday afternoon I was reading the legacy Usage and Cost API migration table from top to bottom, and one row made me stop. The legacy data is a list of time buckets, empty ones included. The new Analytics API returns a list of rows, and only for combinations that actually have usage.
If you like to look at a daily usage table, you may already see the problem. Days you didn't use anything simply vanish from it.
One caveat first. My own account is an individual one, and the Admin API isn't available to individual accounts (the docs say so plainly). So everything below was checked against a mock shaped like the official sample response, not against a live organization. Please read it with that in mind.
Three reasons days look missing
Here are the differences from the migration table that matter for a daily table.
| Item | Legacy Usage and Cost API | Usage and Cost Analytics API |
|---|---|---|
| Empty days / combinations | Empty buckets are returned | No row is created |
| Daily split | bucket_width (default 1d) | time_bucket_width (no default) |
What limit counts | Time buckets | Rows (1–1,000, default 20) |
| Data freshness | Typically within 5 minutes | Typically within 4 hours, up to 24 hours |
| Range per request | No stated cap | Up to 31 days; 24 hours for 1-minute buckets |
So if you call it the way you called the old one, at least three things can leave you with a table that has too few days:
- Days with no usage produce no row at all.
- Without
time_bucket_width, you get a single row that totals the whole range. - Without raising
limit, a page stops at 20 rows and you never read the rest.
Working backwards from what you see
This is the order I'd check in, because each symptom points to a different first suspect.
| What you see | Suspect first | How to check |
|---|---|---|
| Only one row comes back | Missing time_bucket_width | Is time_bucket_width=1d in the request? |
| 4–5 days instead of 7 | Days with no usage | Check your own logs for those days |
| Row count jumps when you add models | One row per combination | Compare group_by[] count to row count |
| Nothing after a certain day | Paging, or data not refreshed yet | has_more and data_refreshed_at |
The last row is the tricky one, because a gap and a freshness delay look identical. Usage after the data_refreshed_at in the response is either missing or partial. A thin most-recent day is not a bug; it is how the API works.
Rebuilding the daily table with zero-fill
The official migration notes themselves say to fill missing days with zeros. I turned that into a small function that folds rows by model and lays out every day in the range.
from datetime import datetime, timedelta, timezone
from collections import defaultdict
def fill_daily(rows, start, end, key="model"):
"""Zero-fill missing days in Analytics API usage rows (time_bucket_width=1d)."""
days = []
d = start
while d < end:
days.append(d)
d += timedelta(days=1)
table = defaultdict(lambda: {day: 0 for day in days})
for row in rows:
day = datetime.fromisoformat(row["starting_at"].replace("Z", "+00:00"))
name = row["group"][key] or "(none)"
table[name][day] += row["usage"]["total_tokens"]
return {k: [(day, v[day]) for day in days] for k, v in table.items()}The calling side follows the official migration example: pass time_bucket_width explicitly, raise limit to 1,000, and let the SDK fetch the remaining pages as you iterate.
import anthropic
client = anthropic.Anthropic() # reads an admin-capable key from the environment
rows = client.beta.organization.analytics.usage.list(
starting_at="2026-09-01T00:00:00Z",
ending_at="2026-09-08T00:00:00Z",
time_bucket_width="1d",
group_by=["model"],
limit=1000,
)
as_dicts = [r.model_dump() for r in rows]I can't run that against a real key, so I ran fill_daily on a mock: rows only for September 1 and 3, laid out across the seven days from the 1st to the 7th.
claude-sonnet-5 [5590800, 0, 120000, 0, 0, 0, 0]
(none) [0, 0, 0, 0, 0, 0, 0]The gap on the 2nd became a 0, and the row with an empty model landed under (none). A null model comes from usage such as code execution, as the docs explain. If you leave it as None and use it as a dictionary key, sorting tends to break later, so I name it up front.
Three places to look when the numbers still disagree
If zero-fill doesn't settle it, check these three, in order.
- Keep
ending_atat or before adata_refreshed_atyou've already seen. When the same range gives different numbers on different days, the usual cause is that you included a day that was still refreshing. - Trust final totals only after a month has been closed for 7+ days. The docs say values can be revised until about 7 days after the end of the calendar month, with October 1 changing until about November 7 as the example.
- Keep each request within 31 days. A longer period can't be fetched in one call, so plan on stitching two requests together for tables that cross a month boundary.
If your numbers disagreed with the legacy API or the Console, start with these three. The legacy API refreshed in about 5 minutes, so "it matched yesterday" is probably just the new API's slower refresh showing through.
Keep the old API running and compare side by side
One more thing I wanted to confirm after writing all this. The docs describe the legacy API as supported, with the new one recommended for new integrations. There's no need to rush the swap.
Put the new table next to the old one, and switch only after they agree. That's the line I drew. Lay the zero-filled table for the same period next to your legacy daily report, ignore differences in the most recent week, and compare only a month that has already closed. Once the difference is gone, switch the source.
If you split monthly spend by workspace, the thinking in my earlier note "Assigning and narrowing spend per workspace" carries over as is. Only the entry-point endpoint changes; the way you distribute the numbers doesn't.
A good first step is to search your current aggregation script for bucket_width once, and write down on a single line each where time_bucket_width and limit will need to go in the new API.