Batch inference

Send thousands of requests in one job and collect the results later at the provider's batch price

Batch inference runs many requests as one job. You submit up to 10,000 requests in a single call, Gateway hands them to the provider’s batch API, and you collect the results when the job finishes. Most batches finish within minutes, and every batch finishes or expires within 24 hours. In exchange for waiting, you pay the route’s batch price, typically 50% of standard.

Use batch for work that doesn’t need an answer right away: evals, backfills, classification, document processing, and embeddings. For interactive traffic, send normal requests.

How batch works

  1. You send POST /v1/batches with an endpoint, a model, and a requests array. Each request has a custom_id and a body in the same shape you’d send to that endpoint.
  2. Gateway validates every line, screens it with your DLP rules, checks your balance, and submits the whole batch to one provider.
  3. You poll GET /v1/batches/{id} until the batch reaches a finished status.
  4. You read the results from GET /v1/batches/{id}/results, one line per request, matched by custom_id.
  5. Gateway bills each request line once, when its result comes back. Lines that errored, expired, or were cancelled cost nothing.

One bad request doesn’t fail the batch. Each line succeeds or fails on its own.

Supported endpoints and providers

endpointProvidersNotes
/v1/chat/completionsAnthropic, OpenAIImages and PDFs by public URL on supported providers
/v1/embeddingsOpenAIBilled on input only
/v1/messagesAnthropicNative Anthropic Messages requests and results
/v1/responsesOpenAINative OpenAI Responses requests and results

A route supports batch when its provider can run the batch’s endpoint and the route has a batch price. To see which models qualify, check GET /v1/models: a batch-capable vendor route lists batch in service_tiers, a pricing.batch price, and the batch endpoints it runs in batch_endpoints.

{
"model": "openai/gpt-5.4",
"vendors": {
"openai": {
"service_tiers": ["standard", "flex", "batch"],
"batch_endpoints": ["/v1/chat/completions", "/v1/embeddings", "/v1/responses"],
"pricing": {
"input_per_million": 2.5,
"output_per_million": 15,
"batch": { "input_per_million": 1.25, "output_per_million": 7.5 }
}
}
}
}

batch in service_tiers only applies to POST /v1/batches; you can’t send service_tier: "batch" on a normal request.

If no route you’re allowed to use can batch the model, the submit fails closed with 403 batch_not_supported and nothing is sent to the provider.

Choosing a provider

Each batch runs on one provider for its whole life. Gateway picks it the same way it picks a vendor for a normal request: your organization’s vendor, region, and zero data retention rules apply first. It then keeps only routes that can batch the endpoint and chooses the cheapest. If your organization prefers its own provider keys, an eligible BYOK route wins over a cheaper managed one.

To limit a batch to specific providers, add provider.only:

{
"endpoint": "/v1/chat/completions",
"model": "anthropic/claude-sonnet-4-6",
"provider": { "only": ["anthropic"] },
"requests": [ ]
}

provider.only can narrow your organization’s rules but never widen them. If none of the listed providers can run the batch, Gateway returns 400 vendor_unavailable. only is the only supported key in provider.

Quickstart

This script submits a two-line batch, waits for it to finish, and prints each result.

batch_quickstart.py
import json
import time
import requests
BASE_URL = "https://api-gateway.merge.dev/v1/batches"
HEADERS = {"Authorization": "Bearer YOUR_API_KEY"}
FINISHED = {"completed", "failed", "expired", "cancelled"}
batch = requests.post(
BASE_URL,
headers=HEADERS,
json={
"endpoint": "/v1/chat/completions",
"model": "anthropic/claude-sonnet-4-6",
"requests": [
{
"custom_id": "ticket-1041",
"body": {
"messages": [{"role": "user", "content": "Classify this ticket: My invoice is wrong."}],
"max_tokens": 50,
},
},
{
"custom_id": "ticket-1042",
"body": {
"messages": [{"role": "user", "content": "Classify this ticket: I cannot log in."}],
"max_tokens": 50,
},
},
],
},
).json()
while batch["status"] not in FINISHED:
time.sleep(60)
batch = requests.get(f"{BASE_URL}/{batch['id']}", headers=HEADERS).json()
print(batch["status"], batch["request_counts"])
if batch["status"] in ("completed", "cancelled"):
results = requests.get(f"{BASE_URL}/{batch['id']}/results", headers=HEADERS)
for line in results.text.splitlines():
row = json.loads(line)
result = row["result"]
if result["type"] == "succeeded":
print(row["custom_id"], result["response"]["choices"][0]["message"]["content"])
else:
print(row["custom_id"], result["type"], result.get("error"))

