---
name: ragextract-api
description: Use the Ragextract REST API (api.ragextract.com/v2) to turn documents into a cited table — upload PDFs, Office files and images to a workspace, define a table whose columns are questions with typed answers, run it, and read back each answer with the page it was read from. Use when the task involves Ragextract, extracting structured fields from a pile of documents, or building a client, script or pipeline against api.ragextract.com. Also covers semantic search over a workspace's pages.
---

# Ragextract API

Ragextract turns a pile of documents into a table you can read down a column of. **Rows are
documents. Columns are questions with an output type.** Every answer carries a citation back to the
page image it came from, plus a confidence score.

Everything the Ragextract application does, it does over this API. Reference:
<https://ragextract.com/developers/api>.

## Before you start

- **Base URL:** `https://api.ragextract.com`. HTTPS only. **There is no sandbox host** — a workspace
  you create for testing is the sandbox, and work in it is charged like any other.
- **Credential:** a personal API key, `psk_…`, in the `x-api-key` header. Minted in the Ragextract
  app under **Integrations → API keys**, shown once, stored hashed. Never ask a user to paste one
  into a file you will commit; read it from the environment.
- **Version:** this document covers **`/v2` only**. A `/v1` also exists, is frozen, and takes a
  workspace key (`sk_…`) instead. If the user has an `sk_` key, none of this will match — they need
  a personal key.

```bash
export RAGEXTRACT_API_KEY="psk_live_…"
export API="https://api.ragextract.com/v2"

curl "$API/verify" -H "x-api-key: $RAGEXTRACT_API_KEY"
```

`GET /v2/verify` answers *what this key can reach* — `kind`, `unscoped`, and the `scopes`
allow-list if there is one. Call it first when a key's reach is uncertain; it costs nothing.

## Rules that apply to every call

1. **Every response is `{ success, total, data }`.** `total` is the number of items in `data` and is
   present on single-item responses too, where it is `1`. Collection endpoints also echo the query
   they applied (`offset`, `limit`, `sort`, `statuses`).
2. **Everything hangs off a workspace.** Every path names one, with exactly three exceptions:
   `GET /v2/verify`, `GET /v2/workspaces` and `GET /v2/share/:fileItemId`.
3. **Timestamps are epoch milliseconds, as numbers.** Never ISO strings. Fields ending `At` are
   nullable unless stated otherwise.
4. **Ids carry a type prefix** — `wks_` workspace, `ds_` file, `dsx_` file item (a page), `dsj_` job,
   `rev_` table, `rcol_` column, `rrow_` row, `rcel_` cell, `rrun_` run, `bndl_` bundle. Treat them
   as opaque strings; the suffix format is not contract.
5. **Unknown request keys are dropped, not rejected.** A misspelled parameter returns `200` with the
   option silently ignored. If an option appears to do nothing, check its spelling here first — that
   is the most common cause of a wrong result from this API.
6. **Bodies are JSON**, except the upload endpoints, which take `multipart/form-data`.
7. **The API calls a document a *file*.** Three endpoints leak the older spelling and answer
   `datasetId` instead of `fileId` — see "Warts" below.

### Errors come back in two different shapes

Check the **status code before parsing the body**.

- **Rejected before the handler ran** (missing/invalid key, unreachable workspace, insufficient
  level, rate limit) → **plain text**. The body is the message and nothing else.
- **Rejected inside the handler** (resource not found, malformed body, insufficient balance) →
  **JSON**, `{ "success": false, "error": "…" }`.

There is no unifying error object and no machine-readable error code. Branch on the status; use the
message for a log line, never for control flow.

| Status | Means |
| --- | --- |
| `400` | Body or query failed validation, or an endpoint precondition did not hold. |
| `401` | No key, unknown key, expired key, or a share token that does not verify. Every bad-credential path returns the same sentence on purpose. |
| `402` | Not enough credits, or the organisation's spend cap would be passed. **JSON with numbers from a run; plain text from search**, because search refuses in middleware. |
| `403` | The workspace or table is reachable but this key's level is below what the endpoint needs. |
| `404` | The resource does not exist, **or** it exists and this key cannot reach it. Deliberately not distinguished — answering `403` would confirm a workspace exists to someone with no access. |
| `429` | Over the rate limit. Back off and retry; there is no header saying how long to wait. |
| `500` | Our side. Safe to retry an idempotent read; for a write, check the resource's state first. |
| `503` | A dependency we refuse to serve around. Retry — the search block claim is idempotent, so a retried search is not charged twice. |

A `402` from a run carries `cost`, `balance`, and `cap` when the refusal was the spend cap.

