Skip to main content
Enriching hundreds of company rows should not keep your application waiting: use the Batch API to run web-grounded Agent API requests asynchronously, then join the results back to your CSV. This recipe fills five columns: headquarters city, founding year, a one-line description, a recent news headline, and its URL. You submit the work once and keep completed rows even when some requests fail. The Batch API is built for this. You write the same Agent API requests to a JSONL file, upload it, create a batch, and come back later. Requests inside a batch do not count toward the Agent API request rate limit (token rate limits still apply, and the batch retries a request that hits one). They run on the flex service tier where the model supports it, including openai/gpt-5.6-sol. Flex halves model token prices only; tool calls such as web_search bill at their normal price. When the batch ends you download an output file, one JSON line per request, and join it back to your rows by the custom_id you chose. This recipe does that for a 12-row sample CSV. enrichment.py builds the request file and validates the results; run_batch.py handles upload, polling, and download. Two test files check the parser and batch lifecycle without an API key. The model returns JSON text for each company; your code validates the field types, joins it to the row, and decides what to retry.

What you need

  • Python 3.10 or newer on macOS or Linux. The shell commands use Unix syntax.
  • A Perplexity API key with API credits. Organizations with Zero Data Retention cannot use batch, because batch keeps inputs and outputs for 30 days; for them the endpoints return 403.
  • httpx, a plain HTTP client. This recipe calls the four endpoints directly: POST /v1/files, POST /v1/batches, GET /v1/batches/{id}, and GET /v1/files/{id}/content. You do not need the Perplexity Python library.
Every line in a batch file must use the same model. This recipe uses openai/gpt-5.6-sol; see the Batch API guide for other supported models and request restrictions.

Set up

Everything lives in one directory. You create the first six files from this page; the script writes the rest.
requirements.txt
Create the directory, save requirements.txt in it, then create a virtual environment, install the packages, and export your key.

The companies

Twelve well-known public companies with a name and a website. The website is in the prompt so the search lands on the right Apple. Save the file as companies.csv. Your real file can include additional columns. The script replaces any existing enrichment or status columns and preserves your other columns. Schema validation checks the shape and types, not whether the claims are true or the news URL is trustworthy. Review returned sources before using the rows for customer-facing claims, and do not treat a web search as an exhaustive check for the latest news.
companies.csv

Build the request file

enrichment.py holds everything that does not touch the network. Each excerpt below is an exact slice of the file, which is at the end of the section.

Constants, schema, and instructions

MODEL is the one model every line uses. RESPONSE_SCHEMA is the JSON shape you want back, sent as response_format so the Agent API returns an object with exactly these five keys (Output Control). INSTRUCTIONS tells the model to confirm every field with web search and to return only the object. The schema is the contract; the instructions improve how often the model meets it.

Custom ids and the CSV

custom_id is how you get results back to rows, because the output file is not in input order. It must be unique within the file and 1 to 64 bytes. make_custom_id builds row-003-nvidia from the row number and a slug of the name, and truncates the slug on a UTF-8 boundary so a long or accented name cannot push it over 64 bytes. read_companies reads the CSV and attaches the id to every row.

One request per row

build_request_body is a normal Agent API request body: the model, the instructions, a prompt with the company name and website, the built-in web_search tool, and the response_format. The same function serves a batch line and a single synchronous POST /v1/agent call, which is how you spot-check a prompt change before you send 800 of them. build_batch_lines wraps each body in the batch envelope: custom_id, method: POST, url: /v1/responses. write_jsonl writes one compact object per line with LF endings and no blank lines, which is what the upload validates.

Parse the results

What the recipe keeps per row

Result is one row’s outcome: a status, the five parsed fields, an error string, the Response id, and the API-reported cost with its included tool subtotal. ok is true only for completed; everything else goes to the retry file. message_text concatenates the output_text parts of every message item in a Response. parse_enrichment loads the JSON, strips a markdown fence if the model added one anyway, and returns None unless all five keys are present.

Output lines and error lines

An output line carries the full Response under response.body. parse_output_line reads response.body.status first: incomplete (for example max_output_tokens reached) and failed are not successes even though the line is in the output file. Only a completed response is parsed, and a response whose text is not the object you asked for becomes parse_error. The error file has two line shapes, and parse_error_line handles both. A request the API rejected has error: null and a response with the HTTP status and error body. A request that stopped without an answer has response: null and an error with a code such as batch_expired, batch_cancelled, poll_timeout, or response_too_large.

Join, retry file, and summary

collect_results indexes every output and error line by custom_id, then walks your rows and marks any row with no line at all as missing. That happens when a batch ends failed and output_file_id is null, and it means no row can silently disappear from the join. write_enriched_csv writes every input row with its original columns, the five new ones, and status, error, and response_id. retry_lines returns the original request lines for every row that is not completed, so the retry file is ready to upload as the next batch. summarize counts rows by status and sums the per-request cost from the output file.

Complete enrichment.py

Save this as enrichment.py. It is the six excerpts above in order, nothing else.
enrichment.py

