# Approva REST API & Webhooks (v1.1)

Approva ships a JSON REST API and signed webhooks so purchase approvals can plug into
Power Automate, Zapier, n8n, or your own code — including Microsoft Business Central
integrations built against it.

## Authentication

Create a key in **Settings → API & Webhooks** (admin). A key belongs to a user and can do
exactly what that user can do in the app — requester keys raise requests, approver keys
approve (never their own requests), admin keys can issue POs. Send it on every call:

    Authorization: Bearer apk_...

The key is shown once at creation and stored hashed. Revoke any time.

## Endpoints

| Method & path | What it does |
|---|---|
| `GET  /api/ping` | Verify a key; returns the acting user + role |
| `GET  /api/requests` | Newest 50 requests; `?status=` filters (`draft`, `pending`, `partially_approved`, `approved`, `rejected`, `po_issued`) |
| `POST /api/requests` | Create a request (optionally `"submit": true`) |
| `GET  /api/requests/{id}` | One request with lines, approvals, PO |
| `POST /api/requests/{id}/submit` | Submit a draft for approval |
| `POST /api/requests/{id}/approve` | Approve at the next level (body: `{"note": "..."}` optional) |
| `POST /api/requests/{id}/reject` | Reject (halts the request) |
| `POST /api/requests/{id}/generate-po` | Issue the numbered PO (admin; fully approved only) |
| `GET  /api/pos` | List purchase orders |
| `GET  /api/vendors` · `GET /api/cost-centers` | Reference data |
| `GET  /api/openapi.json` | OpenAPI 3 spec (no auth) — machine-readable version of this table |

### Create a request

    curl -X POST https://your-install/api/requests \
      -H "Authorization: Bearer apk_..." -H "Content-Type: application/json" \
      -d '{
        "vendor": "Northwind Supplies",
        "cost_center": "OPS",
        "justification": "Replacement toner for the print room",
        "submit": true,
        "lines": [
          {"description": "Toner cartridge X-42", "qty": "4", "unit": "89.50"}
        ]
      }'

Money is decimal-in, integer-cents-out: send `"unit": "89.50"`, read `"unit_cents": 8950`.
All the same validation and approval-threshold math as the UI applies.

## Webhooks

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

- `request.submitted` — a request entered the approval flow
- `request.approved` — a request became FULLY approved (not each level)
- `request.rejected` — an approver rejected it
- `po.issued` — a purchase order was generated

Each delivery is an HTTP POST with a JSON body (`{"event", "at", "request": {...}}`) and:

    X-Approva-Event: request.approved
    X-Approva-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 5-second
timeout — deliberately simple; the **Recent deliveries** log on the settings page shows
every attempt and response code. Design receivers to be idempotent.

## Power Automate (and Logic Apps)

Two ways, no Microsoft partnership required:

1. **Instant trigger via webhook:** in Power Automate, create a flow with the
   **"When an HTTP request is received"** trigger, copy its URL into Approva's webhooks,
   and branch on `body('...')?['event']`. That's approvals driving Teams messages,
   Business Central jobs, or anything else in your tenant.
2. **Custom connector:** Power Automate → Data → Custom connectors → **Import an OpenAPI
   file** → point it at `/api/openapi.json` from your install (or download the file).
   Set security to API Key / Bearer. Every endpoint above becomes a native action.

## Limits, honestly

- The API acts at user level; there is no separate scope system beyond the app's roles.
- One delivery attempt per webhook event (log + idempotent receivers, not a retry queue).
- List endpoints cap at 50–100 rows; this is an SMB approval tool, not a data warehouse.

---

# MCP — the agent door (new in 3.0)

Approva speaks the **Model Context Protocol** at `POST /mcp`, so Claude, ChatGPT agents,
n8n's AI nodes or your own code can *use* Approva instead of merely reading it. It is the
same product underneath: the same bearer key, the same roles, and the same approval guards.
An agent cannot do anything its key's user could not do in the browser.

Transport is **streamable HTTP, stateless**: one JSON-RPC 2.0 request in, one JSON response
out. No session to keep, nothing to clean up — the right shape for a self-hosted app.

```bash
curl -s -X POST https://approva.example.com/mcp \
  -H "Authorization: Bearer apk_xxxx" -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize",
       "params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"my-agent","version":"1"}}}'
```

Then `tools/list` to discover, `tools/call` to act:

```bash
curl -s -X POST https://approva.example.com/mcp \
  -H "Authorization: Bearer apk_xxxx" -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
       "params":{"name":"pending_report","arguments":{}}}'
```

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

## Tools