```bash
# The body is JSON on success and on a handler error, and plain text on an auth,
# permission or rate-limit error. Test the status first.
res=$(curl -s -w '\n%{http_code}' "$API/workspaces" -H "x-api-key: $RAGEXTRACT_API_KEY")
body=${res%$'\n'*}; code=${res##*$'\n'}
[ "$code" -ge 400 ] && echo "failed ($code): $body" && exit 1
```

### Rate limits

- **100 requests/minute** for `GET`.
- **15 requests/minute** for `POST`, `PATCH`, `PUT` and `DELETE`.
- **300 requests/minute** for the upload-session part endpoints, which have their own budget so one
  large multipart upload cannot exhaust the write limit.

Counting happens at the edge location serving you. Over the limit is `429`.

### Permissions

The effective level is the **lower** of the workspace level the key's owner holds and the key's own
scopes. Levels are `1` read, `2` read & write, `3` manage. A key with **no** scopes inherits
everything its owner can reach; adding **any** scope turns it into an allow-list. **Scopes only ever
subtract** — minting a key is never a privilege escalation.

Identity, levels and scopes are cached for **60 seconds**, so revocation takes up to a minute.

**Never call this API from a browser.** It answers cross-origin from anywhere, which makes it easy;
a `psk_` key is a person's credential and putting it in front-end code hands it to every visitor.

## Build order: from nothing to a cited answer

This is the sequence to follow. Only steps 2 and 6 spend money.

```bash
# 1 — a workspace to work in
curl -X POST "$API/workspaces" -H "x-api-key: $RAGEXTRACT_API_KEY" \
  -H "content-type: application/json" -d '{"name": "Vendor contracts"}'
#  → data.id = wks_7Kq2mB4nR8vXpL3d

# 2 — put a document in it. Returns a JOB, not a finished file.
#     CHARGED: 1 credit per page.
curl -X POST "$API/workspaces/$WS/files" -H "x-api-key: $RAGEXTRACT_API_KEY" \
  -F "file=@acme-msa-2024.pdf"
#  → data.id = dsj_L8vB2mQ6xR3kW9pZ   (a job id, not a file id)

# 3 — poll the job until status is SUCCESS
curl "$API/workspaces/$WS/jobs/dsj_L8v…" -H "x-api-key: $RAGEXTRACT_API_KEY"
#  → data.datasetId is the file id (see Warts)

# 4 — a table, its columns, its rows. All three are FREE; nothing is computed yet.
curl -X POST "$API/workspaces/$WS/tables" -H "x-api-key: $RAGEXTRACT_API_KEY" \
  -H "content-type: application/json" -d '{"name": "Vendor contracts"}'

curl -X POST "$API/workspaces/$WS/tables/$TBL/columns" \
  -H "x-api-key: $RAGEXTRACT_API_KEY" -H "content-type: application/json" \
  -d '{"name": "Governing law",
       "prompt": "Which law governs this agreement?",
       "outputType": "text_quote"}'

curl -X POST "$API/workspaces/$WS/tables/$TBL/rows" \
  -H "x-api-key: $RAGEXTRACT_API_KEY" -H "content-type: application/json" \
  -d '{"subjectType": "file", "subjectId": "ds_M3xJ8pQ1vK5nB7wT"}'

# 5 — what would running it cost? FREE to ask, and uses the same arithmetic as the charge.
curl "$API/workspaces/$WS/tables/$TBL/runs/preview" -H "x-api-key: $RAGEXTRACT_API_KEY"
#  → { "cost": 4, "cells": 1, "skippedRowIds": [] }

# 6 — run it. THIS is the charged call.
curl -X POST "$API/workspaces/$WS/tables/$TBL/runs" \
  -H "x-api-key: $RAGEXTRACT_API_KEY" -H "content-type: application/json" -d '{}'

# 7 — read the answers, each with the page it came from
curl "$API/workspaces/$WS/tables/$TBL/cells" -H "x-api-key: $RAGEXTRACT_API_KEY"
#  → value, citations[{ fileId, page, quote, box }], confidence
```

**Always call `runs/preview` before `runs`** when the run is more than a couple of cells. It is free,
it prices the run exactly, and it reports `skippedRowIds` — rows that cannot run because their
document is not Ready.

## Column output types

A column is a prompt plus an `outputType`. Pick the type that matches what you want back; the point
of a typed column is that the answer is a date or a number, not a paragraph you then have to parse.

| `outputType` | Answers with |
| --- | --- |
| `text_quote` | Short text, quoted or paraphrased from the document. **The default.** |
| `number` | A number. Stays a number whatever the table's locale. |
| `date` | An ISO `YYYY-MM-DD` date. Always ISO. |
| `boolean` | Yes or no. |
| `categorical` | One of a fixed set declared in `config.categories`. |
| `list_scalar` | A list of values. Costs more — more retrieval, longer generation. |
| `image` | A picture: the page, or a region of it, that shows what was asked for. |
| `image_list` | Several such regions. |

