CLAUDE LABJP
MCP — Support for the 2026-07-28 spec is rolling out across Claude. The protocol moves from bidirectional and stateful to request/response, so MCP servers can now live on serverless and edge infrastructureEXTENSIONS — Three official extensions have landed: MCP Apps for server-rendered UI, Tasks for async and long-running work, and Enterprise Managed Auth for IdP-based org-wide provisioningADOPTION — MCP passed 400 million monthly SDK downloads, roughly 4x growth this year, settling into its role as the standard way to connect agents to applicationsQUOTA — Today, August 19, is the last day of the 50 percent weekly usage boost for Claude Code subscribers. If you have long agent runs queued, this is the windowPRICING — Claude Sonnet 5's introductory rate of $2 per million input tokens and $10 output ends August 31; standard pricing of $3 and $15 takes over on September 1, twelve days outFIX — A bug where MCP v2 connections endlessly reopened subscriptions against servers with fixed timeouts is resolved, and a forward_user_identity setting was added for user attributionMCP — Support for the 2026-07-28 spec is rolling out across Claude. The protocol moves from bidirectional and stateful to request/response, so MCP servers can now live on serverless and edge infrastructureEXTENSIONS — Three official extensions have landed: MCP Apps for server-rendered UI, Tasks for async and long-running work, and Enterprise Managed Auth for IdP-based org-wide provisioningADOPTION — MCP passed 400 million monthly SDK downloads, roughly 4x growth this year, settling into its role as the standard way to connect agents to applicationsQUOTA — Today, August 19, is the last day of the 50 percent weekly usage boost for Claude Code subscribers. If you have long agent runs queued, this is the windowPRICING — Claude Sonnet 5's introductory rate of $2 per million input tokens and $10 output ends August 31; standard pricing of $3 and $15 takes over on September 1, twelve days outFIX — A bug where MCP v2 connections endlessly reopened subscriptions against servers with fixed timeouts is resolved, and a forward_user_identity setting was added for user attribution
Articles/API & SDK
API & SDK/2026-04-11Intermediate

Running Claude's Message Batches Overnight — What the 50% Discount Actually Covers

What I learned running Claude API Message Batches for real: what the 50% discount covers, the custom_id character restriction, and designing around the 24-hour window and 29-day retention.

Claude API117Message Batches2Async Processing2Cost Optimization9Large-Scale Requests

It started on a night spent sorting assets for a wallpaper app. A few thousand images were waiting to be classified. I could have sent each one to Claude individually for a caption and a category, but I didn't need real-time answers. If a job ran while I slept and the results were ready by morning, that was plenty.

That "not urgent, but high volume" shape is exactly what Message Batches is built for. You submit requests in bulk and they're processed asynchronously.

What I hadn't understood on my first run, though, was the actual discount rate, what the deadline really meant, or which characters custom_id would accept. What follows is the record of correcting all three.

The discount is 50% — the 90% comes from stacking

I had assumed that simply moving work onto batches would cut the bill by ninety percent. The real figure, per the official documentation, is a flat 50%, applied uniformly to input tokens, output tokens, and special tokens alike.

So where does ninety percent come from? I worked the numbers for my own classification job: 3,000 requests, a 900-token shared prompt plus a 60-token per-image description, 8 output tokens for the one-word category, running on Claude Haiku 4.5 (synchronous pricing of $1 input and $5 output per million tokens; batch is half of that).

SetupCost per 3,000 requestsvs. synchronous
Synchronous API, no caching$3.0000
Message Batches$1.500050% lower
Message Batches + 1-hour cache$0.285990.5% lower

The third row breaks down as $0.0009 for the cache write, $0.1350 for cache reads, $0.0900 for the unique portion of the input, and $0.0600 for output — assuming the 900-token shared prompt is written once and read by the remaining 2,999 requests.

In other words, the ninety percent came from stacking three things: batching, model choice, and prompt caching. It isn't what batching does on its own. The docs state plainly that the caching and batch discounts stack, so this layering is entirely above board.

