# Restock REST API, Webhooks & MCP

Restock ships a JSON REST API and signed webhooks so your reorder plan can be driven by
whatever already holds your sales data — a shop platform, an ERP export, a nightly cron,
Power Automate, Zapier, n8n, or a twenty-line script.

The important part: **the numbers this API returns are the numbers on your screen.** The
reorder point, days of cover and suggested order quantity all come from the same deterministic
forecasting engine the report page renders. There is no separate API maths, and no model in the
loop — the same inputs always give the same answer.

## Authentication

Create a key in **API & Webhooks**. A key belongs to a user and can do what a signed-in user
can do. 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 & path | What it does |
|---|---|
| `GET  /healthz` | Liveness check — no auth. `{"ok":true,"app":"restock","version":"3.2.0"}` |
| `GET  /api/ping` | Verify a key |
| `GET  /api/skus` | The catalog. `?q=` searches SKU/name · `?supplier=` · `?active=0|1` · `?limit=` (default 500, max 1000) |
| `GET  /api/reorder-report` | **The money endpoint.** `?status=below\|near\|healthy\|flagged\|all` (default `flagged` = below + near, exactly what the report screen shows) |
| `POST /api/sales-history` | Ingest sales history as JSON rows — the CSV import's equivalent |
| `POST /api/stock-levels` | **Set** on-hand stock per SKU — what a till sends after a sale (3.2.0) |
| `GET  /api/openapi.json` | OpenAPI 3 spec — no auth |

### The reorder report

    curl -H "Authorization: Bearer apk_..." \
      "https://your-install/api/reorder-report"

```json
{
  "today": "2026-08-06",
  "forecast_window_days": 90,
  "target_cover_days": 30,
  "counts": { "below": 5, "near": 3, "healthy": 6 },
  "est_cost_cents": 677880,
  "skus": [
    {
      "sku": "HTS-0002", "name": "Galvanized Deck Screws 4x40 (box)",
      "current_stock": 88, "lead_time_days": 7, "safety_stock_days": 5,
      "avg_daily_demand": 8.4111, "reorder_point": 100.9333,
      "days_of_stock": 10.46, "suggested_qty": 275,
      "est_cost_cents": 189750, "status": "below",
      "moq": 200, "case_pack": 25, "forecast_model": "window",
      "needed_qty": 268, "model_used": "window", "event_extra_units": 0, "is_kit": false
    }
  ]
}
```

Since 3.2.0 each SKU also carries its supplier's order rules and the forecast it planned with. Since 3.3.0 it also carries `price_cents`, the selling price you set on the product (0 when none), so your own agent can work out the margin on what it suggests.
`suggested_qty` is what to order: `needed_qty` (the cover you aim for, less stock and what is on
order) raised to the supplier's minimum `moq` and rounded up to whole cases of `case_pack`.
`forecast_model` is the model chosen for the product (`window`, `weighted` or `last_year`);
`model_used` is the one applied, which is `window` when a product set to `last_year` has no sales
a year back yet. `event_extra_units` is the demand that planned events add inside the lead time
and cover. A kit (`is_kit`) is never suggested itself: its sales count as demand on its parts.

Results are ordered by urgency — `below` first, then `near`, and within each band the SKU with
the fewest days of cover first. That is the order you should buy in, and it is the same order
the report screen shows.

`status` is `below` (at or under the reorder point), `near` (within 25% above it), or
`healthy`. `days_of_stock` is `null` when there is no measurable demand — infinite cover, not
zero.

### Ingest sales history

    curl -X POST https://your-install/api/sales-history \
      -H "Authorization: Bearer apk_..." -H "Content-Type: application/json" \
      -d '{"rows": [{"date": "2026-08-01", "sku": "HTS-0002", "qty": 12},
                    {"date": "2026-08-02", "sku": "HTS-0002", "qty": 9}]}'