The sections below cover each step in detail.

Submit a batch

curl https://api-gateway.merge.dev/v1/batches \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"endpoint": "/v1/chat/completions",
"model": "anthropic/claude-sonnet-4-6",
"metadata": { "job": "ticket-triage-2026-09-24" },
"requests": [
{
"custom_id": "ticket-1041",
"body": {
"messages": [{ "role": "user", "content": "Classify this ticket: My invoice is wrong." }],
"max_tokens": 50
}
},
{
"custom_id": "ticket-1042",
"body": {
"messages": [{ "role": "user", "content": "Classify this ticket: I cannot log in." }],
"max_tokens": 50
}
}
]
}'

Gateway responds with 202 and the batch object:

{
"id": "batch_2f0c8a91d4e64b7c9a1e0b3d5f7a9c21",
"object": "batch",
"endpoint": "/v1/chat/completions",
"model": "anthropic/claude-sonnet-4-6",
"status": "in_progress",
"request_counts": { "total": 2, "completed": 0, "failed": 0, "cancelled": 0, "expired": 0 },
"created_at": 1790265600,
"expires_at": 1790352000,
"completed_at": null,
"usage": { "cost": null, "is_byok": false },
"metadata": { "job": "ticket-triage-2026-09-24" },
"error": null
}

Rules for each line:

  • custom_id is required, unique within the batch, 1 to 64 characters, and uses only letters, digits, _, and -
  • body is what you’d send to the batch’s endpoint. If a line sets model, it must match the batch’s model.
  • Chat lines need a non-empty messages array. stream: true isn’t supported in batch.
  • Web search isn’t supported in batch, because search calls are billed separately from tokens. Lines with web search tools or web_search_options, and search models such as those ending in :online, are rejected with 422 web_search_unsupported.
  • Audio and video aren’t supported. Images and files are supported by public URL; see Images and files.

On Anthropic routes, Gateway forwards messages (including system messages), max_tokens, temperature, top_p, stop, tools, and tool_choice. Parameters that would change the output but that Anthropic batch can’t honor, such as response_format, n greater than 1, logprobs, reasoning_effort, and seed, are rejected with 400 unsupported_params instead of being ignored. For native Anthropic features, send a Messages batch. OpenAI routes receive the line body unchanged.

Track a batch

Poll the batch until its status is completed, failed, expired, or cancelled. Every few minutes is often enough.

cURL
curl https://api-gateway.merge.dev/v1/batches/batch_2f0c8a91d4e64b7c9a1e0b3d5f7a9c21 \
-H "Authorization: Bearer YOUR_API_KEY"
StatusMeaning
validatingGateway accepted the batch and is submitting it
in_progressThe provider is running the requests
finalizingThe provider finished and is preparing the results
completedResults are ready. Some lines may still have failed; check request_counts.
failedThe batch didn’t run. error says why.
expiredThe 24-hour window ended before the provider finished
cancellingA cancel is in progress
cancelledThe batch was cancelled. Lines that finished first are kept and billed.

You can also follow your organization’s batches on the Batches page in the Gateway dashboard.

request_counts shows how many requests completed, failed, expired, or were cancelled. After the batch is billed, usage reports its token totals, cost (what Gateway billed for the batch), and is_byok (whether it ran on your own provider key).

Read the results

Results are available once the batch is completed, or cancelled after some lines finished, and they stay available for 29 days. The response is newline-delimited JSON with one line per request. Match lines to your requests by custom_id, because they can arrive in any order.

