Send a list of URLs in one request, then poll one run ID for the results as they finish, without holding a request open per page.
A Batched Fetch runs one Fetch for each URL in a list, as one job. You send the URLs in one request and get back a run ID, then poll that ID for each page's Markdown and State JSON as it finishes.
import { ExpandClient } from '@expandai/sdk'
const expand = new ExpandClient()
// Start a run
const run = await expand.batched({
urls: ['https://example.com', 'https://example.com/about'],
})
// Poll the run ID it returns
# Start a run
curl 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" \
-H "x-expand-api-key: $EXPAND_API_KEY"| 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, the Appendix, or Highlights. |
| You want results paged from one run ID. | You need everything a single Fetch can return. |
Send all the URLs in one POST. The call returns as soon as the run is accepted. The credit hold and the captures themselves continue in the background.
import { ExpandClient } from '@expandai/sdk'
const expand = new ExpandClient()
const run = await expand.batched(
{ urls: ['https://example.com', 'https://example.com/about'] },
{ idempotencyKey: 'acme-catalog:2026-07' },
)curl https://api.expand.ai/v1/fetch/batched \
-H "x-expand-api-key: $EXPAND_API_KEY"
{
"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.
QUEUED with no results while the run is starting. This is expected. Credits are checked while the run starts, 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"
}payload_mismatch as a client bug rather than polling a run created for different input.acme-catalog:2026-07:<digest>.In the TypeScript SDK:
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".Read status and results back with the run ID. Page through results with limit and offset.
import { ExpandClient } from '@expandai/sdk'
const expand = new ExpandClient()
const page = await expand.getBatched('019...', { limit: '10', offset: '0' })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 expand.getBatched(run.id)
while (!terminal.has(status.batchedStatus)) {
await new Promise((resolve) => setTimeout(resolve, 1000))
status = await expand.getBatched(run.id)
}FAILED or CANCELLED means stop polling the run.status before using it.getBatched query params are strings today, so the examples use "10" and "0".Results come back in pages. Start with offset=0, then keep increasing offset by limit while pagination.hasMore is true.
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.status can differ across pages while the run is still active.batchedStatus describes the whole run. The top-level status describes the current page of results, and each result has its own persisted status.
| Field | Definition | 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 run 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. How to read a failed item, and which to re-run, is in error and originError.
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 | Definition |
|---|---|
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 is not a run failure. That covers a DNS failure, broken TLS, a refused connection, or a connection timeout. The item reads SUCCEEDED, because Expand did its job and established that the URL has nothing to serve, and carries a FetchOriginError in error with no data.
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.
Batched Fetch is item-oriented. Treat the run 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 run went wrong.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.COMPLETED on the run does not override a page-level extraction failure.What kind and retryable mean, and what is charged, is on Errors & retries.
| 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 run did not capture them. New runs 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 Fetching a URL.
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 run did nothing wrong.screenshot or appendix while polling when the original run 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. |
| Links | Supported on completed results | Request through include.links. |
| Response info | Supported | Each result carries data.response with the final URL, requested URL, and status codes. Poll with include.response.includeHeaders: true to get the headers too. |
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 | Not supported | Use Two ways to highlight on a single Fetch or existing Snapshot. |
| Documents | Not supported | Every URL in a run goes to the browser, so a PDF, image, office file, ebook, or audio URL comes back as a failed item. Use single Fetch. See PDFs and other files. |
documentConfig | Accepted, no effect | Reserved for when batched document conversion lands. |