# Extracta REST API & Webhooks (v2.0)

Extracta ships a JSON REST API and signed webhooks so extracted invoice data can flow into your
accounting system, a spreadsheet job, or whatever you already run — without anyone re-typing it.

Extracta uses **your own** LLM API key. Extracted data stays on your server; each document goes only to the
provider you configure (with a local Ollama model, it never leaves your server).

## Authentication

Create a key in **API & Webhooks** (in the sidebar). Send it on every call:

    Authorization: Bearer apk_...

The key is shown once at creation and stored as a SHA-256 hash. Revoke any time.

## Endpoints

| Method & path | What it does |
|---|---|
| `GET  /api/ping` | Verify a key |
| `GET  /api/documents?status=&needs_review=1` | List documents |
| `GET  /api/documents/{id}` | One document with fields and line items |
| `POST /api/documents/{id}/review` | Approve, optionally correcting fields first |
| `GET  /api/export?from=&to=&reviewed=1` | Extracted records in an invoice-date range |
| `GET  /api/openapi.json` | OpenAPI 3 spec (no auth) |

Since 3.3.0 a document read from an e-invoice's XML also carries `einvoice`: its format, the seller's
VAT ID, the buyer, the buyer reference, the account to pay, the amount due, the VAT breakdown per rate
(`category`, `rate`, `basis`, `tax`) and the file's `sha256`. It is `null` for other documents.

Since 3.3.0 every document also carries `recipient_status`: the status recorded on the review page for
a received invoice, as `{"status": "accepted" | "rejected" | "paid", "reported_on": "YYYY-MM-DD" | null,
"reference": "...", "note": "..."}`, or `null` when none is recorded. It records what you reported
through your platform; Extracta reports nothing itself. The MCP `export_rows` tool carries the same
block as `_recipient_status`.

A key acts with the role of the person it belongs to: a viewer's key reads, and
`POST /api/documents/{id}/review` answers it `403` with the permission it lacks (`document.review`).

### Why upload is not in the API

Uploading is deliberately UI-only. A document arrives as bytes from a scanner, a phone camera or
a mail rule, and it then needs a human to glance at the result. What an accounting system actually
wants to talk to is everything *after* that — and that is what the API exposes. If you need
unattended ingestion, turn on the email inbox (Settings → Email inbox, 3.2.0) and forward the
documents to it; they arrive in the queue, and the API handles the rest of the pipeline.

### "Needs review" is an arithmetic fact, not a guess

`needs_review` is not a confidence score invented for the API. It is
`Extraction::reconcile()`'s verdict — the same check the review screen runs:

- do the line items sum to the subtotal (or total)?
- does subtotal + tax equal the total?

If either fails, `needs_review` is `true` and `reconcile_warnings` says exactly what did not add
up. The API, the review screen and the email alert therefore can never disagree about whether a
document is trustworthy. **This matters more than a failed extraction:** a failure is obvious,
whereas a document that extracted "successfully" with numbers that do not reconcile will go into
the books wrong unless somebody looks.

    GET /api/documents?needs_review=1

is the endpoint to poll — extracted, not yet approved, and internally inconsistent.

### Approve or correct

    curl -X POST https://your-install/api/documents/42/review \
      -H "Authorization: Bearer apk_..." -H "Content-Type: application/json" \
      -d '{"fields": {"total": "1,234.56", "invoice_date": "07/03/2026"}}'

Only the fields you send are touched. Every value runs through the **same normalizers the review
form uses** — money parsing (US and EU formats, symbols, negatives), date normalization honoring
your day-first setting, and currency-code resolution — so a value posted here is stored exactly as
the screen would have stored it. Send `{"reviewed": false}` to save corrections without approving.
Since 3.3.0 a date sent with its time (`2026-03-04T08:10:00Z`) is stored as the date it shows
(`2026-03-04`); before, it was stored as no date.

Approving promotes the document to the `reviewed` status, identically to the screen.

**3.2.0:** add a top-level `"account"` (for example `{"account": "6100 Office supplies"}`) to code the
document for the accounting export. It is kept outside `fields`, and exactly as on the review
screen, a changed account teaches the vendor rule. Every document object now also carries
`source` (`upload` or `email`), `account` and `account_source` (`rule`, `manual` or empty), and a
line item's `unit_price` is filled in (it was always `null` before 3.2.0).

### Export

`GET /api/export` filters on the **invoice date**, not the upload date, because a bookkeeper
closing July wants July's invoices however late they were scanned. `reviewed=1` restricts it to
documents a human has approved. Reviewed documents are always included — they are the ones most
worth exporting.