One caveat: cache hits inside a batch are best-effort, with a documented range of 30% to 98%. A 5-minute TTL tends to expire mid-batch, which is why the docs point toward the 1-hour TTL for batch workloads. That matched my experience — with the 5-minute default, reads barely registered. I've written up the caching side separately in my notes on prompt caching.

Start with the smallest possible run

Rather than firing off thousands of requests, confirm the shape with two or three. The custom_id is the key you'll use to map results back to your source data, so give it a meaningful value.

import anthropic
 
client = anthropic.Anthropic()
 
batch = client.messages.batches.create(
    requests=[
        {
            "custom_id": "wallpaper_0001",
            "params": {
                "model": "claude-haiku-4-5-20251001",
                "max_tokens": 512,
                "messages": [
                    {"role": "user", "content": "Classify the mood of this wallpaper in one word: a faint ring of light in the night sky"}
                ],
            },
        },
        {
            "custom_id": "wallpaper_0002",
            "params": {
                "model": "claude-haiku-4-5-20251001",
                "max_tokens": 512,
                "messages": [
                    {"role": "user", "content": "Classify the mood of this wallpaper in one word: a mountain range in morning mist"}
                ],
            },
        },
    ]
)
 
print(f"Batch ID: {batch.id}")

There was no reason to reach for a top-tier model on a task this routine. Haiku handled it, and combined with the batch discount the cost dropped another notch.

I put filenames straight into custom_id, and they bounced

In my first version, I used the image filenames as custom_id values — things like wallpaper_0001.jpg. It felt like good design, since the mapping back to source data was self-evident.

But custom_id is constrained to ^[a-zA-Z0-9_-]{1,64}$: alphanumerics, hyphens, and underscores only. The period in a file extension puts the value outside that set immediately. There's a 64-character ceiling too.

What makes this more awkward is that validation of the params object happens asynchronously — the docs note that validation errors surface once the whole batch has finished processing. A formatting mistake can be something you learn about the next morning.

So I normalized custom_id down to safe characters and kept a reverse lookup table alongside it.

import re
 
def to_custom_id(filename: str) -> str:
    """Normalize to alphanumerics, hyphen, underscore; cap at 64 characters."""
    stem = filename.rsplit(".", 1)[0]
    safe = re.sub(r"[^A-Za-z0-9_-]", "_", stem)
    return safe[:64]
 
# Always keep the reverse map — results only come back keyed by custom_id
index = {}
for name in image_files:
    cid = to_custom_id(name)
    if cid in index:
        raise ValueError(f"custom_id collision: {cid} <- {name}")
    index[cid] = name

The collision check matters because normalization can flatten distinct filenames into the same value. Both img.001.jpg and img-001.jpg become img_001. One such pair hidden among a few thousand files is enough to break your result mapping silently.

ended means finished, not successful

A batch is still processing right after creation. Most finish within the hour, though under load it can stretch to hours. Hammering the endpoint every few seconds buys nothing, so I wait a few minutes and then poll at 60-second intervals.

import time
 
while True:
    batch = client.messages.batches.retrieve(batch.id)
    if batch.processing_status == "ended":
        break
    counts = batch.request_counts
    print(f"Processing... ok {counts.succeeded} / err {counts.errored} / in-flight {counts.processing}")
    time.sleep(60)
 
print("Batch ended")

What you're checking is not "did everything succeed" but "has processing reached ended." The request_counts object carries five fields: processing, succeeded, errored, canceled, and expired. Even once a batch is ended, its contents are a mix — confuse the two and the failures drop on the floor.

Result order isn't guaranteed

This is what caught me first. Results don't necessarily come back in submission order; the docs state outright that batch results may not match input order. The safe approach is to stream them one at a time and map each back to your source data by custom_id.

results = {}
 
for item in client.messages.batches.results(batch.id):
    cid = item.custom_id
    if item.result.type == "succeeded":
        results[cid] = item.result.message.content[0].text
    elif item.result.type == "errored":
        # Don't swallow it — push onto a retry list
        print(f"Failed: {cid} -> {item.result.error}")
        results[cid] = None
    elif item.result.type == "expired":
        print(f"Expired: {cid}")
        results[cid] = None
 