Validation is deliberately identical to the CSV importer's — same strict `YYYY-MM-DD` calendar
check, same refusal of unknown SKUs, same per-row reasons — so a row the CSV path would reject
cannot slip in through the API. Good rows are written even when others fail; the response tells
you exactly which failed and why:

```json
{
  "imported": 30, "skipped": 2,
  "errors": [{ "row": 3, "reason": "Unknown SKU \"NOPE-999\" — add the product first." }],
  "crossed_reorder_point": [ { "sku": "HTS-0009", "status": "below", "suggested_qty": 102 } ]
}
```

`crossed_reorder_point` is the useful bit: the SKUs that *this ingestion* pushed under the
line. Feed it straight into whatever raises purchase orders.

Rows are **added** by default. Send `"replace": true` and the request's rows are totaled per SKU
per date and **replace** what is stored for those SKU-days — the CSV importer always works this way
(since 3.1.4), so re-sending an overlapping export counts it once. The response carries
`replaced_days`: how many stored SKU-days were replaced. With `"replace": true` a `qty` of `0` is
accepted and clears that SKU-day (a till sends it when a refund takes a day back to nothing);
without it, `0` is refused. A negative `qty` is always refused. When **Settings → Stock follows
sales** is on, the response also carries `stock_changed`: how many products' stock moved.

The key's user needs permission to import sales (a member or an administrator). A viewer's key
is refused with `403`, as it is in the browser and over MCP (3.2.0; before, the REST door did not
check the role).

### Set stock levels (3.2.0)

    curl -X POST https://your-install/api/stock-levels \
      -H "Authorization: Bearer apk_..." -H "Content-Type: application/json" \
      -d '{"rows": [{"sku": "HTS-0002", "on_hand": 88}, {"sku": "HTS-0009", "on_hand": 14}]}'

```json
{ "updated": 1, "unchanged": 1, "skipped": 0, "errors": [], "crossed_reorder_point": [] }
```

Each known SKU's current stock is **set** to `on_hand` — not added to. `on_hand` is a whole number
from 0 to 100,000,000; unknown SKUs and bad figures are refused per row with a reason, and good
rows are written. Each product whose figure changed gets a `stock.set` line in the audit trail
naming the key's user, and a SKU this pushes under its reorder point fires `sku.reorder_point`,
as a hand correction does. Same permission as a hand correction: a member or an administrator.

This is what Vendra 3.2.0 sends after every sale, refund, delivery and correction once it is
connected; any till or script can do the same.

## Webhooks

Add receiver URLs in **API & Webhooks**. One event:

- `sku.reorder_point` — a SKU crossed under its reorder point

It is a **crossing, not a state**. It fires on the ingestion that takes a SKU from fine to
below the line, and does *not* fire again on later ingestions while it stays below. One alert
per problem instead of a stream of noise. Both ingestion paths fire it: the CSV import and
`POST /api/sales-history`.

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

    X-Restock-Event: sku.reorder_point
    X-Restock-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:

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

Delivery is one attempt with a 5-second timeout. The **Recent deliveries** log on the settings
page shows every attempt and response code. 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 Restock's webhooks, and raise a purchase task from
   `body('...')?['product']`.
2. **Custom connector:** Power Automate → Data → Custom connectors → **Import an OpenAPI
   file** → point it at `/api/openapi.json`. Set security to Bearer.

## MCP — the agent endpoint (new in 3.0)

Restock speaks **MCP (Model Context Protocol)** on one route, so an assistant can watch your
stock and prepare your buying 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. A `GET` gets
`405` with `Allow: POST`, as the transport requires — and that answer is unauthenticated, so a
client probing for MCP support learns you speak it before it has a key.

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

