Ownware
Home›Vendra›API
Vendra · API

Vendra API, as shipped in the download

Vendra REST API, Webhooks & MCP

Vendra ships a JSON REST API and signed webhooks so your till can talk to the rest of your business — an online store, an accounting package, Power Automate, Zapier, n8n, or your own scripts.

The important part: the API is a second door onto the same rooms, not a bypass. A sale posted here runs the identical server-side pricing and the identical conditional atomic stock decrement as a sale rung up at the till. Overselling is refused with a 422, never allowed through because it arrived over HTTP.

Authentication

Create a key in API & Webhooks (admin). A key belongs to a user and can do exactly what that user can do on screen — a cashier key cannot pull the profit report, the same way a cashier cannot open the Reports page. 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; revocation takes effect on the next request.

Endpoints

Method & pathWhat it does
GET /healthzLiveness check — no auth. {"ok":true,"app":"vendra","version":"3.1.5"}
GET /api/pingVerify a key, see which user it acts as
GET /api/productsList products. ?low-stock=1 for lines at or below their alert level; ?q= searches name/SKU/barcode; ?active=0 for archived; ?limit= (default 200, max 500)
GET /api/salesList sales. ?from=YYYY-MM-DD&to=YYYY-MM-DD (inclusive local days, your configured timezone); ?limit= (default 100, max 500)
POST /api/salesRecord a sale — prices server-side, decrements stock atomically
GET /api/sales/{id}One sale with lines and payments
POST /api/purchasesReceive stock at cost (admin key) — updates weighted-average cost
GET /api/reportRevenue, COGS, gross profit, tax, refunds + per-product breakdown and inventory valuation (admin key)
GET /api/openapi.jsonOpenAPI 3 spec — no auth

Money is integer cents everywhere, in and out. There are no floats in Vendra's money math.

Record a sale

curl -X POST https://your-install/api/sales \
  -H "Authorization: Bearer apk_..." -H "Content-Type: application/json" \
  -d '{"lines": [{"product_id": 1, "qty": 2},
                 {"product_id": 7, "qty": 1, "discount_cents": 50}],
       "payments": [{"method": "cash", "amount_cents": 5000}],
       "customer_id": 3, "note": "phone order"}'

Returns 201 with the completed sale, including the change due. Payment methods are cash, card and other; cash overpayment becomes change, card/other may not exceed the total.

A sale needs an open register. That is how the Z-report reconciles the drawer, so the API obeys the same rule the till does. If the key's user has no open register you get:

409  {"error": "register_closed", "detail": "This key's user has no open register..."}

Open one in the app (Register → Open) — a key used for headless integrations typically acts as a dedicated user whose register stays open.

Overselling is refused, not absorbed:

422  {"error": "unprocessable",
      "detail": "Not enough stock for \"House Espresso Beans 1lb\" (2 left)."}

Nothing is written — the whole sale rolls back. This is the same conditional UPDATE ... WHERE stock_qty >= ? guard the till uses, so two simultaneous requests for the last unit cannot both succeed.

Receive stock

curl -X POST https://your-install/api/purchases \
  -H "Authorization: Bearer apk_..." -H "Content-Type: application/json" \
  -d '{"supplier_id": 2, "reference": "PO-4471",
       "lines": [{"product_id": 1, "qty": 24, "unit_cost_cents": 850}]}'

Goes through the same costing path as the back office: weighted-average cost is recomputed and every unit lands in the immutable stock-movement ledger.

MCP — the agent surface (new in 3.0)

Vendra speaks MCP (Model Context Protocol) at POST /mcp, so an AI assistant can read your catalog and record sales without anyone writing an integration. It is the same server, the same data and the same rules — an agent is just another client.