ok = sum(1 for v in results.values() if v is not None)
print(f"Retrieved {ok} / {len(results)}")

Here's what each result.type means:

typeMeaningBilled?
succeededSuccessful; includes the message itselfYes
erroredInvalid request, or a server-side errorNo
canceledCanceled before reaching the modelNo
expiredNot sent within the 24-hour windowNo

Not being billed for failures means there's no reason to hesitate about resubmitting. I collect the errored and expired custom_ids and send them back as a smaller batch. Automate that step and the next morning becomes a review rather than a repair job.

The 24-hour deadline and the 29-day window

The timing rules feed directly into how you design around them. A batch that doesn't finish within 24 hours expires. You can read results either when every request has completed or once 24 hours have passed — whichever comes first.

Results then stay downloadable for 29 days. The clock starts at creation time, not at the moment processing ended. Miss that distinction and the longer a batch runs, the less time you actually have to collect from it.

In my own setup, I write results to my own storage the moment they arrive, so nothing depends on Anthropic-side retention. Finished batches can also be deleted; if you want to remove one that's still in flight, cancel it first.

There's one more note worth heeding: because batches run with high concurrency, they may go slightly over a workspace's configured spend limit. If you operate close to your ceiling, leave yourself some headroom.

Size your chunks by what you'd want to roll back

A single batch holds up to 100,000 requests or 256 MB, whichever comes first. Exceed the size limit and you'll get a request_too_large error.

Even so, I don't pack them to the ceiling — I submit 2,000 at a time. The reasoning is simple: when you need to re-run part of the work, a smaller rollback unit is easier to live with. Chunk by sequence number and log a batch ID per chunk, and you can see at a glance how far the job got.

def chunk(items, size=2000):
    for i in range(0, len(items), size):
        yield items[i:i + size]
 
for n, group in enumerate(chunk(all_requests)):
    b = client.messages.batches.create(requests=group)
    print(f"chunk {n}: {b.id} ({len(group)} requests)")

Rate limits for batches are tracked separately from the synchronous Messages API — batch consumption doesn't eat into your synchronous ceiling. That separation is what lets you push heavy background work through without stalling your app's normal paths. For deeper production patterns, see my write-up on async design with the Messages Batches API.

Decide what stays off the batch path

Some things simply can't go into a batch. stream: true is rejected, since results come back as a single file rather than a stream. So is max_tokens: 0 for cache pre-warming — an ephemeral cache entry written during batch processing would expire before the follow-up request ran.

More consequential than the parameters the API refuses, though, is the judgment about what you choose not to move. Push work that has to answer a user's tap into a batch and you will degrade the experience. My own line is to batch only work nobody is waiting on: drafting App Store review replies, classifying assets — jobs where "ready by morning" is enough.

As an indie developer, the waiting time itself is time I can spend elsewhere. Since shifting this work to run overnight and land by morning, the shape of my day has gotten noticeably easier to plan.

For a next step, pick one piece of "not urgent" work you already have and confirm the round trip with a two- or three-request batch. Once you've normalized your custom_ids and kept a reverse map, scaling the count up is the easy part.

Thank you for reading.

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-08-01
Swapping Tools Mid-Conversation Without Losing Your Prompt Cache
Tools can now be added and removed between turns, but the tool block sits at the very front of the cache prefix. I measured 12 mutation patterns with fingerprints and built a stable-core plus volatile-tail registry with a guard that refuses unsafe changes.
API & SDK2026-07-02
When 41 of 20,000 Message Batches Requests Quietly Vanished — Field Notes on Reconciling and Requeuing Partial Failures
processing_status: ended does not mean every request succeeded. How errored and expired results hide inside a finished batch, and how a custom_id ledger catches every gap and requeues safely — with real cost and timing numbers.
API & SDK2026-07-01
When Claude API Document Extraction Is Confidently Wrong — Field Notes on Catching Silent Errors with Invariants
In structured extraction from invoices and contracts, the real danger isn't a crash — it's a value that's silently wrong while the schema validates and confidence reads high. Field notes on invariants, two-pass extraction, and tracking field-level error rates.
📚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 →