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
| Scope | Allows |
|---|---|
store.read | Store details |
products.read | Read products |
products.write | Create and edit products |
inventory.read | Read inventory |
inventory.write | Update inventory |
orders.read | Read orders |
orders.write | Fulfill orders |
customers.read | Read customers |
Requests & money
- HTTPS and JSON only. Send bodies with
Content-Type: application/json; the limit is 1 MB. - Money is always an integer in minor units, in requests and responses:
1999is $19.99 in a USD store. The store currency is onGET /v1/storeand on every product and order. - Timestamps are ISO 8601 in UTC. Ids are prefixed strings (
prd_,var_,ord_,oli_,cus_,loc_). - Every response has an
X-Request-Idheader. Include it when you contact support. - The API is server-to-server: there is no CORS support, so browsers cannot call it directly.
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"
}
}| Status | type | Meaning |
|---|---|---|
| 400 | invalid_request_error | Bad parameter or body. param names the field (e.g. variants.0.price). |
| 401 | authentication_error | Missing, malformed, invalid or revoked key. |
| 403 | permission_error | The key lacks the scope the endpoint needs (code missing_scope). |
| 404 | invalid_request_error | Not found in this store (code resource_missing). |
| 409 | invalid_request_error | Conflict, e.g. a handle already used by another product. |
| 413 | invalid_request_error | Body larger than 1 MB. |
| 415 | invalid_request_error | Body is not application/json. |
| 429 | rate_limit_error | Too many requests. Wait Retry-After seconds. |
| 500 | api_error | Our 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.
Store
store.readThe store this key belongs to: name, URL, currency, timezone, status.
curl https://api.merchanthouse.app/v1/store \
-H "Authorization: Bearer $MH_API_KEY"Products
products.readProducts with their options, variants and images, most recently updated first.
| Query parameter | Description |
|---|---|
page, limit | Pagination: page (1-based, default 1), limit (1-100, default 50). |
status | any (default), draft, active or archived. |
q | Case-insensitive match on the product name. |
curl https://api.merchanthouse.app/v1/products?status=active&limit=20 \
-H "Authorization: Bearer $MH_API_KEY"products.writeCreate 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 }
]
}'products.readRetrieve one product.
curl https://api.merchanthouse.app/v1/products/prd_01jc6x2v9k8m3q7r5t4w2y0z1a \
-H "Authorization: Bearer $MH_API_KEY"products.writeMerge update: fields you omit keep their current values.
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"] }'products.writePermanently 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
products.readManual and rule-based collections, alphabetically.
| Query parameter | Description |
|---|---|
page, limit | Pagination: page (1-based, default 1), limit (1-100, default 50). |
curl https://api.merchanthouse.app/v1/collections \
-H "Authorization: Bearer $MH_API_KEY"Inventory
inventory.readInventory locations, default first, with total units on hand.
| Query parameter | Description |
|---|---|
page, limit | Pagination: page (1-based, default 1), limit (1-100, default 50). |
curl https://api.merchanthouse.app/v1/locations \
-H "Authorization: Bearer $MH_API_KEY"inventory.readOne row per variant that tracks inventory, lowest stock first.
| Query parameter | Description |
|---|---|
page, limit | Pagination: page (1-based, default 1), limit (1-100, default 50). |
location_id | Report stock at one location. Without it, available is the total across locations and location_id is null. |
sku | Exact SKU match (case-insensitive). |
q | Match on product name or SKU. |
curl https://api.merchanthouse.app/v1/inventory_levels?sku=HCB-12 \
-H "Authorization: Bearer $MH_API_KEY"inventory.writeSet 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
orders.readOrders with line items, shipping address, fulfillments and refunds, newest first.
| Query parameter | Description |
|---|---|
page, limit | Pagination: page (1-based, default 1), limit (1-100, default 50). |
status | all (default), unfulfilled, fulfilled, unpaid or cancelled. |
q | Order 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"orders.readRetrieve one order by id (ord_...), not by order number.
curl https://api.merchanthouse.app/v1/orders/ord_01jc6x2v9k8m3q7r5t4w2y0z1d \
-H "Authorization: Bearer $MH_API_KEY"orders.writeMark 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
customers.readCustomers, most recently updated first.
| Query parameter | Description |
|---|---|
page, limit | Pagination: page (1-based, default 1), limit (1-100, default 50). |
q | Match on email, first or last name. |
curl https://api.merchanthouse.app/v1/customers?q=smith \
-H "Authorization: Bearer $MH_API_KEY"customers.readRetrieve 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
- Respond with any
2xxwithin 10 seconds. Anything else is retried after 1 min, 5 min, 30 min, 2 h, 12 h and 24 h; an endpoint is disabled after 50 consecutive failures. - Headers:
Merchant-House-Event,Merchant-House-Delivery(stable across retries; use it to de-duplicate) andMerchant-House-Signature. - Deliveries can arrive out of order. When order matters, re-fetch the resource from the API.
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);
});