| Tool | Writes? | What it does |
|---|---|---|
| `list_requests` | no | Requests newest first (max 50); optional `status` filter |
| `get_request` | no | One request with lines, approvals and PO |
| `create_request` | **yes** | Creates a request; `submit: true` sends it for approval |
| `submit_request` | **yes** | Submits a draft (requester or admin only) |
| `approve_request` | **yes** | Records an approval — full guard applies |
| `reject_request` | **yes** | Records a rejection (terminal) |
| `pending_report` | no | Counts by status + everything awaiting a decision |
| `list_vendors` | no | Vendors on file |
| `list_cost_centers` | no | Cost centers on file |

There is deliberately **no `issue_po` tool**. Issuing a purchase order is the moment money
becomes committed to a supplier; that stays a human click in the app, and the REST API keeps
it behind the admin role. An agent can tell you a request is ready — it cannot cut the PO.

## What the guard does to an agent

The rules are the product, so they apply identically here:

- Approving your own request is refused: *"You cannot approve your own request."*
- A second decision by the same person is refused: *"You have already recorded a decision on this request."*
- A decision after the workflow closed is refused: *"This request is no longer awaiting approval."*
- Under a delegation, the delegator's own requests are refused, and delegator + delegate can
  never both be counted on one request.

Refusals come back as a tool result with `isError: true` and the sentence above, so the model
can read what happened and correct itself, rather than a bare protocol error.

## Argument hygiene

Every argument must be a single value. Send a list or an object where text belongs and the
call is refused by name — `lines` on `create_request` is the one allowlisted exception,
because a request genuinely carries line items:

```json
{"content":[{"type":"text","text":"vendor must be a single text or number value, not a list or object."}],"isError":true}
```

Unknown arguments are refused by name too (`Unknown argument: vendorr`), because an agent
that typos a field should be told, not silently half-obeyed. Missing required arguments,
wrong types and out-of-enum values are all named the same way.

## Errors

Protocol problems are JSON-RPC errors: `-32700` (bad JSON), `-32600` (batch requests, which
are not supported), `-32601` (unknown method), `-32602` (unknown tool). Domain refusals are
tool results with `isError: true`. `GET /mcp` answers `405` with `Allow: POST`.

# Named approval steps (new in 3.2)

`GET /api/requests/{id}` (and each request in the list and in webhook payloads) carries:

- `steps`: the named steps frozen on the request at submission, `[{"level": 1, "label": "Marketing lead", "approver_ids": [3]}, …]`, or `[]` when its cost center names nobody;
- `next_step`: while the request awaits a decision, `{"level": 2, "approver_ids": [5], "label": "Finance (Dana Reyes)"}`. An empty `approver_ids` means any approver may sign it. `null` once it is decided.

`GET /api/cost-centers` lists each center's current `steps`, the ones a request raised now would be frozen with.

Approving through the API or the MCP tools goes through the same rule as the Approve button: a key whose user is not named for the next level is refused with `This step is for <label>.`

`GET /cron/digest?t=<token>` (the `/backup/run` token) sends the morning digest when it is switched on in Settings, and answers `{"ok": true, "ran": …, "sent": <people>, "why": …}`.

# Budgets, receiving and sending POs (new in 3.2)

- Each request carries `budget_note`: its cost center's budget position when it was submitted
  ("MKT budget for September 2026: $5,000.00, committed $3,000.00, $5,500.00 with this request — OVER BUDGET by $500.00"),
  or `""` when the center has no budget. A budget flags a request; it never refuses an approval.
- `GET /api/pos` gives each PO a `receipt_state`: `open`, `partial` or `received`.
- Recording receipts and emailing a PO to its vendor are done on the PO's page; no API route or MCP tool does either.


## Connect it to Zapier, Make or n8n

Approva 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 Approva, 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://approva.yourcompany.com`.

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

| Event | Fires when |
|---|---|
| `request.submitted` | A request entered the approval flow |
| `request.approved` | A request became FULLY approved (not each level) |
| `request.rejected` | An approver rejected it |
| `po.issued` | A purchase order was generated |

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

- **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/requests`: Create a request (optionally `"submit": true`)
- `POST /api/requests/{id}/submit`: Submit a draft for approval
- `POST /api/requests/{id}/approve`: Approve at the next level (body: `{"note": "..."}` optional)
- `POST /api/requests/{id}/reject`: Reject (halts the request)
- `POST /api/requests/{id}/generate-po`: Issue the numbered PO (admin; fully approved only)

### Example recipes

- **request.submitted → a row in Google Sheets:** webhook trigger on `request.submitted` (A request entered the approval flow), then the Google Sheets "Create row" action with the fields you want.
- **request.approved → a message in Slack or Teams:** webhook trigger on `request.approved` (A request became FULLY approved (not each level)), then the Slack or Microsoft Teams "Send message" action.
- **A new form entry or row elsewhere → Approva:** the other tool's trigger (for example a new Google Sheets row or a form submission), then an HTTP action calling `POST /api/requests` (Create a request (optionally `"submit": true`)).

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