Other column fields: `prompt` (required), `name` (required), `creditRate` (integer 4–40, default 4 —
**this is the price of the column, set by you**), `config` (a JSON *string*, passed through
verbatim), `isCompositional` (retrieval strategy), `sortOrder`.

**Changing `prompt` or `outputType` bumps the column's `version` and marks every answer that was
produced against an earlier version `stale`.** Changing `name`, `config`, `creditRate` or
`sortOrder` does not — none of them changes the question. Nothing re-runs on its own; a stale answer
stays readable and stays flagged until someone runs it again.

### Image columns find pictures, they do not make them

An image column's value is a list of regions, each naming a `fileId`, a `page`, and a `box` of four
numbers as 0–1 fractions of the page image. **No cropped file is produced anywhere** — the crop
happens at render time over the page image. A failed locate falls back to the whole page, so
"cropped to the region" is the normal case rather than a guarantee. It costs what a typed column
costs. Overriding an image cell replaces the picture with text, permanently.

## What spends credits

Three things, and **nothing else**. Listing, reading, creating a table, adding columns and rows, and
pricing a run are all free.

| | Cost |
| --- | --- |
| **Ingest** | 1 credit per page, once per document. Adding a column to a year-old table costs no more than adding it on day one — the reading is already paid for. |
| **Extraction** | The column's `creditRate` per cell (4–40, default 4), plus `+4` per bundle member past 3. Charged as each cell **succeeds**, so a failed cell is not billed. |
| **Search** | 40 credits per **block of 100** searches. The first search of a block buys the whole block; the next 99 cost nothing extra. |

1 credit = $0.0025 (400 to the dollar). Credits are prepaid; there is no subscription. Search is the
one people do not expect, because a search feels like a read.

## Polling — there are no webhooks

Ingest and extraction are both asynchronous and both are polled.

**Ingest job statuses:** `NOT_STARTED`, `IN_QUEUE`, `IN_PROGRESS`, `SUCCESS`, `ERROR`. The two
terminal states are `SUCCESS` and `ERROR`; **there is no `CANCELED` status on an ingest job**, and a
cancelled one lands in `ERROR` with `statusText` saying so. **`status` can be `null` on the wire** —
an absent status means `NOT_STARTED`, never "finished". Treat a null as unfinished.

`IN_QUEUE` is a normal state, not a failure: over-cap work waits for a free slot. Jobs are never
refused for concurrency. `GET /jobs?statuses=NOT_STARTED,IN_QUEUE,IN_PROGRESS` is the cheap way to
ask "is anything still going".

**Run statuses:** `NOT_STARTED`, `IN_QUEUE`, `IN_PROGRESS`, `SUCCESS`, `ERROR`, `CANCELED`. Watch
`pendingCells` against `totalCells`. A cancelled run has `pendingCells: 0` and a `finishedAt`, so a
progress bar built on those two never sticks. Poll `GET /runs/:runId` — one small response — rather
than refetching the whole grid.

Cancellation is cooperative: the engine re-reads the run's status once per cell.

## Reading answers

`GET /tables/:tableId` returns the whole grid — columns, rows and cells — in one call.
`GET /tables/:tableId/cells` returns every cell, unpaged, with `stale` already computed.

A cell carries:

- `value` — the extracted answer, typed to the column.
- `citations[]` — each with `fileId`, `page`, `quote` and `box` (four 0–1 fractions).
- `confidence` — a score.
- `status` / `statusText` / `blockReason` — `blockReason` is how "out of credits" or "spend cap"
  reaches a client. Such a cell never ran; it is not an error.
- `humanOverride` — a correction. **It sits *beside* `value`, never on top of it**, so the original
  extraction is always still there. `DELETE …/override` drops the correction and leaves the
  extraction, citations and status untouched.
- `stale` — computed server-side across **both** axes: the column's question changed, **or** the
  row's documents changed. Do not describe it as "the column changed".

`GET …/cells/:cellId/events` is the audit trail. `GET …/cells/:cellId/facts` is the per-document
breakdown behind a bundled row.

### Page images and citations

A citation names a page, and the bytes come from a **signed, short-lived share URL**. Listing
endpoints mint one per response in `share.url`.

- `expiresInSeconds` on the listing endpoints, `expiresIn` on `POST /share/:fileItemId`. Both are
  seconds. **Default 600, clamped 60–86,400.**
- **Fetch them, do not file them.** A share URL is minted per response and carries its own authority
  — anyone holding it can read that page until it expires, with no API key.
- `GET /v2/share/:fileItemId?token=…` redeems one and takes **no API key**.

