Extraction & Artifacts
Where parsing runs: document parsing (page reading + layout) runs on Docsfra's own GPU infrastructure — page images are never sent to third-party AI providers. Only the extracted text reaches the configured model lanes (or your own BYOK endpoints).
When a document finishes processing, Docsfra doesn't just give you raw text back — it builds an AI-native representation (AIDR) of the document: a structured "identity card" (memory), the tables and figures found on its pages (structured objects), a graph of the entities and relations mentioned in it (entity graph), the logical dependencies stated within it (reasoning graph), and an index of everything available for that document (artifacts). This section covers the endpoints that expose that representation.
Representation → artifact
Internally, a document's representation is a single JSON object with
independent layers (memory, structured_objects, entity_graph,
...) that are filled in as each extraction step completes. Each layer is
best-effort: if a layer failed to generate (LLM error, or the document
predates the representation pipeline), it's simply absent — this never
blocks delivery of the base document (markdown/JSON export). What you
read through the endpoints below is a read view onto that
representation, not a separate artifact store.
| Endpoint | Layer | What it returns |
|---|---|---|
GET /v1/documents/{id}/memory | memory | The document's identity card: title, type, purpose, summary, topics, section tree |
GET /v1/documents/{id}/objects | structured_objects | Tables (row/cell JSON) and figures found in the document |
GET /v1/documents/{id}/entities | entity_graph | Typed entity nodes and the relations (edges) between them |
GET /v1/documents/{id}/reasoning | reasoning_graph | Logical dependency nodes (obligations, conditions, penalties, ...) and the relations (edges) between them |
GET /v1/documents/{id}/extract | memory + objects + entities + reasoning | All four layers combined in a single response |
GET /v1/documents/{id}/artifacts | — | A catalog of every AI artifact for the document (including the five above), with availability flags |
Auth and entitlement
All six endpoints require Authorization: Bearer <api_key> and are
gated by the extract module entitlement — a key not authorized for
extract gets 403 service_not_enabled (see Getting Started → Entitlement).
Unlike search and ask, none of these endpoints are metered —
reading extraction results doesn't deduct credits, no matter how many
times you call them.
Pricing
Reading is free (see above) — what's billed is the one-time production of an artifact, the first time Docsfra successfully generates it for a document:
| Module | Artifact | Unit | Default rate |
|---|---|---|---|
memory | Document Memory (identity card) | document | 0.10 |
entities | Entity Graph | document | 0.15 |
reasoning | Reasoning Graph | document | 0.15 |
layout | Layout Model (bounding boxes) | page | 0.05 |
Each module is charged once per document (or once per page, for
layout) only when that artifact is actually produced — a failed
production attempt (LLM error, or the module not enabled for the
tenant's plan) is never charged; the layer is simply absent from the
representation, same as described above. structured_objects
(tables/figures) has no separate production charge: table/figure
extraction is deterministic and included in the base pipeline. These
rates can be overridden per tenant, same as the module rates in
Getting Started → Metering and credits — check the
dashboard's usage/pricing screen for your tenant's current rates.
Reiterating: the read endpoints on this page are unmetered
regardless of these production rates — you can call
/memory, /entities, /reasoning, /layout, /extract, and
/artifacts as many times as you like at no cost once an artifact
exists.
Memory (identity card)
GET /v1/documents/{document_id}/memory
curl https://api.docsfra.com/v1/documents/d_9c1a2b3c4d5e/memory \
-H "Authorization: Bearer dip_live_a1b2c3d4e5f6..."
{
"document_id": "d_9c1a2b3c4d5e",
"memory": {
"title": "Bill of Lading — INV-001",
"doc_type": "bill_of_lading",
"purpose": "Proof of shipment and title transfer for a container of goods",
"summary": {
"one_line": "Ocean bill of lading for a 40ft container shipped from Izmir to Rotterdam.",
"paragraph": "This document is the bill of lading covering shipment INV-001, ..."
},
"language": "en",
"entities": [
{ "name": "Acme Logistics A.S.", "type": "company" },
{ "name": "Rotterdam Port Authority", "type": "organization" }
],
"topics": ["shipping", "logistics", "container freight"],
"timeline": [
{ "date": "2026-03-14", "event": "Goods loaded on board" }
],
"definitions": [],
"references": [
{ "kind": "purchase_order", "target": "PO-88231" }
],
"section_tree": [
{ "path": "1", "title": "Shipper / Consignee" },
{ "path": "2", "title": "Cargo Description" }
],
"segments": [
{
"doc_type": "bill_of_lading",
"title": "Bill of Lading — INV-001",
"page_start": 1,
"page_end": 4,
"one_line": "Ocean bill of lading for a 40ft container shipped from Izmir to Rotterdam."
}
]
},
"generated_at": "2026-03-14T10:22:03Z",
"prompt_version": "mem-v1"
}
memory.entities here is a lightweight name/type list meant for a
quick read — it is not the full entity graph (see Entities below).
section_tree is built deterministically from the document's section
headings (not LLM-generated), so it's always present when the document
was chunked with sections.
Multi-document / merged files
A single upload can be a scan that merges several logically distinct
documents (e.g. a bill of lading followed by a packing list and an
invoice, scanned back-to-back into one file). memory.segments is how
that gets surfaced: each entry is one logical document, identified by
its own doc_type, title, one_line summary, and the page range
(page_start–page_end, 1-indexed and inclusive) it occupies within
the merged file. For a single-document upload, segments has exactly
one element covering the whole page range.
Errors
| HTTP | code | Description |
|---|---|---|
| 401 | unauthorized | Missing, invalid, or revoked key |
| 403 | service_not_enabled | Key isn't authorized for the extract module |
| 404 | not_found | Document doesn't exist, or belongs to another tenant |
| 404 | memory_not_available | Document exists but memory hasn't been generated yet (no representation, or the memory layer is empty) |
Objects (tables and figures)
GET /v1/documents/{document_id}/objects
curl https://api.docsfra.com/v1/documents/d_9c1a2b3c4d5e/objects \
-H "Authorization: Bearer dip_live_a1b2c3d4e5f6..."
{
"document_id": "d_9c1a2b3c4d5e",
"objects": {
"tables_count": 1,
"figures_count": 0,
"truncated": false,
"tables": [
{
"object_id": "tbl-3-0",
"page": 3,
"caption": "Cargo Description",
"columns": ["Item", "Qty", "Weight (kg)"],
"rows": [
["Machine parts", "12", "480"],
["Spare parts kit", "3", "60"]
],
"merged_cells": [],
"evidence": { "page_no": 3, "span": null, "source": "page_markdown" }
}
],
"figures": []
},
"generated_at": "2026-03-14T10:22:07Z"
}
Tables are extracted deterministically (GFM pipe-tables and raw HTML
<table>s parsed from each page's markdown) — this layer does not
depend on an LLM call. Large documents are capped at 300 tables per
document / 500 rows per table; if a document hits that limit, the
response is marked "truncated": true.
Errors
| HTTP | code | Description |
|---|---|---|
| 401 | unauthorized | Missing, invalid, or revoked key |
| 403 | service_not_enabled | Key isn't authorized for the extract module |
| 404 | not_found | Document doesn't exist, or belongs to another tenant |
| 404 | objects_not_available | Document exists but structured objects haven't been generated yet |
Entities (typed graph)
GET /v1/documents/{document_id}/entities
curl https://api.docsfra.com/v1/documents/d_9c1a2b3c4d5e/entities \
-H "Authorization: Bearer dip_live_a1b2c3d4e5f6..."
{
"document_id": "d_9c1a2b3c4d5e",
"entities": {
"node_count": 2,
"edge_count": 1,
"nodes": [
{
"entity_id": "e1",
"type": "company",
"canonical_name": "Acme Logistics A.S.",
"attributes": { "role": "shipper" },
"evidence": { "page_no": 1 },
"confidence": 0.6,
"variants": [{ "name": "Acnne Logistics A.S.", "count": 1 }],
"needs_review": true
},
{
"entity_id": "e2",
"type": "port",
"canonical_name": "Rotterdam",
"attributes": {},
"evidence": { "page_no": 1 },
"confidence": 1.0,
"variants": [],
"needs_review": false
}
],
"edges": [
{ "from": "e1", "to": "e2", "relation": "ships_to" }
]
},
"generated_at": "2026-03-14T10:22:11Z"
}
type is an open set (the extraction prompt uses examples like
company, person, amount, date, reference, shipment, vessel,
port, product, bank, but is not restricted to them). Every node
carries the page it was found on when known; edges always reference
entity_ids present in nodes (dangling edges are dropped at
generation time).
OCR reconciliation
Scanned and photographed documents can have the same real-world entity
read differently in different places — common OCR/VLM confusions like
G↔0, 5↔S, 8↔B. Docsfra reconciles these mentions by
consensus into a single canonical node instead of leaving them as
separate entities: confidence (a number between 0 and 1, or
null when it wasn't computed) reflects how dominant the chosen
canonical form was among the document's mentions; variants lists the
other surface forms that were folded into this node, each as {name, count}; and needs_review is true when confidence is low or the
node lacks page provenance, flagging the node as worth a human glance.
A cleanly-read, unambiguous entity simply gets confidence: 1.0, an
empty variants array, and needs_review: false.
Two guardrails keep reconciliation from over-merging: values containing
digits (dates, amounts, references, phone numbers) only merge when they
differ purely by known OCR confusions — a real digit difference
(20,560 vs 20,000) always stays two entities; and two mentions that
play different roles relative to the same entity (say, an issue
date and an expiry date) are never merged, no matter how similar the
strings are. Each entity's evidence also carries the page number and
character span where its surface form was actually found in the
document — derived by searching the text, not asserted by the model;
entities whose surface can't be located keep page_no: null and are
flagged needs_review.
Bbox-targeted re-read
For an entity flagged needs_review, Docsfra can optionally re-read the
source region directly from the page image: the entity's bounding box is
cropped from the source page (with a small padding margin) and sent to a
focused vision model (VLM) for a second, isolated reading — free of the
surrounding page's visual noise. The result either confirms the
existing reading or corrects it:
"evidence": {
"page_no": 1,
"reread": {
"status": "corrected",
"was": "Acnne Logistics A.S.",
"now": "Acme Logistics A.S.",
"model": "google/gemini-2.5-flash"
}
}
When status is "corrected", the canonical reading is updated to now
(the old reading is folded into variants) and needs_review is cleared.
When status is "confirmed", the existing reading is kept as-is and
needs_review is cleared with the confidence raised. This is an optional,
best-effort feature (toggled by REREAD_ENABLED) — when it's off, or the
re-read fails or returns nothing usable, the entity keeps its existing
reconciliation result untouched.
Errors
| HTTP | code | Description |
|---|---|---|
| 401 | unauthorized | Missing, invalid, or revoked key |
| 403 | service_not_enabled | Key isn't authorized for the extract module |
| 404 | not_found | Document doesn't exist, or belongs to another tenant |
| 404 | entities_not_available | Document exists but the entity graph hasn't been generated yet |
Reasoning graph (logical dependencies)
GET /v1/documents/{document_id}/reasoning
Beyond what is in a document (entities, tables), the reasoning graph captures the logical dependencies stated within it — how one clause, obligation, or condition relates to another. Typical patterns: a payment depends on an invoice being issued, a penalty is triggered by a delay, one clause overrides another. This layer is document-internal only (it doesn't reason across multiple documents) and it is deliberately conservative: only relationships the document explicitly establishes are extracted — nothing is inferred or guessed. If a connection isn't stated in the text, it isn't added to the graph.
curl https://api.docsfra.com/v1/documents/d_9c1a2b3c4d5e/reasoning \
-H "Authorization: Bearer dip_live_a1b2c3d4e5f6..."
{
"document_id": "d_9c1a2b3c4d5e",
"reasoning": {
"node_count": 2,
"edge_count": 1,
"nodes": [
{
"node_id": "r1",
"kind": "condition",
"statement": "Delivery is not completed within 15 days of loading.",
"entity_refs": ["e1"],
"evidence": { "page_no": 2 }
},
{
"node_id": "r2",
"kind": "penalty",
"statement": "A penalty of 0.1% per day of delay is applied.",
"entity_refs": [],
"evidence": { "page_no": 2 }
}
],
"edges": [
{ "from": "r2", "to": "r1", "relation": "depends_on" }
]
},
"generated_at": "2026-03-14T10:22:15Z",
"prompt_version": "rsn-v1"
}
kind is one of obligation, condition, clause, deadline,
penalty, right, or fact. entity_refs links a reasoning node back
to entity_ids from the entity graph when the statement is about a
known entity (see Entities above) — it's an empty array when there's no
such link. relation is one of depends_on, requires, overrides,
triggers, references, or excludes; edges always reference
node_ids present in nodes (dangling edges are dropped at generation
time), same guarantee as the entity graph's edges.
/extract's combined response includes this layer too, as a reasoning
key alongside memory, objects, and entities — it can independently
be null under the same rules as the other layers (see Extract below).
Errors
| HTTP | code | Description |
|---|---|---|
| 401 | unauthorized | Missing, invalid, or revoked key |
| 403 | service_not_enabled | Key isn't authorized for the extract module |
| 404 | not_found | Document doesn't exist, or belongs to another tenant |
| 404 | reasoning_not_available | Document exists but the reasoning graph hasn't been generated yet (no representation, the reasoning_graph layer is empty, or the document predates this layer) |
Layout & bounding boxes
GET /v1/documents/{document_id}/layout
Layout captures where things are on the page — for each page, its pixel dimensions and a list of typed blocks (table, text, title, image, footer, ...) with the bounding box each one occupies. This is what lets any citation ("this fact came from here") be drawn as a highlighted rectangle over the source page, instead of just a page number.
curl https://api.docsfra.com/v1/documents/d_9c1a2b3c4d5e/layout \
-H "Authorization: Bearer dip_live_a1b2c3d4e5f6..."
{
"document_id": "d_9c1a2b3c4d5e",
"pages": [
{
"page": 3,
"page_size": [1654, 2339],
"blocks": [
{
"type": "title",
"bbox": [120, 88, 980, 140],
"text_head": "Cargo Description"
},
{
"type": "table",
"bbox": [120, 160, 1520, 640],
"text_head": "Item | Qty | Weight (kg)"
},
{
"type": "text",
"bbox": [120, 660, 1520, 900],
"text_head": "Total gross weight: 540 kg, packed in 3 crates."
}
]
}
]
}
page_size is [width, height] in pixels, and every bbox on
that page ([x1, y1, x2, y2], top-left/bottom-right corners) is in
that same coordinate system — divide by page_size to get normalized
(0–1) coordinates if your renderer needs those instead. text_head
is a short (≤80 char) preview of the block's text, useful for
matching a block to a table row or a chunk without re-parsing the
page. type is one of table, text, title, image,
footer/header (from the underlying layout model; not an
exhaustive enum).
Layout is a best-effort side layer: it comes from a separate layout-detection pass that runs alongside (not instead of) normal parsing, and it can be absent — for documents processed before this layer existed, or when the layout pass itself failed or wasn't configured for that document. Its absence never affects delivery of the base document, memory, objects, entities, or reasoning layers.
This is also why evidence.bbox / evidence.page_size are
optional additions to the evidence objects you see elsewhere on
this page (Objects' table evidence, Entities' and Reasoning graph's
node evidence): when a chunk's source page has layout data and a
layout block could be matched to it, its evidence gains "bbox": [[x1, y1, x2, y2], ...] and "page_size": [W, H] alongside the
existing page_no — when no match is found (or the page has no
layout), evidence keeps its plain { "page_no": ... } shape.
Errors
| HTTP | code | Description |
|---|---|---|
| 401 | unauthorized | Missing, invalid, or revoked key |
| 403 | service_not_enabled | Key isn't authorized for the extract module |
| 404 | not_found | Document doesn't exist, or belongs to another tenant |
| 404 | layout_not_available | Document exists but layout hasn't been generated for it (best-effort layer — not every document has one) |
Extract (combined view)
GET /v1/documents/{document_id}/extract
A single call that returns memory + objects + entities + reasoning together — useful when you want the full extraction picture without four round trips.
curl https://api.docsfra.com/v1/documents/d_9c1a2b3c4d5e/extract \
-H "Authorization: Bearer dip_live_a1b2c3d4e5f6..."
{
"document_id": "d_9c1a2b3c4d5e",
"memory": { "title": "Bill of Lading — INV-001", "...": "..." },
"objects": { "tables_count": 1, "figures_count": 0, "...": "..." },
"entities": { "node_count": 2, "edge_count": 1, "...": "..." },
"reasoning": { "node_count": 2, "edge_count": 1, "...": "..." },
"schema_version": "0.2-ku",
"generated": {
"memory": { "model": "...", "prompt_version": "mem-v1" },
"structured_objects": { "model": null },
"entity_graph": { "model": "...", "prompt_version": "ent-v1", "resolver_version": "res-v1" },
"reasoning_graph": { "model": "...", "prompt_version": "rsn-v1" }
}
}
Unlike the single-layer endpoints, /extract returns 200 even if
individual layers are missing — memory, objects, entities, or
reasoning can each independently be null if that layer failed to
generate or hasn't run yet. It only 404s when the document has no
representation at all (e.g. an old document uploaded before the AIDR
pipeline existed).
Errors
| HTTP | code | Description |
|---|---|---|
| 401 | unauthorized | Missing, invalid, or revoked key |
| 403 | service_not_enabled | Key isn't authorized for the extract module |
| 404 | not_found | Document doesn't exist, or belongs to another tenant |
| 404 | extract_not_available | Document has no representation at all (individual layers being null does not 404 — see above) |
Artifacts (the catalog)
GET /v1/documents/{document_id}/artifacts
The catalog endpoint: one call, every artifact your applications need
for this document, with an available flag and a via pointer to
where you'd actually fetch it. Use it to drive a UI ("what's ready for
this document?") without probing every endpoint individually.
curl https://api.docsfra.com/v1/documents/d_9c1a2b3c4d5e/artifacts \
-H "Authorization: Bearer dip_live_a1b2c3d4e5f6..."
{
"document_id": "d_9c1a2b3c4d5e",
"status": "completed",
"representation": { "schema_version": "0.2-ku", "status": "ready" },
"artifacts": {
"source": { "available": true, "kind": "binary", "via": "GET /v1/jobs/{id}/source" },
"markdown": { "available": true, "kind": "markdown", "via": "GET /v1/jobs/{id} → result.markdown_url" },
"structured_markdown": { "available": true, "kind": "markdown", "via": "GET /v1/jobs/{id} → result.structured_url" },
"canonical_json": { "available": true, "kind": "json", "via": "GET /v1/jobs/{id} → result.json_url" },
"memory": { "available": true, "kind": "json", "via": "GET /v1/documents/{id}/memory" },
"knowledge_units": { "available": true, "kind": "index", "count": 42 },
"structured_objects": { "available": true, "kind": "json", "tables": 1, "figures": 0, "via": "GET /v1/documents/{id}/objects" },
"entities": { "available": true, "kind": "graph", "nodes": 2, "edges": 1, "via": "GET /v1/documents/{id}/entities" },
"extract": { "available": true, "kind": "json", "via": "GET /v1/documents/{id}/extract" },
"embeddings": { "available": true, "kind": "vectors", "model": "text-embedding-3-small", "count": 87 },
"search": { "available": true, "kind": "api", "via": "GET /v1/search" },
"ask": { "available": true, "kind": "api", "via": "POST /v1/ask" }
}
}
available always reflects the real state (a ref being set, a layer
being non-empty, a count being non-zero) — it is never a static
capability flag. via is informational and stays the same string
regardless of availability, so you can build a link even for artifacts
that aren't ready yet (just disable it while available is false).
A document that exists but isn't completed yet still returns
200 — most artifacts will simply read available: false (processing
in progress). An old document with no representation also returns
200 with representation: null and the representation-dependent
artifacts (memory, structured_objects, entities, extract) marked
unavailable — the document itself isn't missing, only those artifacts
are. A 404 only happens when the document truly doesn't exist (or
belongs to another tenant).
Errors
| HTTP | code | Description |
|---|---|---|
| 401 | unauthorized | Missing, invalid, or revoked key |
| 403 | service_not_enabled | Key isn't authorized for the extract module |
| 404 | not_found | Document doesn't exist, or belongs to another tenant |
Provenance (audit trail)
GET /v1/documents/{document_id}/provenance
Every representation carries an audit trail of how it was built: which producer ran each step, which model/version it used, and a content hash for every layer it touched. Use this endpoint when you need to prove how an extracted fact came to be — compliance review, dispute resolution, or just debugging why two runs of the same document produced different output.
curl https://api.docsfra.com/v1/documents/d_9c1a2b3c4d5e/provenance \
-H "Authorization: Bearer dip_live_a1b2c3d4e5f6..."
{
"document_id": "d_9c1a2b3c4d5e",
"schema_version": "0.2-ku",
"status": "ready",
"producers": {
"canonical": { "producer": "parse_document", "rules_version": "canon-v1" },
"memory": { "model": "gpt-4o-mini", "prompt_version": "mem-v1" },
"entity_graph": { "model": "gpt-4o-mini", "prompt_version": "ent-v1", "resolver_version": "res-v1" }
},
"provenance": [
{ "step": "original", "hash": "sha256:1a2b3c...", "at": "2026-03-14T10:21:40Z" },
{ "step": "canonical", "producer": "parse_document", "output_hash": "sha256:4d5e6f...", "at": "2026-03-14T10:21:52Z" },
{ "step": "memory", "producer": "generate_memory", "model": "gpt-4o-mini", "prompt_version": "mem-v1", "output_hash": "sha256:7a8b9c...", "at": "2026-03-14T10:22:03Z" },
{ "step": "knowledge_units", "producer": "ai_index_document", "count": 42, "output_hash": "sha256:0d1e2f...", "at": "2026-03-14T10:22:18Z" },
{ "step": "embeddings", "producer": "embed_chunk_batch", "model": "text-embedding-3-small", "dim": 1536, "at": "2026-03-14T10:22:24Z" }
],
"content_hash": "sha256:c0ffee1234...",
"input_hash": "sha256:1a2b3c..."
}
Each provenance[] entry is append-only — steps are never rewritten in
place, only added as new layers are produced. If a step re-runs (e.g. a
document is re-processed), the previous representation is superseded
rather than mutated, so its provenance trail stays intact and
inspectable on its own. A document without a representation returns
404 provenance_not_available.
Errors
| HTTP | code | Description |
|---|---|---|
| 401 | unauthorized | Missing, invalid, or revoked key |
| 403 | service_not_enabled | Key isn't authorized for the extract module |
| 404 | not_found | Document doesn't exist, or belongs to another tenant |
| 404 | provenance_not_available | Document exists but has no representation yet |
Determinism & reproducibility
Docsfra pins the exact producer version behind every layer, so a given input always maps to a reproducible output:
canon-v1— the canonicalization rules that turn per-page markdown into the single ordered document (page markers, line-ending normalization, whitespace collapsing). No LLM involved: the same pages always canonicalize to the same markdown and the same hash.ctx-v2— the chunking/context rules that produce knowledge units.mem-v1— the prompt version behind the memory (identity card) layer.ent-v1— the prompt version behind the entity graph layer.
These version strings are what you see in producers.*.rules_version /
producers.*.prompt_version above (and in /extract's generated
block). A representation is immutable once built: it always reflects
the pinned versions active at the time it was produced. If Docsfra
later improves a producer (a new mem-v2 prompt, for example), existing
representations keep their original version tag — re-processing a
document produces a new representation (the old one is marked
superseded), never a silent in-place rewrite. content_hash is derived
purely from layer content (canonical text, memory JSON, knowledge unit
text, entity graph), so it changes if and only if the underlying
content changes — it is not a pointer or a timestamp.
Notes
All endpoints in this section are unmetered — no credits are
deducted for reading extraction results, regardless of call volume.
They all share the same extract module entitlement, so a key
authorized for extract can call all of them; search and ask remain
separately entitled and billed (see Semantic Search and Q&A (RAG)).