Message Batches API: 4 Recipes That Ship (2026)
The Message Batches API runs LLM jobs async for 50% off in Claude and OpenAI. Four copy-paste recipes: submit, poll without hammering, match results by custom_id, and survive the 24-hour expiry.
Updated on August 18, 2026
On this page
Quick Answer (2026): The Message Batches API runs your requests asynchronously for 50% off input and output tokens, on both Claude and OpenAI. You submit a pile of requests, walk away, and collect results inside a 24-hour window. The catch: no streaming, no live conversation, and results come back in an order you cannot trust. Tag every request with a unique custom_id and key your results off it. Below are four copy-paste recipes plus the one failure mode that eats a batch job.
Tested against the Anthropic Message Batches API and the OpenAI Batch API as documented in 2026. Model ids used below:
claude-sonnet-5 and gpt-5.
The two batch APIs at a glance (2026)
Scroll to see more
| Anthropic Message Batches | OpenAI Batch API | |
|---|---|---|
| Discount | 50% off input + output | 50% off input + output |
| How you submit | inline requests array | upload a JSONL file first |
| Track a request by | custom_id | custom_id |
| Turnaround | target 24h, else expired | pick a 24h window |
| Streaming | not supported | not supported |
| Results retention | about 29 days | output file until you delete it |
| Rough size cap | up to 10,000 requests or 256 MB | up to 50,000 requests or 200 MB file |
Caps have risen over time on both platforms, so treat the last row as a floor and check the current docs before you assume a single batch can hold your whole job.
Recipe 1: Submit a batch and stop paying full price
Claim: the same request costs half as much if you send it as a batch instead of a live call.
Receipt (Claude, Python):
import anthropic
client = anthropic.Anthropic()
batch = client.messages.batches.create(
requests=[
{
"custom_id": "row-1",
"params": {
"model": "claude-sonnet-5",
"max_tokens": 512,
"messages": [{"role": "user", "content": "Classify sentiment: great product"}],
},
},
{
"custom_id": "row-2",
"params": {
"model": "claude-sonnet-5",
"max_tokens": 512,
"messages": [{"role": "user", "content": "Classify sentiment: terrible support"}],
},
},
]
)
print(batch.id) # msgbatch_...
Receipt (OpenAI, Python): OpenAI wants a JSONL file first, one line per request, each line carrying custom_id, method, url, and a normal request body.
from openai import OpenAI
client = OpenAI()
# batch.jsonl, one request per line:
# {"custom_id": "row-1", "method": "POST", "url": "/v1/chat/completions",
# "body": {"model": "gpt-5", "messages": [{"role": "user", "content": "Classify: great product"}]}}
f = client.files.create(file=open("batch.jsonl", "rb"), purpose="batch")
batch = client.batches.create(
input_file_id=f.id,
endpoint="/v1/chat/completions",
completion_window="24h",
)
print(batch.id) # batch_...
Why: batch endpoints run on spare capacity, so the provider trades your latency for a discount. The params block in a Claude request is just a normal Messages body, which means anything you already do live (system prompts, tools, prompt caching) carries straight into the batch and stacks with the 50% off.
Failure mode: reaching for batches to make one urgent call cheaper. Batches are for volume you can wait on, not for shaving cents off a request a user is staring at.
Ship it when: you have hundreds or thousands of independent requests and nobody is waiting on any single one.
Recipe 2: Poll for completion without hammering the endpoint
Claim: you check a batch the way you check laundry, not the way you refresh a scoreboard.
Receipt (Claude):
import time
while True:
batch = client.messages.batches.retrieve(batch.id)
if batch.processing_status == "ended":
break
time.sleep(30)
On OpenAI the same shape applies: client.batches.retrieve(batch.id) and wait for status == "completed" (it walks through validating, in_progress, finalizing first).
Why: neither API pushes you a webhook by default, so you pull. A request_counts object on the Claude batch (processing, succeeded, errored, canceled, expired) lets you show real progress instead of a spinner.
Failure mode: a one-second poll loop. You will burn rate limit on the status endpoint and learn nothing, because a batch that will take twenty minutes does not change state every second. Poll every 30 to 60 seconds, or just retrieve once an hour for overnight jobs.
Ship it when: your poll interval is measured in tens of seconds, and your process can survive a restart mid-wait (store the batch.id).
Recipe 3: Match results to inputs by custom_id, never by order
Claim: the result stream is a bag, not a queue. Position means nothing.
Receipt (Claude):
results = {}
for entry in client.messages.batches.results(batch.id):
results[entry.custom_id] = entry.result
# result.type is "succeeded", "errored", "canceled", or "expired"
print(results["row-1"].message.content[0].text)
OpenAI returns an output_file_id; download it and read the JSONL, keying each line by its custom_id exactly the same way.
Why: both providers return results asynchronously and explicitly do not guarantee that line 1 out maps to request 1 in. If you zip the output against your input list by index, you will silently attach the wrong answer to the wrong row, and nothing will error.
Failure mode: for i, row in enumerate(results): save(inputs[i], row). This is the quiet data-corruption bug that survives code review because it runs clean on a two-item test batch and mangles a ten-thousand-item production run.
Ship it when: every input row already owns a stable, unique custom_id that traces back to your database primary key.
Recipe 4: Handle partial failures and the 24-hour cliff
Claim: a batch is not all-or-nothing. Individual requests succeed, error, or expire on their own.
Receipt (Claude):
for cid, res in results.items():
if res.type == "succeeded":
save(cid, res.message)
elif res.type == "errored":
requeue_live(cid) # re-send this one as a normal request
elif res.type == "expired":
requeue_live(cid) # the 24h window closed before it ran
elif res.type == "canceled":
pass # you canceled it on purpose
Why: if a batch does not finish inside 24 hours, the leftover requests come back as expired rather than blocking the whole job. A malformed request comes back as errored while its neighbors still succeed. Treat the result set as a checklist to reconcile, not a single return value. For long single-shot reasoning jobs you want to run overnight, this is also how you safely batch extended thinking work without babysitting it.
Failure mode: assuming a completed batch means every request succeeded. It means the batch stopped running. Always read per-request result types and requeue the misses, or you will ship a dataset with silent holes.
Ship it when: your pipeline can re-run the errored and expired slice without redoing the whole batch.
When NOT to use the batch API
- A human is waiting on the answer. Use the standard endpoint.
- You need streaming tokens. Batches do not stream.
- The request is a multi-turn conversation or needs interactive, mid-request tool loops. Claude batches are single-turn.
- You have three requests. The bookkeeping is not worth the 50% on pennies.
For everything else that is bulk, independent, and latency-tolerant (classification, extraction, evals, translation, backfills), batching is the cheapest correct answer. The reference implementations live in the official Anthropic Python SDK.
Cost to test: about $0.01 for a two-request batch on a small model.
Written by
Roo IyerRoo Iyer writes terse, tested prompt and API recipes for PromptAttic. Every recipe ships with a receipt, a failure mode, and a cost to test.
FAQ
How much cheaper is the Message Batches API?
It is 50% off both input and output tokens versus the standard synchronous endpoint, on both Anthropic and OpenAI in 2026. Same models, same output quality, half the token price. The only trade is latency: results arrive within a 24-hour window instead of in real time.
How do I match batch results back to my inputs?
Give every request a unique custom_id when you submit, then key your results dictionary by it. Both APIs return results in a stream whose order is not guaranteed, so never map results to inputs by position. Look each result up by its custom_id, ideally one that traces back to your database primary key.
What happens if a batch does not finish in 24 hours?
On Anthropic the unfinished requests come back with an expired result type when the window closes; on OpenAI the batch moves to expired and only the completed requests have output. The batch does not fail as a whole. Re-send the leftover requests as normal live calls or in a fresh batch.
Can I stream tokens from a batch?
No. Neither the Anthropic Message Batches API nor the OpenAI Batch API supports streaming, and Anthropic batches are single-turn with no interactive mid-request tool loops. Use batches for latency-tolerant bulk work, and use the standard endpoint whenever you need streaming or a live conversation.
How do I submit a batch in Python?
On Anthropic call client.messages.batches.create with a requests list, where each item has a custom_id and a params block that is just a normal Messages request. On OpenAI, upload a JSONL file with client.files.create(purpose='batch'), then call client.batches.create with the input_file_id and completion_window '24h'. Full copy-paste receipts are in the recipes above.
Related recipes
Claude Prompt Caching: 3 Recipes That Pay Off, 2 That Lose Money (June 2026)
Three Claude prompt-caching recipes with real cost math for Sonnet 4.6, Opus 4.7, and Haiku 4.5. Plus two patterns where caching quietly costs you 25% more than not using it.
Claude Extended Thinking: 3 Recipes That Ship (July 2026)
Extended thinking changed in July 2026: on Claude Sonnet 5 and Opus 4.8 you use adaptive thinking and effort, not budget_tokens. Three recipes and the gotchas.
Assistant Prefill on Claude: 4 Recipes That Ship (2026)
Assistant prefill in 2026: four copy-paste recipes that force JSON, kill the preamble, lock the output shape, and hold a persona, plus where prefill breaks.