Authentication is the API key you already have. Send it exactly as you would to /api/*:

Authorization: Bearer <your api key>

The transport is stateless streamable-HTTP: one JSON-RPC request per POST, no session to keep alive. GET /mcp answers 405 with Allow: POST so a misconfigured client is told what to do rather than left guessing.

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://your-install/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 vendra 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):

{
  "type": "mcp",
  "server_label": "vendra",
  "server_url": "https://your-install/mcp",
  "authorization": "apk_xxxx",
  "require_approval": "never"
}

Own Your AI reads a list of servers in this shape:

{
  "mcpServers": [
    { "id": "vendra", "name": "Vendra", "url": "https://your-install/mcp",
      "token": "apk_xxxx", "enabled": true }
  ]
}

**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"* — not an mcpServers array. Pasted as-is, the block above 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://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: Vendra implements the protocol, not an integration with a particular vendor.

Tools

ToolWrites?What it does
list_productsnoCatalog with price and stock. search, active_only, limit
stock_levelsnoStock for one product (product_id or barcode) or all of them
low_stocknoEverything at or below its low-stock threshold
sales_reportnoTotals for a range, by product and by cashier. from, to
record_saleYESRecords a sale and decrements stock

Every description in tools/list states plainly whether the tool writes, because that is what an agent needs in order to decide whether it is allowed to call it.

The rules do not relax for agents

record_sale goes through Sales::checkout() — the same function the till calls. So:

  • stock can never oversell. Ask for nine of something with five in stock and the call comes back as a tool error with the quantity left, and not one unit moves:
  {"content":[{"type":"text","text":"Not enough stock for \"Blue Widget\" (5 left)."}],
   "isError":true}
  • a sale belongs to an open register, exactly as at the counter — that is how the Z-report reconciles cash. No open register, no sale.
  • the movement ledger stays in step with stock, because the same Inventory::post() writes it.

Argument hygiene mirrors the REST API: every argument must be a single value unless the tool declares otherwise (record_sale allows lines and payments to be lists). An unknown argument is refused by name — an agent that typos serach is told, not silently half-obeyed.

Domain refusals come back as tool errors (isError: true) rather than protocol errors, because those are the ones a model can read and correct. Protocol mistakes — bad JSON, an unknown tool — are JSON-RPC errors.

Quick check

curl -s -X POST http://your-install/mcp \
  -H "Authorization: Bearer <key>" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Webhooks

Add receiver URLs in API & Webhooks. Events:

  • sale.recorded — every completed sale, whether it came from the till or the API
  • stock.low — a product crosses its low-stock level

stock.low is a crossing, not a state: it fires on the sale that takes a line from above its threshold to at or below it, and does not fire again on subsequent sales of an already-low line. You get one alert per crossing instead of a stream of noise.

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

X-Vendra-Event: stock.low
X-Vendra-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:

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

Delivery is one attempt with a 5-second timeout — deliberately simple. The **Recent deliveries** log on the settings page shows every attempt and its response code, so a receiver that was down is visible rather than silent. Design receivers to be idempotent.

Power Automate (and Logic Apps)

  1. Instant trigger via webhook: create a flow with the **"When an HTTP request is received"** trigger, paste its URL into Vendra's webhooks, and branch on body('...')?['event'] — a stock.low event can raise a purchase task or ping Teams.
  2. Custom connector: Power Automate → Data → Custom connectors → **Import an OpenAPI file** → point it at /api/openapi.json from your install. Set security to 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 own roles.
  • One delivery attempt per webhook event (log + idempotent receivers, not a retry queue).
  • Returns/refunds are UI-only in 2.0 — they need the operator judgement the screen provides.
  • Product creation is by CSV import or the UI, not POST /api/products; the API is aimed at selling and stocking, not at being a catalogue editor.
  • List endpoints cap at 500 rows. This is a shop till, not a data warehouse.

← Back to Vendra · Manual · Quickstart · Test run

Affiliate program
Recommend tools people own — earn 35% on every sale. 90-day tracking, instant delivery, payouts by Lemon Squeezy.
Become an affiliate →