# Cargora REST API, MCP & Webhooks (v3.0)

Cargora ships a JSON REST API and signed webhooks so the documents you extract — and the
delivery discrepancies they reveal — can reach your purchasing system, Power Automate, Zapier,
n8n, or your own code, without anyone re-keying a packing slip.

The important part: **discrepancies come from one engine.** The short shipments, over
shipments, price gaps and missing lines the API returns are computed by the same
`Reconcile::reconcile()` the report screen renders and the CSV export writes. An integration
that disagreed with the screen about what a supplier owes you would be worse than no
integration at all.

## Authentication

Create a key in **API & Webhooks**. A key belongs to a user and can do what a signed-in user
can do. 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.

Money is integer **cents**. Quantities are numbers (they can be fractional — you buy 2.5 kg of
something).

## Endpoints

| Method & path | What it does |
|---|---|
| `GET  /healthz` | Liveness check — no auth. `{"ok":true,"app":"cargora","version":"3.0.0"}` |
| `GET  /api/ping` | Verify a key |
| `GET  /api/documents` | List documents (no line items — a poller should stay light). `?status=` · `?type=` · `?supplier=` · `?q=` · `?reviewed=0\|1` · `?limit=` |
| `GET  /api/documents/{id}` | Extraction results **and** every discrepancy this document is involved in |
| `POST /api/documents/{id}/review` | Accept the extraction, or correct line items |
| `GET  /api/openapi.json` | OpenAPI 3 spec — no auth |

`status` accepts exactly the statuses the app itself uses: `pending`, `extracted`, `reviewed`,
`failed`.

### One document, with its discrepancies

    GET /api/documents/1

```json
{
  "document": {
    "ref_number": "PO-2026-0142", "doc_type": "po", "status": "reviewed",
    "line_item_count": 6, "total_cents": 218200,
    "reviewed_at": "2026-08-06 17:31:02", "reviewed_note": "checked against the paper slip",
    "line_items": [ {"id": 13, "sku": "NW-4471", "qty": 120, "unit_price_cents": 350,
                     "line_total_cents": 42000} ]
  },
  "reconciliations": [
    {
      "id": 2, "po": {"ref_number": "PO-2026-0142"}, "slip": {"ref_number": "SLIP-88245"},
      "summary": {"matched": 5, "qty_mismatches": 1, "price_mismatches": 1,
                  "missing_on_slip": 1, "extra_on_slip": 1, "discrepancies": 4,
                  "clean": false, "discrepant_lines": 2, "has_discrepancies": true},
      "matched": [ {"sku": "NW-4471", "po_qty": 120, "slip_qty": 90, "qty_delta": -30,
                    "qty_status": "short", "price_status": "match", "discrepancy": true} ],
      "missing_on_slip": [...], "extra_on_slip": [...]
    }
  ]
}
```

Reconciliations are recomputed **live** on every read, so a correction you make to a line item
is reflected immediately — there is no stale stored report to go out of date.

`summary` is the engine's own, passed through untouched, plus two conveniences:
`discrepant_lines` (matched lines with a quantity or price problem) and `has_discrepancies`
(simply the negation of the engine's `clean` flag — so the API can never disagree with the
screen about whether a delivery was clean).

`qty_delta` is **slip minus PO**: negative means short-shipped, positive means over-shipped.

### Review — accept, or correct

    curl -X POST https://your-install/api/documents/3/review \
      -H "Authorization: Bearer apk_..." -H "Content-Type: application/json" \
      -d '{"accept": true, "note": "checked against the paper slip",
           "line_items": [{"id": 13, "qty": 7, "unit_price_cents": 1250}]}'

Corrections apply only to the line items you name; anything omitted is left alone. If you change
a quantity or a unit price without supplying `line_total_cents`, the line total is recomputed
(`7 × 1250 = 8750`) so a document's total cannot drift away from the lines it is made of.

Every correction is validated **before** any of them is written, so a bad line cannot leave the
document half-corrected. A line item belonging to a different document is refused:

    422  {"error": "unprocessable", "detail": "line_items[0]: no line item 1 on this document."}

`accept: true` stamps `reviewed_at` and moves the document to `reviewed` — using the app's own
status machine, the same one the Save button uses. That stamp is the difference between "the
model said so" and "a human accepted it", which is exactly what an audit of an AI extraction
needs. Send `accept: false` to clear the stamp and send it back for another look.

## Webhooks

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

- `document.extracted` — an extraction completed, carrying the parsed header and line items
- `discrepancy.found` — a reconciliation turned up a problem

`discrepancy.found` fires **only when something is wrong** — a short or over shipment, a price
gap, a PO line missing from the slip, or a line delivered that was never ordered. A clean
reconciliation sends nothing, so an alert always means something needs attention.

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

    X-Cargora-Event: discrepancy.found
    X-Cargora-Signature: sha256=<hex HMAC-SHA256 of the RAW body, keyed with the endpoint's secret>

Verify the signature over the raw body before trusting a payload:

```php
$raw = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $raw, $YOUR_SECRET);
if (!hash_equals($expected, $_SERVER['HTTP_X_CARGORA_SIGNATURE'] ?? '')) { http_response_code(401); exit; }
```

