Batch API
What a batch is
A batch is a list of ordinary requests — chat completions, Responses, Anthropic Messages or embeddings — sent in one call and processed whenever the provider has spare capacity during a 24-hour window. In exchange, batch pricing is typically 50% of the model's standard per-token price. Use it for work that doesn't need an answer right now: evaluations, classification, bulk summarization or extraction, synthetic data.
Batch-priced models carry a :batch suffix in OpenRouter's catalog (google/gemini-2.5-flash-lite:batch). Those ids only work here — a synchronous endpoint answers 400 batch_only_model.
Requirements
- An
openaioropenroutershaped key. Other shapes answer404 wrong_api_shape. - Your own OpenRouter key on that FreeRouter key, as a routing target or a remap destination. Batches run on OpenRouter today — the only connected gateway whose batch API keeps results itself. The FreeRouter Starter key can't run batch jobs.
- A model with a batch variant. Filter OpenRouter's models page by the batch variant, or look for
:batchids in LLMscape. A listed variant can still be refused for a particular account; that answersbatch_model_not_availablewith OpenRouter's reason.
Submit a batch
POST /v1/batches with the requests inline — there is no file upload. Every request in a batch uses the same endpoint and model; to mix them, submit separate batches.
curl https://api.freerouter.com/v1/batches \
-H "Authorization: Bearer $FREEROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"endpoint": "/v1/chat/completions",
"model": "google/gemini-2.5-flash-lite",
"requests": [
{ "custom_id": "req-0001", "body": { "messages": [{ "role": "user", "content": "Capital of France?" }], "max_tokens": 16 } },
{ "custom_id": "req-0002", "body": { "messages": [{ "role": "user", "content": "Name a primary color." }], "max_tokens": 16 } }
]
}'
| Field | Notes |
|---|---|
endpoint | Required. /v1/chat/completions, /v1/responses, /v1/messages or /v1/embeddings. |
model | Required. The plain slug or its :batch spelling — both work. A body.model inside a request, if you set one, must name the same model. |
provider | Optional. { "only": ["<provider-slug>"] } pins OpenRouter's upstream providers. |
completion_window | Optional. Only 24h. |
requests | Required, last. Each item is { "custom_id", "body" }; custom_id must be unique in the batch, and body is what you would send to endpoint directly. |
Field order matters: endpoint and model (and provider, completion_window) must come before requests. FreeRouter streams the body through rather than holding it in memory, so it routes on the fields it has read before the requests arrive. A body with requests first, or anything after it, answers 400 invalid_batch_body.
A successful submit answers 202 with the batch object and status: "validating". That means the batch is queued, not finished:
{
"id": "batch_fr_CATeFQbj…",
"object": "batch",
"endpoint": "/v1/chat/completions",
"model": "google/gemini-2.5-flash-lite",
"completion_window": "24h",
"status": "validating",
"created_at": 1791053607,
"request_counts": { "total": 2, "completed": 0, "failed": 0 },
"usage": null,
"results": null,
"error": null
}
The id is FreeRouter's own (batch_fr_…): it carries which provider key holds the batch, and it is bound to the API key that created it — another key gets 404 for it.
Poll for status and results
GET /v1/batches/{id} returns the same object. Poll until status is terminal; most batches finish well inside the window (minutes, in our tests).
| Status | Meaning |
|---|---|
validating → in_progress → finalizing | Still running. results is null. |
completed | Done. results is the full array, inline; usage.cost is what was charged. Individual requests can still have failed. |
failed, expired, cancelled | Terminal without results. |
Each result carries exactly one of response or error, and results are not in submission order — match them by custom_id:
{ "custom_id": "req-0001",
"response": { "status_code": 200, "request_id": "…", "body": { "choices": [ … ] } },
"error": null }
For the first couple of minutes after a submit, OpenRouter can't see its own new batch yet. FreeRouter covers that window and answers validating instead of a 404.
List and delete
GET /v1/batches lists the batches this API key submitted through FreeRouter, newest first: { "object": "list", "data", "first_id", "last_id", "has_more" }, with results always null (fetch one batch for its results). Query parameters: limit (1–100, default 20), after (the previous page's last_id), repeatable status, and created_after / created_before (unix seconds or ISO-8601).
DELETE /v1/batches/{id} purges the batch's inputs and results from OpenRouter (and its upstream provider, where supported) without waiting for the 30-day retention. It only works on a terminal batch — a running one answers 409 batch_not_terminal. There is no cancel: OpenRouter's Batch API does not offer one.
In the dashboard
The dashboard's Batches page lists the workspace's batches with their status, request counts and cost, filterable by API key and status. FreeRouter checks every unfinished batch with OpenRouter every 5 minutes, so the page — and GET /v1/batches — stays current even if you never poll. A finished batch can be deleted there, which purges its inputs and results from OpenRouter.
Notifications
Instead of polling, have FreeRouter tell you when a batch finishes. Notifications are set per API key, under API Keys → Advanced → Batch notifications, and both are off until you turn them on:
- Email — sent to the person who created the key, with the batch's model, request counts and cost.
- Webhook — a signed
POSTto anhttpsURL you choose. Saving a URL generates a signing secret (whsec_…), shown under the URL; Rotate replaces it.
A notification goes out once per batch, within about five minutes of it reaching completed, failed, expired or cancelled. The webhook request:
POST https://example.com/hooks/freerouter
Content-Type: application/json
User-Agent: FreeRouter-Webhooks/1
X-FreeRouter-Event: batch.completed
X-FreeRouter-Delivery: 3f9c2e… # same value on every retry of this event
X-FreeRouter-Signature: t=1791054842,v1=5b1f0c…
{
"type": "batch.completed",
"created_at": 1791054842,
"data": {
"id": "batch_fr_…", "object": "batch", "status": "completed",
"endpoint": "/v1/chat/completions", "model": "google/gemini-2.5-flash-lite",
"request_counts": { "total": 3, "completed": 3, "failed": 0 },
"usage": { "cost": 0.0000019 }, "created_at": 1791054374, "finalized_at": 1791054842, "error": null
}
}
The event never carries results — fetch them with GET /v1/batches/{id}. Verify every delivery before trusting it: the signature is an HMAC-SHA256, keyed with your secret, of the timestamp, a dot, and the raw request body. Reject old timestamps to stop replays:
const crypto = require('crypto');
function verify(rawBody, header, secret, toleranceSec = 300) {
const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=')));
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1 || ''));
}
Answer any 2xx to acknowledge. Anything else — including a redirect, which is not followed, or no answer within 10 seconds — is retried on the next two runs (about 5 and 10 minutes later), then dropped; the Batches page shows the last webhook result. Use X-FreeRouter-Delivery to ignore duplicates. Webhook URLs must be public: https only, no credentials in the URL, and addresses that resolve to private, loopback or link-local networks are refused, both when you save the URL and on every delivery.
How your key's settings apply
- Routing rule. The batch goes to the first eligible target in the rule's order (a split picks the first by weight): an OpenRouter key of your own whose catalog lists the model's
:batchvariant. There is no failover once the upload starts — the body streams through and isn't kept — and the batch then stays on that provider key for its lifetime. - Model remaps apply, matched on the plain model id (so a remap for
openai/gpt-4oalso catchesopenai/gpt-4o:batch) and rolled once per batch: a 20% remap sends about 20% of batches, whole. A remap whose destination can't batch is ignored. - Steering applies to every request, where each endpoint keeps its system prompt: the system message (chat),
instructions(Responses), the top-levelsystem(Messages). Embeddings are left alone. - Not applied: Companion Ads, Monetizable Keyterms, MCP tools and the Jev decisioner — there is no live response to attach to, and a batch has one model.
- Logs. Each submit, read, list and delete is a row in the dashboard Logs tab. Request bodies are never captured for batches.
Limits
Keep each submit under 100 MB and split larger jobs into several batches: OpenRouter accepts up to 200 MB, but a request to api.freerouter.com crosses an edge network with its own body limit. Otherwise the limits are OpenRouter's, passed through as-is: a separate batch rate limit (429) and a balance check against the estimated cost (402). An upload that stalls for two minutes is dropped (408). Inputs and results are kept 30 days. OpenRouter rejects stream: true, base64 or data: images (use public URLs), audio and video, and :online variants. Batch requests are billed to your OpenRouter account at its batch rates.
Errors
| Code | Status | Meaning |
|---|---|---|
batch_not_supported | 400 | No target on this key can batch: no OpenRouter key of your own (the Starter key doesn't count). |
batch_model_not_available | 404 | The model has no :batch variant on your OpenRouter key, per FreeRouter's catalog or OpenRouter itself. |
invalid_batch_body | 400 | endpoint/model missing, or not before requests, or fields after it. |
invalid_endpoint | 400 | endpoint is not one of the four supported paths. |
empty_requests | 400 | requests has no items. |
invalid_json / value_too_large | 400 / 413 | Malformed body, or a single request over 16 MB. |
invalid_batch_id / batch_not_found | 404 | Not an id FreeRouter issued, or not this key's batch. |
batch_expired | 410 | Past the 30-day retention. |
batch_target_unavailable | 409 | The provider key holding the batch is no longer on this API key — re-attach it. |
batch_not_terminal | 409 | Delete on a batch that is still running. |
upstream_timeout | 504 | OpenRouter received the whole batch but did not confirm it in time. It may still have been created — check GET /v1/batches before resubmitting. |
batch_check_unavailable / batch_ids_unavailable | 503 | A temporary FreeRouter-side problem; nothing was submitted. Retry shortly. |
Anything else OpenRouter refuses (a duplicate custom_id is 422, a mismatched body.model is 400, too little credit is 402) is relayed with its status, message and OpenRouter's own numeric code, exactly as an OpenRouter client expects. If you hang up after the upload finishes, the batch is still recorded and appears in GET /v1/batches.
Try it from a terminal
samples/freerouter-batch.js in the FreeRouter repo submits, polls, lists and deletes, and its probe command runs free contract checks. It also takes --direct-openrouter with an sk-or-… key, so you can compare FreeRouter's answers with OpenRouter's byte for byte.
node samples/freerouter-batch.js submit --key $FREEROUTER_API_KEY --model google/gemini-2.5-flash-lite --wait