Money is returned as both integer cents and the app's own decimal string, so nothing has to be
re-derived downstream.

## MCP — the agent surface (new in 3.0)

Extracta speaks **MCP** at `POST /mcp`, authenticated with the API key you already have:

    Authorization: Bearer <your api key>

`GET /mcp` answers **405** with `Allow: POST`.

| Tool | Writes? | What it does |
|---|---|---|
| `list_documents` | no | Documents with status and reviewed flag |
| `document_detail` | no | One document, its fields and the arithmetic check |
| `submit_document` | **YES** | Records extracted values (whitelisted) and marks it reviewed; an optional `account` codes it (3.2.0) |
| `review_decide` | **YES** | Approve as-is, or send back to the queue |
| `export_rows` | no | The extracted rows, ready for a ledger |

### Connect it to an assistant

Mint the key in the app first: **Settings → API keys**. Choose **Read only** when the assistant should
answer questions but never change anything — the endpoint then lists only the read tools and refuses the
rest by name, so a careless prompt cannot write. Full access behaves as before.

Every client needs the same three facts, and nothing in the handshake is vendor-specific:

| | |
|---|---|
| **The address** | `https://your-install/mcp` |
| **The key** | header `Authorization: Bearer apk_xxxx` |
| **The transport** | MCP over streamable HTTP, stateless |

**Claude** — one command, or the same URL and header as a custom connector in the desktop and web apps:

```sh
claude mcp add --transport http extracta https://your-install/mcp \
  --header "Authorization: Bearer apk_xxxx"
```

**ChatGPT and the OpenAI API** — one entry in the Responses API's `tools` array (in ChatGPT itself, the
same URL and key go in as a connector):

```json
{
  "type": "mcp",
  "server_label": "extracta",
  "server_url": "https://your-install/mcp",
  "authorization": "apk_xxxx",
  "require_approval": "never"
}
```