cURL
curl https://api-gateway.merge.dev/v1/batches/batch_2f0c8a91d4e64b7c9a1e0b3d5f7a9c21/results \
-H "Authorization: Bearer YOUR_API_KEY"
{"custom_id": "ticket-1041", "result": {"type": "succeeded", "response": {"id": "msg_01", "object": "chat.completion", "choices": [{"index": 0, "message": {"role": "assistant", "content": "billing"}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 18, "completion_tokens": 2, "total_tokens": 20}}}}
{"custom_id": "ticket-1042", "result": {"type": "errored", "error": {"message": "prompt is too long: 212000 tokens > 200000 maximum"}}}

result.type is succeeded, errored, expired, or canceled. A succeeded line has a response in the endpoint’s normal response shape. Every other type has an error and isn’t billed.

Asking for results before any are ready returns 409 batch_results_not_ready.

Cancel a batch

cURL
curl -X POST https://api-gateway.merge.dev/v1/batches/batch_2f0c8a91d4e64b7c9a1e0b3d5f7a9c21/cancel \
-H "Authorization: Bearer YOUR_API_KEY"

The batch moves to cancelling, then cancelled. Requests that finished before the cancel are kept, returned in the results, and billed. The rest are marked canceled and cost nothing. Cancelling a batch that already finished returns 409 batch_not_cancellable.

List batches

GET /v1/batches returns your organization’s batches, newest first. Keys scoped to a customer see only that customer’s batches.

cURL
curl "https://api-gateway.merge.dev/v1/batches?status=completed&status=failed&created_after=2026-09-01&limit=20" \
-H "Authorization: Bearer YOUR_API_KEY"
ParameterDescription
limitBatches per page, from 1 to 100. Defaults to 20.
afterCursor from the previous page’s last_id
statusFilter by status. Repeat it to match several.
created_afterBatches created at or after this time, as unix seconds or an ISO 8601 date or datetime. A value without a timezone is read as UTC.
created_beforeBatches created before this time, same formats

The response has data, first_id, last_id, and has_more. A page only comes back short when nothing else matches.

Delete a batch

Delete a finished batch to remove it from Gateway and from the provider.

cURL
curl -X DELETE https://api-gateway.merge.dev/v1/batches/batch_2f0c8a91d4e64b7c9a1e0b3d5f7a9c21 \
-H "Authorization: Bearer YOUR_API_KEY"
{
"id": "batch_2f0c8a91d4e64b7c9a1e0b3d5f7a9c21",
"object": "batch.deleted",
"deleted": true,
"deletion": {
"merge": "deleted",
"upstream": { "provider": "anthropic", "status": "deleted" }
}
}

deletion.upstream.status is deleted, failed, unsupported, or not_applicable. If the provider cleanup fails, the batch is still deleted from Gateway and the response says so. Deleting doesn’t remove the batch’s usage and billing history.

A batch can be deleted only once it has finished and been billed. Before that, the request returns 409 batch_not_deletable. After a delete, the batch’s status, results, and delete endpoints all return 404.

Deleting a batch is permanent. Its results can’t be recovered afterwards.

Embeddings batches

Set endpoint to /v1/embeddings and give each line an input: a string, an array of strings, a token array, or an array of token arrays. You can also pass dimensions, encoding_format, and user.

cURL
curl https://api-gateway.merge.dev/v1/batches \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"endpoint": "/v1/embeddings",
"model": "openai/text-embedding-3-small",
"requests": [
{ "custom_id": "doc-001", "body": { "input": "Refund policy for annual plans", "dimensions": 256 } },
{ "custom_id": "doc-002", "body": { "input": ["Shipping times", "Return window"] } }
]
}'

Embeddings batches run on OpenAI, are billed on input tokens only, and allow up to 50,000 inputs per batch.

Messages batches

Set endpoint to /v1/messages to send native Anthropic Messages requests. Each line’s body is a Messages request, and the results are Anthropic message objects rather than chat completions. Messages batches run on Anthropic.

cURL
curl https://api-gateway.merge.dev/v1/batches \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"endpoint": "/v1/messages",
"model": "anthropic/claude-sonnet-4-6",
"requests": [
{
"custom_id": "summary-001",
"body": {
"max_tokens": 300,
"system": "You summarize support tickets in one sentence.",
"messages": [{ "role": "user", "content": "Customer reports duplicate charges on the March invoice." }]
}
}
]
}'
  • max_tokens is required (400 max_tokens_required), and messages must be non-empty
  • Message content must be text. Tool definitions and tool_choice are supported, but tool_use and tool_result blocks in the conversation history aren’t yet
  • system is screened by your DLP rules like the rest of the request

Messages lines are billed exactly like the equivalent chat line, including cache reads and writes.

Responses batches

Set endpoint to /v1/responses to send OpenAI Responses requests. Each line’s body is a Responses request, and the results are OpenAI Response objects. Responses batches run on OpenAI.

cURL
curl https://api-gateway.merge.dev/v1/batches \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"endpoint": "/v1/responses",
"model": "openai/gpt-5.4",
"requests": [
{
"custom_id": "extract-001",
"body": {
"instructions": "Return the invoice number only.",
"input": "Invoice INV-20931 is overdue by 14 days.",
"max_output_tokens": 20
}
}
]
}'
  • input is required and must be non-empty (400 empty_input)
  • Each line runs on its own, so background, previous_response_id, and conversation are rejected with 400 unsupported_params
  • instructions is screened by your DLP rules like the rest of the request