Run the batch

run_batch.py is the HTTP side. It saves the file id and batch id to batch_state.json, and persists the create request’s job name and idempotency key before sending it. Resume with --resume and the same input file. If a create response was lost, resume within the API’s 24-hour idempotency window; after that, inspect your existing batches before creating another.

Errors and the upload

check turns an HTTP error into a BatchError with the step name and response body. A 404 means the batch or file was not found, belongs to another organization, or has expired. upload_input posts the file as multipart/form-data with purpose=batch. The API validates every line before it stores anything. If a line is bad, the upload returns 400 with the line number and reason, and you fix the file rather than discovering the problem a day later.

Create the batch

create_batch posts the file id with endpoint: /v1/responses and a metadata.job label. The Idempotency-Key header makes the create safe to retry within 24 hours. The script saves both the key and metadata before sending the request, so a resume on the next day does not change the request if the original response was lost. Reusing a key with a different request returns 400.

Poll and download

poll_batch reads GET /v1/batches/{id} until the status is completed, expired, cancelled, or failed. It starts at 30 seconds between polls and backs off by half each time, up to five minutes, because a batch that takes hours does not need a request every 30 seconds and the batch endpoints have normal request rate limits. A 429 waits for Retry-After. A network error is logged and retried rather than ending the run, since the batch is still running on the server either way. A final status means processing ended, not that every request succeeded. download_file fetches output_file_id and, when it is not null, error_file_id, writes each to disk so you keep the raw evidence, and parses the lines.

The run

main reads the CSV, writes batch_input.jsonl, and hashes it. With --requests it uploads an existing JSONL instead, such as batch_retry.jsonl, and keeps only the rows in it. With --resume it loads batch_state.json, refuses to continue if the state belongs to a different input file, and skips the upload or create steps it already finished. Then it polls, downloads, joins, writes the retry file, and prints the summary: counts by status, the batch’s token usage, the summed per-request cost, how long the batch took, and one line per row that needs a retry. The batch object’s usage covers the output file only, so it is not your full bill. In all 14 captured responses, response.body.usage.cost.total_cost equaled input_cost + cache_creation_cost + output_cost + tool_calls_cost. The script sums total_cost and displays tool_calls_cost as an included subtotal, not an extra charge. Adding that subtotal again would double-count tools in these responses.

Complete run_batch.py

Save this as run_batch.py. It is the four excerpts above in order, nothing else.
run_batch.py

Prove it without an API key

test_enrichment.py runs the pure functions against fixtures shaped like the Batch API guide’s examples. It checks that the request file has no blank lines, unique ids within 64 bytes, POST, /v1/responses, and one model throughout; that a completed line parses, a fenced one parses, prose and partial objects are rejected, and incomplete is not a success; both error-line shapes; that the join keeps every row and marks a row with no line as missing; and that the retry file holds exactly the original lines for the rows that did not complete. Save it as test_enrichment.py.
test_enrichment.py

Test the batch lifecycle

Save test_run_batch.py next to the other files. It runs the HTTP workflow against a local test double, including a lost create response, a next-day resume with the same idempotency key and metadata, downloads, and a partial failure that exits 1. Run both test files with python -m pytest -q.
test_run_batch.py

Spot-check one request first

Before you send a whole file, send one row’s body to the synchronous endpoint and read what comes back. The body is identical to a batch line, so this checks the prompt, the schema, and the parser against a real response.
Observed on October 21, 2026 using the request builder and parser extracted from this page. The Shopify response reported two search calls, 9,237 input tokens, 682 output tokens, service_tier: default, and a total cost of $0.06412, including $0.04267 for prompt-cache creation.
A second request for Nestlé, row 6, returned five valid fields, including Vevey, 1866, and a Reuters news URL. It reported 8,806 input tokens, 406 output tokens, two search calls, and a total cost of $0.05645 on the default tier. These are synchronous results, not batch results. Field validation does not independently verify the claims or the news URLs.

Run it

From the directory with the six files, with the virtual environment active and the key exported:
The script prints the file id and batch id, then one line per poll. Leave it running, or stop it and come back with python run_batch.py --resume. Batches target completion within 24 hours; this is not a deadline. The script exits 1 if any row needs retrying or the batch did not complete, while still saving the available results. Observed on October 21, 2026 on Linux with Python 3.12, using files extracted from this page. All 12 responses completed, passed field validation, and joined to the correct CSV rows. All reported service_tier: "flex". The batch’s created_at and completed_at were 21 seconds apart; polling and download finished in about 32 seconds. The create response was validating, followed by observed in_progress and completed poll responses. No failed, incomplete, or expired responses appeared, and error_file_id was null.
companies_enriched.csv has every input row. A completed row has the five new columns filled. Any other row has them blank and the reason in error. Open batch_output.jsonl when you want the full Response for a row, including the search results the model used; response_id in the CSV is the key. The API returned 12 JSONL records with custom_id, response.status_code: 200, a full Response under response.body, and error: null. The code joins by id even when output order differs. When the API returns no error-file id, the script writes an empty local batch_errors.jsonl so an older run’s errors cannot remain there.