**Every other client spells the same three facts differently — copy the shape from its own
documentation, not from here.** VS Code is the clearest example of why: its
[configuration reference](https://code.visualstudio.com/docs/agents/reference/mcp-configuration)
(read 6 September 2026) puts servers in `.vscode/mcp.json` under a **`"servers"` object** — *"an
object that maps server names to their configurations"*. Pasted as-is,
a block written for another client will not load there. The id, the URL and the token are what travel; the JSON around
them belongs to whichever client you are configuring.

**A local model, n8n, or your own code** — n8n's **MCP Client** node takes the URL and the same
`Authorization: Bearer` header; a model running on your own machine reaches it through any MCP client, so
nothing leaves your network at all. Writing it yourself is one POST of JSON-RPC 2.0:

```sh
curl -X POST https://your-install/mcp \
  -H "Authorization: Bearer apk_xxxx" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

Any client that speaks MCP's streamable-HTTP transport works, including ones that do not exist yet:
Extracta implements the protocol, not an integration with a particular vendor.

### The whitelist is the product, and it applies to agents too

Extracta declares exactly eight fields — `vendor`, `invoice_number`, `invoice_date`, `due_date`,
`currency`, `subtotal_cents`, `tax_cents`, `total_cents` — plus line items. **Anything else you
send is dropped, not stored.** `submit_document` runs your `fields` object through the same
builder the review form uses, which *names* every field it reads rather than copying keys, so an
extra key has nowhere to go:

    "fields": {"vendor": "Acme Ltd", "total": "120.00", "currency": "€",
               "bank_account": "IE29 …", "internal_note": "call the CFO"}

    → stored: vendor, invoice_number, invoice_date, due_date, currency (EUR),
              subtotal_cents, tax_cents, total_cents (12000), line_items
    → dropped: bank_account, internal_note

The response echoes `stored_fields` so you can see exactly what was kept. Line items narrow the
same way: `description`, `qty`, `unit_cents`, `amount_cents`, nothing else.

Values also go through the product's own parsers, so `"€"` becomes `EUR` and `"120.00"` becomes
`12000` cents — the same normalization a person typing into the review screen gets.

### Reject does not delete

`review_decide` with `"reject"` clears the reviewed flag and returns the document to the queue.
The extracted values are **left alone** — rejecting means "a person should look at this", not
"throw the work away".

### Arithmetic

`document_detail` returns `reconcile`, the product's own cross-check (line items sum to the
subtotal; subtotal + tax equals the total). The API reports that verdict; it never re-adds
anything itself.

## Extraction templates (new in 3.0)

A template is a named **subset** of the declared fields for a document type — a utility bill has
no line items, so its template hides them. A template can only ever narrow the set: the choices
are exactly the declared fields, and the intersection is applied again when the template is read,
so even a hand-edited row cannot introduce a field the extractor does not know.

## Erasure (new in 3.0)

Extracta has no person table — a subject appears as a **value** inside an extracted record. So
**Subject search** looks across the declared fields, the raw extraction, the sender and the file name
(case-insensitively; the source documents themselves are not searched; 4.1.1) and **Redact** blanks
the matching text values in place.

The amounts, the dates and the source document stay. The transaction did happen, and erasing its
arithmetic would falsify the books rather than protect anyone. The audit entry is a count, and the
search term itself is never written to it.


## Webhooks

Add receiver URLs in **API & Webhooks**. Events:

- `document.extracted` — fields have been pulled (payload carries `needs_review`)
- `document.reviewed` — a human approved it

Both fire from **every** path, including the public-demo simulation and the real LLM call, so a
receiver sees the same stream regardless of how the extraction ran.

Each delivery is an HTTP POST with a JSON body and:

    X-Extracta-Event: document.extracted
    X-Extracta-Signature: sha256=<hex HMAC-SHA256 of the raw body, keyed with the endpoint's secret>

Verify the signature before trusting a payload. Delivery is one attempt with a short timeout; the
**Recent deliveries** log shows every attempt and its response code. Design receivers to be
idempotent.

## Email alerts

Set an SMTP server and a notification address in **Settings**. You get a mail when an extraction
succeeds but does not reconcile — the case that would otherwise pass silently.

## Backup

`/backup.json` exports every table with **both** the SMTP password and **your LLM API key**
redacted. Extracta is the one product here that stores a third-party credential, and a backup file
is exactly the sort of thing that ends up somewhere it should not.

## Limits, honestly

- The API acts at user level; there is no separate scope system.
- List endpoints cap at 200 rows.
- One delivery attempt per webhook event (log + idempotent receivers, not a retry queue).
- Upload and re-extraction stay in the UI (see above); the email inbox is the unattended way in.
- Extraction quality is your model's, not Extracta's. The reconcile check is the safety net, and
  it catches arithmetic — not a plausible-looking wrong vendor name.


## Connect it to Zapier, Make or n8n

Extracta talks to other tools without a plugin: it sends a webhook (an HTTPS message) the moment something happens, and it accepts calls to the API above. Zapier, Make and n8n can use both.

### Before you start

- In Extracta, open **API & Webhooks**. Mint an API key (choose **Read only** if the other tool only reads), and add a webhook when you have its URL (below).
- Use your install's full address, for example `https://extracta.yourcompany.com`.

### When something happens in Extracta: send it to another tool

1. In the other tool, create the trigger that gives you a URL to receive webhooks:
   - **Zapier:** the "Webhooks by Zapier" app, trigger **Catch Hook** (Webhooks by Zapier needs a paid Zapier plan).
   - **Make:** module **Webhooks → Custom webhook**.
   - **n8n:** the **Webhook** node.
2. Copy that URL into Extracta under **API & Webhooks**, and choose the event or events below.
3. Make the event happen once in Extracta, so the other tool receives a sample and you can map its fields.

| Event | Fires when |
|---|---|
| `document.extracted` | Fields have been pulled (payload carries ) |
| `document.reviewed` | A human approved it |

Every delivery carries `X-Extracta-Signature`, an HMAC-SHA256 signature over the raw body. Zapier and Make accept the delivery without checking it, so keep the webhook URL private. In n8n you can check it in a Code node before using the data.

### When something happens elsewhere: act in Extracta

- **Zapier:** "Webhooks by Zapier", action **POST** (or **Custom Request**).
- **Make:** module **HTTP → Make a request**.
- **n8n:** the **HTTP Request** node.

Send the request to your install's address plus the path from the Endpoints table above, with the header `Authorization: Bearer <your API key>` and a JSON body. A read-only key cannot write.

The endpoints that change something:

- `POST /api/documents/{id}/review`: Approve, optionally correcting fields first

### Example recipes

- **document.extracted → a row in Google Sheets:** webhook trigger on `document.extracted` (Fields have been pulled (payload carries )), then the Google Sheets "Create row" action with the fields you want.
- **document.reviewed → a message in Slack or Teams:** webhook trigger on `document.reviewed` (A human approved it), then the Slack or Microsoft Teams "Send message" action.

These recipes use only the events and endpoints documented above. We have not run them inside Zapier, Make or n8n ourselves; Extracta's own automated tests check its API.
