Merchant HouseAdmin API

Merchant House Admin API

A REST API for connecting your store to the rest of your business: sync products and stock with a warehouse or ERP, pull orders into your fulfillment tools, and react to events with webhooks. Every request is scoped to one store.

https://api.merchanthouse.app

Prefer a machine-readable spec? The full OpenAPI 3.1 document works with Postman, Insomnia and code generators.

Authentication

Create a secret key in Dashboard → Settings → Developers. Pick only the scopes your integration needs. The full key (mh_sk_...) is shown once; store it in your server's secret manager, never in browser or mobile code. Send it as a bearer token:

curl https://api.merchanthouse.app/v1/store \
  -H "Authorization: Bearer mh_sk_..."

Revoking a key in the dashboard takes effect immediately. Requests with a missing or invalid key get 401; a valid key without the needed scope gets 403. Changes made through the API appear in the store's activity log.

Scopes

ScopeAllows
store.readStore details
products.readRead products
products.writeCreate and edit products
inventory.readRead inventory
inventory.writeUpdate inventory
orders.readRead orders
orders.writeFulfill orders
customers.readRead customers

Requests & money

Pagination

List endpoints take page (1-based) and limit (1 to 100, default 50) and return a list envelope. Keep requesting page + 1 while has_more is true.

{
  "object": "list",
  "data": [ { "object": "product", "id": "prd_...", ... } ],
  "page": 1,
  "has_more": true
}

Errors

Errors use standard HTTP status codes and a consistent body:

{
  "error": {
    "type": "invalid_request_error",
    "code": "parameter_invalid",
    "message": "variants.0.price: price must be an integer amount in minor units, e.g. 1999 for $19.99",
    "param": "variants.0.price",
    "request_id": "req_3f1c9a0e2b7d4c6a8e9f0a1b"
  }
}
StatustypeMeaning
400invalid_request_errorBad parameter or body. param names the field (e.g. variants.0.price).
401authentication_errorMissing, malformed, invalid or revoked key.
403permission_errorThe key lacks the scope the endpoint needs (code missing_scope).
404invalid_request_errorNot found in this store (code resource_missing).
409invalid_request_errorConflict, e.g. a handle already used by another product.
413invalid_request_errorBody larger than 1 MB.
415invalid_request_errorBody is not application/json.
429rate_limit_errorToo many requests. Wait Retry-After seconds.
500api_errorOur fault. Safe to retry with backoff; quote the request id to support.

Rate limits

Each key may make 120 requests per minute. Responses carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds until the window resets). Over the limit you get 429 with a Retry-After header; wait that many seconds and retry. For bulk jobs, spread requests out or use several narrowly scoped keys.

Idempotency

POST requests accept an Idempotency-Key header (any unique string up to 255 characters, such as a UUID). If a network error leaves you unsure whether a request succeeded, retry it with the same key and the same body: you get the original response back with Idempotent-Replayed: true instead of a duplicate product or fulfillment. Reusing a key with a different body returns 400 idempotency_error.

Idempotency is currently best-effort: keys are remembered for 24 hours by the server instance that handled the request. Design retries so that a rare duplicate is detectable (for example by SKU).

Store

GET/v1/storescope store.read

The store this key belongs to: name, URL, currency, timezone, status.

curl https://api.merchanthouse.app/v1/store \
  -H "Authorization: Bearer $MH_API_KEY"

Products

GET/v1/productsscope products.read

Products with their options, variants and images, most recently updated first.

Query parameterDescription
page, limitPagination: page (1-based, default 1), limit (1-100, default 50).
statusany (default), draft, active or archived.
qCase-insensitive match on the product name.
curl https://api.merchanthouse.app/v1/products?status=active&limit=20 \
  -H "Authorization: Bearer $MH_API_KEY"
POST/v1/productsscope products.write

Create a product. name and at least one variant with a price are required; status defaults to draft.

Other fields: handle, vendor, product_type, requires_shipping, seo.title, seo.description, category_ids, collection_ids and, per variant, compare_at_price, cost, barcode, track_inventory, allow_backorder, weight_unit. Unknown fields are rejected.

curl -X POST https://api.merchanthouse.app/v1/products \
  -H "Authorization: Bearer $MH_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "name": "Hill Country Blend",
    "status": "active",
    "description": "<p>Medium roast with notes of pecan and dark chocolate.</p>",
    "tags": ["coffee", "whole bean"],
    "options": [{ "name": "Size", "values": ["12 oz", "2 lb"] }],
    "variants": [
      { "option1": "12 oz", "price": 1800, "sku": "HCB-12", "inventory_quantity": 40, "weight_grams": 340 },
      { "option1": "2 lb", "price": 4200, "sku": "HCB-32", "inventory_quantity": 12, "weight_grams": 907 }
    ]
  }'
GET/v1/products/{id}scope products.read

Retrieve one product.

curl https://api.merchanthouse.app/v1/products/prd_01jc6x2v9k8m3q7r5t4w2y0z1a \
  -H "Authorization: Bearer $MH_API_KEY"
PATCH/v1/products/{id}scope products.write

Merge update: fields you omit keep their current values.

Variants. Omit variants to leave them untouched. When you send variants, it is the complete list: entries with an id update that variant in place (only the fields you send change, and the id is kept), entries without an id are created, and existing variants you leave out are deleted. To change one price, send every variant id and the new price on the one you are changing.
curl -X PATCH https://api.merchanthouse.app/v1/products/prd_01jc6x2v9k8m3q7r5t4w2y0z1a \
  -H "Authorization: Bearer $MH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "status": "archived", "tags": ["coffee", "seasonal"] }'