Retry what failed

batch_retry.jsonl is a valid batch input file containing only the requests that did not complete. Send it as its own batch and write the results to a separate CSV so the first join stays intact:
A retry run overwrites batch_output.jsonl, batch_errors.jsonl, batch_retry.jsonl, and batch_state.json. Copy the first run’s files aside if you want to keep them. Keep retry_input.jsonl unchanged so you can resume with python run_batch.py --resume --requests retry_input.jsonl --out companies_retry.csv. Inspect failed rows before sending them again; configuration errors need a fix, while transient failures may only need a retry. The live 12-row run needed no retry. To test selective retry, a separate local copy omitted Apple’s result and replaced Microsoft’s response text with invalid JSON. The parser kept 10 completed rows, marked one missing and one parse_error, and selected only the two original requests. That was a local failure fixture, not a service failure. Submitting those two requests with the command above created batch_01M5GBBXT2245TAFJZ06224M5N. Both completed on flex and passed field validation. The batch’s timestamps were 14 seconds apart; polling and download finished in about 32 seconds. It used 17,189 input tokens and 868 output tokens, and reported $0.06094 in total request costs. Its retry file was empty.

What it costs and how long it takes

There is no separate batch price. Requests run on the flex tier where the model supports it. Flex halves model token prices only; tool calls such as web_search bill at their normal price. Starting 20 to 24 hours after creation, requests that start or resume run on the default tier so the batch can finish before its 48-hour deadline. Requests that never start are not billed. The 12-row live run reported $0.38162, including $0.06000 in tool_calls_cost. The two-row retry reported $0.06094, including $0.01000 for tools. The combined $0.44256 includes $0.07000 in tool charges; the totals are unchanged because total_cost already included them. The batch-level usage contained token counts, not a cost total. These are API-reported costs for this run, not a billing-ledger reconciliation or a price guarantee; inspect your own results. The live batch metadata put expires_at 48 hours after created_at, and both input and output files had 30-day expiry metadata. A duplicate-id upload returned 400 with error.type: "invalid_request"; cancelling the completed batch returned 409 with error.type: "conflict". The run did not exercise a live request error file, active cancellation, or actual deadline expiry. The error-file parsers and failed-result paths are covered by the offline tests, not those successful live runs. batch_input.jsonl can hold up to 50,000 requests and 200 MB. Your organization also has a cap on unfinished batch requests at once, from 50,000 at tier 0 to 1,000,000 at tier 6; a create that would exceed it returns 429 with the count and limit. The polling calls themselves have normal request rate limits, which is why the script backs off.

Adapt it

  • Your CSV. Any columns work. The prompt reads name and website; change build_request_body if yours are named differently. Everything else keys on custom_id.
  • Your fields. Edit ENRICHMENT_FIELDS and RESPONSE_SCHEMA together, then the prompt. The tests that check the join and the parser use ENRICHMENT_FIELDS, so they follow.
  • Another model. Pick a supported model from the models page, change MODEL, and spot-check the fields you care about before submitting the file.
  • Bigger files. Nothing changes until 50,000 lines or 200 MB. Past that, split the CSV and run one batch per chunk with a different --job name.
  • Unattended runs. Run python run_batch.py under nohup or a scheduler, and on failure rerun with --resume. The state file is per directory.
  • Audit trail. Keep batch_output.jsonl. Each line’s response.body.id can be fetched later with GET /v1/agent/{id}, and the search results in output show which pages the answer came from.

Troubleshooting

Every Batch API error prints as Batch API failed: <step>: HTTP <status> <error body>.
  • 404 means the batch or file was not found, belongs to another organization, or has expired. Check the id and credential, then see the Batch API guide.
  • upload: HTTP 400 names the first bad line and why: a blank line, a second model, a stream: true, a function tool, or a custom_id over 64 bytes. Nothing is stored; fix the line and rerun.
  • create batch: HTTP 400 with a message about Idempotency-Key means the key was reused with a different request. Inspect the saved state and existing batch before starting a fresh upload. Changing --job during --resume intentionally keeps the original saved key and metadata.
  • create batch: HTTP 429 is the unfinished-request cap. Wait for earlier batches to finish or send a smaller file.
  • HTTP 402 means your credits are exhausted. A running batch pauses its unstarted requests until you add credits; the deadline does not move.
  • HTTP 403 means your organization has Zero Data Retention, and batch is not available to it.
  • A batch that ends expired still has an output file. The rows that did not start are in the error file as batch_expired and land in the retry file automatically.
  • parse_error rows are completed responses whose text is not the object the schema describes. Open the row’s line in batch_output.jsonl; if the text is prose or a partial object, tighten INSTRUCTIONS. incomplete rows stopped before the object was finished, for example at max_output_tokens.
  • Input, output, and error files expire 30 days after creation. Download before then; after that, GET /v1/files/{id}/content returns 404.