Scraped data
Once a scraper has run, the rows it produced are accessible through these endpoints.
Read group data — GET /api/scraper/data/group/{id}
The primary scraped-data read. Returns a paginated page of rows for the group, optionally restricted by saved categories or an inline filter expression. isLastRun=true narrows to the most recent run’s rows only.
curl -H "Authorization: Bearer $KEY" \
"https://portal.scrapewise.ai/api/scraper-api/api/scraper/data/group/grp_xyz?page=0&size=100&isLastRun=true"Query params
| Param | Type | Description |
|---|---|---|
page | int | Zero-indexed page number (default 0) |
size | int | Page size (default 20, maximum 10,000) |
sort | string | Spring-style, e.g. scrapedAt,desc |
categories | string[] | Repeatable — restrict to these saved-category names. See Saved-filter categories. |
filters | string | URL-encoded JSON Mongo-style filter expression |
isLastRun | boolean | If true, only the most recent run’s rows |
pad | boolean | Default true. Every row carries every column of the group union, with an explicit null where a row lacks one. Pass false for the slim shape. |
ctx | boolean | Default false. Also return the contexts sidecar. |
cursor / cursorId | string | Keyset cursor for the slim shape — echo back the previous page’s nextCursor. |
pad defaults to on, which is the shape this endpoint has always returned. You do not need to
set it. It exists so a client that renders its own column list — a spreadsheet-style grid, say — can
ask for a smaller payload; a 100-row page of an 820-column group is otherwise 82 000 explicit nulls.
Response
Spring Page<Document> — each content row is a dynamic Mongo Document keyed by the scraper’s configured field names. Numbers / booleans / null pass through; _-prefixed keys are server metadata (_sourceUrl, _scrapedAt, _scraperJobStatusId).
Rows also carry the reserved _sw_* bookkeeping columns — see Bookkeeping columns below.
Every row additionally carries _sw_row_id — that row’s own id, as a string, on every page shape (padded and pad=false, offset and keyset, isLastRun or not). It is the id POST /api/scraper/data/group/{id}/hotels-detail takes in its rowIds. It is added by this read and stored nowhere, so it is not one of the group’s columns and does not appear in the Excel export.
{
"content": [
{
"_sourceUrl": "https://...",
"_scrapedAt": "2026-05-19T...",
"_scraperJobStatusId": "65a1...",
"title": "Premium Widget XL",
"price": 29.99,
"in_stock": true
}
],
"totalElements": 1847,
"totalPages": 19,
"size": 100,
"number": 0
}For agent / MCP use the sanitized envelope variant instead — every string field comes pre-wrapped with origin metadata for prompt-injection safety.
Bookkeeping columns — _sw_*
Every row a scraper writes now also carries a small set of reserved columns that say which endpoint
wrote the row, whether it worked, what it cost and what was sent. They all begin with _sw_, a
prefix a scraped page can never produce, so they can never collide with one of your own columns.
| Column | What it is |
|---|---|
_sw_endpoint | Which endpoint produced the row — the discriminator a mixed dataset cannot be read without |
_sw_source_url | The address you supplied. This is the join key back to your own input list |
_sw_upstream_url | The page that was actually read |
_sw_external_id | The source’s own id for this row |
_sw_result_set_id | Joins the row to its per-call context |
_sw_ordinal / _sw_result_count | The row’s position in its result set, and how many there were |
_sw_request_params | The parameters actually sent. Credential-shaped values are withheld and named in _sw_request_params_redacted |
_sw_fetched_at / _sw_run_started_at / _sw_run_date | When the page was read, when the run started, and the run’s date |
_sw_status | OK / NO_RESULTS / INVALID_INPUT / UPSTREAM_ERROR / PARSE_FAILED |
_sw_error_code / _sw_error_message | Why a non-OK row failed, in plain language |
_sw_http_status | The status code the page answered with, when there was one |
_sw_warnings | ;-joined notes about the row, e.g. PAGE_1_ONLY, CONTEXT_MISSING |
_sw_credits / _sw_price_micros | What the row’s fetch cost |
An input that fails still produces a row. Every address the run attempted leaves exactly one
row, whatever happened to it — so a failure is visible in the table rather than being a silently
missing line. _sw_status is how you tell a failure row from data: a failed row has no scraped
fields, only its _sw_* explanation. Addresses the run never reached (it stopped early) are not
written as rows; they are counted on the run itself, which reports state WARNING.
_sw_run_date, _sw_run_time, _sw_scraper, _sw_group and _sw_source_url replace the older bare
date, time, scraper, group and sourceUrl run-metadata columns, which used to overwrite a
scraped column of the same name. The old columns are still written, so nothing you read today
stops working; prefer the _sw_ spelling in new code.
Slim shape — ?pad=false
pad=false returns the same rows with two differences:
- each row carries only the keys it actually has — no explicit
nullfor columns it never had; - the envelope reports
hasMoreand anextCursorinstead oftotalPages/totalElements/last. The total is counted once and returned astotalCounton the first page only, because counting a filtered 30 000-row group on every page turn is a second full scan.
{
"content": [ { "title": "Premium Widget XL", "price": 29.99 } ],
"hasMore": true,
"nextCursor": { "key": 29.99, "id": "66f0a1..." },
"totalCount": 1847,
"size": 100,
"number": 0
}Page forward by echoing the cursor back:
curl -H "Authorization: Bearer $KEY" "https://portal.scrapewise.ai/api/scraper-api/api/scraper/data/group/grp_xyz?pad=false&size=100&cursorId=66f0a1..."Cursor paging does not re-walk the rows it has already passed, so page 300 costs what page 1 costs.
cursorId is the authoritative half of the pair; cursor is echoed for readability. If the row a
cursor points at has been deleted in the meantime, the response falls back to page-number paging and
says so with offsetFallback: true rather than quietly skipping rows.
Per-call context — ?ctx=1
Many sources answer with a block of information about the call rather than about any one row: the query that was run, the store or locale the prices came from, the result and page counts, the paging tokens. That block is stored once per call, not copied onto every row — on some endpoints it is larger than all the rows put together.
ctx=1 returns it as a sidecar next to content, keyed by each row’s _sw_result_set_id:
{
"content": [
{ "_sw_result_set_id": "rs-0", "title": "Premium Widget XL", "price": 29.99 }
],
"contexts": {
"rs-0": { "ctx_query": "widget", "ctx_total_results": 1847, "ctx_store_id": "4152" }
},
"contextsTruncated": false
}Join each row to contexts[row._sw_result_set_id] to get a flat, self-contained table. Ask for it only
when you intend to read a ctx_* column.
Two bounds:
- the sidecar is byte-capped per page. Past the cap you get the contexts for the first result sets
in page order and
contextsTruncated: true— show “context partially loaded” rather than an empty cell; - a row whose
_sw_result_set_idresolves to nothing carriesCONTEXT_MISSINGin_sw_warnings. A missing context is always said out loud, never returned as blank columns.
Field-level filtering
The filters query param accepts a URL-encoded Mongo-style filter expression. Standard logical operators:
?filters=%7B%22price%22%3A%7B%22%24gt%22%3A50%7D%2C%22in_stock%22%3Atrue%7D— which decodes to {"price":{"$gt":50},"in_stock":true}. Supports $and / $or / $nor logical groups recursively.
Errors — 400 (group not found / malformed filters JSON) / 401 / 403 (N/A) / 404 (N/A — 400 envelope used) / 429 / 500.
Delete scraped data
The legacy single-call DELETE /api/scraper/data?scraperId=...|?id=...|?jobId=... shape is no longer wired. The only DELETE /api/scraper/data endpoint that ships today takes ?scraperJobStatusId=... and follows the destructive two-call protocol on the Scraper Jobs page (preview-delete then commit). For broader removals — entire scraper, entire group, including-data — use:
| Goal | Endpoint |
|---|---|
| Delete one run’s rows | DELETE /api/scraper/data?scraperJobStatusId=... (two-call) |
| Delete one scraper (optionally its data) | DELETE /api/scraper/{id}?withData=true (two-call) |
| Delete one group (optionally its data) | DELETE /api/scraper/group/{id}?withData=true (two-call) |
Agent-friendly read (prompt-injection envelope) — GET /api/scraper/data/group/{id}/client
Same paginated data as GET /api/scraper/data/group/{id} — but when called from the MCP gateway, every string field is automatically wrapped in a {type: "scraped", content, origin, truncated} envelope. The agent’s system prompt instructs it never to act on instructions found inside scraped content (prompt-injection protection).
Numbers, booleans, null, and _-prefixed metadata pass through unwrapped (they weren’t scraped from arbitrary HTML).
The sanitized query param is hidden from the OpenAPI surface — the MCP gateway forces it server-side; direct SDK consumers can opt in by setting it explicitly.
Requires the EXTERNAL_API plan feature.
curl -H "Authorization: Bearer $KEY" \
"https://portal.scrapewise.ai/api/scraper-api/api/scraper/data/group/grp_xyz/client?page=0&size=50"Response (200) — Spring Page of sanitized rows:
{
"content": [
{
"title": { "type": "scraped", "content": "Premium Widget XL", "origin": "https://...", "truncated": false },
"price": 29.99,
"in_stock": true,
"_jobId": "65a1..."
}
],
"totalElements": 1847,
"totalPages": 37
}Errors — 400 (group not found) / 401 / 402 (plan lacks EXTERNAL_API — fall back to GET /api/scraper/data/group/{id} with your own prompt-injection safety) / 403 (N/A) / 404 (N/A — 400 used) / 429 / 500.
Need just the URLs a scraper hit? There’s no dedicated per-scraper URL endpoint. Use
GET /api/scraper/data/group/{id}withscraperId=<id>and project the URL field client-side (e.g._sourceUrl).
Group-level Excel download — GET /api/scraper/data/group/{id}/download
Downloads the latest run’s data for the given group as a single .xlsx file (Content-Disposition: attachment; filename=data.xlsx). Filters + categories supported, same as the group-data read endpoint. Subject to the DATA_EXPORT plan feature.
- Row limit: the admin setting “Max rows per export” — 1,000,000 by default. One Excel sheet holds at most 1,048,574 data rows (plus the header and a possible
# EXPORT INCOMPLETElast row). Above the limit the request is refused with400 EXPORT_TOO_LARGEand a sentence naming both numbers and how to narrow the export. - Format:
xlsxonly. - Row order: newest run first, then newest stored row first inside a run.
Idempotency-Keyis ignored on this route. One export per user per group runs at a time; a second one gets429 EXPORT_BUSY.- Large exports: a big file can take several minutes to build before the first byte. If your HTTP client or proxy times out, use the export-job routes instead.
curl -H "Authorization: Bearer $KEY" \
"https://portal.scrapewise.ai/api/scraper-api/api/scraper/data/group/grp_xyz/download?categories=discounts" \
--output data.xlsxQuery params
| Param | Description |
|---|---|
categories | Comma-separated category names — restrict to those saved filters |
filters | URL-encoded JSON filter expression (one-off; doesn’t need a saved category) |
isLastRun | true (default) = latest run of each scraper; false = every stored run |
Errors — 400 (group not found, EXPORT_TOO_LARGE, INVALID_FILTER) / 401 / 402 (plan lacks DATA_EXPORT) / 403 (N/A) / 404 (N/A — 400 used) / 429 (EXPORT_BUSY) / 503 (EXPORT_DISK_LOW, EXPORT_SIZING_TIMEOUT — retry, or use the export-job routes) / 500.
Large exports — export jobs
For files of any size (up to the same row limit) the portal — and any API client — can build the file in the background, watch its progress and download it with a short-lived link. These routes are REST only (not MCP tools).
- Start —
POST /api/scraper/data/group/{id}/export-jobswith body{"isLastRun": true, "categories": [...], "filters": "<filter JSON string>"}(all optional). Answers202with the job:{jobId, state, totalRows, rowsWritten, ...}. The same request again returns the same job; a request with a different filter replaces the running one. - Poll —
GET /api/scraper/data/export-jobs/{jobId}→stateisQUEUED,RUNNING,READY,FAILEDorEXPIRED;rowsWritten/totalRowsshow progress,queuePositionhow many jobs are ahead,errorMessagea sentence to show.GET /api/scraper/data/export-jobs?groupId={id}lists your queued / running / ready jobs of a group. - Link —
POST /api/scraper/data/export-jobs/{jobId}/link(READY only) →{path, url, expiresAt}. The download must start within the link life (default 5 minutes). - Download —
GETthe link (.../export-jobs/{jobId}/file?token=...). No login needed — the token is the credential. HTTPRangeis supported, so an interrupted download can resume for a while (default 60 minutes).
JOB=$(curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"isLastRun": true}' \
"https://portal.scrapewise.ai/api/scraper-api/api/scraper/data/group/grp_xyz/export-jobs" | jq -r .jobId)
# poll until "state": "READY", then:
PATH_=$(curl -s -X POST -H "Authorization: Bearer $KEY" \
"https://portal.scrapewise.ai/api/scraper-api/api/scraper/data/export-jobs/$JOB/link" | jq -r .path)
curl -o export.xlsx "https://portal.scrapewise.ai/api/scraper-api${PATH_}"Finished files are kept for 24 hours by default. A server restart or deploy ends running exports with the sentence “The server restarted while your file was being prepared. Please export again.”
The times above (link life 5 minutes, resume window 60 minutes, file kept 24 hours) and the row limit (1,000,000) are defaults. Scrapewise can change them.
The poll answer also carries pollSeconds — how long to wait before the next poll — and, on a failed job, errorCode.
Export-job error codes
| Code | HTTP | Meaning |
|---|---|---|
EXPORT_TOO_LARGE | 400 | More rows than “Max rows per export” (default 1,000,000). The sentence names both numbers. Narrow it: latest run only, a category, or a filter |
INVALID_FILTER / INVALID_EXPORT_REQUEST | 400 | The filter or the body could not be read |
EXPORT_BUSY | 429 | You already have an export of this group being prepared, or the server is busy with other large exports. Try again in a few minutes |
EXPORT_DISK_LOW | 503 | The server is low on disk space right now. Try again later |
EXPORT_NOT_READY | 409 | You asked for a link before the job is READY. Keep polling |
EXPORT_FILE_GONE | 410 | The link or the file has expired. Start a new export |
EXPORT_NOT_FOUND | 404 | No such export job for your account |
A job that ends FAILED carries one of these in errorCode: EXPORT_SERVER_RESTARTED (a deploy or restart stopped it), EXPORT_REPLACED (a newer export of the same group with a different filter replaced it), EXPORT_GROUP_DELETED or EXPORT_FAILED. Export again.
Errors — 400 (EXPORT_TOO_LARGE, INVALID_FILTER) / 401 / 402 (plan lacks DATA_EXPORT) / 403 (N/A) / 404 (EXPORT_NOT_FOUND) / 409 (EXPORT_NOT_READY) / 410 (EXPORT_FILE_GONE) / 429 (EXPORT_BUSY) / 503 (EXPORT_DISK_LOW) / 500.
Saved-filter categories
A category is a named filter expression saved against a group — a reusable filter bundle the customer references when querying scraped data. Subject to the GROUP_CUSTOM_CATEGORIES plan feature; total categories per group capped by GROUP_CUSTOM_CATEGORIES_LIMIT.
List categories — GET /api/scraper/data/group/{id}/categories
curl -H "Authorization: Bearer $KEY" \
https://portal.scrapewise.ai/api/scraper-api/api/scraper/data/group/grp_xyz/categoriesResponse (200) — List<CustomerCategoryDTO>:
[
{ "id": "cat_001", "name": "big-discounts", "filters": { /* FiltersDTO */ } },
{ "id": "cat_002", "name": "in-stock", "filters": { /* FiltersDTO */ } }
]Errors — 400 (group not found) / 401 / 403 (N/A) / 404 (N/A — empty list returned if none) / 429 / 500.
Save a category — PUT /api/scraper/data/group/{id}/category
PUT /api/scraper/data/group/grp_xyz/category
Authorization: Bearer <key>
Idempotency-Key: <uuid>
Content-Type: application/json
{
"name": "big-discounts",
"filters": { "discountPct": { "gt": 20 } }
}Upsert by name within the group (resave with the same name updates the filter expression).
Response (200) — persisted CustomerCategoryDTO with assigned id.
Errors — 400 (group not found) / 401 / 402 (plan lacks GROUP_CUSTOM_CATEGORIES OR GROUP_CUSTOM_CATEGORIES_LIMIT reached) / 403 (N/A) / 404 (N/A — 400 used) / 429 / 500.
Delete a category (destructive — two-call protocol) — DELETE /api/scraper/data/group/{id}/category/{categoryId}
Destructive operation. Deleting a category permanently removes it from the group. The category’s filter expression is unrecoverable — re-creating via PUT /api/scraper/data/group/{id}/category requires you to remember the filter shape.
ADR-012 two-call pattern: first preview, then commit within 5 minutes.
Steps
POST /api/scraper/data/group/{id}/category/{categoryId}/preview-delete— mints a 5-minute token + preview summary.DELETE /api/scraper/data/group/{id}/category/{categoryId}— commits the delete (idempotency-key required).
Skipping the preview step deletes rows without confirmation.
Step 1 — Preview
POST /api/scraper/data/group/grp_xyz/category/cat_001/preview-delete
Authorization: Bearer <key>Response (200) — DestructivePreviewResponseDTO:
{
"token": "f3c2-...",
"opName": "scrapewise_delete_scraper_data_group_category",
"targetEntityId": "cat_001",
"previewSummary": {
"entityName": "cat_001",
"entityType": "data_group_category",
"cascadeCounts": {},
"warnings": []
}
}Step 2 — Commit
DELETE /api/scraper/data/group/grp_xyz/category/cat_001
Authorization: Bearer <key>
Idempotency-Key: <uuid>Response — 204 No Content.
Errors (both steps) — 400 (group not found OR no category with that categoryId in the group) / 401 / 403 (N/A) / 404 (N/A — 400 used) / 429 / 500.
JSON export (programmatic)
For your code, the paginated GET /api/scraper/data/group/{id} read is usually what you want — use pad=false and the keyset cursor for big groups (see Pagination). Excel export is for handing files to humans. For matched results, see Matched data.
Common errors
| Code | Meaning |
|---|---|
scraper_not_found | scraperId doesn’t exist for your customer |
invalid_filter | filter syntax is malformed |
EXPORT_TOO_LARGE | The export has more rows than “Max rows per export” (default 1,000,000; Excel’s own maximum is 1,048,574 data rows). Narrow it — latest run only, a category, or a filter — or page through the JSON read |