Restock API, as shipped in the download
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.1.6"} | ||||
GET /api/ping | Verify a key | ||||
GET /api/skus | The catalogue. ?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 | ||||
GET /api/openapi.json | OpenAPI 3 spec — no auth |
The reorder report
curl -H "Authorization: Bearer apk_..." \
"https://your-install/api/reorder-report"
{
"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": 268,
"est_cost_cents": 184920, "status": "below"
}
]
}
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:
{
"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 totalled 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.
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:
$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)
- 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']. - 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:
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):
{
"type": "mcp",
"server_label": "restock",
"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": "restock", "name": "Restock", "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: 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_stockwrites through one guarded path. It calls the sameset_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
rowsoningest_sales, which is the one allowlisted nested argument — the same ruleApi::body()applies to the REST API. - Unknown arguments are refused by name, so an agent that typos
stautsis 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_toanddelta. - Roles apply exactly as they do in the browser. A key belonging to a viewer is refused
adjust_stockandingest_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-historyaccepts 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/skuscaps at 1,000 rows.