Supplia API, as shipped in the download
Supplia API (v2.0)
What is renewing, and when
The endpoint that pays for itself:
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.
{
"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:
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
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
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)>
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:
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):
{
"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 (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:
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
stautsis told so, rather than silently getting unfiltered results. - Out-of-enum values are refused.
categoryis an enum of the register's real category keys on purpose: the product normalizes an unrecognized category toother, 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_vendorintools/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
- 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.
- Copy that URL into Supplia under Settings → API & webhooks, and choose the event or events below.
- 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 supplierPOST /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.