Developer API

API version: v1

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/v1

Authentication

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_only key calling any write endpoint gets 403 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: 40650 is $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" }
  }
}
codeHTTPmeaning
unauthorized401Bad or missing key
feature_disabled / forbidden_scope / forbidden_role403API off for this store, or the key cannot do this
not_found / unknown_product404No such resource in this store
validation_failed422Input invalid — see fields
idempotency_conflict and other conflict codes409The request conflicts with current state
rate_limited429Over the limit — honor Retry-After
internal500Our 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

GET/api/v1/productsany key

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…"
POST/api/v1/productsread-write key required

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.

GET/api/v1/products/{id}any key

Fetch one product by its id.

PATCH/api/v1/products/{id}read-write key required

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}'
GET/api/v1/inventoryany key

Inventory list with stock state per product. Query params: category, search, low_stock_only (true/false). Stores using multiple locations get cross-location totals.

PUT/api/v1/inventory/{productId}read-write key required

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}'
GET/api/v1/ordersany key

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…"
GET/api/v1/orders/{number}any key

One order by its order number, with line items — unit_price_cents, qty, line_total_cents, and actual weights on catch-weight lines.

PUT/api/v1/orders/{number}/trackingread-write key required

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"}'
GET/api/v1/reports/salesany key

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…"
POST/api/v1/fresh-sheets/draftsread-write key required

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 pipeline
  • order.cancelled — an order was cancelled
  • order.paid— an order’s payment settled
  • invoice.created — a net-terms invoice was issued
  • invoice.paid — an invoice was paid
  • stock.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.