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.2.0"}
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.

Landed cost (3.2.0). Add "landed_cost_cents": 1800 for freight, duty and fees on the delivery: it is spread over the lines by their value (by quantity when every line cost nothing), shares adding up to the cent, and each line's share goes into its unit cost before the average is recomputed. The purchase carries landed_cost_cents (part of total_cost_cents), and each line its landed_cents and unit_cost_landed_mc (milli-cents). A product made of other products cannot be received: send its parts.

Products made of other products (3.2.0). A product that is a recipe or bundle carries made_from: [{"product_id", "qty"}]; its stock_qty is how many its parts can make, and it is never low_stock (its parts are). Selling it through POST /api/sales or record_sale takes its parts off the shelf through the same oversell guard.

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"
}

**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://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 judgment 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 catalog editor.
  • List endpoints cap at 500 rows. This is a shop till, not a data warehouse.

Connect it to Zapier, Make or n8n

Vendra 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 Vendra, open 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://vendra.yourcompany.com.

When something happens in Vendra: 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 Vendra under API & Webhooks, and choose the event or events below.
  3. Make the event happen once in Vendra, so the other tool receives a sample and you can map its fields.
EventFires when
sale.recordedEvery completed sale, whether it came from the till or the API
stock.lowA product crosses its low-stock level

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

  • 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/sales: Record a sale — prices server-side, decrements stock atomically
  • POST /api/purchases: Receive stock at cost (admin key) — updates weighted-average cost

Example recipes

  • sale.recorded → a row in Google Sheets: webhook trigger on sale.recorded (Every completed sale, whether it came from the till or the API), then the Google Sheets "Create row" action with the fields you want.
  • stock.low → a message in Slack or Teams: webhook trigger on stock.low (A product crosses its low-stock level), then the Slack or Microsoft Teams "Send message" action.
  • A new form entry or row elsewhere → Vendra: the other tool's trigger (for example a new Google Sheets row or a form submission), then an HTTP action calling POST /api/sales (Record a sale — prices server-side, decrements stock atomically).

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

← Back to Vendra · Manual · Quickstart · Test run