Home›Approva›API
Approva · API

Approva API, as shipped in the download

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 & pathWhat it does
GET /api/pingVerify a key; returns the acting user + role
GET /api/requestsNewest 50 requests; ?status= filters (draft, pending, partially_approved, approved, rejected, po_issued)
POST /api/requestsCreate a request (optionally "submit": true)
GET /api/requests/{id}One request with lines, approvals, PO
POST /api/requests/{id}/submitSubmit a draft for approval
POST /api/requests/{id}/approveApprove at the next level (body: {"note": "..."} optional)
POST /api/requests/{id}/rejectReject (halts the request)
POST /api/requests/{id}/generate-poIssue the numbered PO (admin; fully approved only)
GET /api/posList purchase orders
GET /api/vendors · GET /api/cost-centersReference data
GET /api/openapi.jsonOpenAPI 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.

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:

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 addresshttps://approva.example.com/mcp
The keyheader Authorization: Bearer apk_xxxx
The transportMCP 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 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):

{
  "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 (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://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

ToolWrites?What it does
list_requestsnoRequests newest first (max 50); optional status filter
get_requestnoOne request with lines, approvals and PO
create_requestyesCreates a request; submit: true sends it for approval
submit_requestyesSubmits a draft (requester or admin only)
approve_requestyesRecords an approval — full guard applies
reject_requestyesRecords a rejection (terminal)
pending_reportnoCounts by status + everything awaiting a decision
list_vendorsnoVendors on file
list_cost_centersnoCost 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:

{"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.
EventFires when
request.submittedA request entered the approval flow
request.approvedA request became FULLY approved (not each level)
request.rejectedAn approver rejected it
po.issuedA 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.

← Back to Approva · Manual · Quickstart · Test run