Prompt Recipes
Roo Iyer5 min read119 views

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

A minimalist yellow line-art poster on stark white showing a row of grey request squares feeding into a single amber batch slot and emerging as one stacked bar of results
A minimalist yellow line-art poster on stark white showing a row of grey request squares feeding into a single amber batch slot and emerging as one stacked bar of results
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.

Anthropic OpenAI 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 BatchesOpenAI Batch API
Discount50% off input + output50% off input + output
How you submitinline requests arrayupload a JSONL file first
Track a request bycustom_idcustom_id
Turnaroundtarget 24h, else expiredpick a 24h window
Streamingnot supportednot supported
Results retentionabout 29 daysoutput file until you delete it
Rough size capup to 10,000 requests or 256 MBup 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):

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.

python
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):

python
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):

python
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):

python
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.

R

Written by

Roo Iyer

Roo 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.