One attempt, 5-second timeout; the **Recent deliveries** log shows every attempt and response
code. Design receivers to be idempotent.

## MCP — the endpoint for AI agents (new in 3.0)

Cargora speaks the **Model Context Protocol** at `POST /mcp`, stateless streamable-HTTP: one
JSON-RPC message in, one JSON response out. Point an MCP-capable agent at it and it can read your
purchase orders, ask whether a delivery arrived in full, and correct a mis-extracted line —
without you writing an integration.

Authentication is the **same API key** as the REST surface, and it carries the same role:

    POST /mcp
    Authorization: Bearer apk_...
    Content-Type: application/json

    {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}

`GET /mcp` answers **405 with `Allow: POST`** rather than 404, so a probing client can tell the
difference between "wrong method" and "no MCP support here".

| Tool | Reads or writes | What it does |
|---|---|---|
| `list_documents` | READ-ONLY | POs, packing slips and delivery notes; filter by type, status or supplier. |
| `document_detail` | READ-ONLY | One document with its line items, the per-line arithmetic check, and the reconciliations it takes part in. |
| `line_item_update` | **WRITES** | Correct one extracted line. Omit the line total and it is recomputed from qty × unit price, exactly as the edit form does. |
| `match_status` | READ-ONLY | With a slip: that delivery against the order. Without one: the cumulative picture across **every** slip matched to the PO — ordered vs received vs still outstanding. |
| `discrepancies` | READ-ONLY | Short and over shipments, price gaps, ordered lines missing from a slip, and lines delivered that were never ordered. |
| `export_rows` | READ-ONLY | A document's line items, or a reconciliation report, through Cargora's own exporters. |

Three things worth knowing:

**Nothing is re-derived.** `match_status` and `discrepancies` return the output of the same
`Reconcile::` engine the screens render. The suite asserts equality between the two rather than
trusting the code to stay honest — an agent that disagreed with the receiving desk about what a
supplier owes would be worse than no agent at all.

**Arguments are guarded.** Every argument must be a single scalar; a list or object where one is
not expected is refused, unknown arguments are refused *by name*, and a value outside an enum is
refused with the permitted values. An agent that typos an argument is told, not half-obeyed.

**Roles apply identically.** A key belonging to a viewer can call every read tool and is refused
on `line_item_update` with a message that names the role.

### 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 cargora 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": "cargora",
  "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:
Cargora implements the protocol, not an integration with a particular vendor.

## Three deliberate omissions

**There is no upload endpoint.** Uploading a document is multipart, and extraction behind it is
long-running, spends *your* LLM credit per call, and is guarded by file-type checks and a demo
limiter that live on the upload screen. Exposing that as a JSON endpoint would have meant either
duplicating those guards or quietly weakening them, and an endpoint that can burn an operator's
API budget deserves the deliberation a browser step provides. Documents arrive through the UI;
everything *after* that — listing, reading, reviewing, correcting, and reacting to discrepancies
— is fully available here.

**There is no extraction tool on MCP, and that is on purpose.** Running an extraction calls *your*
LLM provider with *your* key and spends *your* credits. An agent that could trigger it could spend
money in a loop, on a schedule, with nobody watching. Asking for a tool named `extract` returns
`Unknown tool`. Documents are uploaded and extracted by a person; everything after that — reading,
matching, correcting, exporting — is fully available to an agent.

**There is no CSV import.** Cargora's whole job is turning PDFs and photos of documents into
structured data. A CSV of line items would already be structured data, which means it never
needed Cargora. The nearest genuine equivalents already exist: the document export
(`/documents/{id}/export.csv` and `.json`) to get data *out*, and this API to correct it in
place.

## Limits, honestly

- A key carries its user's **role** (viewer / member / admin), so it is scoped exactly as that
  person is — a viewer's key cannot correct a line item, over REST or over MCP.
- One delivery attempt per webhook event (log + idempotent receivers, not a retry queue).
- `GET /api/documents` caps at 500 rows and omits line items by design; fetch a document by id
  when you need its contents.
- Reconciliations are created on the Reconcile screen — pairing the right PO with the right slip
  is a judgment call, and getting it wrong produces a confidently wrong discrepancy report.
- Extraction quality is your model's, not Cargora's. That is precisely why the review step, and
  the `reviewed_at` stamp, exist.


## Connect it to Zapier, Make or n8n

Cargora 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 Cargora, 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://cargora.yourcompany.com`.

### When something happens in Cargora: 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 Cargora under **API & Webhooks**, and choose the event or events below.
3. Make the event happen once in Cargora, so the other tool receives a sample and you can map its fields.

| Event | Fires when |
|---|---|
| `document.extracted` | An extraction completed, carrying the parsed header and line items |
| `discrepancy.found` | A reconciliation turned up a problem |

Every delivery carries `X-Cargora-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 Cargora

- **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`: Accept the extraction, or correct line items

### Example recipes

- **document.extracted → a row in Google Sheets:** webhook trigger on `document.extracted` (An extraction completed, carrying the parsed header and line items), then the Google Sheets "Create row" action with the fields you want.
- **discrepancy.found → a message in Slack or Teams:** webhook trigger on `discrepancy.found` (A reconciliation turned up a problem), 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; Cargora's own automated tests check its API.
