log in
consulting hosting industries the daily tools about contact

Claude's Token Counter: Why Your Estimate and Your Bill Disagree

Client-side token estimation for Claude drifts more than you'd expect. Here's why it happens and how to use Anthropic's actual counting endpoint to close the gap.

I assumed token counting was a solved problem. It isn't - at least not if you're trying to predict your Claude bill before a request hits the wire.

I built a document processing pipeline for a biotech client last year. We were chunking lab reports, running them through Claude to extract structured data, and I had a lightweight estimator in place so we could gate expensive requests and stay under budget. The estimator was close enough on OpenAI. On Claude, it quietly drifted 15-25% on documents with heavy scientific notation and special characters. The bill at the end of month two was a surprise nobody wanted.

Here's what I know now that I wish I'd known then.

Why Client-Side Estimation Drifts

Most token estimators - including the rough words * 1.3 heuristics floating around Stack Overflow - are trained on GPT tokenization. Claude uses its own tokenizer. The vocabulary, merge rules, and how it handles whitespace, punctuation, and non-ASCII characters differ enough to matter at scale.

The drift shows up most in:

  • Scientific and medical text: Greek letters, subscripts written as plain ASCII (CO2, H2O2), units like μg/mL that tokenize differently than you'd guess.
  • Code: Indentation-heavy files, long identifiers, and docstrings all behave differently across tokenizers.
  • Structured data: JSON and XML payloads with lots of brackets, quotes, and colons can produce token counts that surprise you in either direction.
  • System prompts: If you have a big system prompt that rarely changes, you might undercount it consistently and never notice until your volume spikes.

The rough rule: the more your text deviates from plain English prose, the less reliable a GPT-based estimator is for Claude.

Anthropic Gives You an Actual Counting Endpoint

Anthropolog added a count_tokens endpoint that runs your exact payload - messages, system prompt, tools, everything - through the same tokenizer the model will use, before you fire the real request. You get a count back. No tokens are consumed. No model inference happens.

This is the right tool. Use it.

The endpoint lives at POST https://api.anthropic.com/v1/messages/count_tokens and accepts the same shape as a normal messages request, minus max_tokens (which makes sense - you're not generating anything).

Here's a Laravel service class I actually use:

<?php

namespace App\Services;

use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use RuntimeException;

class ClaudeTokenCounter
{
    private PendingRequest $client;
    private string $model;

    public function __construct(string $model = 'claude-opus-4-5')
    {
        $this->model = $model;
        $this->client = Http::baseUrl('https://api.anthropic.com/v1')
            ->withHeaders([
                'x-api-key'         => config('services.anthropic.key'),
                'anthropic-version' => '2023-06-01',
                'content-type'      => 'application/json',
            ])
            ->timeout(10);
    }

    /**
     * Count tokens for a full Claude messages payload.
     *
     * @param  array  $messages  Same format as the messages API.
     * @param  string|null  $system  System prompt text, if any.
     * @param  array  $tools  Tool definitions, if any.
     * @return int  Input token count.
     */
    public function count(array $messages, ?string $system = null, array $tools = []): int
    {
        $payload = [
            'model'    => $this->model,
            'messages' => $messages,
        ];

        if ($system !== null) {
            $payload['system'] = $system;
        }

        if (!empty($tools)) {
            $payload['tools'] = $tools;
        }

        $response = $this->client->post('/messages/count_tokens', $payload);

        if ($response->failed()) {
            throw new RuntimeException(
                'Claude token count failed: ' . $response->body()
            );
        }

        return $response->json('input_tokens');
    }

    /**
     * Check whether a payload fits within a token budget.
     * Returns the count so callers can log it or display it.
     */
    public function fitsWithinBudget(array $messages, int $budget, ?string $system = null): array
    {
        $count = $this->count($messages, $system);

        return [
            'fits'  => $count <= $budget,
            'count' => $count,
            'delta' => $budget - $count,
        ];
    }
}

And calling it from a job that processes incoming documents:

$counter = new ClaudeTokenCounter('claude-opus-4-5');

$messages = [
    ['role' => 'user', 'content' => $documentText],
];

$systemPrompt = "You are a lab data extractor. Return structured JSON only.";

// Gate before sending the real request
$check = $counter->fitsWithinBudget($messages, budget: 8000, system: $systemPrompt);

if (!$check['fits']) {
    Log::warning('Document exceeds token budget', [
        'count'    => $check['count'],
        'budget'   => 8000,
        'document' => $documentId,
    ]);
    // chunk or reject
    return;
}

// Now send the real request, knowing it'll fit
$response = $claude->messages()->create([
    'model'      => 'claude-opus-4-5',
    'max_tokens' => 2048,
    'system'     => $systemPrompt,
    'messages'   => $messages,
]);

The pre-count round trip adds latency - typically 200-400ms in my testing from a Seattle-hosted server. For batch jobs running overnight, that's nothing. For a real-time user-facing feature, you'd want to think about whether it's worth it.

The Gotchas That Bit Me

Tool definitions count. If you're using Claude's tool/function calling, the JSON schema for your tools gets tokenized and billed as input tokens. The count endpoint includes them if you pass them in. Most people forget to include tools when estimating and then wonder why their counts are off.

The system prompt is not free. Obvious in retrospect, but I've seen codebases where the system prompt is assembled from config and nobody was counting it at all. On a long system prompt - say 800 tokens - running 10,000 requests a day, that's 8 million uncounted tokens per day. It adds up.

Count tokens before caching decisions, not after. If you're using prompt caching (the cache_control beta), the token count endpoint will reflect the full token count before cache discounts apply. The actual bill will be lower if the cache hits. Don't use the raw count to predict cost without factoring in your cache hit rate.

Model matters. The tokenizer can vary between model families. Pass the same model to the count endpoint that you're going to use for inference. I've seen people hardcode claude-haiku-3-5 in their counter while sending to Opus and get slightly different results.

Rate limits apply. The count endpoint shares rate limits with the messages endpoint, not a separate generous bucket. If you're running hundreds of concurrent pre-count requests, you can exhaust your request-per-minute limit before you ever send a real inference call. Cache your counts when the input is deterministic.

When I'd Reach for This

I use the count endpoint in any of these situations:

  • Budget gating on batch jobs. Count before processing. If a document is too large, chunk it. Don't find out at inference time.
  • User-facing context windows. I built a chat interface where users could upload documents. I pre-count to show them how much of their context they're using. The accuracy matters because users make decisions based on it.
  • Automated chunking. Rather than guessing at character-count thresholds for splitting text, I chunk, count, and adjust programmatically.
  • Cost attribution. When a client wants per-document cost breakdowns, I count tokens pre-request, log them, and do the math myself against current API pricing. Anthropic's usage logs are good but come with a delay.

I would not use it for short, predictable prompts where the variance doesn't matter. If you're sending simple classification tasks with a fixed template and user text that's always a few sentences, the overhead of an extra HTTP call isn't worth it. You can estimate close enough and move on.

Closing

The token counting endpoint exists precisely because Anthropic knows their tokenizer behaves differently than what developers assume coming from other ecosystems. It's a useful escape hatch - exact, cheap to call, and honest about what you're going to be billed.

Don't sleep on it if you're processing variable-length documents or anything that isn't plain English prose. The 15% drift I was seeing in that biotech pipeline was real money at volume, and it was entirely avoidable.

Related

Need help shipping something like this? Get in touch.