# Supplia API (v2.0)

## What is renewing, and when

The endpoint that pays for itself:

```bash
curl -H "Authorization: Bearer apk_xxxx" \
  "https://supplia.example.com/api/contracts?renewing-within=60"
```

Everything whose renewal date falls in the next 60 days — **plus anything already past its
renewal date**, because a contract that quietly rolled over last week is the expensive case,
not the harmless one. Closed contracts are excluded: they are not renewing.

```json
{
  "contracts": [
    {"id": 12, "vendor": "Acme Ltd", "title": "Annual license",
     "value_cents": 120000, "auto_renew": true,
     "renewal_date": "2026-08-09", "end_date": "2026-08-11",
     "base_status": "active", "status": "expiring", "status_label": "Expiring",
     "renewal": {"state": "critical", "days": 3, "alert": true, "phrase": "Renews in 3 days"},
     "end":     {"state": "critical", "days": 5, "alert": true, "phrase": "Expires in 5 days"}}
  ],
  "count": 1
}
```

Point a weekly digest at it and the auto-renew clause stops being a surprise.

## Status is derived, never stored

`base_status` is what is in the database: only `active` or `closed`. `status` is computed on
every read from the end date and your notice windows:

| `status` | Meaning |
|---|---|
| `closed` | You terminated it. Terminal — dates are ignored |
| `expired` | End date is in the past |
| `expiring` | End date is inside your notice window (default 60 days) |
| `active` | Everything else, including contracts with no end date |

Because nothing writes `expiring` or `expired` to a column, a status can never go stale — there
is no row to update and no cron to forget to run. Filter on it directly:

```bash
curl -H "Authorization: Bearer apk_xxxx" "https://supplia.example.com/api/contracts?status=expiring"
```

## Authentication

Create a key in **Settings → API & webhooks**. It is shown once.

```
Authorization: Bearer apk_<40 hex chars>
```

`/api/*` is CSRF-exempt by design: the key is the credential and browsers never send it
implicitly.

Every write checks the key holder's role, exactly as the screen does: a viewer's key cannot add a supplier or a contract (`403 forbidden`).

## Endpoints

| Method | Path | Notes |
|---|---|---|
| GET | `/api/ping` | Verify a key |
| GET | `/api/vendors` | Suppliers. `?status=active\|inactive` |
| POST | `/api/vendors` | Create a supplier |
| GET | `/api/vendors/{id}` | One supplier with its contracts |
| POST | `/api/vendors/{id}/contracts` | Add a contract |
| GET | `/api/contracts` | `?renewing-within=N` · `?status=active\|expiring\|expired\|closed` |
| GET | `/api/contracts/{id}` | One contract |
| GET | `/api/openapi.json` | OpenAPI 3.0.3 — imports as a custom connector |
| GET | `/healthz` | Unauthenticated `{"ok":true,"app":"supplia","version":"2.0.0"}` |

### Creating a supplier

```bash
curl -X POST https://supplia.example.com/api/vendors \
  -H "Authorization: Bearer apk_xxxx" -H "Content-Type: application/json" \
  -d '{"name": "Acme Ltd", "contact_email": "ap@acme.test", "category": "it"}'
```

Only `name` is required. An unrecognized `category` normalizes to `other` rather than
erroring — the same behavior as the form, so a bulk sync never fails on a taxonomy mismatch.

### Adding a contract

```bash
curl -X POST https://supplia.example.com/api/vendors/4/contracts \
  -H "Authorization: Bearer apk_xxxx" -H "Content-Type: application/json" \
  -d '{"title": "Annual license", "value": "1200.00",
       "renewal_date": "2026-08-09", "end_date": "2026-08-11", "auto_renew": true}'
```

`title` is required; a contract without one cannot be found again. `status` accepts only
`active` or `closed` — the derived states are read-only by construction.

`notice_days` (optional, 0–730) records the notice period the contract's cancellation clause asks
for. Every contract the API returns carries `notice_days` and `cancel_by` — the last day to give
notice, counted back from the renewal date (or the end date) — and, when there is one, a `notice`
block with its window state, like `renewal` and `end` (from 3.1.4).

## Webhooks

| Event | Fires when |
|---|---|
| `supplier.created` | A supplier is added (browser, API **or** AI tool) |
| `contract.created` | A contract is added |
| `contract.closed` | A contract's base status is set to `closed` (sent once, when it changes) |

A CSV import records its rows without sending an event per row.

Signed with your per-endpoint secret:

```
X-Supplia-Event: contract.created
X-Supplia-Signature: sha256=<hmac_sha256(raw_body, secret)>
```

```python
import hmac, hashlib
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers["X-Supplia-Signature"]):
    abort(401)
```

**There is no `contract.renewing` webhook, deliberately.** A renewal window opening is a date
passing, not an event anything in Supplia observes — firing it would need a background job, and
Supplia runs none. Poll `?renewing-within=N` on your own schedule instead: same answer,
computed fresh, and you choose the cadence.

