Fetch many URLs asynchronously and poll for results.
Start many browser captures, poll one run ID, and consume paginated Fetch results as they finish. Batched Fetch is the async fan-out path for processing a list of URLs that share the same settings, without holding open one request per page.
# Start a run
curl -X POST https://api.expand.ai/v1/fetch/batched \
-H "x-expand-api-key: $EXPAND_API_KEY" \
-H "Content-Type: application/json" \
-d '{"urls":["https://example.com","https://example.com/about"]}'
# Poll the run ID it returns
curl "https://api.expand.ai/v1/fetch/batched/019...?limit=10&offset=0" \
URLs
-> POST /v1/fetch/batched
-> { id }
-> GET /v1/fetch/batched/{id}
-> status + paginated results| Use Batched Fetch when | Use single Fetch when |
|---|---|
| You have many URLs with the same settings. | You need one URL now. |
| Your pipeline can poll asynchronously. | You need screenshots, summaries, Appendix, or Highlights. |
| You want results paged from one run ID. | You need the full single-request output model. |
Jump to: Start a run · Poll results · API Reference
Batched Fetch is API/SDK-only today. There is no CLI command or MCP tool for starting a batch.
Send all the URLs in one POST. The call returns once Hatchet durably accepts the run command; billing reservation, lifecycle projection, and browser captures continue in the background.
curl -X POST https://api.expand.ai/v1/fetch/batched \
-H "x-expand-api-key: $EXPAND_API_KEY" \
-H "x-idempotency-key: fastenersolutions:2026-07" \
-H "Content-Type: application/json" \
-d '{
"urls": [
"https://example.com",
"https://example.com/about"
]
}'{
"id": "019..."
}urls is required and must be non-empty. include is optional, and the only public browserConfig field at start is scrollFullPage. Unknown top-level keys are rejected.
Keep the run ID. Every status check and every page of results is read back through it.
An immediate poll can return QUEUED with no results while the accepted command is being projected. This is expected. Billing is checked authoritatively during that initialization, so a run can later become FAILED even though the start request was accepted.
When x-idempotency-key is omitted, the API generates a unique key. Send a stable key when the caller needs to retry a separate request or recover after a process restart. Retrying the same payload with the same explicit key is safe. The API returns a successful response with the same run ID and does not create or charge for another run.
Only reusing the same key with a different request body returns 409 BatchedIdempotencyConflict:
{
"_tag": "BatchedIdempotencyConflict",
"existingRunId": "019...",
"reason": "payload_mismatch"
}Treat payload_mismatch as a client bug rather than polling a run created for different input.
fastenersolutions:2026-07:<digest>.Read status and results back with the run ID. Page through results with limit and offset.
curl "https://api.expand.ai/v1/fetch/batched/019...?limit=10&offset=0" \
-H "x-expand-api-key: $EXPAND_API_KEY"Keep polling while batchedStatus is QUEUED or RUNNING. Stop once it reaches a terminal status.
const terminal = new Set(["COMPLETED", "FAILED", "CANCELLED"])
let status = await client.getBatched(run.id)
while (!terminal.has(status.batchedStatus)) {
await new Promise((resolve) => setTimeout(resolve, 1000))
status = await client.getBatched(run.id)
}FAILED or CANCELLED means stop polling the run.status before using it.batchedStatus describes the whole run. The top-level status describes the current page of results, and each result has its own persisted status.
| Field | Meaning | How to use it |
|---|---|---|
batchedStatus | Overall run status. | Decide whether to keep polling the run. |
status | Current page status, including extraction quality. | Decide whether this page is stable and its requested content was extracted. |
results[].status |
batchedStatus = whole run
status = current pageQUEUED means the run was accepted but may not be executing yet.RUNNING means work is still in progress.COMPLETED on batchedStatus means the run is done, not that every URL produced Markdown.FAILED and CANCELLED are terminal.COMPLETED only when its rows are stable and requested content extraction succeeded. Requested content can legitimately be empty.FAILED after a completed capture if snapshot content could not be read or rendered. Successful siblings on that page remain usable.COMPLETED while the whole run is still RUNNING. Keep the two fields separate in your code; do not collapse them into one status.A poll returns the run status, pagination metadata, and a page of per-URL results.
{
"id": "019...",
"status": "COMPLETED",
"batchedStatus": "RUNNING",
"totalUrls": 20,
"pagination": {
"total": 12,
Handle each result independently. A batch can finish even when one URL was blocked, failed, or redirected. Use results[].status rather than missing Markdown to identify failed items. A blocked result remains SUCCEEDED and carries data.blocked; fully populated siblings remain usable.
If no poll include.markdown is provided, Batched Fetch normalizes results to Markdown by default. For the exact per-result schema, see the API Reference.
error and originErrorAn item carries exactly one of data or error. originError sits outside that pair and can accompany the captured data.
| Field | Meaning |
|---|---|
data | The captured result. Present whenever a snapshot was produced. |
error | Present when this URL produced no snapshot at all. Carries what the synchronous endpoint would have returned: FetchBlocked, FetchPageTooLarge, FetchNavigationFailed, or FetchOriginError. |
originError |
A URL whose origin could not be reached — DNS failure, broken TLS, refused connection, connection timeout — is not a batch failure. The item reads SUCCEEDED at the item level, because Expand did its job and established that the URL has nothing to serve, and carries a FetchOriginError in error with no data. This replaces the bare URL-only stub these URLs used to produce, which explained nothing.
An origin that answered with a 404 or 500 did produce a snapshot, so that item keeps its data and reports the status in originError instead.
kind is one of dns, tls, connectionRefused, connectionReset, connectionClosed, addressUnreachable, emptyResponse, connectionTimeout, or http, and retryable tells you whether another attempt could plausibly succeed based on the exact Chromium network code. See Error Handling for the full taxonomy. Unreachable URLs are not billed; captured error pages are — see Pricing & Usage.
Batched results are paginated. Fetch page 1 with offset=0, then keep increasing offset by limit while pagination.hasMore is true.
offset 0 -> 10 results
offset 10 -> 10 results
offset 20 -> ...limit defaults to 10 and accepts 1 through 100.offset defaults to 0 and must be non-negative.pagination.total is the number of available result rows for the run.pagination.hasMore tells you whether to request the next page.curl "https://api.expand.ai/v1/fetch/batched/019...?limit=100&offset=100" \
-H "x-expand-api-key: $EXPAND_API_KEY"status can differ across pages while the run is still active.Batched Fetch is item-oriented. Treat the batch as a container for many Fetch attempts, then inspect each result before using it.
data.blocked, for example blockedType: "botProtection".error with _tag: "FetchOriginError" and no data. Read its retryable field before scheduling a re-run: false means the URL is dead, not that the batch went wrong.400 or above keeps its data and reports the status in originError.data.response.originStatusCode helps classify each outcome, for example 403 on a blocked page.data.response.FAILED page can indicate an extraction failure; missing Markdown on a COMPLETED page can be legitimate empty content.Retry a start request with the same key and payload after an ambiguous timeout. The API returns the same run ID if the first request already created the run.
| Capability | Batched Fetch support | Notes |
|---|---|---|
| Markdown | Supported | Always requested and returned by default when capture succeeds. |
| HTML | Supported on completed results | Request through include. |
| Meta | Supported | Included by default when available. |
Polling rejects screenshot and appendix include fields with BatchedIncludeUnsupported when the original batch did not capture them. New batches always request Markdown and State JSON. Existing runs retain the formats captured when they were created.
Batched Fetch is narrower than single Fetch by design. Use it when many URLs and asynchronous collection matter more than every single Fetch artifact. For the full set of fields and nested defaults, see Include Options.
The TypeScript SDK wraps the same two calls: batched to start a run, getBatched to poll it.
import { ExpandClient } from "@expandai/sdk"
const client = new ExpandClient()
const run = await client.batched({
urls: ["https://example.com", "https://example.com/about"],
})
let page = await client.getBatched(run.id, { limit: "10", offset: "0" })
while (page.batchedStatus === "QUEUED" || page.batchedStatus === "RUNNING") {
Each successful result returns Main Markdown at data.markdown and State JSON at data.json without extra include configuration when extraction succeeds.
batched() generates one idempotencyKey and reuses it for the client's configured retry policy.idempotencyKey to deduplicate separate calls or calls made after a process restart.ExpandClientApiError with status 409 only for a different payload; its body contains existingRunId and reason: "payload_mismatch".getBatched query params are strings today, so the examples use "10" and "0".CANCELLING while running items drain, or CANCELLED when no work remains; queued items are cancelled immediately.status is COMPLETED while batchedStatus is still RUNNING.COMPLETED means every item has Markdown.error reports retryable: false. The origin is unreachable; the batch did nothing wrong.screenshot or appendix while polling when the original batch did not capture it.payload_mismatch as a recoverable duplicate instead of fixing key reuse.scrollFullPage behavior.| Persisted state for one URL. |
| Handle successful, failed, queued, running, and cancelled URLs independently. |
What the origin did. Present when the origin responded with a status of 400 or above, alongside the captured data. |
limitoffsetCOMPLETED on the run does not override a page-level extraction failure.| Links | Supported on completed results | Request through include.links. |
| Response info | Basic metadata supported | Batched polling returns URL/status metadata, but not response headers today. |
browserConfig.scrollFullPage | Supported at start | Applies to every child fetch. |
| Screenshots | Not supported in batched results | Use single Fetch. |
State JSON / json | Supported | Always requested and returned by default as data.json when extraction succeeds. |
| Appendix | Not supported in batched results | Use single Fetch. |
| Highlights/search | Not supported | Use Highlights on a single Fetch or existing snapshot. |