Message Batches and the Files API: Running Bulk Jobs at Half Price and Uploading Files Once
You send thousands of non-urgent Claude requests at a 50% discount with the Message Batches API, match results by custom_id rather than order, and store the batch id so nothing is sent twice; with the Files API you upload a file once and use it by file_id. Four result types, a nightly BackgroundService job, the 24-hour and 29-day limits, expires_in_seconds, file_id security and caching the document block.
Version note: The code in this post was built with .NET 10, Anthropic C# SDK 13.8.0 and Claude API version 2026-10-21, and its unit tests were run. The tests use the SDK's real client with a fake HttpMessageHandler that never touches the network; the fake batch and file responses are derived from the examples on the batch processing and Files API pages. The cost rows are the calculation that the post's tool makes with the price list. Limits, prices and behavior were taken on 15 November 2026 from the platform.claude.com documentation. This post is based on the official documentation.
Guess First
Selin wants a meta description for each of the 10,000 books in the catalog of Bookstore, the online bookstore used as the shared example across this site's series. There is no hurry; ready by morning is enough. Emre's first draft calls Messages.Create 10,000 times inside a foreach. In the same week Hakan from institutional sales asks questions all day about a publisher's 40 MB spring catalog, and on every question the PDF is sent again as base64.
What is your guess? If the night job crashes at the 6,000th book and starts again, what happens? When bulk requests come back, how do you know which answer belongs to which book? And if Hakan's PDF is uploaded once, does the input token cost of each question go down? In this post we look at two discount tools of the Claude API: the Message Batches API for work that is not urgent, and the Files API so that you stop sending the same file again and again. The general rules of background work were covered in the Background Jobs and Scheduled Jobs series, especially in the post on retries and idempotency.
How Does the Message Batches API Work?
According to the batch processing page, a batch is a package of requests, each one a normal Messages request, that you send at once and that is processed asynchronously. Each request is processed independently. Most batches finish in less than an hour; results are ready either when all requests have finished or after 24 hours, whichever comes first. Requests that do not finish within 24 hours become expired. In return, all usage is billed at 50% of the standard price.
The limits in the documentation: a batch holds at most 100,000 requests or 256 MB, whichever is reached first. Results can be downloaded for 29 days after creation. Batches belong to a workspace; any request in the same workspace can see them. The Batches API has its own rate limits and does not affect the limits of the Messages API. The documentation adds one more warning: because of high parallelism, a batch can go slightly over the workspace's spend limit.
Almost every Messages API feature can be used inside a batch: vision, tool use, server tools, system messages, thinking. Four parameters, however, give a validation error: stream, speed (fast mode), fallbacks and the max_tokens: 0 used for the pre-warm in the seventh post. One more detail matters: the API validates the params object of each request asynchronously and reports the error when the batch has ended. That is why the documentation suggests trying the request shape with a single Messages call first. According to the API version migration page, each request in a batch is processed under the rules of the API version the batch was created with.
Bookstore: A Nightly SEO Job
The code that builds one request per book:
public static class BookSeoBatch
{
public const string Model = "claude-haiku-5-5";
// The documented limit is 100,000 requests or 256 MB per batch; smaller batches are easier to watch and retry.
public const int MaxRequestsPerBatch = 10_000;
private const string CustomIdPrefix = "seo-";
public const string SystemPrompt = """
You write meta descriptions for the book pages of Bookstore, an online bookstore.
Write one English description of 140 to 160 characters. Use only the book data you are given.
Return only the description, without quotes.
""";
// custom_id must match ^[a-zA-Z0-9_-]{1,64}$. Results come back in any order and are matched by this id.
public static string CustomIdFor(Book book) => CustomIdPrefix + book.Isbn;
public static string? IsbnFrom(string customId) =>
customId.StartsWith(CustomIdPrefix, StringComparison.Ordinal) ? customId[CustomIdPrefix.Length..] : null;
public static Request BuildRequest(Book book) => new()
{
CustomID = CustomIdFor(book),
Params = new()
{
Model = Model,
MaxTokens = 300,
System = SystemPrompt,
// A short, well-defined task: no thinking needed on Haiku 5.5.
Thinking = new ThinkingConfigDisabled(),
Messages = [new UserMessageParam { Content = $"Title: {book.Title}\nAuthor: {book.Author}\nAbout: {book.Description}" }],
},
};
public static IEnumerable<BatchCreateParams> Split(IEnumerable<Book> books) =>
books.Chunk(MaxRequestsPerBatch)
.Select(chunk => new BatchCreateParams { Requests = chunk.Select(BuildRequest).ToList() });
}
According to the documentation, custom_id is 1 to 64 characters of letters, digits, hyphens and underscores only. An ISBN-13 fits, and it gives us something else: it tells us directly which book the result belongs to. The documentation states clearly that the order of results is not guaranteed; matching results by index means writing everything to the wrong book. The task is short and well defined, so we turned thinking off on Haiku 5.5; as we saw in the third post, this model allows thinking to be turned off. 10,000 books is far below the documented limit, but sending them in chunks of 10,000 makes watching and resending easier.
The method that sends the batch:
// Sends a batch only for books that still have no description and only when nothing is pending.
public async Task<IReadOnlyList<string>> SubmitMissingAsync(CancellationToken cancellationToken)
{
if (jobs.Pending().Count > 0)
{
return [];
}
List<Book> missing = books.All().Where(b => !seo.HasDescription(b.Isbn)).ToList();
List<string> created = [];
foreach (BatchCreateParams batch in BookSeoBatch.Split(missing))
{
MessageBatch createdBatch = await claude.Messages.Batches.Create(batch, cancellationToken);
// Store the id before anything else; the next run polls this batch instead of sending a new one.
jobs.Add(createdBatch.ID);
created.Add(createdBatch.ID);
logger.LogInformation("SEO batch {BatchId} created with {Count} requests", createdBatch.ID, batch.Requests.Count);
}
return created;
}
Two decisions answer the first question in Guess First. First, no new batch is sent while one is pending. Second, the batch id is written to durable storage as soon as Create returns. If the job is interrupted and starts again, it polls the stored id instead of sending a new batch. Without this check every restart would send the same 10,000 requests again and pay for each of them again. According to the documentation a submitted batch cannot be changed; if you need a change, you cancel it and send a new one.
Collecting Results: custom_id and Four Result Types
processing_status is in_progress first, canceling if it is being canceled, and ended when the work is done. The SDK's ResultsStreaming method reads the results line by line; it does not load a large result file into memory:
// Returns null while the batch is still running.
public async Task<SeoBatchReport?> CollectAsync(string batchId, CancellationToken cancellationToken)
{
MessageBatch batch = await claude.Messages.Batches.Retrieve(batchId, cancellationToken: cancellationToken);
if (batch.ProcessingStatus.Value() != ProcessingStatus.Ended)
{
return null;
}
int saved = 0;
List<string> retry = [];
List<string> rejected = [];
await foreach (MessageBatchIndividualResponse item in claude.Messages.Batches.ResultsStreaming(batchId, cancellationToken: cancellationToken))
{
string? isbn = BookSeoBatch.IsbnFrom(item.CustomID);
if (isbn is null)
{
logger.LogWarning("SEO batch {BatchId}: unknown custom_id {CustomId}", batchId, item.CustomID);
continue;
}
if (item.Result.TryPickSucceeded(out MessageBatchSucceededResult? succeeded))
{
if (TryReadDescription(succeeded.Message, out string description))
{
seo.Save(isbn, description);
saved++;
}
else
{
rejected.Add(isbn);
}
}
else if (item.Result.TryPickErrored(out MessageBatchErroredResult? errored)
&& errored.Error.Error.Type.Raw() == "invalid_request_error")
{
// The request itself is wrong; sending it again gives the same error.
logger.LogError("SEO batch {BatchId}: invalid request for {Isbn}", batchId, isbn);
rejected.Add(isbn);
}
else
{
// Server errors, expired and canceled requests were not billed and can go into the next batch.
retry.Add(isbn);
}
}
jobs.Complete(batchId);
logger.LogInformation("SEO batch {BatchId}: saved={Saved} retry={Retry} rejected={Rejected}",
batchId, saved, retry.Count, rejected.Count);
return new SeoBatchReport(batchId, saved, retry, rejected);
}
The four result types in the documentation and what we do with them:
| Result | According to the documentation | In Bookstore |
|---|---|---|
succeeded | The message result is included | Save it if stop_reason is end_turn |
errored | Not billed; the error can be invalid_request_error or a server error | Validation error: reject and log; others: next batch |
canceled | The batch was canceled and the request was not sent; not billed | Next batch |
expired | 24 hours passed and the request was not sent; not billed | Next batch |
The reason we separate invalid_request_error is in the documentation: this error comes from the request itself, and sending the same request again gives the same error. We also do not save a successful result blindly; an answer cut off at max_tokens or returned as a refusal is not a meta description.
The fake result file in the tests is derived from the documented examples; the second book's result comes first on purpose:
{"custom_id":"seo-9780141439587","result":{"type":"succeeded","message":{"id":"msg_014VwiXbi91y3JMjcpyGBHX5","type":"message","role":"assistant","model":"claude-haiku-5-5","content":[{"type":"text","text":"Emma by Jane Austen: a clever, rich young woman plays matchmaker in her village and misreads almost everyone. A sharp, funny classic."}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":96,"output_tokens":34}}}}
{"custom_id":"seo-9780441172719","result":{"type":"succeeded","message":{"id":"msg_01FqfsLoHwgeFbguDgpz48m7","type":"message","role":"assistant","model":"claude-haiku-5-5","content":[{"type":"text","text":"Dune by Frank Herbert: on the desert planet Arrakis, young Paul Atreides is pulled into a war over the spice that rules the galaxy."}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":98,"output_tokens":35}}}}
{"custom_id":"seo-9780547928227","result":{"type":"errored","error":{"type":"error","error":{"type":"invalid_request_error","message":"max_tokens: Field required"}}}}
{"custom_id":"seo-9780141439549","result":{"type":"expired"}}
A BackgroundService runs the job:
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!options.Value.Enabled)
{
return;
}
using PeriodicTimer timer = new(options.Value.PollInterval);
do
{
try
{
using IServiceScope scope = scopes.CreateScope();
SeoBatchProcessor processor = scope.ServiceProvider.GetRequiredService<SeoBatchProcessor>();
ISeoBatchJobStore jobs = scope.ServiceProvider.GetRequiredService<ISeoBatchJobStore>();
foreach (string batchId in jobs.Pending())
{
await processor.CollectAsync(batchId, stoppingToken);
}
await processor.SubmitMissingAsync(stoppingToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "SEO batch run failed; the next tick tries again");
}
}
while (await timer.WaitForNextTickAsync(stoppingToken));
}
The worker is off by configuration, so no request goes out in a development environment without an API key. The PeriodicTimer interval is 5 minutes; most batches finish within an hour, so there is no reason to ask every few seconds. Each tick first collects the pending batches and then sends a new batch for books that still have no description. The books in the retry list thereby go into a new batch on the next tick by themselves. IBookSeoStore is in memory here; in production it would be an implementation that writes the descriptions to the Books table with EF Core. The lifecycle of BackgroundService and graceful shutdown are in the second post of the Background Jobs and Scheduled Jobs series.
Batches, Caching and Cost
We added a batch column to the estimate command of the evaluation tool from the third post. The assumption: 180 input and 45 output tokens per request.
dotnet run --project tools/Bookstore.ClaudeEval -- estimate 180 45
Assumed tokens per request: input=180 output=45
model per request per 1,000 batch 1,000
claude-fable-5-2 0.003240 3.2400 1.6200
claude-opus-5-5 0.001620 1.6200 0.8100
claude-sonnet-5-5 0.000810 0.8100 0.4050
claude-haiku-5-5 0.000041 0.0405 0.0203
This is not a measurement but a calculation with the documented prices. 10,000 books cost about 0.20 USD with Haiku 5.5 in a batch and about 0.41 USD when sent one by one. The real difference shows up with larger models and longer prompts.
The batch discount stacks with the prompt caching multipliers. According to the documentation, however, the requests in a batch are processed in parallel and in any order, so cache hits are not guaranteed but best effort. If the requests share a long common context, the documentation suggests the 1-hour cache duration, because a batch can take longer than 5 minutes. Our SEO prompt is below 512 tokens and would not be cached anyway, so we did not add a breakpoint.
Files API: Upload Once, Use Many Times
The Files API left beta on 19 August 2026; it no longer needs a beta header and lives under client.Files in the C# SDK. The idea is simple: you upload the file once, get a file_id back, and in later requests you reference that id instead of sending the content again. Uploading, downloading, listing and deleting are free; the file's content is billed as input tokens when it is used in a Messages request.
The code that uploads Hakan's catalog:
// Catalogs change every season; the file deletes itself after 30 days (the API accepts 1 hour to 90 days).
public static readonly TimeSpan Retention = TimeSpan.FromDays(30);
public async Task<PublisherCatalogFile> UploadAsync(
string publisherId, Stream pdf, string fileName, CancellationToken cancellationToken)
{
FileMetadata uploaded = await claude.Files.Upload(new FileUploadParams
{
File = new BinaryContent
{
Stream = pdf,
FileName = fileName,
ContentType = new MediaTypeHeaderValue("application/pdf"),
},
ExpiresInSeconds = (long)Retention.TotalSeconds,
}, cancellationToken);
PublisherCatalogFile file = new(uploaded.ID, uploaded.Filename, uploaded.ExpiresAt);
store.Save(publisherId, file);
logger.LogInformation("Publisher {PublisherId} catalog uploaded as {FileId} ({Bytes} bytes)",
publisherId, uploaded.ID, uploaded.SizeBytes);
return file;
}
In the test, the multipart body the SDK sends looks like this; expires_in_seconds is a separate form field:
--7e43f669-5643-416a-95b6-5aac4247d4b8
Content-Type: text/plain; charset=utf-8
Content-Disposition: form-data; name=expires_in_seconds
2592000
--7e43f669-5643-416a-95b6-5aac4247d4b8
Content-Type: application/pdf
Content-Disposition: form-data; name=file; filename="spring-catalog.pdf"
%PDF-1.7 sample
--7e43f669-5643-416a-95b6-5aac4247d4b8--
According to the documentation, expires_in_seconds can be between 3,600 (1 hour) and 7,776,000 (90 days). The content of an expired file can no longer be downloaded, a Messages request that references it fails before the model runs, and its metadata stays readable for 30 more days. Without an expiry the file stays until you delete it. Limits: 500 MB per file, 1 TB per organization. Files you upload cannot be downloaded (downloadable: false); only files produced by skills and the code execution tool can. We will use that in the ninth post.
The file type decides the block type: PDF and plain text use document, images use image, data files for code execution use container_upload. A wrong match, such as sending an image in a document block, returns 400.
The request that asks the catalog a question:
public static MessageCreateParams BuildRequest(string fileId, string question, ClaudeOptions settings) => new()
{
Model = settings.Model,
MaxTokens = 2048,
System = "You answer questions about a publisher's catalog for Bookstore's buyers. Use only the catalog. Say so when the catalog does not answer the question.",
Messages =
[
new UserMessageParam
{
Content = new List<ContentBlockParam>
{
// The same document in every question: cache it, the file is billed as input tokens each time.
new DocumentBlockParam
{
Source = new FileDocumentSource { FileID = fileId },
Title = "Publisher catalog",
CacheControl = new CacheControlEphemeral(),
},
new TextBlockParam { Text = question },
},
},
],
};
Here is the answer to the last question in Guess First. A file_id makes the HTTP body smaller but does not change the token cost; the model reads the file again on every question. What lowers the token cost is prompt caching from the seventh post. That is why we put cache_control on the document block: Hakan's second question within 5 minutes reads the catalog from the cache.
A file_id Is a Server-Side Reference
The most important warning on the Files API page is about authorization. Files belong to the workspace, not to an end user, a conversation or a session. Any API key with access to the workspace can read every file uploaded there. The documentation explicitly says not to accept a file_id from end users or other untrusted sources; otherwise one user could have another user's upload read.
In our design the file_id never leaves the server. After an upload the endpoint returns only the file name and the expiry; the question endpoint takes a publisherId and looks the id up in the server-side record. This is another form of the "authorization lives in code, not in the model" rule from the fourth post of the LLM Application Security series. For multitenant applications the documentation suggests a separate workspace per tenant; an organization can have 100 workspaces.
The tests check the multipart body, the JSON of the document block, and that the API is never called for an expired file:
Passed! - Failed: 0, Passed: 43, Skipped: 0, Total: 43, Duration: 62 ms - Bookstore.Api.Tests.dll (net10.0)
In Production
Store the batch id durably. An in-memory store is enough for this post's example; production needs a database table. If the application runs on several instances, make sure only one instance runs the job; the distributed lock topic from the Background Jobs and Scheduled Jobs series applies here.
Try the request shape synchronously first. You learn about batch validation errors only at the end. Before sending a 10,000-request batch with a new prompt or parameter, send the same request once with Messages.Create.
Plan for 24 hours. Under heavy demand a batch is processed more slowly and requests can become expired. For work that must be ready by morning, send the batch early in the evening and resend the expired ones on the next tick.
Manage file lifetimes. Files without an expiry stay in the organization's 1 TB of storage. When a new version of the catalog arrives, delete the old one with client.Files.Delete; according to the documentation files cannot be changed, so a new version means a new upload.
Common Pitfalls
Matching results by index. The order is not guaranteed; always use custom_id.
Sending a new batch when an interrupted job restarts. Store the batch id and check pending batches first.
Resending every errored request. An invalid_request_error gives the error again unless it is fixed.
Sending stream or speed in a batch. A validation error, and you learn it only when the batch has ended.
Giving the file_id to the client or taking it from the client. Every file in the workspace can be read with those ids.
Believing the Files API lowers token cost. Prompt caching does.
A Small Experiment
★☆☆ Add a Speed value to the Params in BookSeoBatch.BuildRequest and run the tests. Do the tests catch it? If they do not, write a test that confirms the four parameters a batch does not support are absent from the JSON, and think about when you would see this error on the real API.
★★☆ Add a CancelAsync to SeoBatchProcessor: it calls client.Messages.Batches.Cancel, and once the batch moves from canceling to ended, it collects the results with CollectAsync. Use the documented cancel response as the test fixture.
★★★ Add a clean-up flow to PublisherCatalogService: when a new catalog is uploaded for the same publisher, delete the old file_id with Files.Delete and update the store. Decide what to do when the delete fails and pin that down with a test.
Batches and the Files API made the work you send to Claude cheaper. In the ninth post we move to capabilities that run on Claude's side: ready-made xlsx, pptx and pdf skills through the Skills API, the code execution tool, and the MCP connector that uses remote MCP servers directly from the API.