## Errors

| Code | Meaning |
|---|---|
| `401 unauthorized` | Missing, malformed, revoked or unknown key |
| `404 not_found` | No such supplier / contract |
| `422 validation` | Refused by a guard — `detail` says which, in plain language |

## Money

Every amount is **integer cents** out (`120000` = 1,200.00) and a decimal string in
(`"1200.00"`). No floats, ever.

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

Supplia speaks **MCP (Model Context Protocol)** on one route, so an assistant can read your
supplier register and maintain it without anyone building a bridge first.

    POST /mcp
    Authorization: Bearer apk_...          ← the SAME revocable key the REST API uses
    Content-Type: application/json

Stateless streamable-HTTP: one JSON-RPC message per request, no session, no SSE. Point your
client at the URL, paste the key, and the tool list arrives.

### 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://supplia.example.com/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 supplia https://supplia.example.com/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": "supplia",
  "server_url": "https://supplia.example.com/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://supplia.example.com/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:
Supplia implements the protocol, not an integration with a particular vendor.

### The tools

| Tool | Writes? | What it does |
|---|---|---|
| `list_vendors` | no | The register. Filter by `status`, `category`, or `q` (name contains). |
| `vendor_detail` | no | One supplier with its contracts and documents — each contract carrying its **derived** expiry state, phrase and days, from the same engine the screen uses. |
| `document_status` | no | Compliance documents and their expiry state. `only_alerts: true` narrows to notice/warning/critical/expired. |
| `spend_report` | no | Contract value rolled up by `vendor` or `category`, in the register's own currency. |
| `upsert_vendor` | **YES** | Creates a supplier, or updates one by `vendor_id`. Records an audit entry. |

`upsert_vendor` runs the **same writer** the browser form and `POST /api/vendors` run — there is
no second definition of "a valid vendor" anywhere in the product.

### What the endpoint refuses, and why

- **Arguments must be single values.** Anything nested is rejected — the same guard `Api::body()`
  applies to the REST API.
- **Unknown arguments are refused by name.** An agent that typos `stauts` is told so, rather than
  silently getting unfiltered results.
- **Out-of-enum values are refused.** `category` is an enum of the register's real category keys
  on purpose: the product normalizes an unrecognized category to `other`, which is right behind a
  dropdown that cannot offer a wrong value, and wrong for an agent — it would be told it succeeded
  while its category was quietly discarded. It now gets refused, with the valid keys listed.
- **Roles apply exactly as they do in the browser.** A key belonging to a viewer does not even see
  `upsert_vendor` in `tools/list`, and is refused if it calls it anyway. A different answer for the
  same role would make the agent path a privilege-escalation hole.

Every MCP write lands in the **audit trail** next to the browser and API writes, naming the key's
user as the actor.

### Example

    curl -X POST https://your-install/mcp \
      -H "Authorization: Bearer apk_..." -H "Content-Type: application/json" \
      -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
           "params":{"name":"spend_report","arguments":{"group_by":"category"}}}'

## Renewal reminders (new in 3.2)

Each contract in the API carries `owner_email`: the address its renewal reminders go to (a person or a
shared inbox), or `""` when they go to the team address in Settings. Reminders themselves are sent only on
the schedule an administrator switches on in Settings; no API route or MCP tool sends, pauses or edits one.
`GET /cron/reminders?t=<token>` (the scheduled-backup token) sends what is due today up to the daily cap and
answers `{"ok": true, "ran": …, "sent": <n>, "failed": <n>}`; nothing is sent while reminders are off.

## Search and reading contracts (new in 3.2, pass B)

Search (`/search`) and "Read the key dates" are browser features for signed-in staff; neither has an API route or an
MCP tool, and reading never saves anything on its own: a person confirms each proposal on the contract form, which then
saves through the same path as any edit (and sends the same webhooks).


## Connect it to Zapier, Make or n8n

Supplia 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 Supplia, open **Settings → 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://supplia.yourcompany.com`.

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

| Event | Fires when |
|---|---|
| `supplier.created` | A supplier is added (browser, API or AI tool) |
| `contract.created` | A contract is added |
| `contract.closed` | A contract's base status is set to `closed` (sent once, when it changes) |
| `contract.renewing` | There is no webhook, deliberately. A renewal window opening is a date |

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

- **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/vendors`: Create a supplier
- `POST /api/vendors/{id}/contracts`: Add a contract

### Example recipes

- **supplier.created → a row in Google Sheets:** webhook trigger on `supplier.created` (A supplier is added (browser, API or AI tool)), then the Google Sheets "Create row" action with the fields you want.
- **contract.created → a message in Slack or Teams:** webhook trigger on `contract.created` (A contract is added), then the Slack or Microsoft Teams "Send message" action.
- **A new form entry or row elsewhere → Supplia:** the other tool's trigger (for example a new Google Sheets row or a form submission), then an HTTP action calling `POST /api/vendors` (Create a supplier).

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