```json
{
  "type": "mcp",
  "server_label": "restock",
  "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](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://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:
Restock implements the protocol, not an integration with a particular vendor.

### The tools

| Tool | Writes? | What it does |
|---|---|---|
| `list_products` | no | Every product with **Restock's own forecast**: reorder point, days of stock left, suggested order quantity, status. Filter by `status`, `below_reorder`, `supplier`, `q`. |
| `product_detail` | no | One product with its forecast and recent sales history. |
| `adjust_stock` | **YES** | Corrects a stock level — `set_to` for a counted figure or `delta` to add/remove. |
| `ingest_sales` | **YES** | Adds daily sales rows so the forecast reflects reality. |
| `reorder_report` | no | What to buy now, in the report screen's urgency order, with the estimated cost in integer cents. |

`status` is `below` (at or under the reorder point), `near` (within 25% above it) or `healthy` —
**the same three words the report screen, the REST API and this document use.** An agent that
asks for a status Restock actually returns gets an answer, and `below_reorder: true` means what
it says: only stock at or under the reorder point, never the `near` band.

### What the endpoint refuses, and why

- **No tool computes demand.** Every forecast figure comes from the same `Forecast::metrics()`
  the report screen uses, so an agent can never quote a reorder point Restock disagrees with.
- **`adjust_stock` writes through one guarded path.** It calls the same `set_product_stock()`
  the product form, the REST API and PO receiving call — one audit entry and one
  reorder-crossing alarm, whichever door the change came through.
- **Arguments must be single values.** Anything nested is rejected, except `rows` on
  `ingest_sales`, which is the one allowlisted nested argument — the same rule `Api::body()`
  applies to the REST API.
- **Unknown arguments are refused by name**, so an agent that typos `stauts` is told so rather
  than silently getting unfiltered results.
- **Out-of-enum values are refused**, with the valid options listed.
- **Negative stock is refused**, as is sending both `set_to` and `delta`.
- **Roles apply exactly as they do in the browser.** A key belonging to a viewer is refused
  `adjust_stock` and `ingest_sales` — a different answer for the same role would make the agent
  path a privilege hole.

Every MCP write lands in the **audit trail** beside the browser and API writes, naming the key's
user as the actor and recording `via: mcp`.

### 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":"list_products","arguments":{"below_reorder":true}}}'

## Limits, honestly

- API keys carry their owner's role (viewer / member / admin), so a key is never allowed to do
  something its owner could not do in the browser. A viewer's key reads and nothing more.
- Purchase orders are raised and progressed in the UI, not over MCP or REST. Committing money
  to a supplier is a decision worth a human, and the reorder report already hands an agent
  everything needed to recommend one.
- One delivery attempt per webhook event (log + idempotent receivers, not a retry queue).
- `POST /api/sales-history` accepts at most 50,000 rows per request, matching the CSV cap.
- There is no `POST /api/skus`: products are created through the UI or the CSV import, where
  the dry run can show you what you are about to change. The API is for reading the plan and
  feeding it demand.
- `GET /api/skus` caps at 1,000 rows.


## Connect it to Zapier, Make or n8n

Restock 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 Restock, 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://restock.yourcompany.com`.

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

| Event | Fires when |
|---|---|
| `sku.reorder_point` | A SKU crossed under its reorder point |

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

- **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-history`: Ingest sales history as JSON rows — the CSV import's equivalent
- `POST /api/stock-levels`: **Set** on-hand stock per SKU — what a till sends after a sale (3.2.0)

### Example recipes

- **sku.reorder_point → a row in Google Sheets:** webhook trigger on `sku.reorder_point` (A SKU crossed under its reorder point), then the Google Sheets "Create row" action with the fields you want.
- **sku.reorder_point → a message in Slack or Teams:** webhook trigger on `sku.reorder_point` (A SKU crossed under its reorder point), then the Slack or Microsoft Teams "Send message" action.
- **A new form entry or row elsewhere → Restock:** the other tool's trigger (for example a new Google Sheets row or a form submission), then an HTTP action calling `POST /api/sales-history` (Ingest sales history as JSON rows — the CSV import's equivalent).

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