`GET /files/:fileId/items` lists a document's pages: `col` is the 1-based page number and `row` is
the representation (`pdf`, `jpg` or `embedding_image`; anything else is a `400`). **The default sort
is `-createdAt`, which on pages is upload order rather than page order — sort on `col` if you want
page 1 first.**

## Bundles

A bundle groups related documents so they answer as one row — a master contract and its amendments
as a single subject. Point a row at one with `{"subjectType": "bundle", "subjectId": "bndl_…"}`.
Members carry `role`, `isPrimary`, `effectiveAt` and `sortOrder`. A bundled row costs `+4` credits
per member past the third.

## Uploads

- `POST /workspaces/:workspaceId/files` takes `multipart/form-data` with **exactly one of** `file`
  or `url`, plus optional `expiresInDays`. Single-request uploads are capped at **100 MB**, and the
  organisation's tier caps it further (1 MB Starter, 25 MB Bronze, 100 MB Silver, 300 MB Gold) along
  with pages per job (100 to 1,000).
- Over that, use an upload session: `start` → `append` (per part, its own rate limit) → `end`, or
  `abort`. `start` requires `fileName`, `fileExt` and `fileType`; `append` requires `key`,
  `partNumber` and `file`; `end` requires `key` and `parts`.
- **Formats:** PDF, DOC, DOCX, PPTX, XLSX, JPEG, PNG, WebP. Images ingest as **one page each**.
  Legacy `.xls` is not accepted. Audio and video are not supported.
- The upload returns a **job**. The file id arrives on the job as `datasetId`.

## Search

`POST /workspaces/:workspaceId/search` takes `query` (required), and optionally `fileIds`, `limit`
and `expiresInSeconds`. `query` may be text or an `image_url`; both together is a single combined
query, not two searches. Read access is enough, but **it spends credits** — 40 per block of 100.

## Warts — real, deliberate, and not going away quietly

A client that assumes these away will break.

1. **`GET /jobs`, `GET /jobs/:jobId`, `POST /jobs/:jobId/cancel` and a search match answer
   `datasetId`, not `fileId`.** They are v1's handlers mounted on v2. `POST /files` says `fileId`.
   The value is the same id; only the key differs. A client has to read both.
2. **Search's *request* says `fileIds`.** Sending `datasetIds` returns `200` having searched the
   whole workspace — see rule 5. This is the single most expensive typo available here.
3. **Two error shapes** (above). Middleware failures are `text/plain`; handler failures are JSON.
4. **A `404` may mean "no access"** rather than "not found".
5. **`status` on an ingest job is nullable.**

## Clients, and what this API does not do

- **There is a JavaScript/TypeScript SDK, and for JS callers it is the better choice.**
  `@subworkflow/ragextract` **0.2.0 or later** covers this whole document: it routes on the key
  prefix (`psk_` reaches v2, anything else v1), exposes `client.workspace(id)` handles with
  `.files` / `.tables` / `.bundles` / `.jobs` / `.search()`, and wraps the two awkward parts —
  `files.upload()` goes multipart above 100 MB on its own, and `tables.runAndWait()` polls a run to
  completion. **Pin `^0.2.0`**: 0.1.1 and earlier are v1-only *and* cannot be imported from Node at
  all (no `main`/`exports`, so `import` throws `ERR_MODULE_NOT_FOUND`).
  For any other language, use HTTP as described here — there is no Python client.
- **The n8n community node is v1-only** and hardcodes `https://api.ragextract.com/v1`. It has no
  tables and no bundles.
- **There are no webhooks.** Poll.
- **There is no endpoint for sharing a workspace, inviting someone, buying credits or changing a
  plan.** Those are account acts and they stay in the app.
- **Letting an LLM client read a workspace is a different door** — Ragextract's MCP server at
  `mcp.ragextract.com` is read-only, connected by signing in rather than by a key you hold, and
  gated per organisation. It adds no capability this API does not have; it removes the need to write
  a client. <https://ragextract.com/docs/mcp>

## Provenance

Every fact in this file is traceable to `ragextract-api`: the endpoint list is `src/v2/routes.ts`,
and the accepted parameters and response fields are `docs/v2-contract.json`, a snapshot that repo's
own contract test regenerates and fails on when it moves. **Checked against that snapshot on
2026-09-02**, at 53 endpoints plus `/v2/verify`. The SDK versions named above were checked against
npm the same day.

This file is a copy, and copies drift. If something here disagrees with
<https://ragextract.com/developers/api>, the reference is newer — re-fetch
<https://ragextract.com/SKILL.md>.

## Further reading

- API reference, endpoint by endpoint — <https://ragextract.com/developers/api>
- What the nouns mean (bundles, staleness, citations, corrections) — <https://ragextract.com/docs>
- Rates in money — <https://ragextract.com/pricing>