DELETE/v1/products/{id}scope products.write

Permanently delete a product and its variants. Past orders keep their line items. Returns { object, id, deleted: true }.

curl -X DELETE https://api.merchanthouse.app/v1/products/prd_01jc6x2v9k8m3q7r5t4w2y0z1a \
  -H "Authorization: Bearer $MH_API_KEY"

Collections

GET/v1/collectionsscope products.read

Manual and rule-based collections, alphabetically.

Query parameterDescription
page, limitPagination: page (1-based, default 1), limit (1-100, default 50).
curl https://api.merchanthouse.app/v1/collections \
  -H "Authorization: Bearer $MH_API_KEY"

Inventory

GET/v1/locationsscope inventory.read

Inventory locations, default first, with total units on hand.

Query parameterDescription
page, limitPagination: page (1-based, default 1), limit (1-100, default 50).
curl https://api.merchanthouse.app/v1/locations \
  -H "Authorization: Bearer $MH_API_KEY"
GET/v1/inventory_levelsscope inventory.read

One row per variant that tracks inventory, lowest stock first.

Query parameterDescription
page, limitPagination: page (1-based, default 1), limit (1-100, default 50).
location_idReport stock at one location. Without it, available is the total across locations and location_id is null.
skuExact SKU match (case-insensitive).
qMatch on product name or SKU.
curl https://api.merchanthouse.app/v1/inventory_levels?sku=HCB-12 \
  -H "Authorization: Bearer $MH_API_KEY"
POST/v1/inventory_levels/setscope inventory.write

Set the absolute available quantity of a variant at a location (the default location when location_id is omitted). The change is recorded in the inventory history.

curl -X POST https://api.merchanthouse.app/v1/inventory_levels/set \
  -H "Authorization: Bearer $MH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "variant_id": "var_01jc6x2v9k8m3q7r5t4w2y0z1b", "location_id": "loc_01jc6x2v9k8m3q7r5t4w2y0z1c", "available": 25 }'

Orders

GET/v1/ordersscope orders.read

Orders with line items, shipping address, fulfillments and refunds, newest first.

Query parameterDescription
page, limitPagination: page (1-based, default 1), limit (1-100, default 50).
statusall (default), unfulfilled, fulfilled, unpaid or cancelled.
qOrder number (1001 or #1001) or part of the customer email.
curl https://api.merchanthouse.app/v1/orders?status=unfulfilled \
  -H "Authorization: Bearer $MH_API_KEY"
GET/v1/orders/{id}scope orders.read

Retrieve one order by id (ord_...), not by order number.

curl https://api.merchanthouse.app/v1/orders/ord_01jc6x2v9k8m3q7r5t4w2y0z1d \
  -H "Authorization: Bearer $MH_API_KEY"
POST/v1/orders/{id}/fulfillmentsscope orders.write

Mark items as shipped. Without line_items every remaining shippable item is fulfilled; pass [{ id, quantity }] (order line item ids) for a partial shipment. The customer gets a shipping confirmation email unless notify_customer is false. Returns 201 with the fulfillment and the updated order.

curl -X POST https://api.merchanthouse.app/v1/orders/ord_01jc6x2v9k8m3q7r5t4w2y0z1d/fulfillments \
  -H "Authorization: Bearer $MH_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "carrier": "UPS", "tracking_number": "1Z999AA10123456784", "notify_customer": true }'

Customers

GET/v1/customersscope customers.read

Customers, most recently updated first.

Query parameterDescription
page, limitPagination: page (1-based, default 1), limit (1-100, default 50).
qMatch on email, first or last name.
curl https://api.merchanthouse.app/v1/customers?q=smith \
  -H "Authorization: Bearer $MH_API_KEY"
GET/v1/customers/{id}scope customers.read

Retrieve one customer, including saved addresses.

curl https://api.merchanthouse.app/v1/customers/cus_01jc6x2v9k8m3q7r5t4w2y0z1e \
  -H "Authorization: Bearer $MH_API_KEY"

Webhooks

Add an endpoint URL in Dashboard → Settings → Developers and choose events. Merchant House sends a POST with a JSON body { id, type, created_at, data }, where data is the same resource shape the API returns (orders and products).

order.createdorder.fulfilledorder.refundedorder.cancelledproduct.createdproduct.updatedproduct.deletedinventory.updated

Verifying signatures

The signature header looks like t=1767225600,v1=5f2b.... v1 is the hex HMAC-SHA256 of {t}.{raw body} using your endpoint's signing secret (whsec_...). Compute it over the exact bytes you received, compare in constant time, and reject old timestamps.

import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.MH_WEBHOOK_SECRET; // whsec_...

// Use the raw body: re-serialized JSON will not match the signature.
app.post("/webhooks/merchant-house", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("Merchant-House-Signature") ?? "";
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  const raw = req.body.toString("utf8");

  const expected = crypto.createHmac("sha256", SECRET).update(t + "." + raw).digest("hex");
  const given = Buffer.from(parts.v1 ?? "", "hex");
  const valid =
    given.length === 32 &&
    crypto.timingSafeEqual(given, Buffer.from(expected, "hex")) &&
    Math.abs(Date.now() / 1000 - t) < 300; // reject replays older than 5 minutes

  if (!valid) return res.status(400).send("bad signature");

  const event = JSON.parse(raw); // { id, type, created_at, data }
  // De-duplicate on the Merchant-House-Delivery header (stable across retries).
  console.log(event.type, event.data.id);
  res.sendStatus(200);
});