Developer API
Every Minori Midori store can expose a REST API and outbound webhooks, so the systems a distributor already runs — an ERP, a warehouse tool, an accounting stack, a pricing spreadsheet — can read and update the store without a person in the admin panel. The API is part of the Enterprise tier (currently in early access; talk to us if you want it enabled for your store).
All requests go to your store’s own domain, and every response is scoped to that one store — the same database-enforced isolation described on our security page:
https://{your-store-domain}/api/v1Authentication
Staff create API keys in the store admin under Settings → API. Each key has a scope — read_only or read_write — and the full secret (mm_live_…) is shown exactly once at creation; only a hash is stored, so it can never be displayed again. Send it on every request:
curl https://greenvalley.minorimidori.com/api/v1/products \
-H "Authorization: Bearer mm_live_4f2a…"- A missing, malformed, revoked, or wrong-store key always gets the same
401 unauthorized— no detail about why. - A
read_onlykey calling any write endpoint gets403 forbidden_scope. - Revoking a key in settings takes effect within a minute and is permanent.
Conventions
- JSON in and out, UTF-8.
- Money is always integer cents (
total_cents: 40650is $406.50). Weights are integer hundredths of a pound. - Timestamps are ISO 8601, UTC.
- Field names are
snake_case. Unknown fields in responses must be ignored — we add fields without notice; we never remove or rename them within v1. - Unknown fields in request bodies are rejected with 422.
Errors
Every non-2xx response uses one envelope:
{
"error": {
"code": "validation_failed",
"message": "The request body is invalid.",
"fields": { "base_price_cents": "must be a positive integer" }
}
}| code | HTTP | meaning |
|---|---|---|
unauthorized | 401 | Bad or missing key |
feature_disabled / forbidden_scope / forbidden_role | 403 | API off for this store, or the key cannot do this |
not_found / unknown_product | 404 | No such resource in this store |
validation_failed | 422 | Input invalid — see fields |
idempotency_conflict and other conflict codes | 409 | The request conflicts with current state |
rate_limited | 429 | Over the limit — honor Retry-After |
internal | 500 | Our fault — safe to retry with idempotency |
Rate limits
Per store (shared by all its keys): 300 reads/minute and 60 writes/minute. Exceeding either returns 429 with a Retry-After header in seconds.
Idempotency
Every write endpoint accepts an optional Idempotency-Key header (up to 200 characters, unique per logical request). Retrying with the same key replays the original response instead of applying the change twice; reusing a key with a different payload returns 409 idempotency_conflict. Keys are kept for 24 hours.
curl -X POST https://greenvalley.minorimidori.com/api/v1/products \
-H "Authorization: Bearer mm_live_4f2a…" \
-H "Idempotency-Key: erp-sync-2026-07-24-001" \
-H "Content-Type: application/json" \
-d '{"name": "Heirloom Tomatoes", "price_cents": 425, "unit": "lb"}'Endpoints
List products, including inactive ones. Query params: active (true/false), q (search name or SKU, ≤200 chars), limit (1–200, default 50), offset (≥0). Returns { products, next_offset } — next_offset is null on the last page.
curl "https://greenvalley.minorimidori.com/api/v1/products?active=true&limit=50" \
-H "Authorization: Bearer mm_live_4f2a…"Create a product (201). Body: name (1–120), price_cents (positive integer), unit (each/lb/case), and optionally category, description, sku, case_size, track_inventory, stock_qty, is_active. Your plan’s product limit applies exactly as in the admin.
Fetch one product by its id.
Update price, availability, or description. Body (at least one): base_price_cents (positive integer — for catch-weight products this is the per-pound rate), is_active, description (≤2000, or null to clear).
curl -X PATCH https://greenvalley.minorimidori.com/api/v1/products/8d3f…-a1 \
-H "Authorization: Bearer mm_live_4f2a…" \
-H "Content-Type: application/json" \
-d '{"base_price_cents": 725}'Inventory list with stock state per product. Query params: category, search, low_stock_only (true/false). Stores using multiple locations get cross-location totals.
Set or adjust stock. Body: track_inventory, and either stock_qty (absolute, 0–999999) or adjust_by (relative, non-zero) — not both — plus optional low_stock_threshold. Stores running multi-location inventory reject flat quantity writes here; use the admin’s per-location stock panel instead.
curl -X PUT https://greenvalley.minorimidori.com/api/v1/inventory/8d3f…-a1 \
-H "Authorization: Bearer mm_live_4f2a…" \
-H "Content-Type: application/json" \
-d '{"adjust_by": -12}'List orders, newest first. Query params: status (pending, confirmed, packing, out_for_delivery, shipped, delivered, cancelled), search (order number or customer name), limit (1–100).
curl "https://greenvalley.minorimidori.com/api/v1/orders?status=pending" \
-H "Authorization: Bearer mm_live_4f2a…"One order by its order number, with line items — unit_price_cents, qty, line_total_cents, and actual weights on catch-weight lines.
Record carrier and tracking for a parcel-shipping order. Body: carrier (1–60), tracking_number (1–100). Only orders with shipping fulfillment accept tracking, and not after delivery or cancellation.
curl -X PUT https://greenvalley.minorimidori.com/api/v1/orders/57/tracking \
-H "Authorization: Bearer mm_live_4f2a…" \
-H "Content-Type: application/json" \
-d '{"carrier": "UPS", "tracking_number": "1Z999AA10123456784"}'Sales reports, identical to the admin’s. Query params: group_by (product/customer/time, required), period (7d, 30d, month, last-month, year — default 30d) or a custom from/to pair (YYYY-MM-DD, both required together), and bucket (day/week/month, time grouping only).
curl "https://greenvalley.minorimidori.com/api/v1/reports/sales?group_by=product&period=month" \
-H "Authorization: Bearer mm_live_4f2a…"Create or update a fresh-sheet draft (201). Body: fresh_sheet_id (omit to create), title, intro, product_ids (≤200), show_prices. Sending remains a deliberate human action in the admin — the API drafts, it never broadcasts.
curl -X POST https://greenvalley.minorimidori.com/api/v1/fresh-sheets/drafts \
-H "Authorization: Bearer mm_live_4f2a…" \
-H "Content-Type: application/json" \
-d '{"title": "This week", "product_ids": ["8d3f…-a1", "72cc…-9e"]}'Webhooks
Register up to five HTTPS endpoints in Settings → API, each subscribed to the events you choose. When something happens in the store, we POST a signed notification — no polling.
Event types
order.created— an order was placed (any channel: storefront, AI assistant, or an automatic standing-order/subscription run)order.status_changed— an order moved through the pipelineorder.cancelled— an order was cancelledorder.paid— an order’s payment settledinvoice.created— a net-terms invoice was issuedinvoice.paid— an invoice was paidstock.low— a tracked product crossed its low-stock threshold
Delivery format
POST https://your-endpoint.example.com/hooks/minori
Content-Type: application/json
X-Webhook-Event-Id: evt_5f0c1c1e-8b0a-4b62-9a51-2f4f1b7f2a10
X-Webhook-Event-Type: order.created
X-MinoriMidori-Signature: t=1753380191,v1=6f2a8c…
{
"id": "evt_5f0c1c1e-8b0a-4b62-9a51-2f4f1b7f2a10",
"type": "order.created",
"created_at": "2026-07-24T18:03:11Z",
"data": {
"order_id": "b7e1…",
"number": 57,
"status": "pending",
"fulfillment": "delivery",
"payment_status": "net_terms_open",
"total_cents": 40650,
"customer_id": "19af…",
"placed_at": "2026-07-24T18:03:11Z",
"location_id": null
}
}Payloads are summaries frozen at event time; fetch current detail from the API. Any 2xx within 10 seconds counts as delivered. Redirects are not followed.
Verifying signatures
Each endpoint has a signing secret (shown in settings). The signature header carries a unix timestamp t and one or more v1 HMAC-SHA256 digests of ${t}.${raw request body}:
const crypto = require("node:crypto");
function verify(header, rawBody, secret) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=", 2)),
);
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; // 5 min
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
return header
.split(",")
.filter((p) => p.startsWith("v1="))
.map((p) => p.slice(3))
.some((sig) => {
const a = Buffer.from(sig, "hex");
const b = Buffer.from(expected, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
}During the 24 hours after you rotate a secret, deliveries carry two v1 entries — one per secret — so rotation never drops a delivery.
Delivery semantics
- At-least-once: the same event can arrive more than once — deduplicate by
id. Ordering across events is not guaranteed. - Retries: failed deliveries retry after roughly 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours, and 24 hours, then stop.
- Auto-disable: after 15 consecutive failures the endpoint is disabled and the store owner is emailed. Re-enable it in settings; events from the downtime are not re-sent — reconcile with API reads.
- A slow or failing endpoint never slows the store itself — delivery is fully asynchronous.
What the API deliberately does not do
There is no order placement, no customer-account management, and no payment operation in v1 — those flows stay in the storefront and admin, where their existing safeguards live. There is no sandbox mode yet; test with a read_onlykey, or against endpoints registered as paused. If your integration needs something that isn’t here, tell us — the surface grows by real demand.