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}/line-items | structured_objects (derived) | Typed, deduplicated invoice/customs line items derived from structured_objects tables, plus reconciliation |
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 |
Output language
Among the layers produced at upload time, the fields that contain
narrative text — the memory layer's summary/purpose fields
(summary, purpose) and the reasoning_graph's claim text
(statement) — are written in the tenant's output language, as
selected in the Console under Models at the time of processing.
entity_graph values (canonical_name, attributes) are unaffected
by this setting — they are values extracted verbatim from the
document and stay in the source document's language. Unlike
/v1/ask and /v1/cross-check (see Ask (RAG) → Output language),
the language of these layers cannot be overridden per request, since
generation happens at upload time, before any query is made.
Important: changing the output language setting only affects new
uploads going forward — previously processed documents are not
automatically reprocessed; their existing memory/reasoning layers
stay in whatever language they were generated in.
Auth and entitlement
All endpoints in this section 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 |
Line items (invoice / customs line items)
GET /v1/documents/{document_id}/line-items
Instead of re-parsing the raw table cells from the Objects endpoint yourself (a common need for workflows like customs declaration automation), this endpoint returns a typed, deduplicated list of invoice/customs line items.
The primary extraction path reads each page's text directly and
asks a model for typed line items — no table detection required, so
pages where a line-item table wasn't recognized as a table (but the
items are still present as text) are no longer silently missed. Every
returned item is verified against the source page text (its code and
total must actually appear on that page, in some normalized form)
before being accepted; anything that can't be verified is dropped and
counted in dropped_by_verification, and a page whose request fails
outright is listed in pages_failed — nothing is lost silently. Only
when this path finds zero verified items (feature disabled, pages
unavailable, or the request failed) does the endpoint fall back to the
table-derived path: a deterministic column-name mapper over the
structured_objects tables (a multilingual alias dictionary — TR/EN/IT
— that recognizes mixed headers like "Article/Codice",
"Quantity/Quantità", "Disc./Sconto"), with its own LLM fallback for
when that layer finds zero items or the item totals don't reconcile
against the document total (see below). The top-level pipeline field
tells you which path produced the response ("typed", or absent for
the table-derived path).
curl https://api.docsfra.com/v1/documents/d_9c1a2b3c4d5e/line-items \
-H "Authorization: Bearer dip_live_a1b2c3d4e5f6..."
{
"document_id": "d_9c1a2b3c4d5e",
"line_items": {
"items": [
{
"code": "A100",
"external_ref": null,
"description": "Machine part",
"quantity": 10.0,
"unit": "PZ",
"unit_price": 5.0,
"discount_pct": 10.0,
"discount_amount": null,
"line_total": 45.0,
"tariff_code": null,
"origin_country": null,
"page_no": 3,
"extraction_method": "typed",
"confidence": 0.7
}
],
"item_count": 1,
"subtotal": null,
"discount_total": null,
"total": 45.0,
"currency": "EUR",
"reconciliation": {
"matched": true,
"computed_total": 45.0,
"document_total": 45.0,
"diff": 0.0,
"hint": "Line item total matches the document total."
},
"unmapped_tables": [],
"pipeline": "typed",
"pages_failed": [],
"dropped_by_verification": 0
},
"generated_at": "2026-03-14T10:22:07Z"
}
Fields
| Field | Description |
|---|---|
code | Item/product code — null if it couldn't be read (never fabricated) |
external_ref | A serial/lot number or ID found glued to the code or reported separately (typed path's serial_no) — null if none |
description | Item description, in the document's own language |
quantity, unit | Quantity (number) and unit (text, e.g. "PZ"/"KG") |
unit_price | Unit price |
discount_pct, discount_amount | Discount rate/amount — the typed path only ever fills discount_pct; the table path fills whichever the table carries and leaves the other null |
line_total | The line's extended total |
tariff_code, origin_country | Customs tariff code (HS code) and country of origin — null if not present in the source |
page_no | The source page the item was found on — ALWAYS present as a field (null if genuinely unknown, never fabricated) |
extraction_method | "typed" (primary, page-text based), "table" (deterministic column mapping fallback), or "llm" (the table path's own fallback) |
confidence | A number between 0 and 1, reflecting field-fill ratio for typed/table items; for llm items it's a fixed low constant |
pipeline (top level, inside line_items) is "typed" when the primary
path produced the response, and absent when the response came from the
table-derived fallback path. pages_failed lists the (1-based) page
numbers whose typed-extraction request failed outright (network/HTTP/parse
error) — only present for the typed path, always [] when every page
succeeded. dropped_by_verification counts typed items that were
returned by the model but couldn't be verified against the source page
text, and were therefore dropped rather than trusted.
Document-level subtotal/discount_total/total are derived from
summary rows found inside the table rows themselves (multilingual
labels like "Total"/"Totale"/"Subtotal") — a field that couldn't be
found stays null. currency is picked by majority vote from the
currency symbols/codes (€/$/EUR/USD, etc.) seen in item cells and
summary rows; null if none is found. unmapped_tables lists the
indices (matching the tables array in the /objects response) of
tables that didn't look like line-item tables (fewer than 2 mapped
core fields, including at least one of code/description) — nothing
is silently dropped, you can always see which tables were skipped and
why.
Number format
Line item tables can use either European ("1.234,56") or US
("1,234.56") number formatting — the format is resolved with a
single, document-wide decision (not row by row), so the same
document is never interpreted inconsistently.
Deduplication
The same line item could be counted twice — once from the raw table
rows in /objects, once again in your own post-processing. This
endpoint deduplicates items using a (code, quantity, line_total, page_no, external_ref) signature (external_ref carries a serial/lot
number when the source distinguishes otherwise-identical rows — e.g.
the typed path's serial_no, or an ID glued onto a product code in a
table cell). If the same code repeats on a different page (e.g. a
split shipment, or a legitimately repeated line), it stays as two
separate items — the signature deliberately includes the page number,
so this is never a blind code-only merge.
Reconciliation
Items are always returned even when reconciliation.matched is
false — this endpoint never returns 422. A false value can mean
one of: (a) the computed item total differs from the document total
beyond a rounding tolerance (maybe an item was mis-read or missed),
(b) no total label could be found in the document at all
(document_total: null), or (c) no items were found at all
(computed_total: null). The hint field always explains which case
applies — use it client-side as a "flag for human review" signal.
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 | line_items_not_available | Document exists but structured objects haven't been generated yet (line items are derived from them) |
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)).