Call Fetch, Highlights, Batched Fetch, Crawl, and WebSync from TypeScript with @expandai/sdk, and turn the results into citation links.
@expandai/sdk is a typed client for the Expand API. It reads your API key from the environment, retries transient failures, and returns the same Markdown text and JSON object the API does.
Set EXPAND_API_KEY and the client picks it up. Pass apiKey only when the key comes from somewhere else, such as a secrets manager. The constructor throws when it finds no key, so a missing key fails at startup rather than on the first request.
export EXPAND_API_KEY="xpnd_..."
import { ExpandClient } from '@expandai/sdk'const expand = new ExpandClient({ apiKey: process.env.EXPAND_API_KEY, timeoutMs: 180_000,})
Option
Definition
Default
apiKey
Your API key, sent as the x-expand-api-key header.
process.env.EXPAND_API_KEY
baseUrl
Where requests go.
https://api.expand.ai
Note
fetch(), fetchJson() with Fetch parameters, snapshots.get(), and the Batched Fetch methods take timeoutMs and maxRetries in their final options argument to override the client's defaults for one call. snapshots.get() uses the same timeout and retry options. Other Crawl and Snapshot methods have their own options.
fetchJson() calls /v1/fetch/json and returns a JSON object: the Main Markdown in markdown, the State JSON in json, and the Snapshot in meta. Use it when your code reads the fields separately.
When the origin never answered, the result is a FetchOriginError instead of content. Narrow with isFetchOriginError() before reading the fields.
isFetchOriginError() only catches an origin that never answered. Its kind names the failure: dns, tls, connectionRefused, connectionReset, connectionClosed, addressUnreachable, emptyResponse, or connectionTimeout. An origin that answered with an error still returns content, and reports the status in originError.
fetch() calls /v1/fetch and returns Markdown text as one string, with the metadata in YAML frontmatter at the top. Use it when the result goes straight into a prompt.
Both methods take an include string in their second argument. It is the URL's ?include= parameter, and it turns State JSON and the Appendix on and off. The request body's include does the same with appendix and json when you leave the string out, and in JSON Mode it also asks for extras outside the document, such as a screenshot.
{ include: null } leaves the parameter off the URL, the same as not passing it. What each token does, and the defaults, are on Fetching a URL.
Batched Fetch
batched() starts a run and returns its id. getBatched() polls it. Each result carries its own status next to its data, so a URL that failed or was cancelled does not hide the ones that worked.
const run = await expand.batched({ urls: ['https://www.figma.com/pricing/', 'https://www.figma.com/about/'],})let status = await expand.getBatched(run.id, { limit: '10', offset: '0' })while (status.batchedStatus === 'QUEUED' || status.batchedStatus === 'RUNNING') { await new Promise((resolve) => setTimeout(resolve, 1000)) status =
getBatched() takes limit, offset, and include as strings, because they are URL parameters.
When you leave out idempotencyKey, the SDK generates one and reuses it for its own retries. Pass a stable key to make separate calls, or calls after a restart, land on the same run.
Reusing a key with a different payload returns 409 BatchedIdempotencyConflict.
The run's lifecycle, its options, and the result shape are on Batched Fetch.
Highlights
Fetch Highlights
Add highlights to a JSON Mode Fetch and the snippets come back under data.highlights.snippets, next to the whole page.
Each snippet has source (markdown, appendix, or statejson), text, score, and location. A State JSON snippet also has json, the matched value. The SDK does not add a citationUrl to snippets. Build one with the citation helpers.
This reads the public catalog without sending credentials. Adding highlights switches to an authenticated POST and costs one Highlights credit. The return type follows the options you pass.
Snapshot Highlights
snapshots.get() with highlights calls /v1/snapshots/<snapshotId>/highlights and highlights a Snapshot you already have, without fetching the page again. Pass meta.snapshotId from the earlier Fetch.
The result has highlights.snippets in the same shape as above, plus snapshotId, playground, response, and durationMs.
Warning
Without include, Highlights uses the Main Markdown and State JSON. When you pass include, a layer you leave out is off. Turning all three off returns 400 BadRequest.
How the two ways differ, and how to tune maxResults and minScore, is on Two ways to highlight.
Wayback Machine
Citation helpers
A snippet's location points at the exact spot on the Snapshot. The helpers turn it into a link a person can open.
import { citationUrl, resolvePlaygroundHost } from '@expandai/sdk'if (!isFetchOriginError(page)) { const host = resolvePlaygroundHost(page.meta.playground, 'https://expand.land') for (const snippet of page.data?.highlights?.snippets ?? []) { console.log(snippet.text, citationUrl(page.meta.snapshotId, snippet.location, host)) }}
Helper
Definition
citationUrl(snapshotId, location, host?)
The citation link for one snippet, from location.evidenceId.
A Fetch with a saved Snapshot returns meta.resources. Its snapshotId is the public ID, and the snapshots methods read the catalog behind it without an API key and without fetching the page again.
Catalog links are paths on the API origin, so resolve them against meta.resources.url before downloading. snapshots.network() and snapshots.preview() return unknown, so narrow the value before using it.
crawls.create() starts a crawl. It returns one crawl per registrable domain in your sources, and needs an idempotency key you keep. crawls(id) picks up a crawl you started earlier, and crawls.merge() reads several as one stream.
page.changed says what changed in changed (content, stateJson, extraction) and how in diff: changed State JSON paths with their old and new values, and a Markdown diff.
page.removed says why in evidence.kind: http-404, http-410, content-gone, or redirected-away, with the checks behind it.
page.failed gives a reason: blocked, not-found, timeout, error, robots-disallowed, robots-unreachable, or . It carries no content. Pages left out by never produce one.
event.page holds the formats you asked for when you created the crawl: markdown, stateJson, html.
Content in an event is capped at 64 KiB, filled in this order: State JSON, Markdown, the diff, then HTML. A field that does not fit is left out and named in event.page.truncated. The full capture is always available by its captureId:
The SDK retries a failed attempt up to maxRetries times, with backoff, inside the call's timeoutMs:
Timeouts, connection errors, 408, 409, 429, and 5xx are retried. A 409 is retried unless it is a Batched Fetch conflict, BatchedIdempotencyConflict or BatchedCancellationConflict, which the SDK never retries.
The 502 page errors (FetchNavigationFailed, FetchCaptureFailed) and 504 FetchCaptureTimeout are not. All are verdicts about the page, and a retry would re-run a full capture for the same outcome. A 504 does not prove the page was at fault, so a deliberate manual retry can still make sense.
529 FetchCapacityTimeout is retried, honoring its Retry-After: 5.
What each error means, and whether to retry it, is on Errors & retries.
Choosing timeoutMs
timeoutMs is one budget for the whole call, retries and backoff included, and the SDK enforces it on your side. A Fetch can take up to its 150-second budget plus a few seconds to answer, so the 60000 default cuts off long captures with ExpandClientTimeoutError before Expand answers.
Recommendation
Set timeoutMs to 180_000 for a single attempt. Add another 180 seconds per retry you allow, or set maxRetries: 0 when you only want one verdict.
ExpandService is the same client for applications already built on Effect. It exposes the same methods, with failures typed in the error channel as ExpandSdkError, ExpandApiError, ExpandConnectionError, or ExpandTimeoutError.
import { NodeRuntime } from '@effect/platform-node'import { Effect } from 'effect'import { ExpandService, isFetchOriginError } from '@expandai/sdk'const program = Effect.gen(function* () { const expand = yield* ExpandService const page = yield* expand.fetchJson({ url: 'https://www.figma.com/pricing/' }) if (isFetchOriginError(page)) { yield* Effect.logWarning(page.kind, page.retryable)
ExpandService.layer() takes the same options as ExpandClient. You do not need it to use the SDK from plain Promise code.
Next steps
Fetching a URL: the two modes, what each layer holds, and the include options.
Two ways to highlight: Fetch Highlights and Snapshot Highlights, and how to tune them.