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.

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

Examples on this page use the {your-store-domain} placeholder — the store admin’s Settings → APIpage shows your store’s exact base URL and request samples pre-filled with it.

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://{your-store-domain}/api/v1/products \
  -H "Authorization: Bearer mm_live_4f2a…"
  • A missing, malformed, revoked, or wrong-store key always gets the same 401 unauthorized, with 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://{your-store-domain}/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://{your-store-domain}/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://{your-store-domain}/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://{your-store-domain}/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://{your-store-domain}/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://{your-store-domain}/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://{your-store-domain}/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, layout(classic or market). Omitting layout keeps the saved choice when editing and uses Classic when creating. Optional color_override accepts #RRGGBB; null resets it to the store’s published Appearance brand color. Sending remains a deliberate human action in the admin: the API drafts, it never broadcasts.

curl -X POST https://{your-store-domain}/api/v1/fresh-sheets/drafts \
  -H "Authorization: Bearer mm_live_4f2a…" \
  -H "Content-Type: application/json" \
  -d '{"title": "This week", "layout": "market", "color_override": "#246b45", "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.updated: staff amended an order’s items, prices, or promo code; the payload carries the new totals, the previous total, and a structured diff of the changes
  • 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, so 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.