Reasoning tokens are billed as output and cached input as cache reads, the same as the equivalent chat line.

Images and files

Chat lines can include images and files by public http or https URL. The provider downloads the file; Gateway never fetches it.

ProviderImagesFiles
AnthropicYesPDFs
OpenAIYesNo
{
"custom_id": "receipt-001",
"body": {
"max_tokens": 200,
"messages": [
{
"role": "user",
"content": [
{ "type": "text", "text": "What is the total on this receipt?" },
{ "type": "image_url", "image_url": { "url": "https://assets.acme.com/receipts/r-2231.png" } }
]
}
]
}
}

For a PDF on Anthropic, use a file part: { "type": "file", "file": { "file_data": "https://assets.acme.com/contracts/msa.pdf" } }.

These are rejected with 400 unsupported_batch_media: data: URIs and base64 content, non-http(s) URLs, private or localhost addresses, provider file IDs, and media the chosen provider doesn’t accept. Media is only allowed in user messages.

DLP rules can’t scan images or files. If your organization has DLP rules, batches with images or files are rejected with 422 batch_media_blocked_by_dlp, so no unscreened content reaches a provider.

Billing

  • Price: each line is priced like a normal request, including prompt caching, long-context, and time-of-day pricing, and then the route’s batch discount is applied to the whole line. Cached tokens get the batch discount too.
  • Locked at submit: the discount is fixed when you submit the batch, so a price change while it runs doesn’t change its bill.
  • When you’re charged: once per line, when results come back. Errored, expired, and cancelled lines are free.
  • Your own keys: on a BYOK key, the provider bills you directly and Gateway charges only its usual fee.
  • Prepaid balances: a new batch is refused with 402 batch_budget_exceeded if its estimated cost, plus the estimates of your batches that haven’t been billed yet, is more than your remaining balance.

See Service tiers for the interactive flex tier, the other way to trade latency for price.

Limits

LimitValue
Requests per batch10,000
Request body size50 MB
Embedding inputs per batch50,000
Open batches per organization25 by default
Completion window24 hours
Results retention29 days

On OpenAI, Gateway deletes the uploaded input file when the batch ends, and all batch files expire after 30 days.

Errors

StatusCodeWhat it means
400unsupported_batch_endpointendpoint isn’t one of the four batch endpoints
400empty_batchrequests is empty
400invalid_custom_id, duplicate_custom_idA custom_id is malformed or repeated
400batch_model_mismatchA line’s model differs from the batch’s model
400stream_unsupported, empty_messages, empty_input, max_tokens_required, invalid_request_bodyA line can’t run in a batch. The message names the custom_id.
400unsupported_paramsA line uses a parameter the batch’s provider or endpoint can’t honor
400unsupported_batch_mediaAn image or file isn’t a public URL, or the provider doesn’t accept it
400vendor_unavailable, unsupported_parameterprovider.only matches no eligible provider, or provider has a key other than only
402batch_budget_exceededThe batch doesn’t fit your remaining prepaid balance
403batch_not_enabledBatch isn’t enabled for your organization
403batch_not_supportedThe model’s route has no batch price, or its provider can’t run this endpoint
409batch_results_not_ready, batch_not_cancellable, batch_not_deletableThe batch isn’t in the right status for this action
413batch_body_too_large, batch_too_largeThe batch is over a size limit
422blocked_by_dlp_policyYour DLP rules block one or more lines. Nothing was sent to the provider.
422batch_media_blocked_by_dlpYour organization has DLP rules and the batch contains images or files
422web_search_unsupportedA line or the model asks for web search
429too_many_open_batchesYou have the maximum number of open batches
429batch_submit_busy, provider_batch_queue_fullGateway or the provider is at capacity. Retry after the Retry-After header. On OpenAI, a full queue shows up instead as a failed batch with error.code set to provider_batch_queue_full.

On a managed key, the provider limits how much batch work can be queued at once. The batch wasn’t run and wasn’t billed. Resubmit it later, or split it into smaller batches.

Providers finish most batches within minutes, but they can take up to 24 hours. Anything unfinished at 24 hours expires, and expired lines aren’t billed.

The model’s route doesn’t have a batch price yet, or its provider doesn’t run this endpoint in batch. Try another supported model, or contact your Merge account team.

Next steps