# MerchFox API reference

> The complete MerchFox seller API documentation in one file: guides first, then every endpoint with parameters, response fields, example responses and a curl request.
> Interactive version: https://api-docs.merchfox.com

- Live: `https://api.merchfox.com`
- Sandbox: `https://api-sandbox.merchfox.com`

AI agents: read [Building with AI agents](#guide-ai-agents) before calling the API.

## Contents

- Guides
  - [Getting started](#guide-getting-started) — Base URLs, authentication, the response envelope, errors, limits and conventions.
  - [Access & permissions](#guide-access-permissions) — Who can call what: API keys, signed-in sessions and seller team members.
  - [Orders & fulfillment](#guide-orders) — Find a product, place an order, follow it through production and shipping.
  - [Example client](#guide-example-client) — A complete Node.js and Python script: wallet, SKU lookup, draft order, submit or delete.
  - [Shipping labels](#guide-shipping-labels) — Quote, buy and cancel labels, SCAN forms and parcel tracking.
  - [Webhooks](#guide-webhooks) — Receive events instead of polling, and verify that they came from MerchFox.
  - [Wallet & payments](#guide-wallet-payments) — How orders and labels are paid for, balances, transactions and top-ups.
  - [Shopify](#guide-shopify) — Connect a Shopify store, sync orders and push tracking back.
  - [Building with AI agents](#guide-ai-agents) — Rules for AI coding agents that read these docs and call the API.
- Endpoint reference
  - API keys: [Create API key](#create_api_key), [List API keys](#list_api_keys), [Update API key](#update_api_key), [Revoke API key](#revoke_api_key)
  - Orders: [List blueprints](#list_blueprints), [List SKU reference](#list_import_reference), [List blueprint variants](#list_blueprint_variants), [List shipping services](#list_provider_fulfillment_services), [Create order](#order_quick), [Create order + submit](#order_quick_submit), [Quote order](#order_quote), [List orders](#list_orders), [Order stats](#order_stats), [Print-file status](#order_quick_assets), [Get order](#get_order), [Submit order](#submit_order), [Delete draft order](#delete_order), [Update tracking (seller label)](#patch_order_tracking), [Create settlement request](#create_settlement_request), [List settlement requests](#list_settlement_requests)
  - Fulfillment: [List order fulfillments](#list_order_fulfillments), [Get fulfillment](#get_fulfillment)
  - Wallet: [Get wallet](#wallet_get), [Wallet summary](#wallet_summary), [List wallet transactions](#wallet_list_transactions), [List payment accounts](#wallet_list_payment_accounts), [Top-up limits and card fee](#wallet_topup_limits), [Start a card top-up](#wallet_topup_stripe), [Request bank-transfer top-up](#wallet_topup_bank), [List top-up requests](#wallet_list_topup_requests), [Cancel a top-up request](#wallet_topup_cancel)
  - Webhooks: [List webhooks](#list_webhooks), [Create webhook](#create_webhook), [Update webhook](#update_webhook), [Delete webhook](#delete_webhook)
  - Mockup: [List templates](#mockup_list_templates)
  - Shopify: [Begin install (OAuth)](#shopify_install), [List connected stores](#shopify_list_stores), [Get connected store](#shopify_get_store), [Update store sync settings](#shopify_update_store_settings), [Push tracking to Shopify](#shopify_push_tracking), [Disconnect store](#shopify_disconnect_store)
  - Users: [Get user profile](#users_me), [Update user profile](#users_update_profile)
  - Shipping: [Create a shipping label](#label_create), [Get a shipping label](#label_get), [List warehouses](#label_warehouses_list), [Create a SCAN form](#label_scanform_create), [Start tracking parcels](#label_tracking_register), [Get parcel tracking](#label_tracking_get), [Get tracking for many parcels](#label_tracking_lookup), [List tracked parcels](#label_tracking_list), [Check tracking access](#label_tracking_access), [Set your tracking webhook](#label_webhook_set), [Send a test delivery](#label_webhook_test), [Price a label](#label_quote), [List labels](#label_list), [Buy a draft label](#label_confirm), [Cancel a label](#label_cancel)

---

<a id="guide-getting-started"></a>

## Guide: Getting started

_Base URLs, authentication, the response envelope, errors, limits and conventions._

### Base URLs

| Environment | Base URL | Use it for |
| --- | --- | --- |
| Sandbox | `https://api-sandbox.merchfox.com` | Building and testing. Separate data from live. |
| Live | `https://api.merchfox.com` | Real orders, real charges. |

Every path in this reference starts with `/api/v1`. Keys belong to one environment: a live key does not work on sandbox and a sandbox key does not work on live. Create live keys in the seller portal under **My Account → API keys** ([open it](https://seller.merchfox.com/app/account?tab=api-keys) — shop owner only).

#### Set up sandbox (self-service)

1. In the seller portal open **My Account → API keys → Sandbox** and click **Enable sandbox testing**. This creates your separate sandbox account instantly — no approval needed.
2. Create a sandbox key on the same tab (keys start with `mfx_test_`). The secret is shown once.
3. Call `https://api-sandbox.merchfox.com` with that key.

What sandbox is for: everything up to a **draft** — SKU lookup, creating, reading, listing and deleting draft orders, key restrictions (`restrict_to_own_orders`), webhooks and error handling. Sandbox wallets start at $0 and there is no test-money top-up, so submitting an order or buying a label answers HTTP 409 / 402 with `Insufficient balance` — handle that response, then test the paid flow with a small real order on live.

For a quick test use SKU `12101` (unisex T-shirt) and the sample print file `https://api-docs.merchfox.com/sample-print.png` (4500 × 5400 PNG). [Example client](#guide-example-client) runs the whole flow in Node.js or Python.

#### Webhooks while developing

Webhook URLs must be public `https://` addresses — `localhost` and private addresses are refused. To receive deliveries on your laptop, expose it with a tunnel such as `ngrok http 3000` or `cloudflared tunnel --url http://localhost:3000` and register the tunnel's https URL. The Explorer's **Test webhook** page (top bar) sends a correctly signed sample delivery to any URL without creating a subscription. See [Webhooks](#guide-webhooks).

### Authenticate

Send both headers on every request:

```bash
curl https://api-sandbox.merchfox.com/api/v1/orders?limit=5 \
  -H "X-Mfx-App-Key: $MERCHFOX_KEY" \
  -H "X-Mfx-App-Secret: $MERCHFOX_SECRET"
```

- A key looks like `mfx_live_` followed by 32 hex characters (sandbox keys start with `mfx_test_`); the secret is 48 hex characters and is shown **once**, when the key is created.
- An API key acts as the shop owner. A few account endpoints refuse API keys and need a signed-in session instead — see [Access & permissions](#guide-access-permissions).
- Call the API from your server only. Browsers on other sites are not allowed to call it (no CORS), and a key in a web page can be read by anyone.
- Optional hardening: restrict a key to your servers' IP addresses (My Account → API keys → Manage IPs). A call from any other address is refused with HTTP 403, code `1003`.
- A missing, wrong, expired or revoked key returns HTTP 401, code `1001`, with the same message in every case.

### The response envelope

Every JSON response has the same four fields:

```json
{
  "code": 0,
  "data": { "...": "..." },
  "message": "Success",
  "request_id": "3f9a1c2b7d4e5f60"
}
```

- `code` is `0` on success and non-zero on error. **Always check `code`.** Most errors also use the matching HTTP status, but the API key and webhook management endpoints answer every error with HTTP 200 and put the error only in `code`.
- `data` is the result (an object, an array, or `null`). Each endpoint documents its own `data` under **Response fields**.
- `message` is `"Success"`, or a readable reason for the error — show it to whoever has to fix the request.
- `request_id` is also returned in the `X-Request-Id` header. Keep it from failed calls and include it when you contact support. You may send your own `X-Request-Id` and it is echoed back.

### Error codes

| `code` | HTTP | Meaning | What to do |
| --- | --- | --- | --- |
| `1001` | 401 | Missing, invalid, expired or revoked credentials | Check the key and secret. Do not retry. |
| `1003` | 403 | Not allowed: endpoint needs a signed-in session, key's IP allowlist, or a missing team permission | Fix access. Do not retry. |
| `2001` | 400 | Invalid request — `message` says which field | Fix the request. Do not retry unchanged. |
| `2002` | 404 | Not found, or not yours | Check the id. |
| `2003` | 409 | Conflict with the current state (duplicate `orderid`, wrong order status, not enough balance to submit) | Read `message`; change state first. |
| `2006` | 402 | Shipping labels: wallet cannot cover the label | Top up, then retry. |
| `4001` | 429 | Too many requests from your IP address | Wait until `X-RateLimit-Reset`, then retry. |
| `4002` | 429 | Too many purchases on this API key | Wait `Retry-After` seconds, then retry. |
| `5000` | 500 / 503 | Server error | Retry with back-off; contact support with `request_id` if it persists. |
| `5001` | 500 / 502 | A service or carrier is temporarily unavailable, or the carrier refused an address | Read `message`; retry later or fix the address. |

### Rate limits

Limits are counted per minute **per client IP address** and per group of endpoints (the same on live and sandbox):

| Endpoints | Requests per minute |
| --- | --- |
| Most endpoints (orders, labels, catalog, Shopify, …) | 120 |
| `/orders/quick` and `/orders/quick/submit` | 30 |
| `/wallet/*` | 30 |
| `/webhooks/*` | 30 |

Every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds when the window resets). Over the limit the API answers HTTP 429 with code `4001` (`Rate limit exceeded`); wait until `X-RateLimit-Reset`, then retry with exponential back-off and jitter — never in a tight loop. Several servers behind one IP share its limit.

**Purchases are also paced per API key**, whatever the IP: calls that spend wallet money — submitting an order (including `/orders/quick/submit` and `submit: true`) and buying a label (`buy: true` or confirm) — are allowed 5 in a burst, then 30 per minute. Over that: HTTP 429, code `4002`, with `Retry-After` and `data.retry_after_seconds`. Nothing is charged on a refusal.

Some endpoints add their own, lower limit, noted in their descriptions — for example card top-ups (5 per minute), wallet summary (45 per minute) and API key management (10 changes per minute).

### Conventions

- **JSON in, JSON out.** Send `Content-Type: application/json`. Booleans must be JSON `true`/`false` and numbers JSON numbers — `"true"` or `"10"` as strings are rejected on most endpoints, and so are unknown fields.
- **Money** is US dollars. Orders and the wallet use JSON numbers (`17.75`); shipping labels use decimal strings (`"8.45"`). There are no cents and no other currencies.
- **Times** are ISO-8601 / RFC 3339 in UTC (`2026-10-16T14:02:11Z`). API key management uses unix seconds.
- **Ids**: orders are UUIDs; most other resources use 24-character hex ids. Some endpoints return stored records with an `_id` field — use the field names exactly as each endpoint documents them.
- **Paging** differs by endpoint and is documented on each list: orders use `limit` + `skip`, wallet transactions and tracked parcels use `page`, several lists return everything at once.
- **Retries without duplicates**: orders are de-duplicated by your `orderid`; label purchases by an `Idempotency-Key` header. See [Orders & fulfillment](#guide-orders) and [Shipping labels](#guide-shipping-labels).

### Your first order in four calls

1. **Find the SKU** — [List SKU reference](#list_import_reference) gives every orderable `variantSku` with product type, size and color.
2. **Create a draft** — [Create order](#order_quick) with your `orderid`, the address and `products: [{ "sku": "...", "designUrlsByArea": { "FRONT": "https://..." } }]`. Nothing is charged. The draft's `data.order.total` is the exact amount the wallet will be charged — use it as your price check. (To price without creating anything, [Quote order](#order_quote) takes catalog `productId` + `variantId` from your products.)
3. **Check the wallet** — [Get wallet](#wallet_get): `balance` must cover `total`.
4. **Pay and produce** — [Submit order](#submit_order), then follow it with [Get order](#get_order) or [List order fulfillments](#list_order_fulfillments). Changed your mind before paying? [Delete draft order](#delete_order).

---

<a id="guide-access-permissions"></a>

## Guide: Access & permissions

_Who can call what: API keys, signed-in sessions and seller team members._

### Two ways to authenticate

| Credential | Send | Acts as |
| --- | --- | --- |
| API key (integrations) | `X-Mfx-App-Key` + `X-Mfx-App-Secret` | The shop owner — full access to the shop's data, unless the key is restricted to its own orders (below). |
| Signed-in session | `Authorization: Bearer <accessToken>` from Login | The signed-in person: the shop owner, or a team member with only the permissions the owner granted. |

Endpoints marked **Session only** in this reference accept only a signed-in session.

### What an API key cannot do

An API key is an integration credential: it has no two-factor check and cannot be signed out like a session. These endpoints therefore refuse API keys with code `1003` and need a signed-in session instead:

- Managing API keys, their IP allowlists and sandbox keys — [Create API key](#create_api_key), [List API keys](#list_api_keys), [Update API key](#update_api_key), [Revoke API key](#revoke_api_key).
- Signed-in devices and sign-in history.
- Account endpoints under `/users` and `/auth`, except `GET /users/me` and `PATCH /users/me/profile` — for example changing the password.
- Support tickets.

Team management is not part of the public API: the owner invites and manages staff in the seller portal. API keys and webhooks can only be managed by the shop owner — a team member gets code `1003` even with every permission.

A key can also be limited to your servers' IP addresses (My Account → API keys → Manage IPs: single addresses or CIDR ranges, up to 20). A call from any other address answers HTTP 403, code `1003`.

### Restricting a key to its own orders

Every order created through an API key records that key: `apiKeyId` and `apiKeyName` on the order object (null for orders created in the portal, by CSV import, by marketplace sync, or before order tagging was released). The owner can filter [List orders](#list_orders) with `?apiKeyId=…`.

Turn on `restrict_to_own_orders` for a key (when creating it, or with [Update API key](#update_api_key)) to give each integration — for example one per brand — only its own orders. Off by default, and it can be switched on or off at any time. Live and sandbox keys both support it: in the seller portal open **My Account → API keys**, then **Edit** on the key (sandbox keys are on the Sandbox tab). When it is on, requests made with that key:

- list and count only orders that key created;
- get code `2002` (not found) for any other order, exactly like a missing one — the response never reveals that the order exists;
- get code `2003` (conflict) when reusing an `idempotencyKey` or `orderid` that another key's order already uses;
- can still create orders, which are tagged with that key.

Changes take effect within a few seconds.

### Seller team members

The shop owner invites staff in the seller portal (My Account → Team). Each member signs in with their own email and holds only the permissions below; [Get user profile](#users_me) (`GET /users/me`, called with the member's signed-in session) returns them as `permissions`. A shop owner, and any API key, has full access and needs none of them.

| Permission | `:read` allows | `:write` allows |
| --- | --- | --- |
| `seller_orders` | Orders, tracking and fulfillment status | Create, edit, submit and cancel orders |
| `seller_catalog` | Catalog and product listings | Create and edit products |
| `seller_designs` | Design library and mockups | Upload and edit designs |
| `seller_wallet` | Balance and transactions | Top up the wallet |
| `seller_labels` | Labels, rates, warehouses, SCAN forms, tracking | Buy labels (charged to the wallet), manage warehouses |
| `seller_orders_platform` | Marketplace orders across connected shops | Push, archive and manage marketplace orders |
| `seller_store` | Connected shops and their settings | Connect shops and change automation |
| `seller_analytics` | Revenue, profit and order statistics | — |
| `seller_support` | Support tickets | Open tickets and reply |

**Order access by API key.** The owner can limit a member to the orders of selected API keys (`apiKeyScope` on [Get user profile](#users_me); null means all orders). Check it by calling `GET /users/me` with the member's own session — an API key always reads the owner. Such a member:

- sees only those keys' orders, including their tracking and fulfillment;
- cannot create or import orders;
- loses `seller_wallet`, `seller_labels`, `seller_orders_platform`, `seller_store`, `seller_analytics` and `seller_support`, because those cover every key's orders.

### Access errors

| Code | HTTP | Meaning | What to do |
| --- | --- | --- | --- |
| `1001` | 401 | Missing, invalid, expired or revoked credentials. | Check the key and secret, or sign in again. |
| `1003` | 403 | The credential is valid but not allowed here: an API key on a session-only endpoint, a call from outside the key's IP allowlist, or a team member without the permission. | Use a signed-in session, call from an allowed address, or ask the shop owner for the permission. |
| `2002` | 404 | The resource does not exist or is outside your access (another seller, or another key for a restricted key). | Check the id. A restricted key only sees its own orders. |
| `2003` | 409 | Conflict, e.g. an idempotency key or order id already in use. | Use a new `idempotencyKey` / `orderid`. |

API key and webhook management answer these errors with HTTP 200 and the code in `code`; always check `code`.

---

<a id="guide-orders"></a>

## Guide: Orders & fulfillment

_Find a product, place an order, follow it through production and shipping._

### The flow

1. **Find the SKU.** [List SKU reference](#list_import_reference) returns one row per orderable variant: `variantSku`, product type, provider, size, color, the printing methods it supports and the shipping services it allows. Cache it on your side and refresh it daily, not per order.
2. **Create the order** with [Create order](#order_quick). It is a **PENDING draft**: nothing is charged and nothing is printed.
3. **Submit it** with [Submit order](#submit_order). The wallet is charged the order `total` and the order goes to production (`PAID`). Or do steps 2 and 3 at once with [Create order + submit](#order_quick_submit) — or `"submit": true` on Create order.
4. **Follow it** with [Get order](#get_order), [List orders](#list_orders) and [List order fulfillments](#list_order_fulfillments) until it is `SHIPPED` and `DELIVERED`.

### A minimal order

```json
{
  "orderid": "SHOP-10045",
  "firstName": "Jane",
  "lastName": "Doe",
  "address1": "1 Main St",
  "city": "Austin",
  "state": "TX",
  "zip": "78701",
  "country": "US",
  "products": [
    {
      "sku": "12101",
      "quantity": 1,
      "designUrlsByArea": { "FRONT": "https://cdn.example.com/designs/front.png" }
    }
  ]
}
```

- `orderid` is **your** order number. It is required and becomes the order's `externalId`.
- Print files must be publicly downloadable URLs (a Google Drive link must be shared "Anyone with the link").
- `fulfillmentServiceKey` picks the shipping service (`FIRST_CLASS` by default; see the SKU's `shippingMethod` column).
- For your own shipping label, send `sellerLabelUrl` (see [Your own shipping label](#guide-orders) below); the order then needs a tracking number before it can be submitted.

### Your own shipping label

To ship with a label you bought elsewhere, upload the PDF first, then pass the returned key as `sellerLabelUrl` on [Create order](#order_quick) (an https URL to the PDF also works):

```bash
curl -X POST https://api.merchfox.com/api/v1/assets/upload-seller-label \
  -H "X-Mfx-App-Key: $MERCHFOX_KEY" -H "X-Mfx-App-Secret: $MERCHFOX_SECRET" \
  -F "file=@label-SHOP-10045.pdf;type=application/pdf"
```

```json
{ "code": 0, "data": { "originalKey": "labels/3f2a9c1e-0b7d-4e2a-9f11-2c4d5e6f7a80-label-SHOP-10045.pdf" }, "message": "Success", "request_id": "..." }
```

- One multipart field named `file`: a real PDF (sent as `application/pdf`, content starting with `%PDF`), at most 10 MB. Keep the `.pdf` extension in the filename.
- Errors: HTTP 400 `No file uploaded`, `Seller label must be a PDF file` or `Invalid file: seller label must be a valid PDF`; HTTP 413 `File too large`.
- The tracking number is read from the label automatically; you can also set it with [Update tracking (seller label)](#patch_order_tracking) while the order is a draft. A seller-label order is submitted once its tracking number is known.
- Team members need `seller_orders:write`.

### Two creation flows

| | Default (`createProducts` omitted) | `"createProducts": true` |
| --- | --- | --- |
| Catalog products | Matched to the SKU (found or created) | A new product per line |
| Print files | Taken from your URLs | Downloaded in the background |
| Response `data` | `{ products, order, submit? }` | The order itself plus `assets: "PROCESSING"` |
| Same `orderid` again | Returns the existing order (safe retry) | Refused with HTTP 409, code `2003` |
| With submit | Charged in the same request | Charged after every file has downloaded |
| Needed for | Most orders | Per-line `printingMethod`, `sellingPrice`, large-format areas, neck labels |

With `createProducts: true`, follow the background download with [Print-file status](#order_quick_assets): `PROCESSING` → `READY` or `FAILED` (a URL could not be downloaded, or the job took longer than 15 minutes). Nothing is charged for a `FAILED` order.

### Retrying safely

- In the default flow, sending the same `orderid` again returns the order that already exists — never a duplicate. Retry timeouts with the **same** `orderid`. If the first call had already submitted it, a retry with `submit` answers HTTP 409 `Cannot submit order in status: PAID` — the order is paid once; confirm with [Get order](#get_order).
- With `createProducts: true`, a repeated `orderid` is refused (409). Look the order up with [List orders](#list_orders) `?search=<orderid>` instead of retrying.
- Never invent a new `orderid` to "get past" a 409 — that creates a second real order.

### Order statuses

| Status | Meaning | You can |
| --- | --- | --- |
| `PENDING` | Draft. Not paid, not in production. | Submit, delete, edit seller-label tracking |
| `PAID` | Wallet charged; sent to the print provider. | Open a pre-production cancel request |
| `IN_PRODUCTION` | Being printed. | Open an in-production cancel request |
| `SHIPPED`, `IN_TRANSIT` | Handed to the carrier; tracking available. | Open a post-ship request |
| `DELIVERED` | Delivered. | Open a delivered claim |
| `CANCELLED` | Cancelled (after an approved request, or by the provider). | — |
| `REFUNDED` | Fully refunded to the wallet. | — |

You may also see `SUBMITTED`, `PAYMENT_PENDING` or `PAYMENT_FAILED` on older orders, and `SUBMITTING` for a few seconds while a submit is running. A failed payment returns the order to `PENDING`.

**Where tracking lives.** For orders MerchFox ships, tracking is per provider: `providerTrackingByProviderId` (and `providerTrackingCarrierByProviderId`) on the order, and `trackingNumber` / `carrier` on each fulfillment. For seller-label orders it is `sellerLabelTrackingNumber`.

### Changing your mind

- **Before payment**: [Delete draft order](#delete_order) removes it and frees the `orderid`.
- **After payment**: orders cannot be cancelled directly. Open a [settlement request](#create_settlement_request) — the request type must match the order's status (pre-production cancel, in-production cancel, post-ship request, delivered claim). A person at MerchFox reviews it; if approved, the refund goes back to the wallet. Follow it with [List settlement requests](#list_settlement_requests).
- If the print provider cancels an order, MerchFox opens a request for it automatically (`kind: PROVIDER_CANCELLED`) and decides between a reprint and a refund.

### Fulfillments

An order is split into one fulfillment per print provider. [List order fulfillments](#list_order_fulfillments) shows each one's status (`SUBMITTED`, `IN_PRODUCTION`, `SHIPPED`, `DELIVERED`, `FAILED`, …), tracking and items; `data` is `null` until the first fulfillment exists. Retrying a `FAILED` fulfillment is handled by MerchFox — contact support with the order id if one stays failed.

### Keys limited to their own orders

A key with `restrict_to_own_orders` only sees orders it created; anything else answers `2002` (not found). See [Access & permissions](#guide-access-permissions).

---

<a id="guide-example-client"></a>

## Guide: Example client

_A complete Node.js and Python script: wallet, SKU lookup, draft order, submit or delete._

A complete, dependency-light client for the most common flow: check the wallet, look up a SKU, create a draft order, read it back, then submit it (or delete the draft). Run it against **sandbox** first.

Both scripts read the key from the environment — never hard-code it:

```bash
export MERCHFOX_BASE_URL=https://api-sandbox.merchfox.com
export MERCHFOX_KEY=mfx_test_...
export MERCHFOX_SECRET=...
```

What the scripts rely on (see each endpoint for the full reference):

- every response is `{ code, data, message, request_id }` — success is `code === 0`;
- [Create order](#order_quick) without `createProducts` returns the order in `data.order`;
- the same `orderid` sent again returns the same order, so a retry never creates a duplicate;
- [Submit order](#submit_order) answers HTTP 409 (code `2003`) when the wallet cannot cover the order, and the order stays a draft;
- the sample print file `https://api-docs.merchfox.com/sample-print.png` and SKU `12101` are safe to use for tests.

### Node.js (18 or newer, no packages)

```javascript
// merchfox-example.mjs — node merchfox-example.mjs [--submit]
const BASE = process.env.MERCHFOX_BASE_URL ?? 'https://api-sandbox.merchfox.com';
const HEADERS = {
  'X-Mfx-App-Key': process.env.MERCHFOX_KEY,
  'X-Mfx-App-Secret': process.env.MERCHFOX_SECRET,
  'Content-Type': 'application/json',
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

class MerchfoxError extends Error {
  constructor(status, body) {
    super(`HTTP ${status} code ${body?.code}: ${body?.message} (request_id ${body?.request_id})`);
    this.status = status;
    this.code = body?.code;
  }
}

// One call with retries for network errors, 429 and 5xx only.
async function call(method, path, body) {
  for (let attempt = 1; ; attempt++) {
    let res;
    try {
      res = await fetch(BASE + path, { method, headers: HEADERS, body: body ? JSON.stringify(body) : undefined });
    } catch (err) {
      if (attempt >= 5) throw err;
      await sleep(2 ** attempt * 500);
      continue;
    }
    const json = await res.json().catch(() => null);
    if (res.ok && json?.code === 0) return json.data;
    const retryable = res.status === 429 || res.status >= 500;
    if (!retryable || attempt >= 5) throw new MerchfoxError(res.status, json);
    const wait = Number(res.headers.get('Retry-After')) * 1000 || 2 ** attempt * 500;
    await sleep(wait + Math.random() * 250);
  }
}

const submit = process.argv.includes('--submit');

const wallet = await call('GET', '/api/v1/wallet');
console.log('balance', wallet.balance);

const rows = await call('GET', '/api/v1/blueprints/import-reference?limit=2000');
const sku = rows.find((r) => r.variantSku === '12101') ?? rows[0];
console.log('using SKU', sku.variantSku, sku.productType, sku.size, sku.color);

const orderid = `TEST-${Date.now()}`; // your own shop order number in real use
const created = await call('POST', '/api/v1/orders/quick', {
  orderid,
  firstName: 'Jane',
  lastName: 'Doe',
  address1: '1 Main St',
  city: 'Austin',
  state: 'TX',
  zip: '78701',
  country: 'US',
  products: [
    {
      sku: sku.variantSku,
      quantity: 1,
      designUrlsByArea: { FRONT: 'https://api-docs.merchfox.com/sample-print.png' },
    },
  ],
});
const order = created.order;
console.log('draft', order.id, order.status, 'total', order.total);

if (submit && wallet.balance >= order.total) {
  const paid = await call('POST', `/api/v1/orders/${order.id}/submit`);
  console.log('submitted', paid.status);
  for (let i = 0; i < 10; i++) {
    const current = await call('GET', `/api/v1/orders/${order.id}`);
    console.log('status', current.status);
    if (current.status !== 'PAID' && current.status !== 'SUBMITTING') break;
    await sleep(30_000); // production takes hours to days; webhooks are better for long waits
  }
} else {
  await call('DELETE', `/api/v1/orders/${order.id}`); // drafts cost nothing; clean up the test
  console.log('draft deleted');
}
```

### Python (3.9 or newer, `pip install requests`)

```python
# merchfox_example.py — python merchfox_example.py [--submit]
import os, random, sys, time
import requests

BASE = os.environ.get("MERCHFOX_BASE_URL", "https://api-sandbox.merchfox.com")
HEADERS = {
    "X-Mfx-App-Key": os.environ["MERCHFOX_KEY"],
    "X-Mfx-App-Secret": os.environ["MERCHFOX_SECRET"],
}


class MerchfoxError(Exception):
    def __init__(self, status, body):
        body = body or {}
        super().__init__(f"HTTP {status} code {body.get('code')}: {body.get('message')} (request_id {body.get('request_id')})")
        self.status, self.code = status, body.get("code")


def call(method, path, body=None):
    """One call; retries network errors, 429 and 5xx only."""
    for attempt in range(1, 6):
        try:
            res = requests.request(method, BASE + path, headers=HEADERS, json=body, timeout=40)
        except requests.RequestException:
            if attempt == 5:
                raise
            time.sleep(2 ** attempt * 0.5)
            continue
        try:
            data = res.json()
        except ValueError:
            data = None
        if res.ok and data and data.get("code") == 0:
            return data.get("data")
        if not (res.status_code == 429 or res.status_code >= 500) or attempt == 5:
            raise MerchfoxError(res.status_code, data)
        wait = float(res.headers.get("Retry-After") or 2 ** attempt * 0.5)
        time.sleep(wait + random.random() / 4)


submit = "--submit" in sys.argv

wallet = call("GET", "/api/v1/wallet")
print("balance", wallet["balance"])

rows = call("GET", "/api/v1/blueprints/import-reference?limit=2000")
sku = next((r for r in rows if r["variantSku"] == "12101"), rows[0])
print("using SKU", sku["variantSku"], sku["productType"], sku["size"], sku["color"])

orderid = f"TEST-{int(time.time() * 1000)}"  # your own shop order number in real use
created = call("POST", "/api/v1/orders/quick", {
    "orderid": orderid,
    "firstName": "Jane", "lastName": "Doe",
    "address1": "1 Main St", "city": "Austin", "state": "TX", "zip": "78701", "country": "US",
    "products": [{
        "sku": sku["variantSku"],
        "quantity": 1,
        "designUrlsByArea": {"FRONT": "https://api-docs.merchfox.com/sample-print.png"},
    }],
})
order = created["order"]
print("draft", order["id"], order["status"], "total", order["total"])

if submit and wallet["balance"] >= order["total"]:
    paid = call("POST", f"/api/v1/orders/{order['id']}/submit")
    print("submitted", paid["status"])
    for _ in range(10):
        current = call("GET", f"/api/v1/orders/{order['id']}")
        print("status", current["status"])
        if current["status"] not in ("PAID", "SUBMITTING"):
            break
        time.sleep(30)  # production takes hours to days; webhooks are better for long waits
else:
    call("DELETE", f"/api/v1/orders/{order['id']}")  # drafts cost nothing; clean up the test
    print("draft deleted")
```

### Taking it further

- Replace the polling loop with [Webhooks](#guide-webhooks) once `fulfillment.failed` matters to you, and poll [Get order](#get_order) a few times a day for status.
- Keep your own `orderid` (the shop order number) as the link between your system and MerchFox — Get, submit and delete accept it in place of the UUID.
- Store the key and secret in a secret manager, and restrict the key to your servers' IPs (My Account → API keys → Manage IPs).

---

<a id="guide-shipping-labels"></a>

## Guide: Shipping labels

_Quote, buy and cancel labels, SCAN forms and parcel tracking._

Buy USPS labels from your own system, paid from the wallet. This is separate from print orders: use it for parcels you pack and ship yourself.

### The flow

1. **Save your origin once** in the portal (Order Label → Warehouses) and read its id with [List warehouses](#label_warehouses_list). Sending `warehouseId` saves repeating the address on every label.
2. **Price it** (optional) with [Price a label](#label_quote): `price`, `requiredTotal` and `payable` tell you whether the wallet can cover it.
3. **Create it** with [Create a shipping label](#label_create):
   - without `buy` → a draft (`pending`) you confirm later with [Buy a draft label](#label_confirm) or in the portal;
   - with `"buy": true` → queued for purchase immediately.
4. **Wait for `ready`** with [Get a shipping label](#label_get) — usually within a minute. Then `trackingNumber` and `labelUrl` (the PDF) are set.
5. **Hand over the parcels.** For a batch, create a [SCAN form](#label_scanform_create) so the driver scans one barcode.

### Statuses

| Status | Meaning |
| --- | --- |
| `pending` | Draft. Nothing charged. |
| `queued`, `buying` | Being bought. |
| `ready` | Bought. Tracking number and PDF available. |
| `failed` | The purchase did not go through. Nothing charged; the reason is shown in the portal. |
| `cancelling` | A void was requested at the carrier. |
| `cancelled` | Voided and fully refunded (or closed before purchase). |
| `disputed` | Voided with a 10% fee: 90% refunded. |

### Money

- The wallet is charged **once, after the carrier has created the label**: postage plus a small handling fee. That final amount can differ slightly from the quote.
- Creating a label — even a draft — is refused with HTTP 402 (code `2006`) when the wallet cannot cover the quote. Nothing is created then.
- Wallets without an overdraft also set aside a refundable deposit with each label (`depositHold`); it sits in the wallet's `heldAmount` until it is released.
- [Cancel a label](#label_cancel): a draft or queued label is just closed; a `ready` label is voided at the carrier — `cancelled` (full refund), `disputed` (10% kept) or back to `ready` if the carrier refuses (for example after the parcel was scanned).

### Retries and pacing

- Send an `Idempotency-Key` **header** on create (your order number works). A retry with the same key returns the original label instead of buying a second one.
- Purchases are paced per API key: 5 in a burst, then 30 per minute (shared with order submits). Over that: HTTP 429, code `4002`, `Retry-After` and `data.retry_after_seconds`; nothing is charged. Creating drafts is not paced.
- All label calls also share the general limit of 120 requests per minute per IP address (code `4001`).

### Addresses and parcels

- `to` needs `name`, `street1`, `city`, `state` (2-letter code) and `zip`; `street2` is optional and `country` defaults to US.
- Weights are ounces and sizes inches unless you send `weightUnit: "lb"` or `sizeUnit: "cm"`.
- Field problems come back together in `data.errors`. An address the carrier cannot use is refused with HTTP 500, code `5001`, and a message naming the address.

### SCAN forms

One form covers labels shipping from one address on one mailing day, up to 500. Only `ready` labels you bought can go on it, and each label on one form only. Numbers that cannot be added come back in `rejected` with a reason; the rest are on the form. Print `pdfUrl` for the driver.

### Parcel tracking (on request)

Tracking updates are enabled per account — [Check tracking access](#label_tracking_access) tells you whether yours has it; otherwise tracking calls answer HTTP 403 (code `1003`).

1. Register numbers with [Start tracking parcels](#label_tracking_register) (up to 100 per call, free).
2. Read status with [Get parcel tracking](#label_tracking_get), [Get tracking for many parcels](#label_tracking_lookup) or [List tracked parcels](#label_tracking_list). Switch on `statusCategory`, not on the carrier's wording.
3. Or receive pushes: [Set your tracking webhook](#label_webhook_set) and test it with [Send a test delivery](#label_webhook_test).

#### Tracking webhook deliveries

```http
POST /your/endpoint
Content-Type: application/json
User-Agent: MerchFox-Webhooks/1
MerchFox-Event: tracking.updated
MerchFox-Delivery: 6711b2c3d4e5f60718293a4b
MerchFox-Signature: t=1760623200,v1=5d41402abc4b2a76b9719d911017c592...

{"event":"tracking.updated","data":{"trackingNumber":"9400100000000000000000","status":"Arrived at USPS Regional Facility","statusCategory":"IN_TRANSIT","events":[...],"delivered":false}}
```

- **Verify** every delivery: HMAC-SHA256 of `t + "." + raw body`, keyed with your full secret string (including the `whsec_` prefix), hex-encoded, must equal `v1`. Compare in constant time and reject old timestamps (we suggest 5 minutes).
- **Answer 2xx within 10 seconds.** Anything else (including redirects) is retried after 30 s, 2 min, 10 min, 30 min, 2 h, 6 h and 6 h — 8 attempts in all. `MerchFox-Delivery` stays the same across retries: use it to ignore repeats.
- After 20 failed attempts in a row the endpoint is switched off and updates are dropped until you save the URL again (which re-enables it).
- The secret is shown once, on first save or with `rotateSecret: true`.

This tracking webhook is separate from the account [webhooks](#guide-webhooks): different headers, signature format and secret.

---

<a id="guide-webhooks"></a>

## Guide: Webhooks

_Receive events instead of polling, and verify that they came from MerchFox._

Webhooks push events to your server so you do not have to poll. Manage subscriptions with [List](#list_webhooks), [Create](#create_webhook), [Update](#update_webhook) and [Delete webhook](#delete_webhook). Only the shop owner (or an API key) can manage them.

> **Event availability.** `fulfillment.failed` is delivered today. The other event types — `order.created`, `order.paid`, `order.cancelled`, `order.refunded`, `order.fulfilled`, `fulfillment.shipped`, `wallet.topup.approved`, `wallet.topup.rejected`, `subscription.activated`, `subscription.cancelled`, `subscription.deleted` — can be subscribed to ahead of time but are **not sent yet**. Until they are, poll [List orders](#list_orders), [Get order](#get_order) and [List top-up requests](#wallet_list_topup_requests) for those changes. Parcel tracking has its own webhook — see [Shipping labels](#guide-shipping-labels).

### Create a subscription

```bash
curl -X POST https://api.merchfox.com/api/v1/webhooks/me \
  -H "X-Mfx-App-Key: $MERCHFOX_KEY" -H "X-Mfx-App-Secret: $MERCHFOX_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://api.example.com/hooks/merchfox","events":["fulfillment.failed"]}'
```

The response contains `secret` — 64 hex characters, shown **once**. Store it like a password. It cannot be shown again or rotated; to replace it, delete the subscription and create a new one.

The URL must start with `https://`, contain no username or password, and be on the public internet: localhost, internal host names (`*.local`, `*.internal`, …) and private, link-local or cloud-metadata addresses are refused when you save it — and checked again on every delivery, so a host name that later resolves to a private address is refused too.

### What a delivery looks like

```http
POST /hooks/merchfox
Content-Type: application/json
User-Agent: MerchFox-Webhooks/1.0
X-Mfx-Event: fulfillment.failed
X-Mfx-Event-Id: evt_01JA2B3C4D5E6F7G8H9J0K1M2N
X-Mfx-Delivery-Id: wdel_01JA2B3C4D5E6F7G8H9J0K1M2P
X-Mfx-Subscription: wsub_01JA2B3C4D5E6F7G8H9J0K1M2Q
X-Mfx-Timestamp: 1760623200
X-Mfx-Signature: sha256=7f2b9c...
X-Mfx-Api-Version: v1
X-Mfx-Attempt: 1
```

```json
{
  "id": "evt_01JA2B3C4D5E6F7G8H9J0K1M2N",
  "type": "fulfillment.failed",
  "api_version": "v1",
  "occurred_at": "2026-10-16T14:00:00.123456Z",
  "delivery": { "id": "wdel_01JA2B3C4D5E6F7G8H9J0K1M2P", "subscription": "wsub_01JA2B3C4D5E6F7G8H9J0K1M2Q", "attempt": 1 },
  "data": {
    "fulfillment_order_id": "6710f5a9a1b2c3d4e5f60301",
    "order_id": "019ef525-3657-7a1b-9c2d-3e4f5a6b7c8d",
    "status": "FAILED",
    "from_status": "SUBMITTED",
    "provider_id": "6650a1b2c3d4e5f6a7b8c9d1",
    "provider_slug": "merchfox-us",
    "external_fulfillment_id": null,
    "error": "Variant unavailable at the print provider",
    "reason": null
  }
}
```

`provider_id`, `provider_slug`, `external_fulfillment_id`, `error` and `reason` may be null.

### Verify the signature

`X-Mfx-Signature` is `sha256=` followed by the hex HMAC-SHA256 of **`<X-Mfx-Timestamp>.<raw body>`**, keyed with your secret string. Use the raw bytes you received — parsing and re-serialising JSON changes them.

```javascript
// Node.js (Express: app.post(path, express.raw({ type: 'application/json' }), handler))
const crypto = require('crypto');

function verify(req, secret) {
  const ts = req.get('X-Mfx-Timestamp') || '';
  const sig = req.get('X-Mfx-Signature') || '';
  const expected = 'sha256=' + crypto.createHmac('sha256', secret)
    .update(ts + '.').update(req.body) // req.body is the raw Buffer
    .digest('hex');
  const fresh = Math.abs(Date.now() / 1000 - Number(ts)) <= 300;
  return fresh && sig.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}
```

```python
# Python
import hashlib, hmac, time

def verify(headers, raw_body: bytes, secret: str) -> bool:
    ts = headers.get("X-Mfx-Timestamp", "")
    expected = "sha256=" + hmac.new(secret.encode(), ts.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    fresh = ts.isdigit() and abs(time.time() - int(ts)) <= 300
    return fresh and hmac.compare_digest(headers.get("X-Mfx-Signature", ""), expected)
```

Reject anything that fails (HTTP 401) and do not act on it. The timestamp is new on every attempt, so a 5-minute window does not break retries.

### Respond, retry, deduplicate

- Answer with any **2xx within 10 seconds**, then do the work in the background. Redirects are not followed: a 3xx answer counts as a failed delivery and is not retried.
- Timeouts, network errors, 429 and 5xx are retried: 8 attempts in all, about 30 s, 1, 2, 4, 8, 16 and 32 minutes apart (±20%). Other 4xx, 3xx, TLS errors and refused destinations are not retried.
- Delivery is **at least once**: the same event can arrive more than once. De-duplicate on `id` (`X-Mfx-Event-Id`); `X-Mfx-Delivery-Id` changes on every attempt.
- If an endpoint fails 50 times within 5 minutes, it is muted for 15 minutes (`muted_until` in List webhooks). Events that occur while a subscription is muted or paused (`active: false`) are **dropped**, not queued — after an outage, catch up by polling.

### Test before going live

The Explorer's **Test webhook** page (top bar) sends a correctly signed sample `fulfillment.failed` delivery to any URL, without creating a subscription.

---

<a id="guide-wallet-payments"></a>

## Guide: Wallet & payments

_How orders and labels are paid for, balances, transactions and top-ups._

Everything on MerchFox is paid from your prepaid **wallet**, in US dollars: orders when they are submitted, shipping labels when they are bought. Keep it topped up before you submit orders.

### Balance

[Get wallet](#wallet_get) returns:

- `balance` — what you can spend now;
- `heldAmount` — refundable shipping-label deposits, already taken out of `balance`;
- `allowNegativeBalance` / `negativeBalanceLimit` — an overdraft, when MerchFox has granted one;
- `frozen` — while true, nothing can be paid from the wallet.

A payment succeeds when `balance` covers it (or it stays within the overdraft). Otherwise submitting an order answers HTTP 409 (code `2003`, `Insufficient balance`) and buying a label answers HTTP 402 (code `2006`); nothing is charged and you can retry after topping up.

### Ledger

[List wallet transactions](#wallet_list_transactions) is the full history, newest first, with a signed `amount` and the balance before and after each row. Page with `page` and `limit` (max 100) until `page` reaches `totalPages`; filter one kind with `type` — for example `ORDER_PAYMENT`, `REFUND`, `TOP_UP`, `LABEL_PURCHASE`, `LABEL_REFUND`. [Wallet summary](#wallet_summary) gives lifetime totals.

### Top up by bank transfer

1. [List payment accounts](#wallet_list_payment_accounts) — MerchFox's receiving accounts. Pick one whose `type` is not `STRIPE` and note its **`id`** (not `_id`).
2. [Request bank-transfer top-up](#wallet_topup_bank) with `amount` (a JSON number) and `accountId`. You get `transferContent` and the account details in `bankInfo`.
3. Send the money and **put `transferContent` in the transfer memo** — without it the payment cannot be matched.
4. Upload a screenshot of the transfer — `POST /api/v1/wallet/top-up/{topUpId}/proof`, one multipart field `file`, PNG, JPEG or WebP (extension, type and content must agree), at most 5 MB:

   ```bash
   curl -X POST https://api.merchfox.com/api/v1/wallet/top-up/$TOPUP_ID/proof \
     -H "X-Mfx-App-Key: $MERCHFOX_KEY" -H "X-Mfx-App-Secret: $MERCHFOX_SECRET" \
     -F "file=@transfer.png;type=image/png"
   ```

   The response `data` is the updated request (id in `_id`, `status: "PROOF_SUBMITTED"`, `proofImageUrl` holding a storage key, not a link). Only one proof per request: a second upload, or one for a card or closed request, answers HTTP 400 (`Can only upload proof for pending requests`); an unknown id answers 404. Other errors: `No file uploaded`, `File extension … not allowed`, `MIME type … not allowed`, `File content does not match declared type` (400) and `File too large` (413). Team members need `seller_wallet:write`.

5. MerchFox reviews it and credits the wallet. Follow it in [List top-up requests](#wallet_list_topup_requests): `PENDING` → `PROOF_SUBMITTED` → `APPROVED` (or `REJECTED`, with `adminNote`).

A request without proof expires after 14 days. Cancel one you will not pay with [Cancel a top-up request](#wallet_topup_cancel) — possible only before the proof is uploaded.

### Top up by card

Card top-ups are completed in the seller portal (**Wallet → Top up**), where the card form runs. The card is charged your amount plus a processing fee — see [Top-up limits and card fee](#wallet_topup_limits) — and the wallet is credited as soon as the payment succeeds. [Start a card top-up](#wallet_topup_stripe) is the call the portal makes; it is not useful from a server on its own.

### Limits

[Top-up limits and card fee](#wallet_topup_limits) returns the current minimum and maximum per method (by default up to $10,000 per top-up) and the card fee. Read it instead of hard-coding limits.

### Refunds

Refunds land back in the wallet: an approved settlement request on an order (`REFUND`), a cancelled shipping label (`LABEL_REFUND`), or a released label deposit (`LABEL_HOLD_RELEASE`). Withdrawing the wallet balance to a bank is handled by MerchFox support (`BALANCE_REFUND`).

---

<a id="guide-shopify"></a>

## Guide: Shopify

_Connect a Shopify store, sync orders and push tracking back._

Connect a Shopify store so its orders flow into MerchFox automatically, and push tracking back so Shopify marks orders shipped and emails the buyer.

### Connect a store

1. Call [Begin install](#shopify_install) with `shop=<handle>.myshopify.com`. It returns `{ url }` — it does not redirect.
2. Open that URL **in a browser where the seller is signed in to the MerchFox seller portal**. Shopify asks the store owner to approve the app.
3. Shopify returns to MerchFox, which links the store and lands the browser on the portal's store page with `shopify_connected=1`.

The link works once and expires after 10 minutes. If the browser is not signed in to the seller portal as the same seller, Shopify still shows its consent screen but the store is **not** connected (`shopify_error=session_mismatch`). A server-only integration cannot finish this step on its own — give the URL to the seller. A store already connected to another MerchFox account is refused (`shopify_error=store_conflict`).

### What happens after connecting

- Orders from every `ACTIVE` store are imported automatically — through Shopify webhooks as they happen, plus an hourly check. The first sync reaches back 90 days.
- [List connected stores](#shopify_list_stores) shows each store's `status`, settings, `last_sync_at` and `last_sync_error`.
- `DISCONNECTED` means the app was uninstalled in the Shopify admin. Orders stop syncing; connect it again with Begin install.

### Settings

Change them with [Update store sync settings](#shopify_update_store_settings) (send only what you change):

| Setting | Effect |
| --- | --- |
| `auto_push_to_fulfillment` | New Shopify orders are sent to fulfillment automatically. On for stores connected from the portal. |
| `auto_sync_tracking` | Tracking is pushed back to Shopify. Must be on for [Push tracking to Shopify](#shopify_push_tracking) to do anything (otherwise `syncStatus: SKIPPED`). |
| `default_fulfillment_service_key` | MerchFox shipping service when no mapping matches (`ECONOMY`, `FIRST_CLASS`, `RUSH`, `PRIORITY`, `EXPEDITE`, `OVERNIGHT`, or empty for the default). |
| `shipping_method_mapping` | Shopify shipping-method label → MerchFox service key, up to 50 labels. Labels match case-insensitively, exactly or as the start of the order's shipping method. |

### Push tracking

[Push tracking to Shopify](#shopify_push_tracking) with the MerchFox `orderId`, the Shopify order id (numeric or `gid://shopify/Order/…`), `trackingNumber` and `carrier`:

- `SYNCED` — Shopify marked the order fulfilled and emailed the buyer.
- `FAILED` — still HTTP 200; `syncError` explains why (for example the store must be reconnected, or the order has no open fulfillment). Nothing retries automatically — fix it and call again.
- Only the first open fulfillment order on the Shopify order is fulfilled, with all its remaining items. Orders split across several Shopify locations are fulfilled only partly.

### Disconnect

[Disconnect store](#shopify_disconnect_store) removes the store from MerchFox and asks Shopify to remove MerchFox's order webhooks. Orders already imported stay. To remove the app from Shopify too, uninstall it in the Shopify admin.

---

<a id="guide-ai-agents"></a>

## Guide: Building with AI agents

_Rules for AI coding agents that read these docs and call the API._

This page is written for AI coding agents (and the people who supervise them) that read this reference and then write or run code against the MerchFox API. Follow these rules exactly.

### Before any call

- **Start on sandbox.** Use the sandbox base URL and a sandbox key until the integration works end to end. Switch to live only when the person you work for says so.
- **Never print, log or commit the API secret.** Read `X-Mfx-App-Key` and `X-Mfx-App-Secret` from environment variables or a secret store. Never put them in source code, URLs, query strings, screenshots or chat output. The same applies to webhook signing secrets.
- **Server side only.** Never call the API from a browser or mobile app with a real key — anyone could read the secret from the page.
- **Use only the endpoints, fields and values in this reference.** Do not guess paths, fields or enum values. Unknown body fields are rejected, and so are strings where numbers or booleans are expected (`"10"`, `"true"`). If something you need is not documented, stop and ask a person.

### Calls that spend money or cannot be undone

Confirm with a person before calling any of these on live, and never call them in a loop:

- [Create order + submit](#order_quick_submit), [Create order](#order_quick) with `"submit": true`, and [Submit order](#submit_order) — charge the wallet and start production.
- [Create a shipping label](#label_create) with `"buy": true` and [Buy a draft label](#label_confirm) — charge the wallet.
- [Delete draft order](#delete_order), [Cancel a label](#label_cancel), [Revoke API key](#revoke_api_key), [Delete webhook](#delete_webhook), [Disconnect store](#shopify_disconnect_store), [Cancel a top-up request](#wallet_topup_cancel).

Prefer the two-step flow while testing: create the order as a draft with [Create order](#order_quick), check it, then [Submit order](#submit_order). Orders cannot be cancelled through the API once paid — that needs a settlement request reviewed by a person.

### Retries and duplicates

- Every order needs your own unique `orderid`, and you must **keep the same `orderid` when retrying**. In the default flow a repeat returns the existing order; with `createProducts: true` a repeat answers HTTP 409 — then look the order up instead of creating another. Never change the `orderid` to get past a 409.
- For labels, send an `Idempotency-Key` header (for example your order number) and reuse it on retry.
- Retry only network errors, timeouts, HTTP 429 and HTTP 5xx, with exponential back-off and jitter (for example 1 s, 2 s, 4 s, 8 s, at most 5 attempts). On HTTP 429 wait for `Retry-After` (code `4002`, purchases) or until the `X-RateLimit-Reset` time (code `4001`) before retrying.
- **Never retry a 4xx other than 429** unchanged, and never retry `code` 1001, 1003, 2001, 2002, 2003 or 2006. Read `message`, fix the cause, or ask a person.
- After a timeout on a call that creates or charges something, **look the resource up first** ([List orders](#list_orders) with `search=<orderid>`, [List labels](#label_list)) before trying again.

### Reading responses

- Check `code`, not only the HTTP status: `0` is success. API key and webhook management endpoints return errors with HTTP 200.
- Keep `request_id` from every failed response; a person needs it to contact support.
- Read the field names each endpoint documents under **Response fields** — some return `id`, some `_id`; orders and wallet amounts are numbers, label amounts are strings. Do not infer units: everything is US dollars.
- Creating an order or buying a label returns before the work is finished. Poll [Get order](#get_order), [Print-file status](#order_quick_assets) or [Get a shipping label](#label_get) every few seconds, with a time limit, until the documented final state.
- Only `fulfillment.failed` webhooks are delivered today; do not wait for other webhook events — poll instead (see [Webhooks](#guide-webhooks)).

### Permissions

An API key acts as the shop owner. Key management, sessions and most account endpoints refuse API keys with code `1003`; do not try to work around this — those actions belong to a person in the seller portal. See [Access & permissions](#guide-access-permissions).

### Getting this reference

- Full reference in one file: `https://api-docs.merchfox.com/merchfox-api.md`
- Short index for AI tools: `https://api-docs.merchfox.com/llms.txt`
- Each endpoint section has an `Explorer id` — the anchor of that section in the file (for example `#order_quick`).

---

## Endpoint reference

## API keys

<a id="create_api_key"></a>

### Create API key

`POST /api/v1/seller/202311/me/api-keys`

- Auth: Session only (`Authorization: Bearer <accessToken>`) — API keys are refused
- Explorer id: `create_api_key`

Create a new key + secret for the shop owner. The `secret` is returned ONCE — MerchFox stores only a hash and can never show it again. Keys look like `mfx_live_` + 32 hex characters (sandbox keys start with `mfx_test_`); secrets are 48 hex characters. Up to 25 active keys per account (revoked and expired keys do not count). Only the shop owner can manage keys; team members get code 1003. Errors come back as HTTP 200 with the error in `code` — always check `code`. Limited to 10 create/update/revoke calls per minute (code 4001). An IP allowlist for the key is managed in the seller portal (My Account → API keys). Requires a signed-in session (`Authorization: Bearer <accessToken>` from Login); API keys cannot call key-management endpoints.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `name` | body | string | yes | Human-readable label for the key, at most 100 characters. Example: `Production integration`. |
| `expires_in_days` | body | integer | no | Days until the key expires. Omit, or send 0, for a key that never expires. Values above 3650 are treated as 3650. Expiry cannot be changed later. Example: `365`. |
| `restrict_to_own_orders` | body | boolean | no | When true, requests made with this key can only see and act on orders created with this key. Default false. Can be changed later with Update API key. Example: `false`. |

#### Example request

```bash
curl -X POST 'https://api.merchfox.com/api/v1/seller/202311/me/api-keys' \
  -H 'Authorization: Bearer <YOUR_ACCESS_TOKEN>' \
  -H 'Content-Type: application/json' \
  -d '{"name":"Production integration","expires_in_days":365,"restrict_to_own_orders":false}'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.id` | string | Key record id. |
| `data.name` | string | Human-readable label. |
| `data.key` | string | Public key (`mfx_live_` + 32 hex) — send as X-Mfx-App-Key on every request. |
| `data.secret` | string | Secret — returned ONCE on create. Send as X-Mfx-App-Secret. Store in your secrets manager immediately. |
| `data.created_at` | int | Unix seconds at creation. |
| `data.expires_at` | int\|null | Unix seconds at expiry (null for never). |
| `data.warning` | string | Reminder that the secret is shown only once. |
| `data.restrict_to_own_orders` | bool | True when requests made with this key can only see and act on orders created with this key. |

#### Response: Success (HTTP 200)

`secret` is returned ONCE. Copy + store it in your secrets manager before closing the response — it cannot be recovered later.

```json
{
  "code": 0,
  "data": {
    "id": "6650a1b2c3d4e5f6a7b8c9d0",
    "name": "Production integration",
    "key": "mfx_live_3f9a1c2b7d4e5f60a1b2c3d4e5f60718",
    "secret": "9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b",
    "created_at": 1747188400,
    "expires_at": 1778724400,
    "warning": "Save the secret now — it will never be shown again.",
    "restrict_to_own_orders": false
  },
  "message": "Success",
  "request_id": "e5f6a7b8c9d0e1f2"
}
```

#### Response: 25 active keys already (HTTP 200)

seller-info answers HTTP 200 and puts the error in `code`.

```json
{
  "code": 2001,
  "data": null,
  "message": "max 25 active API keys; revoke one before creating another",
  "request_id": "e5f6a7b8c9d0e1f3"
}
```

#### Response: Called with an API key (HTTP 403)

Key management needs a signed-in session. The gateway refuses API keys before the request reaches the service.

```json
{
  "code": 1003,
  "data": null,
  "message": "Forbidden",
  "request_id": "e5f6a7b8c9d0e1f4"
}
```

#### Response: Team member (HTTP 200)

Only the shop owner can manage API keys.

```json
{
  "code": 1003,
  "data": null,
  "message": "Shop owner access required",
  "request_id": "e5f6a7b8c9d0e1f5"
}
```

#### Response: Too many requests (HTTP 200)

More than 10 create/update/revoke calls in a minute.

```json
{
  "code": 4001,
  "data": null,
  "message": "rate limit exceeded",
  "request_id": "e5f6a7b8c9d0e1f6"
}
```

<a id="list_api_keys"></a>

### List API keys

`GET /api/v1/seller/202311/me/api-keys`

- Auth: Session only (`Authorization: Bearer <accessToken>`) — API keys are refused
- Explorer id: `list_api_keys`

Your keys, newest first: every active key plus the 50 most recent revoked or expired ones. Secrets are never returned — `key_prefix` shows the first 12 characters of the key so you can tell them apart. No paging. Requires a signed-in session (`Authorization: Bearer <accessToken>` from Login); API keys cannot call key-management endpoints.

#### Parameters

No parameters.

#### Example request

```bash
curl -X GET 'https://api.merchfox.com/api/v1/seller/202311/me/api-keys' \
  -H 'Authorization: Bearer <YOUR_ACCESS_TOKEN>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.items` | []object | Every active key plus the 50 most recent revoked or expired ones, newest first. Secrets are never returned. |
| `data.items[].id` | string | Key record id. |
| `data.items[].name` | string | Label. |
| `data.items[].key_prefix` | string | First 12 characters of the public key followed by …, so you can recognize the key. |
| `data.items[].created_at` | int | Unix seconds at creation. |
| `data.items[].last_used_at` | int\|null | Unix seconds at last request seen. |
| `data.items[].expires_at` | int\|null | Unix seconds at expiry (null for never). |
| `data.items[].revoked` | bool | True once the key has been revoked. |
| `data.items[].active` | bool | True when the key is neither revoked nor expired. |
| `data.items[].ip_allowlist_count` | int | Number of IP allowlist entries on the key. |
| `data.items[].restrict_to_own_orders` | bool | True when requests made with this key can only see and act on orders created with this key. |

#### Response: Success (HTTP 200)

Secrets are NEVER returned by list. Only metadata.

```json
{
  "code": 0,
  "data": {
    "items": [
      {
        "id": "6650a1b2c3d4e5f6a7b8c9d0",
        "name": "Production integration",
        "key_prefix": "mfx_live_3f9…",
        "created_at": 1747188400,
        "last_used_at": 1747188000,
        "expires_at": 1778724400,
        "revoked": false,
        "active": true,
        "ip_allowlist_count": 0,
        "restrict_to_own_orders": true
      },
      {
        "id": "6740b2c3d4e5f6a7b8c9d0e1",
        "name": "Old staging key",
        "key_prefix": "mfx_live_a1b…",
        "created_at": 1732000000,
        "last_used_at": null,
        "expires_at": null,
        "revoked": true,
        "active": false,
        "ip_allowlist_count": 0,
        "restrict_to_own_orders": false
      }
    ]
  },
  "message": "Success",
  "request_id": "f6a7b8c9d0e1f2a3"
}
```

<a id="update_api_key"></a>

### Update API key

`PATCH /api/v1/seller/202311/me/api-keys/{id}`

- Auth: Session only (`Authorization: Bearer <accessToken>`) — API keys are refused
- Explorer id: `update_api_key`

Turn order restriction on or off for a live key — the only setting that can be changed after creation (name and expiry cannot). Takes effect within a few seconds. Sandbox keys are changed in the seller portal (My Account → API keys → Sandbox → Edit). Returns code 2002 (not found, HTTP 200) for unknown or revoked keys. Requires a signed-in session (`Authorization: Bearer <accessToken>` from Login); API keys cannot call key-management endpoints.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | The key ID returned from create / list. |
| `restrict_to_own_orders` | body | boolean | yes | When true, requests made with this key can only see and act on orders created with this key. Other orders (created by other keys, in the portal, by marketplace sync, or before order tagging was released) return code 2002 (not found) and are excluded from lists and stats. Off by default; can be changed later with Update API key. Example: `true`. |

#### Example request

```bash
curl -X PATCH 'https://api.merchfox.com/api/v1/seller/202311/me/api-keys/{id}' \
  -H 'Authorization: Bearer <YOUR_ACCESS_TOKEN>' \
  -H 'Content-Type: application/json' \
  -d '{"restrict_to_own_orders":true}'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.id` | string | Echo of the key id. |
| `data.restrict_to_own_orders` | bool | The new setting. |

#### Response: Success (HTTP 200)

Requests made with this key now see only orders created with this key. Other orders return 404 and are left out of lists and stats.

```json
{
  "code": 0,
  "data": {
    "id": "6650a1b2c3d4e5f6a7b8c9d0",
    "restrict_to_own_orders": true
  },
  "message": "Success",
  "request_id": "b8c9d0e1f2a3b4c5"
}
```

#### Response: Unknown or revoked key (HTTP 200)

```json
{
  "code": 2002,
  "data": null,
  "message": "api key not found",
  "request_id": "b8c9d0e1f2a3b4c6"
}
```

<a id="revoke_api_key"></a>

### Revoke API key

`DELETE /api/v1/seller/202311/me/api-keys/{id}`

- Auth: Session only (`Authorization: Bearer <accessToken>`) — API keys are refused
- Explorer id: `revoke_api_key`
- Destructive: cannot be undone.

Revoke a key. It stops working within seconds and cannot be re-enabled — create a new key instead. The key stays in the list as revoked, and orders it created keep their apiKeyId. Returns code 2002 (not found, HTTP 200) for unknown or already revoked keys. Requires a signed-in session (`Authorization: Bearer <accessToken>` from Login); API keys cannot call key-management endpoints.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | The key ID returned from create / list. |

#### Example request

```bash
curl -X DELETE 'https://api.merchfox.com/api/v1/seller/202311/me/api-keys/{id}' \
  -H 'Authorization: Bearer <YOUR_ACCESS_TOKEN>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.id` | string | Echo of the key id. |
| `data.revoked` | bool | Always true after a successful call. |
| `data.revoked_at` | int | Unix seconds at revocation. |

#### Response: Success (HTTP 200)

```json
{
  "code": 0,
  "data": {
    "id": "6650a1b2c3d4e5f6a7b8c9d0",
    "revoked": true,
    "revoked_at": 1747188400
  },
  "message": "Success",
  "request_id": "a7b8c9d0e1f2a3b4"
}
```

#### Response: Unknown or already revoked key (HTTP 200)

```json
{
  "code": 2002,
  "data": null,
  "message": "api key not found",
  "request_id": "a7b8c9d0e1f2a3b5"
}
```

## Orders

<a id="list_blueprints"></a>

### List blueprints

`GET /api/v1/blueprints`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `list_blueprints`

The product catalog: each product type (T-shirt, mug, hoodie, …) with the provider versions that make it. Returns a plain array. For each blueprint, `providerBlueprints[]` lists the providers; a provider entry's `id` is the `providerBlueprintId` used by List blueprint variants. Filter with `category` or `providerId`; `view=list` returns a lighter version.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `category` | query | string | no | Coarse category filter (e.g. TSHIRT, MUG, HOODIE, POSTER). Example: `TSHIRT`. |
| `providerId` | query | string | no | Restrict to blueprints offered by a specific fulfillment provider. |
| `view` | query | string | no | `list` returns a lighter response (smaller mockup data); default `full`. Example: `list`. |

#### Example request

```bash
curl -X GET 'https://api.merchfox.com/api/v1/blueprints' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | []object | A JSON array; each element has the fields below. |
| `data[].id` | string | Blueprint id — the `blueprintId` used elsewhere. |
| `data[].name` | string | Product type name, e.g. "Unisex T-Shirt". |
| `data[].slug` | string | URL-friendly name. |
| `data[].category` | string | Category, e.g. TSHIRT, HOODIE, MUG. |
| `data[].brand` | string | Garment brand, when any. |
| `data[].description` | string | Description. |
| `data[].printAreas` | []object | Printable areas (FRONT, BACK, …) and their sizes. |
| `data[].isFeatured` | bool | Featured in the catalog. |
| `data[].providerBlueprints` | []object | Provider versions of this product. |
| `data[].providerBlueprints[].id` | string | The `providerBlueprintId` — pass it to List blueprint variants. |
| `data[].providerBlueprints[].providerId` | string | Provider id. |
| `data[].providerBlueprints[].blueprintId` | string | Parent blueprint. |
| `data[].providerBlueprints[].provider` | object | Provider `id`, `name`, `slug`, `logo`. |
| `data[].providerBlueprints[]._count` | object | `variants`: number of orderable variants. |

#### Response: Success (HTTP 200)

```json
{
  "code": 0,
  "data": [
    {
      "id": "6650b0c1d2e3f4a5b6c7d801",
      "name": "Unisex T-Shirt Gildan 5000",
      "slug": "unisex-t-shirt-gildan-5000",
      "category": "TSHIRT",
      "brand": "Gildan",
      "isFeatured": true,
      "providerBlueprints": [
        {
          "id": "6650b0c1d2e3f4a5b6c7d901",
          "providerId": "6650a1b2c3d4e5f6a7b8c9d1",
          "blueprintId": "6650b0c1d2e3f4a5b6c7d801",
          "provider": {
            "id": "6650a1b2c3d4e5f6a7b8c9d1",
            "name": "MerchFox Fulfillment US",
            "slug": "merchfox-us",
            "logo": null
          },
          "_count": {
            "variants": 48
          }
        }
      ]
    }
  ],
  "message": "Success",
  "request_id": "d4e5f6a7b8c9d0e1"
}
```

<a id="list_import_reference"></a>

### List SKU reference

`GET /api/v1/blueprints/import-reference`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `list_import_reference`

Flattened catalog rows — one entry per fulfillable SKU with human-readable product type, provider, size, and color. Use this to build your local SKU lookup table before calling POST /orders/quick.

**DB-safe usage:** prefer `blueprintId` or `providerBlueprintId` to narrow the query. For a single product type, `GET .../variants` (below) is lighter. When syncing the full catalog, paginate with `limit` (max 2000) + `offset` — do not poll the unfiltered endpoint in a tight loop. Omitting all query params returns the full catalog (seller-portal CSV export behaviour).

**Printing method columns.** `printingMethod` lists the methods this SKU can be printed with, separated by ` | ` (e.g. `"DTF | DTG"`). `defaultPrintingMethod` is the method used when your order line sends no `printingMethod` (`null` when none can be picked automatically). `printingMethodRequired`: when `true`, POST /orders/quick and /orders/quick/submit need `"createProducts": true` plus a per-line `printingMethod` set to exactly one value from `printingMethod`; when `false`, you can omit it, or send another listed value to override the default.

Example row: `{"productType":"Unisex T-Shirt Gildan 5000","providerName":"MerchFox Fulfillment US","printingMethod":"DTF | DTG","defaultPrintingMethod":"DTF","printingMethodRequired":false,"variantSku":"12101","size":"M","color":"WHITE","shippingMethod":"1-FIRST_CLASS|2-PRIORITY"}`

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `blueprintId` | query | string | no | Restrict to one master blueprint id from GET /blueprints. Example: `bp_tshirt_unisex`. |
| `category` | query | string | no | Restrict to a blueprint category (TSHIRT, MUG, HOODIE, …). Example: `TSHIRT`. |
| `providerId` | query | string | no | Restrict to SKUs offered by one fulfillment provider. |
| `providerBlueprintId` | query | string | no | Restrict to one provider+blueprint binding — same id as `providers[].providerBlueprintId` from GET /blueprints. Example: `pb_printify_tshirt`. |
| `limit` | query | integer | no | Page size when paginating a large catalog sync. Max 2000 per request. Example: `500`. |
| `offset` | query | integer | no | Skip this many rows before returning results (use with `limit`). Example: `0`. |

#### Example request

```bash
curl -X GET 'https://api.merchfox.com/api/v1/blueprints/import-reference' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | []object | A JSON array; each element has the fields below. |
| `data[].productType` | string | Human-readable blueprint name. |
| `data[].providerName` | string | Fulfillment provider display name. |
| `data[].printingMethod` | string | Decoration methods for this row (pipe-separated). |
| `data[].defaultPrintingMethod` | string\|null | Method used when an order line sends none; null when none can be picked automatically. |
| `data[].printingMethodRequired` | bool | True when orders need `"createProducts": true` plus a per-line `printingMethod`. |
| `data[].variantSku` | string | Provider SKU code — pass as `sku` in POST /orders/quick. |
| `data[].size` | string | Size label (e.g. M, L, XL). |
| `data[].color` | string | Colour label. |
| `data[].shippingMethod` | string | Allowed shipping keys for this SKU (pipe-separated). |

#### Response: Success (filtered) (HTTP 200)

Pass blueprintId or paginate with limit/offset for large catalogs.

```json
{
  "code": 0,
  "data": [
    {
      "productType": "Unisex T-Shirt",
      "providerName": "Printify",
      "printingMethod": "DTG",
      "variantSku": "8264",
      "size": "M",
      "color": "White",
      "shippingMethod": "1-FIRST_CLASS|2-PRIORITY"
    },
    {
      "productType": "Unisex T-Shirt",
      "providerName": "Printify",
      "printingMethod": "DTG",
      "variantSku": "8265",
      "size": "L",
      "color": "White",
      "shippingMethod": "1-FIRST_CLASS|2-PRIORITY"
    }
  ],
  "message": "Success",
  "request_id": "e5f6a7b8c9d0e1f2"
}
```

<a id="list_blueprint_variants"></a>

### List blueprint variants

`GET /api/v1/blueprints/{blueprintId}/providers/{providerBlueprintId}/variants`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `list_blueprint_variants`

SKU list for one blueprint + provider combination — the lightest way to fetch orderable size/colour codes. Pass `blueprintId` and `providerBlueprintId` from GET /blueprints, then use each row's `sku` in POST /orders/quick. Optional `decorationMethodId` narrows to variants available for that print method.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `blueprintId` | path | string | yes | Master blueprint id from GET /blueprints. Example: `bp_tshirt_unisex`. |
| `providerBlueprintId` | path | string | yes | Provider-specific blueprint id from GET /blueprints → providers[]. Example: `pb_printify_tshirt`. |
| `decorationMethodId` | query | string | no | Filter to variants available for this decoration method. Omit to use the provider default. |

#### Example request

```bash
curl -X GET 'https://api.merchfox.com/api/v1/blueprints/{blueprintId}/providers/{providerBlueprintId}/variants' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | []object | A JSON array; each element has the fields below. |
| `data[].id` | string | Internal variant id. |
| `data[].sku` | string | Provider SKU — use in POST /orders/quick `products[].sku`. |
| `data[].size` | string | Size label. |
| `data[].color` | string | Colour label. |
| `data[].colorHex` | string | Hex swatch for the colour, when available. |
| `data[].stockStatus` | string | IN_STOCK \| LOW_STOCK \| OUT_OF_STOCK \| DISCONTINUED (discontinued variants are omitted). |
| `data[].currency` | string | Price currency for this variant (cost not exposed). |
| `data[].decorationMethodId` | string | Decoration method this variant is tagged to, if any. |
| `data[].templateGroup` | string | Mockup/template grouping key when set. |

#### Response: Success (HTTP 200)

```json
{
  "code": 0,
  "data": [
    {
      "id": "v_blue_M",
      "sku": "8264",
      "size": "M",
      "color": "White",
      "colorHex": "#FFFFFF",
      "stockStatus": "IN_STOCK",
      "currency": "USD"
    },
    {
      "id": "v_blue_L",
      "sku": "8265",
      "size": "L",
      "color": "White",
      "colorHex": "#FFFFFF",
      "stockStatus": "IN_STOCK",
      "currency": "USD"
    }
  ],
  "message": "Success",
  "request_id": "f6a7b8c9d0e1f2a3"
}
```

<a id="list_provider_fulfillment_services"></a>

### List shipping services

`GET /api/v1/blueprints/providers/{providerId}/fulfillment-services`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `list_provider_fulfillment_services`

The shipping services a print provider offers in the US, in display order. Send a row's `key` as `fulfillmentServiceKey` when creating an order. `allowSellerLabel` / `sellerLabelOnly` tell you which work with your own label. Costs here are indicative (taken from one of the provider's products) — the draft order `total` is the exact price. Unknown or private providers answer HTTP 403 (code 1003).

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `providerId` | path | string | yes | Provider id — `providerBlueprints[].providerId` from List blueprints. Example: `6650a1b2c3d4e5f6a7b8c9d1`. |

#### Example request

```bash
curl -X GET 'https://api.merchfox.com/api/v1/blueprints/providers/{providerId}/fulfillment-services' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | []object | A JSON array; each element has the fields below. |
| `data[]._id` | string | Internal document id. |
| `data[].id` | string | Service id. |
| `data[].key` | string | The code to send as `fulfillmentServiceKey` (e.g. FIRST_CLASS, PRIORITY, RUSH, EXPEDITE, OVERNIGHT, CBT_STANDARD). |
| `data[].label` | string | Display name, when set. |
| `data[].description` | string | Description, when set. |
| `data[].serviceCost` | number | Indicative shipping charge for the first item (USD). Varies by product — Quote order or the draft order total is authoritative. |
| `data[].additionalItemCost` | number | Indicative charge per extra item (USD). |
| `data[].minDeliveryDays` | int | Fastest delivery, in days, when set. |
| `data[].maxDeliveryDays` | int | Slowest delivery, in days, when set. |
| `data[].allowSellerLabel` | bool | Can be used with your own shipping label (SELLER_LABEL orders). |
| `data[].sellerLabelOnly` | bool | Only for SELLER_LABEL orders (e.g. CBT_*). |
| `data[].sortOrder` | int | Display order (ascending). |
| `data[].isActive` | bool | Always true here. |
| `data[].providerBlueprintId` | string | Provider product the row was taken from. |
| `data[].createdAt` | string | ISO-8601 creation time. |
| `data[].updatedAt` | string | ISO-8601 last change. |

#### Response: Success (HTTP 200)

A plain array in display order. Costs are indicative; the draft order total is authoritative.

```json
{
  "code": 0,
  "data": [
    {
      "_id": "6650d1e2f3a4b5c6d7e8f901",
      "id": "fs_first_class_us",
      "key": "FIRST_CLASS",
      "label": "Standard",
      "serviceCost": 4.75,
      "additionalItemCost": 2,
      "minDeliveryDays": 3,
      "maxDeliveryDays": 7,
      "allowSellerLabel": true,
      "sellerLabelOnly": false,
      "sortOrder": 1,
      "isActive": true,
      "providerBlueprintId": "6650b0c1d2e3f4a5b6c7d901",
      "createdAt": "2026-01-10T08:00:00.000Z",
      "updatedAt": "2026-06-01T08:00:00.000Z"
    }
  ],
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: Provider not available (HTTP 403)

Unknown provider, or a private provider your account is not allowed to use.

```json
{
  "code": 1003,
  "data": null,
  "message": "This fulfillment provider is not available.",
  "request_id": "b1c2d3e4f5a6b7c8"
}
```

<a id="order_quick"></a>

### Create order

`POST /api/v1/orders/quick`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `order_quick`

The single-call way for integrators to create an order: send your shop `orderid`, the shipping address and one or more lines with a `sku` and print-file URLs. The order is created as a **PENDING draft** — nothing is charged until it is submitted (send `"submit": true`, call **Submit order**, use **Create order + submit**, or submit in the portal).

**Two flows, chosen by `createProducts`.**

• **Default (`createProducts` omitted or false).** Each line is matched to a catalog product for that `sku` (found or created) and the order is created in the same request. The response is `data = { products, order, submit? }` — read the order from `data.order`. With `"submit": true` the wallet is charged **in the same request** and `data.submit` is `{ orderId, status: "PAID" }`. Sending the same `orderid` again returns the existing order (HTTP 200) instead of a duplicate — safe to retry.

• **`createProducts: true`.** A new catalog product is created for every line and the print files are downloaded in the background. The response comes back in about a second with the order itself in `data` plus `assets: "PROCESSING"`; follow it with **Print-file status** until `READY` or `FAILED`. Here a repeated `orderid` is refused with HTTP 409 (code 2003) whatever the state of the earlier order. Needed for per-line `printingMethod`, `sellingPrice`, `largeFormatAreas`, `hasSpecialPrintArea` and neck labels.

**Freeing an orderid.** Orders cannot be cancelled through the API. To reuse an `orderid`, delete the draft with **Delete draft order** while it is still PENDING; after payment, open a settlement request instead.

**Printing method.** Some product types can only be produced once a printing method (for example DTG, DTF or inkjet) is set. The method is normally resolved from the `sku` (see `defaultPrintingMethod` / `printingMethodRequired` in **List SKU reference**). If the API answers `This provider requires a decoration method (e.g. DTG, DTF). Please select a printing method for the product.`, send the request again with `"createProducts": true` and add `printingMethod` to that line, e.g. `"printingMethod": "Inkjet printing"`. The failed request created no order, so you can retry with the same `orderid`.

Common printing-method errors:
• `products[N].printingMethod requires "createProducts": true …` → add `"createProducts": true` at the top level of the body.
• `products[N]: printing_method "…" was not found … — options: …` → send one of the listed options.
• `Invalid JSON body` → check value types: `createProducts` and `submit` must be JSON booleans and `quantity` a JSON number.

**Per line** (see the `products` parameter): `sku` and `designUrlsByArea` are required; `blueprintId` (only to tell apart a `sku` that matches several catalog rows), `productId` and `providerBlueprintId` are optional and ignored when `createProducts` is true. Order-list previews go in `mockupFrontUrl` / `mockupBackUrl` / `mockupNeckInnerUrl`. Legacy V1 aliases (`firstname`, `zipcode`, `methodship`, `areafront`, …) are still accepted.

The order is tagged with the calling API key (`apiKeyId`, `apiKeyName`). A team member limited to API-key orders cannot create orders (code 1003). See Guides → Access & permissions and Guides → Orders & fulfillment.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orderid` | body | string | yes | Your shop order reference (required). It becomes the order's `externalId`. Default flow: sending it again returns the existing order (no duplicate). With `createProducts: true`: sending it again returns HTTP 409 (code 2003) — delete the draft first if you must recreate it. Example: `354354354353`. |
| `idempotencyKey` | body | string | no | System-managed — auto-filled from `orderid`. Do not send unless you need to override the default idempotency key. |
| `externalId` | body | string | no | System-managed — auto-filled from `orderid`. Shown in webhooks and the seller portal. Do not send unless you need a different display reference. |
| `firstName` | body | string | yes | Recipient first name. Legacy alias: `firstname`. Example: `Jane`. |
| `lastName` | body | string | no | Recipient last name. Legacy alias: `lastname`. Example: `Doe`. |
| `address1` | body | string | yes | Street address line 1. Example: `1 Main St`. |
| `address2` | body | string | no | Street address line 2 (apartment, suite, etc.). |
| `city` | body | string | yes | City. Example: `Austin`. |
| `state` | body | string | no | State / province code. Example: `TX`. |
| `country` | body | string | yes | ISO 3166-1 alpha-2 country code (uppercase). Example: `US`. |
| `zip` | body | string | yes | Postal / ZIP code. Legacy alias: `zipcode`. Example: `78701`. |
| `email` | body | string | no | Accepted for compatibility with V1 payloads but not stored or used. Example: `buyer@example.com`. |
| `phone` | body | string | no | Recipient phone number. Example: `+1-555-0100`. |
| `fulfillmentServiceKey` | body | string | no | Provider shipping service: FIRST_CLASS (default), PRIORITY, RUSH, EXPEDITE, OVERNIGHT, CBT_STANDARD, CBT_RUSH, or CBT_EXPEDITE. Legacy alias: `methodship` integer 1–5 maps to the non-CBT values. Example: `FIRST_CLASS`. Values: `FIRST_CLASS` (Standard shipping (default).); `PRIORITY` (USPS Priority.); `RUSH` (Rush production + shipping.); `EXPEDITE` (Expedited.); `OVERNIGHT` (Overnight.); `CBT_STANDARD` (CBT standard shipping. Seller-label orders only.); `CBT_RUSH` (CBT rush production. Seller-label orders only.); `CBT_EXPEDITE` (CBT expedited production. Seller-label orders only.). |
| `sellerLabelUrl` | body | string | no | Seller-provided shipping label: an https URL, or the `originalKey` returned by the label upload (see Guides → Orders & fulfillment → Your own shipping label). Sets shippingMode=SELLER_LABEL. Legacy alias: `label`. |
| `priorityTrackingAddOnEnabled` | body | boolean | no | When true, enables the Early In-Transit Scan add-on. Legacy alias: `scanrequest` "0"/"1". Example: `false`. |
| `printAreaCount` | body | integer | no | Set to 2 for double-sided / special print. Legacy alias: `special_print` "0"/"1". Applies to the default flow only (ignored with `createProducts: true`). Example: `1`. |
| `submit` | body | boolean | no | When true, also calls POST /orders/{id}/submit after creating the draft — charges the wallet and sends to fulfillment. Default false (PENDING draft only). Example: `false`. |
| `createProducts` | body | boolean | no | Opt into the product-mint flow: a brand-new catalog product is created for each line and its print files are attached in the background (`assets: PROCESSING` → `READY`). Set to `true` to apply the per-line `printingMethod`, `sellingPrice`, `largeFormatAreas`, `hasSpecialPrintArea`, and neck-label (`NECK_LABEL_INNER` / `MOCKUP_NECK_LABEL_INNER`) fields. Required whenever a line sends `printingMethod`. Must be a JSON boolean (`true`), not the string "true". Default false. Example: `true`. |
| `products` | body | array | yes | Array of product lines (max 50). Minimum per line: `sku` + print-file URLs via designUrlsByArea. `blueprintId` is optional when sku is unique. Optional mockupFrontUrl / mockupBackUrl for order-list previews. Per-line `printingMethod`, `sellingPrice`, `largeFormatAreas`, `hasSpecialPrintArea` and neck-label areas apply when `createProducts` is true. |

**`products`**

Per-line fields (canonical names):

• sku (required) — provider variant code (e.g. "8264", "55104"). When blueprintId is omitted, the server resolves blueprint + provider from sku automatically. If the same sku exists on more than one catalog row, the API returns an error — add blueprintId and retry (this works only when createProducts is false; with createProducts: true the sku must be unique — contact support if it is not).
• blueprintId — optional when sku is unique; required to disambiguate duplicate sku rows. Ignored when createProducts is true.
• providerBlueprintId — optional; narrows sku lookup when a blueprint has multiple providers. Ignored when createProducts is true.
• quantity — 1-10000, default 1.
• productName — title for the catalog product created for this line. Legacy: `productname`.
• printingMethod — optional print method for this line, e.g. "Inkjet printing". Accepted only together with "createProducts": true (otherwise the request is rejected). Take the value from the printingMethod column of GET /api/v1/blueprints/import-reference, on the row whose variantSku equals this sku; if the column lists several methods separated by |, send exactly one of them. Matching ignores upper/lower case. When omitted, the method is resolved automatically from the sku — send it only if the API asks for a printing method for this line. Legacy: `printing_method`.
• designUrlsByArea (required — at least one area) — print files keyed by side: FRONT, BACK, SLEEVE_LEFT, SLEEVE_RIGHT, COLLAR, NECK_LABEL_INNER. Each URL must be publicly downloadable (a Google Drive link must be shared "Anyone with the link"), otherwise that line fails and the order stays PENDING. Legacy flat aliases: `areafront`, `areaback`, `lefthand`, `righthand`, `collar`.
• mockupFrontUrl / mockupBackUrl / mockupNeckInnerUrl — optional order-list preview images (legacy `mockupfront`, `mockupback`, `mockup_neck_inner`). A `mockupUrlsByArea` object is not read — use these fields.
• hasSpecialPrintArea — optional bool for double-sided / special print (per line). Legacy: `special_print`.
• sellingPrice — optional retail price for this line (number, 0.01-99999.99). Omit to use the default floor price (2x base cost), same as leaving selling_price blank in the CSV template. Legacy: `selling_price`.
• largeFormatAreas — optional array of areas printed at large / oversize format, e.g. ["FRONT","BACK","SLEEVE_LEFT","SLEEVE_RIGHT"]. Flat 0/1 aliases from the CSV template also accepted: `xl_front`, `xl_back`, `xl_left_hand`, `xl_right_hand`.

Note: printingMethod, sellingPrice, largeFormatAreas, hasSpecialPrintArea and the NECK_LABEL_INNER / MOCKUP_NECK_LABEL_INNER areas are attributes of the fresh product minted for each line — send "createProducts": true to use the product-mint flow that applies them.

```json
[
  {
    "sku": "55104",
    "quantity": 1,
    "sellingPrice": 24.99,
    "hasSpecialPrintArea": true,
    "largeFormatAreas": [
      "FRONT"
    ],
    "designUrlsByArea": {
      "FRONT": "https://cdn.example.com/front.png",
      "BACK": "https://cdn.example.com/back.png",
      "NECK_LABEL_INNER": "https://cdn.example.com/neck.png"
    },
    "mockupFrontUrl": "https://cdn.example.com/mockup-front.jpg",
    "mockupNeckInnerUrl": "https://cdn.example.com/mockup-neck.jpg"
  }
]
```

#### Example request

```bash
curl -X POST 'https://api.merchfox.com/api/v1/orders/quick' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>' \
  -H 'Content-Type: application/json' \
  -d '{"orderid":"354354354353","firstName":"Jane","lastName":"Doe","address1":"1 Main St","city":"Austin","state":"TX","country":"US","zip":"78701","email":"buyer@example.com","phone":"+1-555-0100","fulfillmentServiceKey":"FIRST_CLASS","priorityTrackingAddOnEnabled":false,"printAreaCount":1,"submit":false,"createProducts":true,"products":[{"sku":"55104","quantity":1,"sellingPrice":24.99,"hasSpecialPrintArea":true,"largeFormatAreas":["FRONT"],"designUrlsByArea":{"FRONT":"https://cdn.example.com/front.png","BACK":"https://cdn.example.com/back.png","NECK_LABEL_INNER":"https://cdn.example.com/neck.png"},"mockupFrontUrl":"https://cdn.example.com/mockup-front.jpg","mockupNeckInnerUrl":"https://cdn.example.com/mockup-neck.jpg"}]}'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.products` | []object | Default flow (`createProducts` omitted or false): the catalog product used for each line (found or created). |
| `data.order` | object | Default flow: the order — same fields as Get order. Status PENDING, or PAID when `submit` was true. |
| `data.order.id` | string | Order id (UUID). |
| `data.order.sellerId` | string | Owning seller. |
| `data.order.source` | enum | Where the order came from: MANUAL (API and portal), SHOPIFY, ETSY, WOOCOMMERCE, EBAY, TIKTOK, WALMART, … |
| `data.order.externalId` | string | Your reference (the shop `orderid` on quick orders). |
| `data.order.idempotencyKey` | string | Idempotency key the order was created with. |
| `data.order.status` | enum | PENDING (draft, not paid) \| PAID (wallet charged, sent to production) \| IN_PRODUCTION \| SHIPPED \| IN_TRANSIT \| DELIVERED \| CANCELLED \| REFUNDED. SUBMITTED, PAYMENT_PENDING and PAYMENT_FAILED exist on older orders; SUBMITTING may show for a few seconds during submit. |
| `data.order.currency` | string | Always "USD". |
| `data.order.subtotal` | number | Sum of line totals. |
| `data.order.shippingTotal` | number | Shipping. |
| `data.order.shippingSurcharge` | number | Destination surcharge, if any. |
| `data.order.handlingFee` | number | Handling fee. |
| `data.order.extraPrintFee` | number | Extra print-area fee. |
| `data.order.rushFee` | number | Rush production fee. |
| `data.order.labelFee` | number | Seller-label handling fee. |
| `data.order.priorityTrackingAddOnFee` | number | Early in-transit scan add-on fee. |
| `data.order.surgeFee` | number | Seasonal surcharges; itemised in `surgeBreakdown`. |
| `data.order.surgeBreakdown` | []object | Each surcharge: `surgePeriodId`, `label`, `amount`. Empty when none. |
| `data.order.taxEnabled` | bool | Whether tax was applied. |
| `data.order.taxRate` | number | Tax rate (0-1). |
| `data.order.taxAmount` | number | Tax. |
| `data.order.total` | number | What the wallet is charged on submit: subtotal + all fees + taxAmount. |
| `data.order.fulfillmentServiceKey` | enum | FIRST_CLASS \| PRIORITY \| RUSH \| EXPEDITE \| OVERNIGHT \| CBT_STANDARD \| CBT_RUSH \| CBT_EXPEDITE. |
| `data.order.shippingMode` | enum | PROVIDER (provider ships with its own label) \| SELLER_LABEL (you supply the label). |
| `data.order.shippingFirstName` | string | Recipient first name. The address is flat, in the fields below. |
| `data.order.shippingLastName` | string | Recipient last name. |
| `data.order.shippingAddress1` | string | Street address. |
| `data.order.shippingAddress2` | string | Apartment, suite, etc., when given. |
| `data.order.shippingCity` | string | City. |
| `data.order.shippingState` | string | State / region code. |
| `data.order.shippingZip` | string | ZIP / postal code. |
| `data.order.shippingCountry` | string | ISO 3166-1 alpha-2 country code. |
| `data.order.shippingPhone` | string | Recipient phone, when given. |
| `data.order.providerTrackingByProviderId` | object | Tracking number per provider id, once shipped. |
| `data.order.providerTrackingCarrierByProviderId` | object | Carrier per provider id, once shipped. |
| `data.order.sellerLabelTrackingNumber` | string\|null | SELLER_LABEL orders: tracking number read from or set for your label. |
| `data.order.sellerLabelCarrier` | string\|null | SELLER_LABEL orders: carrier of your label. |
| `data.order.items` | []object | Line items. |
| `data.order.items[].productId` | string | Catalog product id. |
| `data.order.items[].variantId` | string | Variant id within the product. |
| `data.order.items[].quantity` | int | Quantity. |
| `data.order.items[].unitPrice` | number | Per-unit price in USD. |
| `data.order.items[].totalPrice` | number | quantity × unitPrice. |
| `data.order.items[].providerId` | string | Print provider that makes this line. |
| `data.order.items[].variantColor` | string | Variant color, when known. |
| `data.order.items[].variantSize` | string | Variant size, when known. |
| `data.order.items[].variantSku` | string | Provider variant code, when known. |
| `data.order.events` | []object | History, oldest first (single-order responses only). |
| `data.order.events[].eventType` | string | What happened, e.g. ORDER_CREATED, STATUS_CHANGED_TO_PAID. |
| `data.order.events[].createdAt` | string | ISO-8601 time of the event. |
| `data.order.apiKeyId` | string\|null | API key the order was created through. Null for orders created in the portal, by CSV import (unless imported through that key), by marketplace sync, or before order tagging was released. |
| `data.order.apiKeyName` | string\|null | Name of that API key at the time the order was created (later renames are not reflected). |
| `data.order.createdBy` | object\|null | Who created the order. |
| `data.order.createdBy.type` | enum | API_KEY \| USER. |
| `data.order.createdBy.id` | string | API key id (type API_KEY) or user id (type USER). |
| `data.order.lastActionBy` | object\|null | Who last submitted, cancelled, edited (draft), or updated tracking on the order. |
| `data.order.lastActionBy.type` | enum | API_KEY \| USER. |
| `data.order.lastActionBy.id` | string | API key id (type API_KEY) or user id (type USER). |
| `data.order.lastActionBy.action` | enum | SUBMIT \| CANCEL \| EDIT_DRAFT \| UPDATE_TRACKING. |
| `data.order.lastActionBy.at` | string | ISO-8601 timestamp of the action. |
| `data.order.paidAt` | string\|null | When the wallet was charged. |
| `data.order.createdAt` | string | ISO-8601 creation time. |
| `data.order.updatedAt` | string | ISO-8601 time of the last change. |
| `data.submit` | object | Only when `submit` was true: `{ orderId, status: "PAID" }`, or `{ deferred: true, reason: "SELLER_LABEL_TRACKING_PENDING" }` for a seller-label order whose tracking is still being read (it submits itself later). |
| `data.assets` | enum | Only with `createProducts: true` — then `data` is the order itself (fields as Get order) plus `assets`: PROCESSING, later READY or FAILED (see Print-file status). |

#### Response: Success — default flow (HTTP 200)

Without `createProducts`. The order is in `data.order` as a PENDING draft; `data.products` holds the catalog product used for each line. Abridged.

```json
{
  "code": 0,
  "data": {
    "products": [
      {
        "id": "6650c1d2e3f4a5b6c7d8e901",
        "name": "Unisex T-Shirt — TEST-PUSH-0029"
      }
    ],
    "order": {
      "id": "019f276a-f9bb-7728-be66-cbb08f82b92e",
      "sellerId": "653f2c1a4e5d6f7890123456",
      "source": "MANUAL",
      "externalId": "TEST-PUSH-0029",
      "idempotencyKey": "TEST-PUSH-0029",
      "status": "PENDING",
      "currency": "USD",
      "subtotal": 12.5,
      "shippingTotal": 4.75,
      "handlingFee": 0.5,
      "taxAmount": 0,
      "total": 17.75,
      "fulfillmentServiceKey": "FIRST_CLASS",
      "shippingMode": "PROVIDER",
      "shippingFirstName": "Jane",
      "shippingLastName": "Doe",
      "shippingAddress1": "1 Main St",
      "shippingCity": "Austin",
      "shippingState": "TX",
      "shippingCountry": "US",
      "shippingZip": "78701",
      "items": [
        {
          "productId": "6650c1d2e3f4a5b6c7d8e901",
          "variantId": "6650c1d2e3f4a5b6c7d8ea01",
          "quantity": 1,
          "unitPrice": 12.5,
          "totalPrice": 12.5,
          "variantSku": "12101"
        }
      ],
      "apiKeyId": "6650a1b2c3d4e5f6a7b8c9d0",
      "apiKeyName": "Production integration",
      "createdAt": "2026-10-16T14:00:00.000Z",
      "updatedAt": "2026-10-16T14:00:00.000Z"
    }
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: With createProducts: true — files downloading (HTTP 200)

The order itself is in `data`, with status PENDING and assets PROCESSING. Print files attach in the background; poll GET /orders/quick-order/{id}/assets for READY/FAILED. No wallet charge on this endpoint. Abridged order object; money fields are numbers in USD.

```json
{
  "code": 0,
  "data": {
    "id": "019f276a-f9bb-7728-be66-cbb08f82b92e",
    "sellerId": "653f2c1a4e5d6f7890123456",
    "source": "MANUAL",
    "externalId": "TEST-PUSH-0029",
    "idempotencyKey": "qo:653f2c1a4e5d6f7890123456:TEST-PUSH-0029",
    "status": "PENDING",
    "assets": "PROCESSING",
    "currency": "USD",
    "subtotal": 11.95,
    "shippingTotal": 4.99,
    "shippingSurcharge": 0,
    "handlingFee": 0.5,
    "extraPrintFee": 0,
    "rushFee": 0,
    "labelFee": 0,
    "priorityTrackingAddOnFee": 0,
    "surgeFee": 0,
    "surgeBreakdown": [],
    "taxEnabled": false,
    "taxRate": 0,
    "taxAmount": 0,
    "total": 17.44,
    "fulfillmentServiceKey": "FIRST_CLASS",
    "shippingMode": "PROVIDER",
    "shippingFirstName": "Jane",
    "shippingLastName": "Doe",
    "shippingAddress1": "1 Main St",
    "shippingCity": "Austin",
    "shippingState": "TX",
    "shippingCountry": "US",
    "shippingZip": "78701",
    "items": [
      {
        "productId": "665f0a2b1c9d440012ab34cd",
        "variantId": "55104",
        "quantity": 1,
        "unitPrice": 11.95,
        "totalPrice": 11.95,
        "variantColor": "Black",
        "variantSize": "L"
      }
    ],
    "events": [
      {
        "eventType": "ORDER_CREATED",
        "createdAt": "2026-05-14T04:10:00.000Z"
      }
    ],
    "createdAt": "2026-05-14T04:10:00.000Z",
    "updatedAt": "2026-05-14T04:10:00.000Z"
  },
  "message": "Success",
  "request_id": "a1b2c3d4e5f6a7b8"
}
```

#### Response: Duplicate orderid (createProducts: true) (HTTP 409)

With `createProducts: true`, an orderid that was used before is refused. Delete the draft first to reuse it. (The default flow returns the existing order instead.)

```json
{
  "code": 2003,
  "data": null,
  "message": "orderid \"TEST-PUSH-0029\" already exists (order 019f276a-f9bb-7728-be66-cbb08f82b92e, status PENDING). Use a different orderid.",
  "request_id": "a1b2c3d4e5f6a7b9"
}
```

#### Response: Ambiguous SKU (HTTP 400)

The same sku exists on more than one catalog row and blueprintId was omitted.

```json
{
  "code": 2001,
  "data": null,
  "message": "products[0]: sku \"8264\" matches more than one catalog row — add blueprintId to disambiguate",
  "request_id": "a1b2c3d4e5f6a7c0"
}
```

#### Response: Invalid SKU (HTTP 400)

```json
{
  "code": 2001,
  "data": null,
  "message": "products[0]: No active catalog variant found for sku \"99999\"",
  "request_id": "a1b2c3d4e5f6a7c1"
}
```

#### Response: Missing orderid (HTTP 400)

```json
{
  "code": 2001,
  "data": null,
  "message": "orderid is required",
  "request_id": "a1b2c3d4e5f6a7c2"
}
```

<a id="order_quick_submit"></a>

### Create order + submit

`POST /api/v1/orders/quick/submit`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `order_quick_submit`
- Destructive: cannot be undone.

Same body as **Create order**, but the order is submitted (wallet charged, sent to production) without a separate call. Use it when the shop order is already paid and you want MerchFox to print and ship straight away.

• **Default (`createProducts` omitted or false).** The order is created and submitted in the same request: when the response comes back, `data.submit` is `{ orderId, status: "PAID" }` and the wallet has been charged. If the wallet cannot cover it the request fails with HTTP 409 (code 2003) and the order stays a PENDING draft — top up and call **Submit order**. A seller-label order whose tracking number is still being read returns `data.submit = { deferred: true, reason: "SELLER_LABEL_TRACKING_PENDING" }` and submits itself once the tracking is known. Repeating the same `orderid` returns the existing order; if that order was already paid, the repeated submit answers HTTP 409 (`Cannot submit order in status: PAID`) — it is never charged twice.

• **`createProducts: true`.** The response returns in about a second with `status: "PENDING"` and `assets: "PROCESSING"`. The wallet is charged **only after** every print file has downloaded, so you never pay for an order that cannot be produced. Poll **Print-file status**: `READY` with `submittedAt` set means it was charged and sent; `READY` with `submittedAt` null means the automatic submit failed (for example low balance) and the order is still PENDING — top up and call **Submit order**; `FAILED` means a design URL could not be downloaded and nothing was charged. A repeated `orderid` returns HTTP 409.

**Printing method.** Some product types can only be produced once a printing method (for example DTG, DTF or inkjet) is set. The method is normally resolved from the `sku` (see `defaultPrintingMethod` / `printingMethodRequired` in **List SKU reference**). If the API answers `This provider requires a decoration method (e.g. DTG, DTF). Please select a printing method for the product.`, send the request again with `"createProducts": true` and add `printingMethod` to that line, e.g. `"printingMethod": "Inkjet printing"`. The failed request created no order, so you can retry with the same `orderid`.

Common printing-method errors:
• `products[N].printingMethod requires "createProducts": true …` → add `"createProducts": true` at the top level of the body.
• `products[N]: printing_method "…" was not found … — options: …` → send one of the listed options.
• `Invalid JSON body` → check value types: `createProducts` and `submit` must be JSON booleans and `quantity` a JSON number.

**Per line** (see the `products` parameter): `sku` and `designUrlsByArea` are required; `blueprintId` (only to tell apart a `sku` that matches several catalog rows), `productId` and `providerBlueprintId` are optional and ignored when `createProducts` is true. Order-list previews go in `mockupFrontUrl` / `mockupBackUrl` / `mockupNeckInnerUrl`. Legacy V1 aliases (`firstname`, `zipcode`, `methodship`, `areafront`, …) are still accepted.

The order is tagged with the calling API key (`apiKeyId`, `apiKeyName`). A team member limited to API-key orders cannot create orders (code 1003). See Guides → Access & permissions and Guides → Orders & fulfillment.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orderid` | body | string | yes | Your shop order reference (required). It becomes the order's `externalId`. Default flow: sending it again returns the existing order (no duplicate). With `createProducts: true`: sending it again returns HTTP 409 (code 2003) — delete the draft first if you must recreate it. Example: `354354354353`. |
| `idempotencyKey` | body | string | no | System-managed — auto-filled from `orderid`. |
| `externalId` | body | string | no | System-managed — auto-filled from `orderid`. |
| `firstName` | body | string | yes | Recipient first name. Legacy alias: `firstname`. Example: `Jane`. |
| `lastName` | body | string | no | Recipient last name. Legacy alias: `lastname`. Example: `Doe`. |
| `address1` | body | string | yes | Street address line 1. Example: `1 Main St`. |
| `address2` | body | string | no | Street address line 2. |
| `city` | body | string | yes | City. Example: `Austin`. |
| `state` | body | string | no | State / province code. Example: `TX`. |
| `country` | body | string | yes | ISO 3166-1 alpha-2 country code (uppercase). Example: `US`. |
| `zip` | body | string | yes | Postal / ZIP code. Legacy alias: `zipcode`. Example: `78701`. |
| `email` | body | string | no | Accepted for compatibility with V1 payloads but not stored or used. Example: `buyer@example.com`. |
| `phone` | body | string | no | Recipient phone number. Example: `+1-555-0100`. |
| `fulfillmentServiceKey` | body | string | no | Provider shipping service: FIRST_CLASS (default), PRIORITY, RUSH, EXPEDITE, OVERNIGHT, CBT_STANDARD, CBT_RUSH, or CBT_EXPEDITE. Legacy alias: `methodship` integer 1–5 (non-CBT values only). Example: `FIRST_CLASS`. Values: `FIRST_CLASS` (Standard shipping (default).); `PRIORITY` (USPS Priority.); `RUSH` (Rush production + shipping.); `EXPEDITE` (Expedited.); `OVERNIGHT` (Overnight.); `CBT_STANDARD` (CBT standard shipping. Seller-label orders only.); `CBT_RUSH` (CBT rush production. Seller-label orders only.); `CBT_EXPEDITE` (CBT expedited production. Seller-label orders only.). |
| `sellerLabelUrl` | body | string | no | Seller-provided shipping label: an https URL, or the `originalKey` returned by the label upload (see Guides → Orders & fulfillment → Your own shipping label). Sets shippingMode=SELLER_LABEL. Legacy alias: `label`. |
| `priorityTrackingAddOnEnabled` | body | boolean | no | When true, enables the Early In-Transit Scan add-on. Legacy alias: `scanrequest` "0"/"1". Example: `false`. |
| `printAreaCount` | body | integer | no | Set to 2 for double-sided / special print. Legacy alias: `special_print` "0"/"1". Applies to the default flow only (ignored with `createProducts: true`). Example: `1`. |
| `createProducts` | body | boolean | no | Opt into the product-mint flow: a brand-new catalog product is created for each line and its print files are attached in the background before the wallet is charged. Set to `true` to apply the per-line `printingMethod`, `sellingPrice`, `largeFormatAreas`, `hasSpecialPrintArea`, and neck-label (`NECK_LABEL_INNER` / `MOCKUP_NECK_LABEL_INNER`) fields. Required whenever a line sends `printingMethod`. Must be a JSON boolean (`true`), not the string "true". Default false. Example: `true`. |
| `products` | body | array | yes | Array of product lines (max 50). Minimum per line: `sku` + print-file URLs via designUrlsByArea. `blueprintId` optional when sku is unique. Per-line `printingMethod`, `sellingPrice`, `largeFormatAreas`, `hasSpecialPrintArea` and neck-label areas apply when `createProducts` is true. |

**`products`**

Per-line fields (canonical names):

• sku (required) — provider variant code. When blueprintId is omitted, server resolves blueprint + provider from sku. If sku matches more than one catalog row, add blueprintId (this works only when createProducts is false; with createProducts: true the sku must be unique — contact support if it is not).
• blueprintId — optional disambiguation. Ignored when createProducts is true.
• providerBlueprintId — optional; narrow provider. Ignored when createProducts is true.
• quantity — 1-10000, default 1.
• productName — title for the catalog product created for this line.
• printingMethod — optional print method for this line, e.g. "Inkjet printing". Accepted only together with "createProducts": true (otherwise the request is rejected). Take the value from the printingMethod column of GET /api/v1/blueprints/import-reference, on the row whose variantSku equals this sku; if the column lists several methods separated by |, send exactly one of them. Matching ignores upper/lower case. When omitted, the method is resolved automatically from the sku — send it only if the API asks for a printing method for this line. Legacy: `printing_method`.
• designUrlsByArea (required — at least one area) — print files: FRONT, BACK, SLEEVE_LEFT, SLEEVE_RIGHT, COLLAR, NECK_LABEL_INNER. Each URL must be publicly downloadable or the line fails and the order stays PENDING (unpaid). Legacy: `areafront`, `areaback`, `lefthand`, `righthand`, `collar`.
• mockupFrontUrl / mockupBackUrl / mockupNeckInnerUrl — optional order-list preview images. A `mockupUrlsByArea` object is not read — use these fields.
• hasSpecialPrintArea — optional bool for double-sided / special print (per line). Legacy: `special_print`.
• sellingPrice — optional retail price for this line (number, 0.01-99999.99). Omit to use the default floor price (2x base cost), same as leaving selling_price blank in the CSV template. Legacy: `selling_price`.
• largeFormatAreas — optional array of areas printed at large / oversize format, e.g. ["FRONT","BACK","SLEEVE_LEFT","SLEEVE_RIGHT"]. Flat 0/1 aliases from the CSV template also accepted: `xl_front`, `xl_back`, `xl_left_hand`, `xl_right_hand`.

Note: sellingPrice, largeFormatAreas, hasSpecialPrintArea and the NECK_LABEL_INNER / MOCKUP_NECK_LABEL_INNER areas are attributes of the fresh product minted for each line — send "createProducts": true to use the product-mint flow that applies them.

```json
[
  {
    "sku": "55104",
    "quantity": 1,
    "sellingPrice": 24.99,
    "hasSpecialPrintArea": true,
    "largeFormatAreas": [
      "FRONT"
    ],
    "designUrlsByArea": {
      "FRONT": "https://cdn.example.com/front.png",
      "BACK": "https://cdn.example.com/back.png",
      "NECK_LABEL_INNER": "https://cdn.example.com/neck.png"
    },
    "mockupFrontUrl": "https://cdn.example.com/mockup-front.jpg",
    "mockupNeckInnerUrl": "https://cdn.example.com/mockup-neck.jpg"
  }
]
```

#### Example request

```bash
curl -X POST 'https://api.merchfox.com/api/v1/orders/quick/submit' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>' \
  -H 'Content-Type: application/json' \
  -d '{"orderid":"354354354353","firstName":"Jane","lastName":"Doe","address1":"1 Main St","city":"Austin","state":"TX","country":"US","zip":"78701","email":"buyer@example.com","phone":"+1-555-0100","fulfillmentServiceKey":"FIRST_CLASS","priorityTrackingAddOnEnabled":false,"printAreaCount":1,"createProducts":true,"products":[{"sku":"55104","quantity":1,"sellingPrice":24.99,"hasSpecialPrintArea":true,"largeFormatAreas":["FRONT"],"designUrlsByArea":{"FRONT":"https://cdn.example.com/front.png","BACK":"https://cdn.example.com/back.png","NECK_LABEL_INNER":"https://cdn.example.com/neck.png"},"mockupFrontUrl":"https://cdn.example.com/mockup-front.jpg","mockupNeckInnerUrl":"https://cdn.example.com/mockup-neck.jpg"}]}'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.products` | []object | Default flow (`createProducts` omitted or false): the catalog product used for each line (found or created). |
| `data.order` | object | Default flow: the order — same fields as Get order. Status PENDING, or PAID when `submit` was true. |
| `data.order.id` | string | Order id (UUID). |
| `data.order.sellerId` | string | Owning seller. |
| `data.order.source` | enum | Where the order came from: MANUAL (API and portal), SHOPIFY, ETSY, WOOCOMMERCE, EBAY, TIKTOK, WALMART, … |
| `data.order.externalId` | string | Your reference (the shop `orderid` on quick orders). |
| `data.order.idempotencyKey` | string | Idempotency key the order was created with. |
| `data.order.status` | enum | PENDING (draft, not paid) \| PAID (wallet charged, sent to production) \| IN_PRODUCTION \| SHIPPED \| IN_TRANSIT \| DELIVERED \| CANCELLED \| REFUNDED. SUBMITTED, PAYMENT_PENDING and PAYMENT_FAILED exist on older orders; SUBMITTING may show for a few seconds during submit. |
| `data.order.currency` | string | Always "USD". |
| `data.order.subtotal` | number | Sum of line totals. |
| `data.order.shippingTotal` | number | Shipping. |
| `data.order.shippingSurcharge` | number | Destination surcharge, if any. |
| `data.order.handlingFee` | number | Handling fee. |
| `data.order.extraPrintFee` | number | Extra print-area fee. |
| `data.order.rushFee` | number | Rush production fee. |
| `data.order.labelFee` | number | Seller-label handling fee. |
| `data.order.priorityTrackingAddOnFee` | number | Early in-transit scan add-on fee. |
| `data.order.surgeFee` | number | Seasonal surcharges; itemised in `surgeBreakdown`. |
| `data.order.surgeBreakdown` | []object | Each surcharge: `surgePeriodId`, `label`, `amount`. Empty when none. |
| `data.order.taxEnabled` | bool | Whether tax was applied. |
| `data.order.taxRate` | number | Tax rate (0-1). |
| `data.order.taxAmount` | number | Tax. |
| `data.order.total` | number | What the wallet is charged on submit: subtotal + all fees + taxAmount. |
| `data.order.fulfillmentServiceKey` | enum | FIRST_CLASS \| PRIORITY \| RUSH \| EXPEDITE \| OVERNIGHT \| CBT_STANDARD \| CBT_RUSH \| CBT_EXPEDITE. |
| `data.order.shippingMode` | enum | PROVIDER (provider ships with its own label) \| SELLER_LABEL (you supply the label). |
| `data.order.shippingFirstName` | string | Recipient first name. The address is flat, in the fields below. |
| `data.order.shippingLastName` | string | Recipient last name. |
| `data.order.shippingAddress1` | string | Street address. |
| `data.order.shippingAddress2` | string | Apartment, suite, etc., when given. |
| `data.order.shippingCity` | string | City. |
| `data.order.shippingState` | string | State / region code. |
| `data.order.shippingZip` | string | ZIP / postal code. |
| `data.order.shippingCountry` | string | ISO 3166-1 alpha-2 country code. |
| `data.order.shippingPhone` | string | Recipient phone, when given. |
| `data.order.providerTrackingByProviderId` | object | Tracking number per provider id, once shipped. |
| `data.order.providerTrackingCarrierByProviderId` | object | Carrier per provider id, once shipped. |
| `data.order.sellerLabelTrackingNumber` | string\|null | SELLER_LABEL orders: tracking number read from or set for your label. |
| `data.order.sellerLabelCarrier` | string\|null | SELLER_LABEL orders: carrier of your label. |
| `data.order.items` | []object | Line items. |
| `data.order.items[].productId` | string | Catalog product id. |
| `data.order.items[].variantId` | string | Variant id within the product. |
| `data.order.items[].quantity` | int | Quantity. |
| `data.order.items[].unitPrice` | number | Per-unit price in USD. |
| `data.order.items[].totalPrice` | number | quantity × unitPrice. |
| `data.order.items[].providerId` | string | Print provider that makes this line. |
| `data.order.items[].variantColor` | string | Variant color, when known. |
| `data.order.items[].variantSize` | string | Variant size, when known. |
| `data.order.items[].variantSku` | string | Provider variant code, when known. |
| `data.order.events` | []object | History, oldest first (single-order responses only). |
| `data.order.events[].eventType` | string | What happened, e.g. ORDER_CREATED, STATUS_CHANGED_TO_PAID. |
| `data.order.events[].createdAt` | string | ISO-8601 time of the event. |
| `data.order.apiKeyId` | string\|null | API key the order was created through. Null for orders created in the portal, by CSV import (unless imported through that key), by marketplace sync, or before order tagging was released. |
| `data.order.apiKeyName` | string\|null | Name of that API key at the time the order was created (later renames are not reflected). |
| `data.order.createdBy` | object\|null | Who created the order. |
| `data.order.createdBy.type` | enum | API_KEY \| USER. |
| `data.order.createdBy.id` | string | API key id (type API_KEY) or user id (type USER). |
| `data.order.lastActionBy` | object\|null | Who last submitted, cancelled, edited (draft), or updated tracking on the order. |
| `data.order.lastActionBy.type` | enum | API_KEY \| USER. |
| `data.order.lastActionBy.id` | string | API key id (type API_KEY) or user id (type USER). |
| `data.order.lastActionBy.action` | enum | SUBMIT \| CANCEL \| EDIT_DRAFT \| UPDATE_TRACKING. |
| `data.order.lastActionBy.at` | string | ISO-8601 timestamp of the action. |
| `data.order.paidAt` | string\|null | When the wallet was charged. |
| `data.order.createdAt` | string | ISO-8601 creation time. |
| `data.order.updatedAt` | string | ISO-8601 time of the last change. |
| `data.submit` | object | Only when `submit` was true: `{ orderId, status: "PAID" }`, or `{ deferred: true, reason: "SELLER_LABEL_TRACKING_PENDING" }` for a seller-label order whose tracking is still being read (it submits itself later). |
| `data.assets` | enum | Only with `createProducts: true` — then `data` is the order itself (fields as Get order) plus `assets`: PROCESSING, later READY or FAILED (see Print-file status). |

#### Response: Success — default flow (charged) (HTTP 200)

Without `createProducts`. The wallet was charged in this request: `data.submit.status` is PAID. Abridged.

```json
{
  "code": 0,
  "data": {
    "products": [
      {
        "id": "6650c1d2e3f4a5b6c7d8e901",
        "name": "Unisex T-Shirt — TEST-PUSH-0029"
      }
    ],
    "order": {
      "id": "019f276a-f9bb-7728-be66-cbb08f82b92e",
      "sellerId": "653f2c1a4e5d6f7890123456",
      "source": "MANUAL",
      "externalId": "TEST-PUSH-0029",
      "idempotencyKey": "TEST-PUSH-0029",
      "status": "PENDING",
      "currency": "USD",
      "subtotal": 12.5,
      "shippingTotal": 4.75,
      "handlingFee": 0.5,
      "taxAmount": 0,
      "total": 17.75,
      "fulfillmentServiceKey": "FIRST_CLASS",
      "shippingMode": "PROVIDER",
      "shippingFirstName": "Jane",
      "shippingLastName": "Doe",
      "shippingAddress1": "1 Main St",
      "shippingCity": "Austin",
      "shippingState": "TX",
      "shippingCountry": "US",
      "shippingZip": "78701",
      "items": [
        {
          "productId": "6650c1d2e3f4a5b6c7d8e901",
          "variantId": "6650c1d2e3f4a5b6c7d8ea01",
          "quantity": 1,
          "unitPrice": 12.5,
          "totalPrice": 12.5,
          "variantSku": "12101"
        }
      ],
      "apiKeyId": "6650a1b2c3d4e5f6a7b8c9d0",
      "apiKeyName": "Production integration",
      "createdAt": "2026-10-16T14:00:00.000Z",
      "updatedAt": "2026-10-16T14:00:00.000Z"
    },
    "submit": {
      "orderId": "019f276a-f9bb-7728-be66-cbb08f82b92e",
      "status": "PAID"
    }
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: Not enough balance (HTTP 409)

Default flow: the order was created but could not be paid. It stays a PENDING draft — top up and call Submit order.

```json
{
  "code": 2003,
  "data": null,
  "message": "Insufficient balance",
  "request_id": "b2c3d4e5f6a7b8cf"
}
```

#### Response: With createProducts: true — will submit when files are ready (HTTP 200)

Returns immediately with status PENDING and assets PROCESSING. The wallet is charged and the order sent to the provider ONLY after every print file attaches. Poll GET /orders/quick-order/{id}/assets: READY + status PAID = charged & sent; FAILED = not charged. Abridged order object.

```json
{
  "code": 0,
  "data": {
    "id": "019f276a-f9bb-7728-be66-cbb08f82b92e",
    "sellerId": "653f2c1a4e5d6f7890123456",
    "source": "MANUAL",
    "externalId": "TEST-PUSH-0029",
    "idempotencyKey": "qo:653f2c1a4e5d6f7890123456:TEST-PUSH-0029",
    "status": "PENDING",
    "assets": "PROCESSING",
    "currency": "USD",
    "subtotal": 11.95,
    "shippingTotal": 4.99,
    "shippingSurcharge": 0,
    "handlingFee": 0.5,
    "extraPrintFee": 0,
    "rushFee": 0,
    "labelFee": 0,
    "priorityTrackingAddOnFee": 0,
    "surgeFee": 0,
    "surgeBreakdown": [],
    "taxEnabled": false,
    "taxRate": 0,
    "taxAmount": 0,
    "total": 17.44,
    "fulfillmentServiceKey": "FIRST_CLASS",
    "shippingMode": "PROVIDER",
    "shippingFirstName": "Jane",
    "shippingLastName": "Doe",
    "shippingAddress1": "1 Main St",
    "shippingCity": "Austin",
    "shippingState": "TX",
    "shippingCountry": "US",
    "shippingZip": "78701",
    "items": [
      {
        "productId": "665f0a2b1c9d440012ab34cd",
        "variantId": "55104",
        "quantity": 1,
        "unitPrice": 11.95,
        "totalPrice": 11.95,
        "variantColor": "Black",
        "variantSize": "L"
      }
    ],
    "events": [
      {
        "eventType": "ORDER_CREATED",
        "createdAt": "2026-05-14T04:10:00.000Z"
      }
    ],
    "createdAt": "2026-05-14T04:10:00.000Z",
    "updatedAt": "2026-05-14T04:10:00.000Z"
  },
  "message": "Success",
  "request_id": "b2c3d4e5f6a7b8c9"
}
```

#### Response: Duplicate orderid (createProducts: true) (HTTP 409)

```json
{
  "code": 2003,
  "data": null,
  "message": "orderid \"TEST-PUSH-0029\" already exists (order 019f276a-f9bb-7728-be66-cbb08f82b92e, status PENDING). Use a different orderid.",
  "request_id": "b2c3d4e5f6a7b8d0"
}
```

#### Response: Ambiguous SKU (HTTP 400)

Same as /orders/quick — add blueprintId when sku matches multiple catalog rows.

```json
{
  "code": 2001,
  "data": null,
  "message": "products[0]: sku \"8264\" matches more than one catalog row — add blueprintId to disambiguate",
  "request_id": "b2c3d4e5f6a7b8d1"
}
```

<a id="order_quote"></a>

### Quote order

`POST /api/v1/orders/quote`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `order_quote`

Preview the total cost of an order without committing anything. Takes `items` + `shippingAddress` and returns pricing only — nothing is stored and nothing is charged. Returns the order-level amounts in USD (`subtotal`, `shippingTotal`, `handlingFee`, `extraPrintFee`, `rushFee`, `labelFee`, `priorityTrackingAddOnFee`, `surgeFee`, `taxAmount`, `total`) plus per-provider costs (`costsByProviderId`) — use this before showing checkout to the buyer.

**Surge surcharges.** `surgeFee` is the total peak-season surcharge (USD) included in the order total. `surgeBreakdown` lists it per surcharge — `[{ surgePeriodId, label, amount }]` — and its amounts add up exactly to `surgeFee`. Several surcharges can apply at once (for example a carrier surcharge and a holiday surcharge), each as its own entry. `surgeBreakdown` is an empty array when there is no surcharge, and on orders created before it existed or whose surcharge was adjusted by MerchFox support; use `surgeFee` then. Seller-label orders carry no surge surcharge.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `items` | body | array | yes | Array (max 50). Two accepted line shapes: (1) Catalog product — { productId, variantId, quantity, unitPrice, … }. (2) Design-first — { templateId, sku, quantity } where templateId is one of your saved designs (GET /mockup/templates) and sku is the size/colour within that design; blueprint, provider and price are resolved server-side, so productId/variantId/unitPrice are not needed (unitPrice is ignored if sent). quantity 1-10000. |
| `shippingAddress` | body | object | yes | Recipient address. PROVIDER mode needs full address; SELLER_LABEL mode needs only `country`. country is ISO 3166-1 alpha-2 uppercase. |
| `source` | body | string | no | MANUAL (default), SHOPIFY, ETSY, WOOCOMMERCE, EBAY, TIKTOK or WALMART. Example: `MANUAL`. |
| `fulfillmentServiceKey` | body | string | no | Provider service code (FIRST_CLASS, PRIORITY, RUSH, EXPEDITE, OVERNIGHT, CBT_STANDARD, CBT_RUSH, CBT_EXPEDITE; CBT_* for seller-label orders only). List per provider via GET /blueprints/providers/{providerId}/fulfillment-services. Example: `FIRST_CLASS`. |
| `shippingMode` | body | string | no | PROVIDER (default) — fulfillment provider ships and charges shipping. SELLER_LABEL — seller uploads a USPS label, only a labeling fee is charged. Example: `PROVIDER`. |
| `taxEnabled` | body | boolean | no | When true and taxRate is set, tax is added to the quote. |
| `taxRate` | body | number | no | Tax rate as a JSON number from 0 to 1 (e.g. 0.0825 = 8.25%). A string such as "0.0825" is rejected. Ignored unless taxEnabled. Example: `0.0825`. |

**`items`**

```json
[
  {
    "templateId": "665f0a2b1c9d440012ab34cd",
    "sku": "GILDAN-5000-BLACK-M",
    "quantity": 2
  }
]
```

**`shippingAddress`**

```json
{
  "firstName": "Jane",
  "lastName": "Doe",
  "address1": "1 Main St",
  "city": "Austin",
  "state": "TX",
  "country": "US",
  "zip": "78701",
  "phone": "+1-555-0100"
}
```

#### Example request

```bash
curl -X POST 'https://api.merchfox.com/api/v1/orders/quote' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>' \
  -H 'Content-Type: application/json' \
  -d '{"items":[{"templateId":"665f0a2b1c9d440012ab34cd","sku":"GILDAN-5000-BLACK-M","quantity":2}],"shippingAddress":{"firstName":"Jane","lastName":"Doe","address1":"1 Main St","city":"Austin","state":"TX","country":"US","zip":"78701","phone":"+1-555-0100"},"source":"MANUAL","fulfillmentServiceKey":"FIRST_CLASS","shippingMode":"PROVIDER","taxRate":0.0825}'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.subtotal` | number | Sum of line totals. |
| `data.shippingTotal` | number | Shipping. |
| `data.shippingSurcharge` | number | Destination surcharge. |
| `data.handlingFee` | number | Handling fee. |
| `data.extraPrintFee` | number | Extra print-area fee. |
| `data.rushFee` | number | Rush fee. |
| `data.labelFee` | number | Seller-label fee. |
| `data.priorityTrackingAddOnEnabled` | bool | Echo of the add-on flag. |
| `data.priorityTrackingAddOnFee` | number | Add-on fee. |
| `data.priorityTrackingAddOnChannel` | string\|null | Which pricing the add-on used: PROVIDER or SELLER_LABEL; null when the add-on is off. |
| `data.surgeFee` | number | Seasonal surcharges. |
| `data.surgeBreakdown` | []object | `surgePeriodId`, `label`, `amount` per surcharge. |
| `data.taxEnabled` | bool | Whether tax applies. |
| `data.taxRate` | number | Tax rate (0-1). |
| `data.taxAmount` | number | Tax. |
| `data.variantTaxTotal` | number | Shipping tax resolved per variant and service; already included in `taxAmount` (it applies even when `taxEnabled` is false). |
| `data.total` | number | subtotal + shippingTotal + all fees + surgeFee + taxAmount. |
| `data.shippingMode` | enum | PROVIDER \| SELLER_LABEL. |
| `data.shippingMethod` | string | Shipping method used for pricing. |
| `data.fulfillmentServiceKey` | enum | Service priced. |
| `data.shippingByProviderId` | object | Shipping per provider id. |
| `data.costsByProviderId` | object | Fee breakdown per provider id. |

#### Response: Success (HTTP 200)

Two surge surcharges apply, so `surgeBreakdown` has two entries; their amounts add up to `surgeFee`. total = subtotal + shippingTotal + handlingFee + extraPrintFee + rushFee + labelFee + priorityTrackingAddOnFee + surgeFee + taxAmount.

```json
{
  "code": 0,
  "data": {
    "subtotal": 39.98,
    "shippingTotal": 6.49,
    "shippingSurcharge": 0,
    "handlingFee": 0.5,
    "extraPrintFee": 0,
    "rushFee": 0,
    "labelFee": 0,
    "priorityTrackingAddOnEnabled": false,
    "priorityTrackingAddOnFee": 0,
    "priorityTrackingAddOnChannel": null,
    "surgeFee": 2.15,
    "surgeBreakdown": [
      {
        "surgePeriodId": "3f1c9a52-7d4e-4b8a-9c1f-2e6d5a7b8c90",
        "label": "USPS peak surcharge",
        "amount": 0.9
      },
      {
        "surgePeriodId": "8b2e4d61-0a3f-4c7b-b5d2-9e1f3a4c5d6e",
        "label": "Holiday surcharge",
        "amount": 1.25
      }
    ],
    "taxEnabled": false,
    "taxRate": 0,
    "taxAmount": 0,
    "variantTaxTotal": 0,
    "total": 49.12,
    "shippingMode": "PROVIDER",
    "shippingMethod": "STANDARD",
    "fulfillmentServiceKey": "FIRST_CLASS",
    "shippingByProviderId": {
      "prov_9": 6.49
    },
    "costsByProviderId": {
      "prov_9": {
        "shipping": 6.49,
        "handlingFee": 0.5,
        "extraPrintFee": 0,
        "rushFee": 0,
        "labelFee": 0,
        "shippingSurcharge": 0,
        "surgeFee": 2.15
      }
    }
  },
  "message": "Success",
  "request_id": "b7c8d9e0f1a2b3c4"
}
```

#### Response: Unsupported destination (HTTP 400)

```json
{
  "code": 2001,
  "data": null,
  "message": "Could not calculate shipping for this destination and fulfillment service. Verify shipping country and state match the delivery address, then try again.",
  "request_id": "b7c8d9e0f1a2b3c5"
}
```

<a id="list_orders"></a>

### List orders

`GET /api/v1/orders`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `list_orders`

Orders on your account, newest first (sorting is fixed). Combine any of the optional filters. Use `search` to find an order by shop orderid / externalId, part of the internal UUID, recipient name, or display ref (#019EF525-3657). Response: `{ total, orders }` — page with `limit` (default 50, maximum 200; a larger value is rejected with code 2001) and `skip`. List rows do not include `events`. A key with `restrict_to_own_orders` only sees, and counts, orders it created itself. See Guides → Access & permissions.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `status` | query | string | no | Lifecycle filter. One of: PENDING, PAYMENT_PENDING, PAYMENT_FAILED, PAID, SUBMITTED, IN_PRODUCTION, SHIPPED, IN_TRANSIT, DELIVERED, CANCELLED, REFUNDED. Example: `PAID`. |
| `search` | query | string | no | Free-text search (max 100 chars). Matches externalId / shop orderid, partial internal UUID, shipping name, or display ref like 019EF525-3657 (# optional). Example: `354354354353`. |
| `source` | query | string | no | Order source filter (case-insensitive): MANUAL, SHOPIFY, ETSY, WOOCOMMERCE, EBAY, TIKTOK, WALMART. Quick orders and CSV imports are MANUAL, as are orders from POST /orders sent without `source`. Example: `MANUAL`. |
| `apiKeyId` | query | string | no | Only orders created through this API key (24-character hex key id from List API keys). Orders created in the portal, by marketplace sync, or before order tagging was released have no `apiKeyId` and never match. Example: `6650a1b2c3d4e5f6a7b8c9d0`. |
| `country` | query | string | no | Ship-to country — ISO 3166-1 alpha-2 (uppercase). Example: `US`. |
| `shippingMode` | query | string | no | PROVIDER (MerchFox label) or SELLER_LABEL (seller-supplied label). Example: `PROVIDER`. Values: `PROVIDER` (Standard — MerchFox/provider shipping.); `SELLER_LABEL` (Seller uploaded their own label.). |
| `dateFrom` | query | integer | no | Created-at range start — Unix epoch milliseconds (inclusive). Example: `1735689600000`. |
| `dateTo` | query | integer | no | Created-at range end — Unix epoch milliseconds. The server includes everything up to 24 hours after this value, so pass the start of the last day you want (e.g. midnight UTC), not its end. Example: `1738367999999`. |
| `minTotal` | query | string | no | Minimum order grand total (USD decimal). Example: `20`. |
| `maxTotal` | query | string | no | Maximum order grand total (USD decimal). Example: `100`. |
| `limit` | query | integer | no | Page size. Default 50, max 200. Default: `50`. Example: `50`. |
| `skip` | query | integer | no | Pagination offset (0-based). Use with `limit` for page 2+: skip = limit × (page − 1). Default: `0`. Example: `0`. |

#### Example request

```bash
curl -X GET 'https://api.merchfox.com/api/v1/orders?limit=50&skip=0' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.total` | int | Orders matching the filters (all pages). |
| `data.orders` | []object | This page, newest first. Same fields as Get order without `events`. |
| `data.orders[].id` | string | Order id (UUID). |
| `data.orders[].sellerId` | string | Owning seller. |
| `data.orders[].source` | enum | Where the order came from: MANUAL (API and portal), SHOPIFY, ETSY, WOOCOMMERCE, EBAY, TIKTOK, WALMART, … |
| `data.orders[].externalId` | string | Your reference (the shop `orderid` on quick orders). |
| `data.orders[].idempotencyKey` | string | Idempotency key the order was created with. |
| `data.orders[].status` | enum | PENDING (draft, not paid) \| PAID (wallet charged, sent to production) \| IN_PRODUCTION \| SHIPPED \| IN_TRANSIT \| DELIVERED \| CANCELLED \| REFUNDED. SUBMITTED, PAYMENT_PENDING and PAYMENT_FAILED exist on older orders; SUBMITTING may show for a few seconds during submit. |
| `data.orders[].currency` | string | Always "USD". |
| `data.orders[].subtotal` | number | Sum of line totals. |
| `data.orders[].shippingTotal` | number | Shipping. |
| `data.orders[].shippingSurcharge` | number | Destination surcharge, if any. |
| `data.orders[].handlingFee` | number | Handling fee. |
| `data.orders[].extraPrintFee` | number | Extra print-area fee. |
| `data.orders[].rushFee` | number | Rush production fee. |
| `data.orders[].labelFee` | number | Seller-label handling fee. |
| `data.orders[].priorityTrackingAddOnFee` | number | Early in-transit scan add-on fee. |
| `data.orders[].surgeFee` | number | Seasonal surcharges; itemised in `surgeBreakdown`. |
| `data.orders[].surgeBreakdown` | []object | Each surcharge: `surgePeriodId`, `label`, `amount`. Empty when none. |
| `data.orders[].taxEnabled` | bool | Whether tax was applied. |
| `data.orders[].taxRate` | number | Tax rate (0-1). |
| `data.orders[].taxAmount` | number | Tax. |
| `data.orders[].total` | number | What the wallet is charged on submit: subtotal + all fees + taxAmount. |
| `data.orders[].fulfillmentServiceKey` | enum | FIRST_CLASS \| PRIORITY \| RUSH \| EXPEDITE \| OVERNIGHT \| CBT_STANDARD \| CBT_RUSH \| CBT_EXPEDITE. |
| `data.orders[].shippingMode` | enum | PROVIDER (provider ships with its own label) \| SELLER_LABEL (you supply the label). |
| `data.orders[].shippingFirstName` | string | Recipient first name. The address is flat, in the fields below. |
| `data.orders[].shippingLastName` | string | Recipient last name. |
| `data.orders[].shippingAddress1` | string | Street address. |
| `data.orders[].shippingAddress2` | string | Apartment, suite, etc., when given. |
| `data.orders[].shippingCity` | string | City. |
| `data.orders[].shippingState` | string | State / region code. |
| `data.orders[].shippingZip` | string | ZIP / postal code. |
| `data.orders[].shippingCountry` | string | ISO 3166-1 alpha-2 country code. |
| `data.orders[].shippingPhone` | string | Recipient phone, when given. |
| `data.orders[].providerTrackingByProviderId` | object | Tracking number per provider id, once shipped. |
| `data.orders[].providerTrackingCarrierByProviderId` | object | Carrier per provider id, once shipped. |
| `data.orders[].sellerLabelTrackingNumber` | string\|null | SELLER_LABEL orders: tracking number read from or set for your label. |
| `data.orders[].sellerLabelCarrier` | string\|null | SELLER_LABEL orders: carrier of your label. |
| `data.orders[].items` | []object | Line items. |
| `data.orders[].items[].productId` | string | Catalog product id. |
| `data.orders[].items[].variantId` | string | Variant id within the product. |
| `data.orders[].items[].quantity` | int | Quantity. |
| `data.orders[].items[].unitPrice` | number | Per-unit price in USD. |
| `data.orders[].items[].totalPrice` | number | quantity × unitPrice. |
| `data.orders[].items[].providerId` | string | Print provider that makes this line. |
| `data.orders[].items[].variantColor` | string | Variant color, when known. |
| `data.orders[].items[].variantSize` | string | Variant size, when known. |
| `data.orders[].items[].variantSku` | string | Provider variant code, when known. |
| `data.orders[].apiKeyId` | string\|null | API key the order was created through. Null for orders created in the portal, by CSV import (unless imported through that key), by marketplace sync, or before order tagging was released. |
| `data.orders[].apiKeyName` | string\|null | Name of that API key at the time the order was created (later renames are not reflected). |
| `data.orders[].createdBy` | object\|null | Who created the order. |
| `data.orders[].createdBy.type` | enum | API_KEY \| USER. |
| `data.orders[].createdBy.id` | string | API key id (type API_KEY) or user id (type USER). |
| `data.orders[].lastActionBy` | object\|null | Who last submitted, cancelled, edited (draft), or updated tracking on the order. |
| `data.orders[].lastActionBy.type` | enum | API_KEY \| USER. |
| `data.orders[].lastActionBy.id` | string | API key id (type API_KEY) or user id (type USER). |
| `data.orders[].lastActionBy.action` | enum | SUBMIT \| CANCEL \| EDIT_DRAFT \| UPDATE_TRACKING. |
| `data.orders[].lastActionBy.at` | string | ISO-8601 timestamp of the action. |
| `data.orders[].paidAt` | string\|null | When the wallet was charged. |
| `data.orders[].createdAt` | string | ISO-8601 creation time. |
| `data.orders[].updatedAt` | string | ISO-8601 time of the last change. |

#### Response: Success (HTTP 200)

Newest first. `total` is the number of orders matching the filters (for paging with `limit`/`skip`). Each order is abridged here and carries no `events` (use GET /orders/{id} for those). Money fields are numbers in USD.

```json
{
  "code": 0,
  "data": {
    "total": 2,
    "orders": [
      {
        "id": "7a2c8d9e-1f3b-4a5c-9d6e-0f8b1c2d3e4f",
        "sellerId": "653f2c1a4e5d6f7890123456",
        "source": "MANUAL",
        "externalId": "354354354353",
        "status": "PAID",
        "currency": "USD",
        "subtotal": 39.98,
        "shippingTotal": 6.49,
        "handlingFee": 0.5,
        "surgeFee": 2.15,
        "surgeBreakdown": [
          {
            "surgePeriodId": "3f1c9a52-7d4e-4b8a-9c1f-2e6d5a7b8c90",
            "label": "USPS peak surcharge",
            "amount": 0.9
          },
          {
            "surgePeriodId": "8b2e4d61-0a3f-4c7b-b5d2-9e1f3a4c5d6e",
            "label": "Holiday surcharge",
            "amount": 1.25
          }
        ],
        "taxAmount": 0,
        "total": 49.12,
        "fulfillmentServiceKey": "FIRST_CLASS",
        "shippingMode": "PROVIDER",
        "shippingCountry": "US",
        "items": [
          {
            "productId": "p_abc",
            "variantId": "v_blue_M",
            "quantity": 2,
            "unitPrice": 19.99,
            "totalPrice": 39.98
          }
        ],
        "apiKeyId": "6650a1b2c3d4e5f6a7b8c9d0",
        "apiKeyName": "Production integration",
        "createdBy": {
          "type": "API_KEY",
          "id": "6650a1b2c3d4e5f6a7b8c9d0"
        },
        "lastActionBy": {
          "type": "API_KEY",
          "id": "6650a1b2c3d4e5f6a7b8c9d0",
          "action": "SUBMIT",
          "at": "2026-05-14T04:11:30.000Z"
        },
        "paidAt": "2026-05-14T04:11:30.000Z",
        "createdAt": "2026-05-14T04:10:00.000Z",
        "updatedAt": "2026-05-14T04:11:30.000Z"
      },
      {
        "id": "b3d8c9e1-2f4b-4a6c-8d7e-1f9b2c3d4e5f",
        "sellerId": "653f2c1a4e5d6f7890123456",
        "source": "SHOPIFY",
        "externalId": "354354354352",
        "status": "SHIPPED",
        "currency": "USD",
        "subtotal": 24,
        "shippingTotal": 5.49,
        "handlingFee": 0.5,
        "surgeFee": 0,
        "surgeBreakdown": [],
        "taxAmount": 0,
        "total": 29.99,
        "fulfillmentServiceKey": "FIRST_CLASS",
        "shippingMode": "PROVIDER",
        "shippingCountry": "US",
        "providerTrackingByProviderId": {
          "prov_9": "9400111899223197428347"
        },
        "providerTrackingCarrierByProviderId": {
          "prov_9": "USPS"
        },
        "items": [
          {
            "productId": "p_def",
            "variantId": "v_black_L",
            "quantity": 1,
            "unitPrice": 24,
            "totalPrice": 24
          }
        ],
        "apiKeyId": null,
        "apiKeyName": null,
        "createdBy": null,
        "lastActionBy": null,
        "paidAt": "2026-05-13T15:01:00.000Z",
        "createdAt": "2026-05-13T15:00:00.000Z",
        "updatedAt": "2026-05-14T09:30:00.000Z"
      }
    ]
  },
  "message": "Success",
  "request_id": "a7b8c9d0e1f2a3b4"
}
```

<a id="order_stats"></a>

### Order stats

`GET /api/v1/orders/stats`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `order_stats`

Dashboard numbers for the last 7, 30 or 90 days: paid orders, revenue and average order value, plus one entry per day in `chartData`. Drafts (PENDING) are not counted; revenue leaves out cancelled and refunded orders; days are UTC days of the payment date. A key with `restrict_to_own_orders` only sees, and counts, orders it created itself. See Guides → Access & permissions.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `days` | query | integer | no | 7, 30 or 90. Any other value is treated as 30. Default: `30`. |

#### Example request

```bash
curl -X GET 'https://api.merchfox.com/api/v1/orders/stats?days=30' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.days` | int | Window used: 7, 30 or 90. |
| `data.totalOrders` | int | Paid orders in the window (drafts excluded). |
| `data.totalRevenue` | number | Their total in USD, excluding cancelled and refunded orders. |
| `data.avgOrderValue` | number | totalRevenue / orders counted. |
| `data.chartData` | []object | One entry per UTC day (by payment date). |
| `data.chartData[].date` | string | YYYY-MM-DD. |
| `data.chartData[].label` | string | Short display label. |
| `data.chartData[].revenue` | number | Revenue that day. |
| `data.chartData[].orders` | int | Orders that day. |

#### Response: Success (HTTP 200)

`chartData` has one entry per day of the window (2 shown).

```json
{
  "code": 0,
  "data": {
    "days": 7,
    "totalOrders": 42,
    "totalRevenue": 1187.32,
    "avgOrderValue": 28.27,
    "chartData": [
      {
        "date": "2026-10-15",
        "label": "Oct 15",
        "revenue": 160.4,
        "orders": 6
      },
      {
        "date": "2026-10-16",
        "label": "Oct 16",
        "revenue": 212.9,
        "orders": 8
      }
    ]
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

<a id="order_quick_assets"></a>

### Print-file status

`GET /api/v1/orders/quick-order/{id}/assets`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `order_quick_assets`

Progress of the background print-file download for an order created with `"createProducts": true` (orders created without it have no print-file job and return 404).

`assets` values:
• PROCESSING — files are still downloading. Poll every few seconds.
• READY — every print file is attached. For an order created with **Create order + submit**, check `submittedAt`: set means the order was charged and sent; null means the automatic submit failed (for example low balance) and the order is still PENDING — top up and call **Submit order**. Orders from **Create order** stay PENDING drafts until you submit them.
• FAILED — a design URL could not be downloaded (see `error`), or the job did not finish within 15 minutes. Nothing was charged. Delete the draft and create it again with fixed URLs.

The response does not include the order status — call **Get order** for that. If the calling key has `restrict_to_own_orders`, an order created any other way returns code 2002 (not found), exactly like a missing order.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | The order `id` (UUID) returned at creation. Your shop `orderid` does not work here. Example: `019f276a-f9bb-7728-be66-cbb08f82b92e`. |

#### Example request

```bash
curl -X GET 'https://api.merchfox.com/api/v1/orders/quick-order/{id}/assets' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.orderId` | string | Order id. |
| `data.externalOrderId` | string | Your shop orderid. |
| `data.assets` | enum | PROCESSING \| READY \| FAILED. Jobs not finished after 15 minutes become FAILED. |
| `data.autoSubmit` | bool | True when created with /orders/quick/submit (the order submits itself once READY). |
| `data.productIds` | string[] | Catalog products created for this order. |
| `data.error` | string\|null | Why it FAILED; null otherwise. |
| `data.submittedAt` | string\|null | Set when the auto-submit succeeded. READY with submittedAt null on an auto-submit order means the submit failed (for example low balance) and the order is still PENDING. |
| `data.finishedAt` | string\|null | When assets reached READY or FAILED. |

#### Response: Still processing (HTTP 200)

```json
{
  "code": 0,
  "data": {
    "orderId": "019f276a-f9bb-7728-be66-cbb08f82b92e",
    "externalOrderId": "TEST-PUSH-0029",
    "assets": "PROCESSING",
    "autoSubmit": false,
    "productIds": [
      "665f0a2b1c9d440012ab34cd"
    ],
    "error": null
  },
  "message": "Success",
  "request_id": "aa11bb22cc33dd44"
}
```

#### Response: Ready and submitted (HTTP 200)

```json
{
  "code": 0,
  "data": {
    "orderId": "019f276a-f9bb-7728-be66-cbb08f82b92e",
    "externalOrderId": "TEST-PUSH-0030",
    "assets": "READY",
    "autoSubmit": true,
    "productIds": [
      "665f0a2b1c9d440012ab34cd"
    ],
    "error": null,
    "submittedAt": "2026-07-03T10:00:12.480Z",
    "finishedAt": "2026-07-03T10:00:12.480Z"
  },
  "message": "Success",
  "request_id": "bb22cc33dd44ee55"
}
```

#### Response: Failed (a design URL could not be downloaded) (HTTP 200)

The order stays PENDING and unpaid. Delete the draft, fix the URL and create it again.

```json
{
  "code": 0,
  "data": {
    "orderId": "019f276a-f9bb-7728-be66-cbb08f82b92e",
    "externalOrderId": "TEST-PUSH-0031",
    "assets": "FAILED",
    "autoSubmit": true,
    "productIds": [
      "665f0a2b1c9d440012ab34cd"
    ],
    "error": "Could not fetch image for FRONT (https://drive.google.com/...): 403 Forbidden",
    "finishedAt": "2026-07-03T10:00:09.220Z"
  },
  "message": "Success",
  "request_id": "cc33dd44ee55ff66"
}
```

#### Response: Not created with createProducts (or not yours) (HTTP 404)

```json
{
  "code": 2002,
  "data": null,
  "message": "No quick-order asset state for this order",
  "request_id": "dd44ee55ff66aa77"
}
```

<a id="get_order"></a>

### Get order

`GET /api/v1/orders/{id}`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `get_order`

Full order detail including items, shipping address, tracking, pricing breakdown, and current status.

**Where tracking lives (once shipped).** MerchFox-fulfilled orders expose it as `providerTrackingByProviderId` — an object keyed by provider id, e.g. `{ "prov_9": "9400111899223" }` — with matching carriers in `providerTrackingCarrierByProviderId`. SELLER_LABEL orders (where you supplied the label) expose it as `sellerLabelTrackingNumber` (string). There is no flat top-level tracking number: read whichever of these two is populated. The order object sits directly under the response `data` (i.e. `data.providerTrackingByProviderId`), not under `data.order`.

The `{id}` path accepts any seller order reference: internal UUID, your shop `orderid` from POST /orders/quick (same value as `idempotencyKey` and `externalId`), or `externalId` alone. UI display refs like `#019EF525-3657` also work. 404 if the order does not belong to the authenticated seller (IDOR-safe — never leaks existence).

**Surge surcharges.** `surgeFee` is the total peak-season surcharge (USD) included in the order total. `surgeBreakdown` lists it per surcharge — `[{ surgePeriodId, label, amount }]` — and its amounts add up exactly to `surgeFee`. Several surcharges can apply at once (for example a carrier surcharge and a holiday surcharge), each as its own entry. `surgeBreakdown` is an empty array when there is no surcharge, and on orders created before it existed or whose surcharge was adjusted by MerchFox support; use `surgeFee` then. Seller-label orders carry no surge surcharge. If the calling key has `restrict_to_own_orders`, an order created any other way returns code 2002 (not found), exactly like a missing order.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Order reference — internal UUID, shop orderid / externalId from quick order, or UI display ref (#019EF525-3657). Example: `019f2c29-3cf7-7553-96fb-ea8b122cd509`. |

#### Example request

```bash
curl -X GET 'https://api.merchfox.com/api/v1/orders/{id}' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.id` | string | Order id (UUID). |
| `data.sellerId` | string | Owning seller. |
| `data.source` | enum | Where the order came from: MANUAL (API and portal), SHOPIFY, ETSY, WOOCOMMERCE, EBAY, TIKTOK, WALMART, … |
| `data.externalId` | string | Your reference (the shop `orderid` on quick orders). |
| `data.idempotencyKey` | string | Idempotency key the order was created with. |
| `data.status` | enum | PENDING (draft, not paid) \| PAID (wallet charged, sent to production) \| IN_PRODUCTION \| SHIPPED \| IN_TRANSIT \| DELIVERED \| CANCELLED \| REFUNDED. SUBMITTED, PAYMENT_PENDING and PAYMENT_FAILED exist on older orders; SUBMITTING may show for a few seconds during submit. |
| `data.currency` | string | Always "USD". |
| `data.subtotal` | number | Sum of line totals. |
| `data.shippingTotal` | number | Shipping. |
| `data.shippingSurcharge` | number | Destination surcharge, if any. |
| `data.handlingFee` | number | Handling fee. |
| `data.extraPrintFee` | number | Extra print-area fee. |
| `data.rushFee` | number | Rush production fee. |
| `data.labelFee` | number | Seller-label handling fee. |
| `data.priorityTrackingAddOnFee` | number | Early in-transit scan add-on fee. |
| `data.surgeFee` | number | Seasonal surcharges; itemised in `surgeBreakdown`. |
| `data.surgeBreakdown` | []object | Each surcharge: `surgePeriodId`, `label`, `amount`. Empty when none. |
| `data.taxEnabled` | bool | Whether tax was applied. |
| `data.taxRate` | number | Tax rate (0-1). |
| `data.taxAmount` | number | Tax. |
| `data.total` | number | What the wallet is charged on submit: subtotal + all fees + taxAmount. |
| `data.fulfillmentServiceKey` | enum | FIRST_CLASS \| PRIORITY \| RUSH \| EXPEDITE \| OVERNIGHT \| CBT_STANDARD \| CBT_RUSH \| CBT_EXPEDITE. |
| `data.shippingMode` | enum | PROVIDER (provider ships with its own label) \| SELLER_LABEL (you supply the label). |
| `data.shippingFirstName` | string | Recipient first name. The address is flat, in the fields below. |
| `data.shippingLastName` | string | Recipient last name. |
| `data.shippingAddress1` | string | Street address. |
| `data.shippingAddress2` | string | Apartment, suite, etc., when given. |
| `data.shippingCity` | string | City. |
| `data.shippingState` | string | State / region code. |
| `data.shippingZip` | string | ZIP / postal code. |
| `data.shippingCountry` | string | ISO 3166-1 alpha-2 country code. |
| `data.shippingPhone` | string | Recipient phone, when given. |
| `data.providerTrackingByProviderId` | object | Tracking number per provider id, once shipped. |
| `data.providerTrackingCarrierByProviderId` | object | Carrier per provider id, once shipped. |
| `data.sellerLabelTrackingNumber` | string\|null | SELLER_LABEL orders: tracking number read from or set for your label. |
| `data.sellerLabelCarrier` | string\|null | SELLER_LABEL orders: carrier of your label. |
| `data.items` | []object | Line items. |
| `data.items[].productId` | string | Catalog product id. |
| `data.items[].variantId` | string | Variant id within the product. |
| `data.items[].quantity` | int | Quantity. |
| `data.items[].unitPrice` | number | Per-unit price in USD. |
| `data.items[].totalPrice` | number | quantity × unitPrice. |
| `data.items[].providerId` | string | Print provider that makes this line. |
| `data.items[].variantColor` | string | Variant color, when known. |
| `data.items[].variantSize` | string | Variant size, when known. |
| `data.items[].variantSku` | string | Provider variant code, when known. |
| `data.events` | []object | History, oldest first (single-order responses only). |
| `data.events[].eventType` | string | What happened, e.g. ORDER_CREATED, STATUS_CHANGED_TO_PAID. |
| `data.events[].createdAt` | string | ISO-8601 time of the event. |
| `data.apiKeyId` | string\|null | API key the order was created through. Null for orders created in the portal, by CSV import (unless imported through that key), by marketplace sync, or before order tagging was released. |
| `data.apiKeyName` | string\|null | Name of that API key at the time the order was created (later renames are not reflected). |
| `data.createdBy` | object\|null | Who created the order. |
| `data.createdBy.type` | enum | API_KEY \| USER. |
| `data.createdBy.id` | string | API key id (type API_KEY) or user id (type USER). |
| `data.lastActionBy` | object\|null | Who last submitted, cancelled, edited (draft), or updated tracking on the order. |
| `data.lastActionBy.type` | enum | API_KEY \| USER. |
| `data.lastActionBy.id` | string | API key id (type API_KEY) or user id (type USER). |
| `data.lastActionBy.action` | enum | SUBMIT \| CANCEL \| EDIT_DRAFT \| UPDATE_TRACKING. |
| `data.lastActionBy.at` | string | ISO-8601 timestamp of the action. |
| `data.paidAt` | string\|null | When the wallet was charged. |
| `data.createdAt` | string | ISO-8601 creation time. |
| `data.updatedAt` | string | ISO-8601 time of the last change. |
| `data.settlementRequestOptions` | object | Which settlement request kinds you can open on this order now. |
| `data.billingSummary` | object | What was charged and refunded. |

#### Response: Success (HTTP 200)

Abridged — the order object carries more fields (design and mockup URLs per item, seller-label fields, …). Money fields are numbers in USD. `surgeBreakdown` is empty on orders without a surge surcharge.

```json
{
  "code": 0,
  "data": {
    "id": "7a2c8d9e-1f3b-4a5c-9d6e-0f8b1c2d3e4f",
    "sellerId": "653f2c1a4e5d6f7890123456",
    "source": "MANUAL",
    "externalId": "#1042",
    "idempotencyKey": "#1042",
    "status": "SHIPPED",
    "currency": "USD",
    "subtotal": 39.98,
    "shippingTotal": 6.49,
    "shippingSurcharge": 0,
    "handlingFee": 0.5,
    "extraPrintFee": 0,
    "rushFee": 0,
    "labelFee": 0,
    "priorityTrackingAddOnFee": 0,
    "surgeFee": 2.15,
    "surgeBreakdown": [
      {
        "surgePeriodId": "3f1c9a52-7d4e-4b8a-9c1f-2e6d5a7b8c90",
        "label": "USPS peak surcharge",
        "amount": 0.9
      },
      {
        "surgePeriodId": "8b2e4d61-0a3f-4c7b-b5d2-9e1f3a4c5d6e",
        "label": "Holiday surcharge",
        "amount": 1.25
      }
    ],
    "taxEnabled": false,
    "taxRate": 0,
    "taxAmount": 0,
    "total": 49.12,
    "fulfillmentServiceKey": "FIRST_CLASS",
    "shippingMode": "PROVIDER",
    "shippingFirstName": "Jane",
    "shippingLastName": "Doe",
    "shippingAddress1": "1 Main St",
    "shippingCity": "Austin",
    "shippingState": "TX",
    "shippingCountry": "US",
    "shippingZip": "78701",
    "providerTrackingByProviderId": {
      "prov_9": "9400111899223197428347"
    },
    "providerTrackingCarrierByProviderId": {
      "prov_9": "USPS"
    },
    "items": [
      {
        "productId": "p_abc",
        "variantId": "v_blue_M",
        "quantity": 2,
        "unitPrice": 19.99,
        "totalPrice": 39.98,
        "providerId": "prov_9",
        "variantColor": "Blue",
        "variantSize": "M"
      }
    ],
    "events": [
      {
        "eventType": "ORDER_CREATED",
        "createdAt": "2026-05-14T04:10:00.000Z"
      },
      {
        "eventType": "STATUS_CHANGED_TO_PAID",
        "createdAt": "2026-05-14T04:11:30.000Z"
      },
      {
        "eventType": "STATUS_CHANGED_TO_SHIPPED",
        "createdAt": "2026-05-14T16:40:00.000Z"
      }
    ],
    "apiKeyId": "6650a1b2c3d4e5f6a7b8c9d0",
    "apiKeyName": "Production integration",
    "createdBy": {
      "type": "API_KEY",
      "id": "6650a1b2c3d4e5f6a7b8c9d0"
    },
    "lastActionBy": {
      "type": "API_KEY",
      "id": "6650a1b2c3d4e5f6a7b8c9d0",
      "action": "SUBMIT",
      "at": "2026-05-14T04:11:30.000Z"
    },
    "paidAt": "2026-05-14T04:11:30.000Z",
    "createdAt": "2026-05-14T04:10:00.000Z",
    "updatedAt": "2026-05-14T16:40:00.000Z"
  },
  "message": "Success",
  "request_id": "c9d0e1f2a3b4c5d6"
}
```

#### Response: Not found / not yours (HTTP 404)

```json
{
  "code": 2002,
  "data": null,
  "message": "Order not found",
  "request_id": "c9d0e1f2a3b4c5d7"
}
```

<a id="submit_order"></a>

### Submit order

`POST /api/v1/orders/{id}/submit`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `submit_order`

Charges the wallet and sends a draft to production. Returns `{ orderId, status: "PAID" }`. `{id}` accepts the internal UUID, your shop `orderid` / `externalId`, or the display ref (#019EF525-3657) — same as **Get order**. Only PENDING (or PAYMENT_FAILED) orders can be submitted.

All refusals are HTTP 409 with code 2003 and a message saying why: wrong status, a submit already in progress, a print file that failed to download, not enough wallet balance (the order stays PENDING — top up and call again), or a seller-label order whose tracking number is not known yet (`SELLER_LABEL_TRACKING_PENDING`; it submits itself once the tracking is read).

Submits are paced per API key together with label purchases: 5 in a burst, then 30 per minute (HTTP 429, code 4002, with `Retry-After`).

For create and submit in one call, use **Create order + submit**. If the calling key has `restrict_to_own_orders`, an order created any other way returns code 2002 (not found), exactly like a missing order.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Order reference — internal UUID, shop orderid / externalId from quick order, or UI display ref (#019EF525-3657). Example: `354354354353`. |

#### Example request

```bash
curl -X POST 'https://api.merchfox.com/api/v1/orders/{id}/submit' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.orderId` | string | Order id. |
| `data.status` | enum | PAID — the wallet was charged and the order sent to production. |

#### Response: Success (HTTP 200)

```json
{
  "code": 0,
  "data": {
    "orderId": "7a2c8d9e-1f3b-4a5c-9d6e-0f8b1c2d3e4f",
    "status": "PAID"
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: Not enough balance (HTTP 409)

The order stays PENDING. Top up and submit again.

```json
{
  "code": 2003,
  "data": null,
  "message": "Insufficient balance",
  "request_id": "d0e1f2a3b4c5d6e8"
}
```

#### Response: Seller label not read yet (HTTP 409)

A seller-label order whose tracking number is still being read from the label. It submits itself once the tracking is known.

```json
{
  "code": 2003,
  "data": null,
  "message": "SELLER_LABEL_TRACKING_PENDING",
  "request_id": "d0e1f2a3b4c5d6e9"
}
```

<a id="delete_order"></a>

### Delete draft order

`DELETE /api/v1/orders/{id}`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `delete_order`
- Destructive: cannot be undone.

Permanently deletes a draft (PENDING or PAYMENT_FAILED) and frees its `orderid` for reuse. Returns `{ orderId, deleted: true }`. `{id}` accepts the internal UUID, your shop `orderid` / `externalId`, or the display ref (#019EF525-3657) — same as **Get order**. Any other status is refused with HTTP 409 (code 2003) — after payment, open a settlement request instead. If the calling key has `restrict_to_own_orders`, an order created any other way returns code 2002 (not found), exactly like a missing order.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Order reference — internal UUID, shop orderid / externalId from quick order, or UI display ref (#019EF525-3657). Example: `354354354353`. |

#### Example request

```bash
curl -X DELETE 'https://api.merchfox.com/api/v1/orders/{id}' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.orderId` | string | The deleted order id. |
| `data.deleted` | bool | Always true. |

#### Response: Success (HTTP 200)

```json
{
  "code": 0,
  "data": {
    "orderId": "7a2c8d9e-1f3b-4a5c-9d6e-0f8b1c2d3e4f",
    "deleted": true
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: Not a draft (HTTP 409)

```json
{
  "code": 2003,
  "data": null,
  "message": "Cannot delete order in status: PAID. Only draft (PENDING) or failed payment orders can be deleted.",
  "request_id": "f2a3b4c5d6e7f8a9"
}
```

<a id="patch_order_tracking"></a>

### Update tracking (seller label)

`PATCH /api/v1/orders/{id}/tracking`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `patch_order_tracking`

Sets or clears the seller-label tracking number, carrier or label on a **PENDING** seller-label draft (shippingMode = SELLER_LABEL). Returns the full updated order. `{id}` accepts the internal UUID, your shop `orderid` / `externalId`, or the display ref (#019EF525-3657) — same as **Get order**. Refused with HTTP 409 once the order is no longer PENDING, and with code 2001 for an order that is not a seller-label order or an empty body. If the calling key has `restrict_to_own_orders`, an order created any other way returns code 2002 (not found), exactly like a missing order.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Order reference — internal UUID, shop orderid / externalId from quick order, or UI display ref (#019EF525-3657). Example: `354354354353`. |
| `sellerLabelTrackingNumber` | body | string | no | New tracking number (max 120 chars). Send null to clear. Example: `9400111899223197428347`. |
| `sellerLabelCarrier` | body | string | no | Carrier name (max 60 chars). E.g. USPS, FedEx, UPS. Send null to clear. Example: `USPS`. |
| `sellerLabelUrl` | body | string | no | New label URL or S3 key (max 500 chars). https-only when passing a URL; key form when using /assets/upload-seller-label output. |

#### Example request

```bash
curl -X PATCH 'https://api.merchfox.com/api/v1/orders/{id}/tracking' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>' \
  -H 'Content-Type: application/json' \
  -d '{"sellerLabelTrackingNumber":"9400111899223197428347","sellerLabelCarrier":"USPS"}'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.id` | string | Order id (UUID). |
| `data.sellerId` | string | Owning seller. |
| `data.source` | enum | Where the order came from: MANUAL (API and portal), SHOPIFY, ETSY, WOOCOMMERCE, EBAY, TIKTOK, WALMART, … |
| `data.externalId` | string | Your reference (the shop `orderid` on quick orders). |
| `data.idempotencyKey` | string | Idempotency key the order was created with. |
| `data.status` | enum | PENDING (draft, not paid) \| PAID (wallet charged, sent to production) \| IN_PRODUCTION \| SHIPPED \| IN_TRANSIT \| DELIVERED \| CANCELLED \| REFUNDED. SUBMITTED, PAYMENT_PENDING and PAYMENT_FAILED exist on older orders; SUBMITTING may show for a few seconds during submit. |
| `data.currency` | string | Always "USD". |
| `data.subtotal` | number | Sum of line totals. |
| `data.shippingTotal` | number | Shipping. |
| `data.shippingSurcharge` | number | Destination surcharge, if any. |
| `data.handlingFee` | number | Handling fee. |
| `data.extraPrintFee` | number | Extra print-area fee. |
| `data.rushFee` | number | Rush production fee. |
| `data.labelFee` | number | Seller-label handling fee. |
| `data.priorityTrackingAddOnFee` | number | Early in-transit scan add-on fee. |
| `data.surgeFee` | number | Seasonal surcharges; itemised in `surgeBreakdown`. |
| `data.surgeBreakdown` | []object | Each surcharge: `surgePeriodId`, `label`, `amount`. Empty when none. |
| `data.taxEnabled` | bool | Whether tax was applied. |
| `data.taxRate` | number | Tax rate (0-1). |
| `data.taxAmount` | number | Tax. |
| `data.total` | number | What the wallet is charged on submit: subtotal + all fees + taxAmount. |
| `data.fulfillmentServiceKey` | enum | FIRST_CLASS \| PRIORITY \| RUSH \| EXPEDITE \| OVERNIGHT \| CBT_STANDARD \| CBT_RUSH \| CBT_EXPEDITE. |
| `data.shippingMode` | enum | PROVIDER (provider ships with its own label) \| SELLER_LABEL (you supply the label). |
| `data.shippingFirstName` | string | Recipient first name. The address is flat, in the fields below. |
| `data.shippingLastName` | string | Recipient last name. |
| `data.shippingAddress1` | string | Street address. |
| `data.shippingAddress2` | string | Apartment, suite, etc., when given. |
| `data.shippingCity` | string | City. |
| `data.shippingState` | string | State / region code. |
| `data.shippingZip` | string | ZIP / postal code. |
| `data.shippingCountry` | string | ISO 3166-1 alpha-2 country code. |
| `data.shippingPhone` | string | Recipient phone, when given. |
| `data.providerTrackingByProviderId` | object | Tracking number per provider id, once shipped. |
| `data.providerTrackingCarrierByProviderId` | object | Carrier per provider id, once shipped. |
| `data.sellerLabelTrackingNumber` | string\|null | SELLER_LABEL orders: tracking number read from or set for your label. |
| `data.sellerLabelCarrier` | string\|null | SELLER_LABEL orders: carrier of your label. |
| `data.items` | []object | Line items. |
| `data.items[].productId` | string | Catalog product id. |
| `data.items[].variantId` | string | Variant id within the product. |
| `data.items[].quantity` | int | Quantity. |
| `data.items[].unitPrice` | number | Per-unit price in USD. |
| `data.items[].totalPrice` | number | quantity × unitPrice. |
| `data.items[].providerId` | string | Print provider that makes this line. |
| `data.items[].variantColor` | string | Variant color, when known. |
| `data.items[].variantSize` | string | Variant size, when known. |
| `data.items[].variantSku` | string | Provider variant code, when known. |
| `data.events` | []object | History, oldest first (single-order responses only). |
| `data.events[].eventType` | string | What happened, e.g. ORDER_CREATED, STATUS_CHANGED_TO_PAID. |
| `data.events[].createdAt` | string | ISO-8601 time of the event. |
| `data.apiKeyId` | string\|null | API key the order was created through. Null for orders created in the portal, by CSV import (unless imported through that key), by marketplace sync, or before order tagging was released. |
| `data.apiKeyName` | string\|null | Name of that API key at the time the order was created (later renames are not reflected). |
| `data.createdBy` | object\|null | Who created the order. |
| `data.createdBy.type` | enum | API_KEY \| USER. |
| `data.createdBy.id` | string | API key id (type API_KEY) or user id (type USER). |
| `data.lastActionBy` | object\|null | Who last submitted, cancelled, edited (draft), or updated tracking on the order. |
| `data.lastActionBy.type` | enum | API_KEY \| USER. |
| `data.lastActionBy.id` | string | API key id (type API_KEY) or user id (type USER). |
| `data.lastActionBy.action` | enum | SUBMIT \| CANCEL \| EDIT_DRAFT \| UPDATE_TRACKING. |
| `data.lastActionBy.at` | string | ISO-8601 timestamp of the action. |
| `data.paidAt` | string\|null | When the wallet was charged. |
| `data.createdAt` | string | ISO-8601 creation time. |
| `data.updatedAt` | string | ISO-8601 time of the last change. |

#### Response: Success (HTTP 200)

Returns the whole updated order (abridged here).

```json
{
  "code": 0,
  "data": {
    "id": "7a2c8d9e-1f3b-4a5c-9d6e-0f8b1c2d3e4f",
    "status": "PENDING",
    "shippingMode": "SELLER_LABEL",
    "sellerLabelTrackingNumber": "9400111899223197428347",
    "sellerLabelCarrier": "USPS",
    "total": 14.9,
    "updatedAt": "2026-05-14T04:12:00.000Z"
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: No longer a draft (HTTP 409)

```json
{
  "code": 2003,
  "data": null,
  "message": "Draft updates (tracking or seller label) are only allowed while the order is pending. Submit or delete the order to start over.",
  "request_id": "a3b4c5d6e7f8a9b1"
}
```

<a id="create_settlement_request"></a>

### Create settlement request

`POST /api/v1/orders/{id}/settlement-requests`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `create_settlement_request`

Ask MerchFox to cancel, refund, reprint or investigate a paid order. A person reviews it; the wallet is credited only if it is approved. Returns the request with HTTP 201. `{id}` accepts the internal UUID, your shop `orderid` / `externalId`, or the display ref (#019EF525-3657) — same as **Get order**.

**`kind` must match the order's status** (otherwise HTTP 409):

| Order status | kind | Allowed reasonCode |
| --- | --- | --- |
| PAID / SUBMITTED | PRE_PRODUCTION_CANCEL | CHANGED_MIND, WRONG_ADDRESS, DUPLICATE_ORDER, OTHER |
| IN_PRODUCTION | IN_PRODUCTION_CANCEL | QUALITY_CONCERN, DELAY, OTHER |
| SHIPPED / IN_TRANSIT | POST_SHIP_REQUEST (+ `postShipSubkind`) | LOST_PACKAGE, DAMAGED_IN_TRANSIT, WRONG_ITEM, OTHER |
| DELIVERED | DELIVERED_CLAIM (+ at least one proof image) | DEFECTIVE_PRINT, DAMAGED_DELIVERY, WRONG_SIZE_OR_COLOR, NOT_AS_DESCRIBED, OTHER |

A reasonCode outside the kind's list is refused with code 2001. Only one open request per order; drafts, cancelled and refunded orders cannot have one (HTTP 409). If the calling key has `restrict_to_own_orders`, an order created any other way returns code 2002 (not found), exactly like a missing order.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Order reference — internal UUID, shop orderid / externalId from quick order, or UI display ref (#019EF525-3657). Example: `354354354353`. |
| `kind` | body | string | yes | PRE_PRODUCTION_CANCEL (pre-print) · IN_PRODUCTION_CANCEL (during printing) · POST_SHIP_REQUEST (after ship) · DELIVERED_CLAIM (after delivery). Example: `POST_SHIP_REQUEST`. |
| `reasonCode` | body | string | yes | Must be one of the codes allowed for the chosen `kind` (see the table in the description). Example: `DAMAGED_IN_TRANSIT`. |
| `sellerNotes` | body | string | yes | Explanation shown to the admin reviewer (max 4000 chars). The clearer this is, the faster approval typically lands. Example: `Buyer reported damaged box on arrival; replacement requested.`. |
| `postShipSubkind` | body | string | no | Required when kind = POST_SHIP_REQUEST. One of CANCELLATION or RETURN. Example: `RETURN`. |
| `proofImageUrls` | body | array | no | JSON array of https URLs (max 10, each ≤ 500 chars). Required for DELIVERED_CLAIM (min 1); optional otherwise. SSRF-safe — only https origins. |
| `requestedResolution` | body | string | no | Suggested resolution. One of REPRINT, REFUND_LINE, REFUND_FULL, PARTIAL_REFUND, NO_PAYOUT. Admin may override. Example: `REFUND_FULL`. |

**`proofImageUrls`**

```json
[
  "https://cdn.example.com/proof1.jpg"
]
```

#### Example request

```bash
curl -X POST 'https://api.merchfox.com/api/v1/orders/{id}/settlement-requests' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>' \
  -H 'Content-Type: application/json' \
  -d '{"kind":"POST_SHIP_REQUEST","reasonCode":"DAMAGED_IN_TRANSIT","sellerNotes":"Buyer reported damaged box on arrival; replacement requested.","postShipSubkind":"RETURN","proofImageUrls":["https://cdn.example.com/proof1.jpg"],"requestedResolution":"REFUND_FULL"}'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.id` | string | Settlement request id. |
| `data.orderId` | string | The order. |
| `data.sellerId` | string | Your seller id. |
| `data.kind` | enum | PRE_PRODUCTION_CANCEL \| IN_PRODUCTION_CANCEL \| POST_SHIP_REQUEST \| DELIVERED_CLAIM (PROVIDER_CANCELLED is opened by MerchFox). |
| `data.status` | enum | PENDING_ADMIN \| APPROVED \| REJECTED. |
| `data.postShipSubkind` | enum\|null | CANCELLATION \| RETURN when kind = POST_SHIP_REQUEST. |
| `data.reasonCode` | enum | The reason you gave. |
| `data.sellerNotes` | string\|null | Your notes. |
| `data.proofImageUrls` | string[] | Your proof images. |
| `data.requestedResolution` | enum\|null | What you asked for. |
| `data.slaDueAt` | string\|null | Review deadline, when one applies. |
| `data.refundAmount` | number\|null | USD refunded to the wallet once approved. |
| `data.adminNotes` | string\|null | MerchFox notes once reviewed. |
| `data.resolution` | enum\|null | REPRINT \| REFUND_LINE \| REFUND_FULL \| PARTIAL_REFUND \| NO_PAYOUT once reviewed. |
| `data.reviewedAt` | string\|null | When it was reviewed. |
| `data.createdAt` | string | ISO-8601 creation time. |
| `data.updatedAt` | string | ISO-8601 time of the last change. |

#### Response: Created (HTTP 201)

```json
{
  "code": 0,
  "data": {
    "id": "0193a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
    "orderId": "7a2c8d9e-1f3b-4a5c-9d6e-0f8b1c2d3e4f",
    "sellerId": "653f2c1a4e5d6f7890123456",
    "kind": "POST_SHIP_REQUEST",
    "status": "PENDING_ADMIN",
    "postShipSubkind": "RETURN",
    "reasonCode": "DAMAGED_IN_TRANSIT",
    "sellerNotes": "Box arrived crushed; buyer sent photos.",
    "proofImageUrls": [
      "https://cdn.example.com/claims/box-1.jpg"
    ],
    "requestedResolution": "REFUND_FULL",
    "slaDueAt": null,
    "refundAmount": null,
    "adminNotes": null,
    "resolution": null,
    "reviewedAt": null,
    "createdAt": "2026-05-14T04:20:00.000Z",
    "updatedAt": "2026-05-14T04:20:00.000Z"
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: Wrong kind for the order status (HTTP 409)

Here kind POST_SHIP_REQUEST was sent for an order that is not shipped yet.

```json
{
  "code": 2003,
  "data": null,
  "message": "Post-ship cancellation or return requests are only allowed after the order is Shipped (Shipped or In Transit).",
  "request_id": "b4c5d6e7f8a9b0c2"
}
```

#### Response: Reason not allowed for this kind (HTTP 400)

```json
{
  "code": 2001,
  "data": null,
  "message": "reasonCode is not valid for this settlement kind",
  "request_id": "b4c5d6e7f8a9b0c3"
}
```

<a id="list_settlement_requests"></a>

### List settlement requests

`GET /api/v1/orders/{id}/settlement-requests`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `list_settlement_requests`

All settlement requests on an order, as a plain array — including any opened by MerchFox when the print provider cancelled the order (`kind: PROVIDER_CANCELLED`). `{id}` accepts the internal UUID, your shop `orderid` / `externalId`, or the display ref (#019EF525-3657) — same as **Get order**. `status` is PENDING_ADMIN until reviewed, then APPROVED or REJECTED with `resolution`, `refundAmount` and `adminNotes`. If the calling key has `restrict_to_own_orders`, an order created any other way returns code 2002 (not found), exactly like a missing order.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Order reference — internal UUID, shop orderid / externalId from quick order, or UI display ref (#019EF525-3657). Example: `354354354353`. |

#### Example request

```bash
curl -X GET 'https://api.merchfox.com/api/v1/orders/{id}/settlement-requests' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | []object | A JSON array; each element has the fields below. |
| `data[].id` | string | Settlement request id. |
| `data[].orderId` | string | The order. |
| `data[].sellerId` | string | Your seller id. |
| `data[].kind` | enum | PRE_PRODUCTION_CANCEL \| IN_PRODUCTION_CANCEL \| POST_SHIP_REQUEST \| DELIVERED_CLAIM (PROVIDER_CANCELLED is opened by MerchFox). |
| `data[].status` | enum | PENDING_ADMIN \| APPROVED \| REJECTED. |
| `data[].postShipSubkind` | enum\|null | CANCELLATION \| RETURN when kind = POST_SHIP_REQUEST. |
| `data[].reasonCode` | enum | The reason you gave. |
| `data[].sellerNotes` | string\|null | Your notes. |
| `data[].proofImageUrls` | string[] | Your proof images. |
| `data[].requestedResolution` | enum\|null | What you asked for. |
| `data[].slaDueAt` | string\|null | Review deadline, when one applies. |
| `data[].refundAmount` | number\|null | USD refunded to the wallet once approved. |
| `data[].adminNotes` | string\|null | MerchFox notes once reviewed. |
| `data[].resolution` | enum\|null | REPRINT \| REFUND_LINE \| REFUND_FULL \| PARTIAL_REFUND \| NO_PAYOUT once reviewed. |
| `data[].reviewedAt` | string\|null | When it was reviewed. |
| `data[].createdAt` | string | ISO-8601 creation time. |
| `data[].updatedAt` | string | ISO-8601 time of the last change. |

#### Response: Success (HTTP 200)

A plain array.

```json
{
  "code": 0,
  "data": [
    {
      "id": "0193a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
      "orderId": "7a2c8d9e-1f3b-4a5c-9d6e-0f8b1c2d3e4f",
      "sellerId": "653f2c1a4e5d6f7890123456",
      "kind": "POST_SHIP_REQUEST",
      "status": "APPROVED",
      "postShipSubkind": "RETURN",
      "reasonCode": "DAMAGED_IN_TRANSIT",
      "sellerNotes": "Box arrived crushed; buyer sent photos.",
      "proofImageUrls": [
        "https://cdn.example.com/claims/box-1.jpg"
      ],
      "requestedResolution": "REFUND_FULL",
      "slaDueAt": null,
      "refundAmount": 17.75,
      "adminNotes": "Photos verified; full refund issued.",
      "resolution": "REFUND_FULL",
      "reviewedAt": "2026-05-14T05:05:00.000Z",
      "createdAt": "2026-05-14T04:20:00.000Z",
      "updatedAt": "2026-05-14T05:05:00.000Z"
    }
  ],
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

## Fulfillment

<a id="list_order_fulfillments"></a>

### List order fulfillments

`GET /api/v1/fulfillment/order/{orderId}`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `list_order_fulfillments`

The fulfillments of one order, as a plain array — one per print provider handling part of it. Each has its own status, tracking and items. `data` is null while the order has no fulfillment yet (drafts, or right after payment). 404 when the order is not yours. If the calling key has `restrict_to_own_orders`, an order created any other way returns code 2002 (not found).

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orderId` | path | string | yes | Order id (UUID). |

#### Example request

```bash
curl -X GET 'https://api.merchfox.com/api/v1/fulfillment/order/{orderId}' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | []object | A JSON array; each element has the fields below. |
| `data[].id` | string | Fulfillment id (24-character hex). |
| `data[].orderId` | string | The order. |
| `data[].providerId` | string | Print provider handling this part of the order. |
| `data[].providerSlug` | string | Short provider name. |
| `data[].status` | enum | PENDING \| SUBMITTED \| CONFIRMED \| IN_PRODUCTION \| QUALITY_CHECK \| SHIPPED \| IN_TRANSIT \| DELIVERED \| CANCELLATION_REQUESTED \| FAILED \| CANCELLED. |
| `data[].shippingMode` | enum | PROVIDER \| SELLER_LABEL. |
| `data[].externalFulfillmentId` | string\|null | The provider's reference, once submitted. |
| `data[].providerExternalOrderIds` | string[] | All provider-side order references. |
| `data[].trackingNumber` | string\|null | Tracking number once shipped. |
| `data[].carrier` | string\|null | Carrier once shipped. |
| `data[].trackingUrl` | string\|null | Carrier tracking page, when known. |
| `data[].retryCount` | int | Times MerchFox re-submitted it (at most 3). |
| `data[].items` | []object | Items in this fulfillment. |
| `data[].items[].id` | string | Fulfillment item id. |
| `data[].items[].orderItemId` | string | The order line this item came from. |
| `data[].items[].variantId` | string | Variant id. |
| `data[].items[].quantity` | int | Quantity. |
| `data[].items[].printSide` | string | Print area of this item (e.g. FRONT, BACK). |
| `data[].items[].designFrontUrl` | string\|null | Front print file. |
| `data[].items[].designBackUrl` | string\|null | Back print file. |
| `data[].items[].sellerMockupUrl` | string\|null | Your mockup for this print side. |
| `data[].items[].printFileUrl` | string\|null | Print-ready file sent to the provider, once generated. |
| `data[].items[].printFileStatus` | enum | PENDING \| READY \| FAILED. |
| `data[].transitions` | []object | Status history. |
| `data[].transitions[].id` | string | Transition id. |
| `data[].transitions[].fromStatus` | string | Previous status. |
| `data[].transitions[].toStatus` | string | New status. |
| `data[].transitions[].createdAt` | string | When it changed. |
| `data[].submittedAt` | string\|null | When it was sent to the provider. |
| `data[].shippedAt` | string\|null | When it shipped. |
| `data[].deliveredAt` | string\|null | When it was delivered. |
| `data[].createdAt` | string | ISO-8601 creation time. |
| `data[].updatedAt` | string | ISO-8601 time of the last change. |

#### Response: Success (HTTP 200)

A plain array — one entry per provider handling part of the order.

```json
{
  "code": 0,
  "data": [
    {
      "id": "6710f5a9a1b2c3d4e5f60301",
      "orderId": "7a2c8d9e-1f3b-4a5c-9d6e-0f8b1c2d3e4f",
      "providerId": "6650a1b2c3d4e5f6a7b8c9d1",
      "providerSlug": "merchfox-us",
      "status": "SHIPPED",
      "shippingMode": "PROVIDER",
      "externalFulfillmentId": "MF-US-884213",
      "providerExternalOrderIds": [
        "MF-US-884213"
      ],
      "trackingNumber": "9400111899223197428347",
      "carrier": "USPS",
      "trackingUrl": null,
      "retryCount": 0,
      "items": [
        {
          "id": "6710f5a9a1b2c3d4e5f60311",
          "orderItemId": "6710f5a9a1b2c3d4e5f60211",
          "variantId": "6650c1d2e3f4a5b6c7d8ea01",
          "quantity": 1,
          "printSide": "FRONT",
          "designFrontUrl": "https://cdn.example.com/designs/front.png",
          "designBackUrl": null,
          "sellerMockupUrl": "https://cdn.example.com/mockups/front.jpg",
          "printFileUrl": "https://cdn.example.com/print/6710f5a9-front.png",
          "printFileStatus": "READY"
        }
      ],
      "transitions": [
        {
          "id": "6710f5aaa1b2c3d4e5f60401",
          "fromStatus": "PENDING",
          "toStatus": "SUBMITTED",
          "createdAt": "2026-10-16T14:05:00.000Z"
        },
        {
          "id": "6711a0c1a1b2c3d4e5f60402",
          "fromStatus": "SUBMITTED",
          "toStatus": "SHIPPED",
          "createdAt": "2026-10-18T09:30:00.000Z"
        }
      ],
      "submittedAt": "2026-10-16T14:05:00.000Z",
      "shippedAt": "2026-10-18T09:30:00.000Z",
      "deliveredAt": null,
      "createdAt": "2026-10-16T14:04:58.000Z",
      "updatedAt": "2026-10-18T09:30:00.000Z"
    }
  ],
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: No fulfillment yet (HTTP 200)

Drafts, or an order that was just paid.

```json
{
  "code": 0,
  "data": null,
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: Order not found (HTTP 404)

```json
{
  "code": 2002,
  "data": null,
  "message": "Fulfillment not found",
  "request_id": "fa1b2c3d4e5f7101"
}
```

<a id="get_fulfillment"></a>

### Get fulfillment

`GET /api/v1/fulfillment/{id}`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `get_fulfillment`

One fulfillment: provider, status, tracking, items with their print files, and the status history in `transitions`. 404 when it is not yours. Retrying or refreshing a fulfillment is done by MerchFox staff, not through the API — contact support if one is stuck in FAILED.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Fulfillment id (24-character hex) from List order fulfillments. |

#### Example request

```bash
curl -X GET 'https://api.merchfox.com/api/v1/fulfillment/{id}' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.id` | string | Fulfillment id (24-character hex). |
| `data.orderId` | string | The order. |
| `data.providerId` | string | Print provider handling this part of the order. |
| `data.providerSlug` | string | Short provider name. |
| `data.status` | enum | PENDING \| SUBMITTED \| CONFIRMED \| IN_PRODUCTION \| QUALITY_CHECK \| SHIPPED \| IN_TRANSIT \| DELIVERED \| CANCELLATION_REQUESTED \| FAILED \| CANCELLED. |
| `data.shippingMode` | enum | PROVIDER \| SELLER_LABEL. |
| `data.externalFulfillmentId` | string\|null | The provider's reference, once submitted. |
| `data.providerExternalOrderIds` | string[] | All provider-side order references. |
| `data.trackingNumber` | string\|null | Tracking number once shipped. |
| `data.carrier` | string\|null | Carrier once shipped. |
| `data.trackingUrl` | string\|null | Carrier tracking page, when known. |
| `data.retryCount` | int | Times MerchFox re-submitted it (at most 3). |
| `data.items` | []object | Items in this fulfillment. |
| `data.items[].id` | string | Fulfillment item id. |
| `data.items[].orderItemId` | string | The order line this item came from. |
| `data.items[].variantId` | string | Variant id. |
| `data.items[].quantity` | int | Quantity. |
| `data.items[].printSide` | string | Print area of this item (e.g. FRONT, BACK). |
| `data.items[].designFrontUrl` | string\|null | Front print file. |
| `data.items[].designBackUrl` | string\|null | Back print file. |
| `data.items[].sellerMockupUrl` | string\|null | Your mockup for this print side. |
| `data.items[].printFileUrl` | string\|null | Print-ready file sent to the provider, once generated. |
| `data.items[].printFileStatus` | enum | PENDING \| READY \| FAILED. |
| `data.transitions` | []object | Status history. |
| `data.transitions[].id` | string | Transition id. |
| `data.transitions[].fromStatus` | string | Previous status. |
| `data.transitions[].toStatus` | string | New status. |
| `data.transitions[].createdAt` | string | When it changed. |
| `data.submittedAt` | string\|null | When it was sent to the provider. |
| `data.shippedAt` | string\|null | When it shipped. |
| `data.deliveredAt` | string\|null | When it was delivered. |
| `data.createdAt` | string | ISO-8601 creation time. |
| `data.updatedAt` | string | ISO-8601 time of the last change. |

#### Response: Success (HTTP 200)

```json
{
  "code": 0,
  "data": {
    "id": "6710f5a9a1b2c3d4e5f60301",
    "orderId": "7a2c8d9e-1f3b-4a5c-9d6e-0f8b1c2d3e4f",
    "providerId": "6650a1b2c3d4e5f6a7b8c9d1",
    "providerSlug": "merchfox-us",
    "status": "SHIPPED",
    "shippingMode": "PROVIDER",
    "externalFulfillmentId": "MF-US-884213",
    "providerExternalOrderIds": [
      "MF-US-884213"
    ],
    "trackingNumber": "9400111899223197428347",
    "carrier": "USPS",
    "trackingUrl": null,
    "retryCount": 0,
    "items": [
      {
        "id": "6710f5a9a1b2c3d4e5f60311",
        "orderItemId": "6710f5a9a1b2c3d4e5f60211",
        "variantId": "6650c1d2e3f4a5b6c7d8ea01",
        "quantity": 1,
        "printSide": "FRONT",
        "designFrontUrl": "https://cdn.example.com/designs/front.png",
        "designBackUrl": null,
        "sellerMockupUrl": "https://cdn.example.com/mockups/front.jpg",
        "printFileUrl": "https://cdn.example.com/print/6710f5a9-front.png",
        "printFileStatus": "READY"
      }
    ],
    "transitions": [
      {
        "id": "6710f5aaa1b2c3d4e5f60401",
        "fromStatus": "PENDING",
        "toStatus": "SUBMITTED",
        "createdAt": "2026-10-16T14:05:00.000Z"
      },
      {
        "id": "6711a0c1a1b2c3d4e5f60402",
        "fromStatus": "SUBMITTED",
        "toStatus": "SHIPPED",
        "createdAt": "2026-10-18T09:30:00.000Z"
      }
    ],
    "submittedAt": "2026-10-16T14:05:00.000Z",
    "shippedAt": "2026-10-18T09:30:00.000Z",
    "deliveredAt": null,
    "createdAt": "2026-10-16T14:04:58.000Z",
    "updatedAt": "2026-10-18T09:30:00.000Z"
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: Not found (HTTP 404)

```json
{
  "code": 2002,
  "data": null,
  "message": "Fulfillment not found",
  "request_id": "fa1b2c3d4e5f7102"
}
```

## Wallet

<a id="wallet_get"></a>

### Get wallet

`GET /api/v1/wallet`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `wallet_get`

Your wallet balance. `balance` is what you can spend right now; `heldAmount` is money set aside as refundable shipping-label deposits and is already excluded from `balance`. The wallet is created on first use, so this never returns 404. Amounts are USD.

#### Parameters

No parameters.

#### Example request

```bash
curl -X GET 'https://api.merchfox.com/api/v1/wallet' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data._id` | string | Wallet id. |
| `data.sellerId` | string | Your seller id. |
| `data.balance` | number | Spendable USD balance. Label deposits are already taken out. |
| `data.heldAmount` | number | USD held as refundable shipping-label deposits (not spendable, not part of balance). |
| `data.allowNegativeBalance` | bool | True when MerchFox has given this wallet an overdraft. |
| `data.negativeBalanceLimit` | number | How far below zero an overdraft wallet may go. |
| `data.frozen` | bool | True while MerchFox has frozen the wallet; nothing can be paid from it. |
| `data.lifetimeTopUps` | number | Total ever topped up. |
| `data.lifetimeOrderPayments` | number | Total ever paid for orders. |
| `data.lifetimeRefunds` | number | Total ever refunded to the wallet. |
| `data.lifetimeAdjustmentsNet` | number | Net of all manual adjustments. |
| `data.transactionCount` | int | Number of ledger rows. |
| `data.createdAt` | string | ISO-8601 creation time. |
| `data.updatedAt` | string | ISO-8601 time of the last change. |

#### Response: Success (HTTP 200)

```json
{
  "code": 0,
  "data": {
    "_id": "6710f0c2a1b2c3d4e5f60001",
    "sellerId": "018f2a1b-4c5d-7e6f-8a9b-0c1d2e3f4a5b",
    "balance": 1240.55,
    "heldAmount": 4.84,
    "allowNegativeBalance": false,
    "negativeBalanceLimit": 0,
    "frozen": false,
    "lifetimeTopUps": 5000,
    "lifetimeOrderPayments": 3712.61,
    "lifetimeRefunds": 18.5,
    "lifetimeAdjustmentsNet": 0,
    "transactionCount": 214,
    "createdAt": "2026-03-02T15:04:11.000Z",
    "updatedAt": "2026-10-16T09:12:40.000Z"
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: Team member without wallet access (HTTP 403)

A signed-in team member who lacks the wallet permission. API keys act as the owner and never get this.

```json
{
  "code": 1003,
  "data": null,
  "message": "Required permission: seller_wallet:read",
  "request_id": "7c1e2d3f4a5b6c70"
}
```

<a id="wallet_summary"></a>

### Wallet summary

`GET /api/v1/wallet/summary`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `wallet_summary`

Lifetime totals for the wallet — top-ups, order payments, refunds and net adjustments — plus the amount and number of top-up requests still waiting to complete. Takes no parameters. Limited to 45 calls per minute per seller (HTTP 429 beyond that).

#### Parameters

No parameters.

#### Example request

```bash
curl -X GET 'https://api.merchfox.com/api/v1/wallet/summary' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.currency` | string | Always "USD". |
| `data.lifetimeTopUps` | number | Total ever topped up. |
| `data.lifetimeOrderPayments` | number | Total ever paid for orders. |
| `data.lifetimeRefunds` | number | Total ever refunded. |
| `data.lifetimeAdjustmentsNet` | number | Net of all manual adjustments. |
| `data.pendingTopUpRequestsAmount` | number | Sum of top-up requests not finished yet (PENDING, PROOF_SUBMITTED or PROCESSING). |
| `data.pendingTopUpRequestsCount` | int | Number of those requests. |
| `data.transactionCount` | int | Number of ledger rows. |

#### Response: Success (HTTP 200)

```json
{
  "code": 0,
  "data": {
    "currency": "USD",
    "lifetimeTopUps": 5000,
    "lifetimeOrderPayments": 3712.61,
    "lifetimeRefunds": 18.5,
    "lifetimeAdjustmentsNet": 0,
    "pendingTopUpRequestsAmount": 500,
    "pendingTopUpRequestsCount": 1,
    "transactionCount": 214
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: Too many requests (HTTP 429)

More than 45 calls in a minute. Wait and retry.

```json
{
  "code": 4001,
  "data": null,
  "message": "Too many summary requests. Please wait a moment.",
  "request_id": "7c1e2d3f4a5b6c71"
}
```

<a id="wallet_list_transactions"></a>

### List wallet transactions

`GET /api/v1/wallet/transactions`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `wallet_list_transactions`

The wallet ledger, newest first, one page at a time. Each row has a signed `amount` (negative = money out) and the balance before and after it. Response: `{ items, total, page, limit, totalPages }` — keep increasing `page` until it reaches `totalPages`.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `page` | query | integer | no | Page number, starting at 1. Default 1. Example: `1`. |
| `limit` | query | integer | no | Rows per page, 1-100. Default 20; values above 100 are capped at 100. Example: `20`. |
| `type` | query | string | no | Only rows of this transaction type (exact match). Omit for all types. Example: `ORDER_PAYMENT`. Values: `TOP_UP` (Wallet top-up credited.); `TOP_UP_REVERSAL` (A card top-up was refunded or disputed; the credit is taken back.); `TOP_UP_REVERSAL_RELEASE` (A top-up reversal was released back to the wallet.); `ORDER_PAYMENT` (An order was paid on submit.); `ORDER_ADJUSTMENT` (Price adjustment on an order that was already paid.); `REFUND` (Order refund (cancellation or settlement).); `BALANCE_REFUND` (Wallet balance refunded to you by MerchFox.); `LABEL_PURCHASE` (A shipping label was bought.); `LABEL_REFUND` (A shipping label was cancelled and refunded.); `LABEL_HOLD` (Refundable label deposit set aside (moves to heldAmount).); `LABEL_HOLD_RELEASE` (Label deposit returned to the balance.); `HOLD_SETTLED` (Part or all of a deposit was used to settle extra carrier charges.); `HOLD_RELEASE` (Deposit released after review.); `AFFILIATE_COMMISSION` (Affiliate commission credited.); `AFFILIATE_COMMISSION_CLAWBACK` (Affiliate commission taken back.); `ADJUSTMENT` (Manual adjustment by MerchFox (older rows).). |

#### Example request

```bash
curl -X GET 'https://api.merchfox.com/api/v1/wallet/transactions' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.items` | []object | Ledger rows, newest first. |
| `data.items[]._id` | string | Transaction id. |
| `data.items[].walletId` | string | Wallet the row belongs to. |
| `data.items[].type` | enum | Transaction type — see the `type` parameter for every value. |
| `data.items[].amount` | number | Signed USD amount: positive adds to the balance, negative takes from it. |
| `data.items[].balanceBefore` | number | Balance before this row. |
| `data.items[].balanceAfter` | number | Balance after this row. |
| `data.items[].description` | string | Human-readable description. |
| `data.items[].referenceId` | string | What the row is about — e.g. the order id, label id or top-up id. |
| `data.items[].referenceType` | string | Kind of `referenceId` (e.g. ORDER, LABEL, TOP_UP). |
| `data.items[].adjustedOrderId` | string | Order id for ORDER_ADJUSTMENT rows. Omitted otherwise. |
| `data.items[].parentTransactionId` | string | The row this one reverses or settles, when there is one. Omitted otherwise. |
| `data.items[].metadata` | object | Extra details for some types (e.g. `heldDelta` on HOLD_SETTLED / HOLD_RELEASE). Omitted when empty. |
| `data.items[].createdAt` | string | ISO-8601 time the row was written. |
| `data.items[].updatedAt` | string | ISO-8601 time of the last change. |
| `data.total` | int | Rows matching the filter. |
| `data.page` | int | Page requested. |
| `data.limit` | int | Page size requested (the server caps it at 100). |
| `data.totalPages` | int | Number of pages. |

#### Response: Success (HTTP 200)

```json
{
  "code": 0,
  "data": {
    "items": [
      {
        "_id": "6710f5a9a1b2c3d4e5f60112",
        "walletId": "6710f0c2a1b2c3d4e5f60001",
        "type": "ORDER_PAYMENT",
        "amount": -18.42,
        "balanceBefore": 1258.97,
        "balanceAfter": 1240.55,
        "description": "Order #019EF525-3657",
        "referenceId": "019ef525-3657-7a1b-9c2d-3e4f5a6b7c8d",
        "referenceType": "ORDER",
        "createdAt": "2026-10-16T09:12:40.000Z",
        "updatedAt": "2026-10-16T09:12:40.000Z"
      },
      {
        "_id": "6710e1b0a1b2c3d4e5f600f9",
        "walletId": "6710f0c2a1b2c3d4e5f60001",
        "type": "TOP_UP",
        "amount": 500,
        "balanceBefore": 758.97,
        "balanceAfter": 1258.97,
        "description": "Bank transfer top-up",
        "referenceId": "6710d9c4a1b2c3d4e5f600e1",
        "referenceType": "TOP_UP",
        "createdAt": "2026-10-15T17:40:02.000Z",
        "updatedAt": "2026-10-15T17:40:02.000Z"
      }
    ],
    "total": 214,
    "page": 1,
    "limit": 20,
    "totalPages": 11
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: Team member without wallet access (HTTP 403)

A signed-in team member who lacks the wallet permission. API keys act as the owner and never get this.

```json
{
  "code": 1003,
  "data": null,
  "message": "Required permission: seller_wallet:read",
  "request_id": "7c1e2d3f4a5b6c70"
}
```

<a id="wallet_list_payment_accounts"></a>

### List payment accounts

`GET /api/v1/wallet/payment-accounts`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `wallet_list_payment_accounts`

The MerchFox accounts you can send a top-up to (bank transfer, Payoneer, PingPong, WorldFirst, Airwallex and card), in display order. These are MerchFox's receiving accounts — not your own bank or cards. Use an account's `id` (not `_id`) as `accountId` in **Request bank-transfer top-up**; `accountInfo` holds the transfer details to show the payer.

#### Parameters

No parameters.

#### Example request

```bash
curl -X GET 'https://api.merchfox.com/api/v1/wallet/payment-accounts' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | []object | A JSON array; each element has the fields below. |
| `data[]._id` | string | Internal document id. Do not send this as `accountId`. |
| `data[].id` | string | Account id (UUID) — send this as `accountId` when requesting a bank-transfer top-up. |
| `data[].name` | string | Display name, e.g. "Bank of America (USD)". |
| `data[].type` | enum | BANK_TRANSFER \| STRIPE \| PINGPONG \| PAYONEER \| WORLDFIRST \| AIRWALLEX \| OTHERS. STRIPE is the card option and cannot be used for a bank-transfer request. |
| `data[].description` | string | Notes for the payer. |
| `data[].accountInfo` | object | Transfer details to show the payer (for example account name, account number, routing number, bank name, email). Keys vary by type. |
| `data[].isActive` | bool | Always true here — inactive accounts are not listed. |
| `data[].displayOrder` | int | Sort position (ascending). |
| `data[].createdAt` | string | ISO-8601 creation time. |
| `data[].updatedAt` | string | ISO-8601 time of the last change. |

#### Response: Success (HTTP 200)

```json
{
  "code": 0,
  "data": [
    {
      "_id": "66a0b1c2d3e4f5a6b7c8d901",
      "id": "0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
      "name": "US bank transfer (ACH / wire)",
      "type": "BANK_TRANSFER",
      "description": "Put the transfer code in the memo.",
      "accountInfo": {
        "accountName": "MerchFox Example LLC",
        "accountNumber": "000123456789",
        "routingNumber": "021000021",
        "bankName": "Example Bank"
      },
      "isActive": true,
      "displayOrder": 1,
      "createdAt": "2026-01-10T08:00:00.000Z",
      "updatedAt": "2026-06-01T08:00:00.000Z"
    },
    {
      "_id": "66a0b1c2d3e4f5a6b7c8d902",
      "id": "0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a6c",
      "name": "Card",
      "type": "STRIPE",
      "description": "Pay by card in the seller portal.",
      "accountInfo": {
        "feePercent": 2.9,
        "feeFixedCents": 30
      },
      "isActive": true,
      "displayOrder": 2,
      "createdAt": "2026-01-10T08:00:00.000Z",
      "updatedAt": "2026-06-01T08:00:00.000Z"
    }
  ],
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

<a id="wallet_topup_limits"></a>

### Top-up limits and card fee

`GET /api/v1/wallet/top-up/limits`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `wallet_topup_limits`

Minimum and maximum top-up amount for each method and the card processing fee that is added on top of a card top-up. Check this before creating a top-up instead of hard-coding limits — they can change.

#### Parameters

No parameters.

#### Example request

```bash
curl -X GET 'https://api.merchfox.com/api/v1/wallet/top-up/limits' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.currency` | string | Always "USD". |
| `data.stripe` | object | Card top-up limits. |
| `data.stripe.min` | number | Smallest card top-up. |
| `data.stripe.max` | number | Largest card top-up. |
| `data.bankTransfer` | object | Bank-transfer top-up limits. A min of 0 means any amount above $0. |
| `data.bankTransfer.min` | number | Smallest bank transfer. |
| `data.bankTransfer.max` | number | Largest bank transfer. |
| `data.stripeFee` | object | Card fee added on top of a card top-up. |
| `data.stripeFee.percent` | number | Percentage part, e.g. 2.9. |
| `data.stripeFee.fixedCents` | int | Fixed part in cents, e.g. 30. |
| `data.stripeFee.label` | string | Ready-to-show text, e.g. "2.9% + $0.30". |

#### Response: Success (HTTP 200)

```json
{
  "code": 0,
  "data": {
    "currency": "USD",
    "stripe": {
      "min": 0.5,
      "max": 10000
    },
    "bankTransfer": {
      "min": 0,
      "max": 10000
    },
    "stripeFee": {
      "percent": 2.9,
      "fixedCents": 30,
      "label": "2.9% + $0.30"
    }
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

<a id="wallet_topup_stripe"></a>

### Start a card top-up

`POST /api/v1/wallet/top-up/stripe`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `wallet_topup_stripe`

Starts a card top-up and returns a Stripe PaymentIntent `clientSecret`. The card is charged `chargedAmount` (your amount plus the card fee) and the wallet receives `amount` once Stripe confirms the payment. The payment itself is completed with Stripe in the seller portal (Wallet → Top up); an unpaid attempt is cancelled automatically after about an hour, and starting a new one replaces any earlier open attempt. For server-to-server integrations, use a bank-transfer top-up. Limited to 5 calls per minute.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `amount` | body | number | yes | Amount to add to the wallet in USD, a JSON number with at most 2 decimals. Default limits $0.50-$10,000 — see **Top-up limits and card fee**. Example: `100`. |

#### Example request

```bash
curl -X POST 'https://api.merchfox.com/api/v1/wallet/top-up/stripe' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>' \
  -H 'Content-Type: application/json' \
  -d '{"amount":100}'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.topUpId` | string | Top-up request id — find it in **List top-up requests**. |
| `data.clientSecret` | string | Stripe PaymentIntent client secret used to complete the card payment with Stripe. Treat it as sensitive. |
| `data.amount` | number | USD credited to the wallet once paid. |
| `data.feeAmount` | number | Card fee added on top. |
| `data.chargedAmount` | number | Total the card is charged: amount + feeAmount. |
| `data.feeLabel` | string | The fee rate as text, e.g. "2.9% + $0.30". |

#### Response: Success (HTTP 200)

```json
{
  "code": 0,
  "data": {
    "topUpId": "6711021fa1b2c3d4e5f60201",
    "clientSecret": "pi_3Q0example_secret_example",
    "amount": 100,
    "feeAmount": 3.2,
    "chargedAmount": 103.2,
    "feeLabel": "2.9% + $0.30"
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: Amount out of range (HTTP 400)

```json
{
  "code": 2001,
  "data": null,
  "message": "Maximum top-up is $10,000",
  "request_id": "7c1e2d3f4a5b6c72"
}
```

<a id="wallet_topup_bank"></a>

### Request bank-transfer top-up

`POST /api/v1/wallet/top-up/bank-transfer`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `wallet_topup_bank`

Opens a bank-transfer top-up request (status PENDING) and returns `transferContent` — the code you must put in the transfer memo — plus the receiving account details in `bankInfo`. After sending the money, upload a screenshot of the transfer (see Guides → Wallet & payments); MerchFox reviews it and credits the wallet. A request with no proof expires after 14 days. Unknown body fields are rejected. Limited to 3 calls per minute.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `amount` | body | number | yes | Amount in USD, a JSON number with at most 2 decimals (for example 500 or 499.99). Default maximum $10,000. Example: `500`. |
| `accountId` | body | string | yes | The `id` of the receiving account from **List payment accounts** (any active type except STRIPE). Max 128 characters. Example: `0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b`. |
| `bankName` | body | string | no | The bank you are sending from — helps MerchFox match the transfer. Max 200 characters. Example: `Chase`. |

#### Example request

```bash
curl -X POST 'https://api.merchfox.com/api/v1/wallet/top-up/bank-transfer' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>' \
  -H 'Content-Type: application/json' \
  -d '{"amount":500,"accountId":"0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b","bankName":"Chase"}'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.topUpId` | string | Top-up request id. Upload the transfer proof against it. |
| `data.transferContent` | string | Code to write in the transfer memo, e.g. "MF-6710F0C2-M2ABCD12". Without it MerchFox cannot match your money. |
| `data.bankInfo` | object | Receiving account details (the `accountInfo` of the chosen payment account). |
| `data.amount` | number | Echo of the requested USD amount. |

#### Response: Success (HTTP 200)

```json
{
  "code": 0,
  "data": {
    "topUpId": "6710d9c4a1b2c3d4e5f600e1",
    "transferContent": "MF-6710F0C2-M2ABCD12",
    "bankInfo": {
      "accountName": "MerchFox Example LLC",
      "accountNumber": "000123456789",
      "routingNumber": "021000021",
      "bankName": "Example Bank"
    },
    "amount": 500
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: Invalid body (HTTP 400)

A string amount, a missing accountId or an unknown field (for example `currency`) is rejected.

```json
{
  "code": 2001,
  "data": null,
  "message": "property currency should not exist, amount must be a number conforming to the specified constraints, accountId should not be empty",
  "request_id": "7c1e2d3f4a5b6c73"
}
```

#### Response: Unknown account (HTTP 404)

`accountId` is not an active non-card account from List payment accounts.

```json
{
  "code": 2002,
  "data": null,
  "message": "Payment account not found",
  "request_id": "7c1e2d3f4a5b6c74"
}
```

<a id="wallet_list_topup_requests"></a>

### List top-up requests

`GET /api/v1/wallet/top-up-requests`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `wallet_list_topup_requests`

All your top-up requests, newest first, as a plain array (no paging, no filters). Card attempts you abandoned are left out. Watch `status`: PENDING → PROOF_SUBMITTED → APPROVED for bank transfers; PROCESSING → APPROVED for cards.

#### Parameters

No parameters.

#### Example request

```bash
curl -X GET 'https://api.merchfox.com/api/v1/wallet/top-up-requests' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | []object | A JSON array; each element has the fields below. |
| `data[]._id` | string | Top-up request id (the `topUpId` returned at creation). |
| `data[].walletId` | string | Wallet being topped up. |
| `data[].sellerId` | string | Your seller id. |
| `data[].method` | enum | STRIPE (card) \| BANK_TRANSFER. |
| `data[].amount` | number | USD amount credited to the wallet when approved. |
| `data[].status` | enum | PENDING (waiting for your transfer proof) \| PROOF_SUBMITTED (under review) \| PROCESSING (card payment in progress, or being approved) \| APPROVED (credited) \| REJECTED \| CANCELLED \| EXPIRED (no proof within 14 days) \| FAILED (card declined). |
| `data[].feeAmount` | number | Card fee added on top (STRIPE only). |
| `data[].chargedAmount` | number | Total charged to the card: amount + feeAmount (STRIPE only). |
| `data[].transferContent` | string | Code to put in the bank transfer memo (bank transfers only). |
| `data[].bankName` | string | The bank you said you are sending from, if given. |
| `data[].bankAccountInfo` | string | JSON text of the receiving account details at the time of the request. |
| `data[].proofImageUrl` | string | Storage key of the uploaded transfer proof, once uploaded. |
| `data[].adminNote` | string | Note from MerchFox review (e.g. rejection reason). |
| `data[].reviewedAt` | string | ISO-8601 time the request was approved, rejected, cancelled or expired. |
| `data[].createdAt` | string | ISO-8601 creation time. |
| `data[].updatedAt` | string | ISO-8601 time of the last change. |

#### Response: Success (HTTP 200)

```json
{
  "code": 0,
  "data": [
    {
      "_id": "6710d9c4a1b2c3d4e5f600e1",
      "walletId": "6710f0c2a1b2c3d4e5f60001",
      "sellerId": "018f2a1b-4c5d-7e6f-8a9b-0c1d2e3f4a5b",
      "method": "BANK_TRANSFER",
      "amount": 500,
      "status": "PROOF_SUBMITTED",
      "transferContent": "MF-6710F0C2-M2ABCD12",
      "bankName": "Chase",
      "bankAccountInfo": "{\"accountName\":\"MerchFox Example LLC\",\"accountNumber\":\"000123456789\"}",
      "proofImageUrl": "proofs/018f2a1b-4c5d-7e6f-8a9b-0c1d2e3f4a5b/6710d9c4a1b2c3d4e5f600e1/3f1c.png",
      "createdAt": "2026-10-15T16:02:11.000Z",
      "updatedAt": "2026-10-15T16:20:45.000Z"
    }
  ],
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

<a id="wallet_topup_cancel"></a>

### Cancel a top-up request

`POST /api/v1/wallet/top-up/{id}/cancel`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `wallet_topup_cancel`
- Destructive: cannot be undone.

Cancels a bank-transfer request before you upload proof, or a card attempt before it is paid. Always HTTP 200: check `cancelled`. When it is false, `reason` says why — `paid` (already credited), `under_review` (proof uploaded) or `closed` (already expired, rejected or cancelled). Limited to 10 calls per minute.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | The request `_id` (the `topUpId` returned when it was created). Example: `6710f0c2a1b2c3d4e5f60718`. |

#### Example request

```bash
curl -X POST 'https://api.merchfox.com/api/v1/wallet/top-up/{id}/cancel' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.cancelled` | bool | True when the request was cancelled now. |
| `data.reason` | enum | Only when cancelled is false: paid \| under_review \| closed. |

#### Response: Cancelled (HTTP 200)

```json
{
  "code": 0,
  "data": {
    "cancelled": true
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: Already under review (HTTP 200)

Proof was uploaded, so the request can no longer be cancelled.

```json
{
  "code": 0,
  "data": {
    "cancelled": false,
    "reason": "under_review"
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: Not found (HTTP 404)

```json
{
  "code": 2002,
  "data": null,
  "message": "Top-up request not found",
  "request_id": "7c1e2d3f4a5b6c75"
}
```

## Webhooks

<a id="list_webhooks"></a>

### List webhooks

`GET /api/v1/webhooks/me`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `list_webhooks`

Every webhook subscription on your account, as `{ items: [...] }` (no paging). Each item shows its delivery health: `failure_count` (failures since the last success), `last_success_at`, `last_failure_at` and `muted_until` — set when 50 failures pile up within 5 minutes, which pauses deliveries for 15 minutes. Events that fire while a subscription is muted or inactive are dropped, not queued. The secret is never returned here. Only the shop owner can manage webhooks; errors come back as HTTP 200 with the error in `code`.

#### Parameters

No parameters.

#### Example request

```bash
curl -X GET 'https://api.merchfox.com/api/v1/webhooks/me' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.items` | []object | All your subscriptions. No paging. |
| `data.items[].id` | string | Subscription id (`wsub_` + ULID). |
| `data.items[].url` | string | Delivery URL. |
| `data.items[].events` | string[] | Subscribed event types. |
| `data.items[].description` | string | Your label; empty string when not set. |
| `data.items[].active` | bool | Your on/off switch. |
| `data.items[].failure_count` | int | Failed attempts since the last successful delivery. |
| `data.items[].last_success_at` | string | Last successful delivery. Omitted until there is one. |
| `data.items[].last_failure_at` | string | Last failed attempt. Omitted after a success. |
| `data.items[].muted_until` | string | Deliveries are paused until this time after 50 failures within 5 minutes. Omitted when never muted; may be in the past. |
| `data.items[].api_version` | string | Payload version, always "v1". |
| `data.items[].created_at` | string | Creation time. |
| `data.items[].updated_at` | string | Last change. |

#### Response: Success — two active subscriptions (HTTP 200)

`active` is your on/off switch. `muted_until` appears after 50 failures within 5 minutes and pauses deliveries until that time; it stays visible after it has passed.

```json
{
  "code": 0,
  "data": {
    "items": [
      {
        "id": "wsub_01HXYZ123ABC456DEF789GHI",
        "url": "https://api.example.com/hooks/merchfox/orders",
        "events": [
          "fulfillment.failed"
        ],
        "description": "Production — failed fulfillments",
        "active": true,
        "failure_count": 0,
        "last_success_at": "2026-05-18T08:15:22.418Z",
        "api_version": "v1",
        "created_at": "2026-05-17T11:02:40.000Z",
        "updated_at": "2026-05-18T08:15:22.418Z"
      },
      {
        "id": "wsub_01HXYZ987WXY654STU321JKL",
        "url": "https://api.example.com/hooks/merchfox/fulfillment",
        "events": [
          "fulfillment.failed"
        ],
        "description": "Support dashboard",
        "active": true,
        "failure_count": 2,
        "last_success_at": "2026-05-18T07:51:09.117Z",
        "last_failure_at": "2026-05-18T03:22:01.554Z",
        "api_version": "v1",
        "created_at": "2026-05-16T09:14:00.000Z",
        "updated_at": "2026-05-18T07:51:09.117Z"
      }
    ]
  },
  "message": "Success",
  "request_id": "wh1b2c3d4e5f6a01"
}
```

#### Response: Success — empty (no subscriptions yet) (HTTP 200)

```json
{
  "code": 0,
  "data": {
    "items": []
  },
  "message": "Success",
  "request_id": "wh1b2c3d4e5f6a02"
}
```

<a id="create_webhook"></a>

### Create webhook

`POST /api/v1/webhooks/me`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `create_webhook`

Register an HTTPS endpoint for event deliveries. The response contains the signing `secret` (64 hex characters) ONCE — save it immediately; it cannot be shown again or rotated (delete and re-create the webhook for a new one).

Every delivery is a POST with `X-Mfx-Timestamp` (unix seconds) and `X-Mfx-Signature: sha256=<hex>`, where the hex is HMAC-SHA256 of `<X-Mfx-Timestamp>.<raw body>` keyed with the secret string. Verify it before trusting the event — see Guides → Webhooks for code.

**Event availability:** `fulfillment.failed` is delivered today. The other event types are accepted so you can subscribe ahead of time, but are not sent yet — poll the API for those changes.

Up to 8 attempts per event (retries after about 30 s, 1, 2, 4, 8, 16 and 32 min) on timeouts, network errors, 429 and 5xx; other 4xx answers and redirects are not retried — redirects are never followed. Each attempt waits at most 10 seconds.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `url` | body | string | yes | Your endpoint. Must start with https://, max 2048 characters, no username/password in the URL, and be on the public internet: localhost, internal host names and private, link-local or cloud-metadata addresses are refused, both when saved and on every delivery. Example: `https://api.example.com/hooks/merchfox`. |
| `events` | body | array | yes | Array of event types (at least one). Allowed values: fulfillment.failed (delivered today), order.created, order.paid, order.cancelled, order.refunded, order.fulfilled, fulfillment.shipped, wallet.topup.approved, wallet.topup.rejected, subscription.activated, subscription.cancelled, subscription.deleted (accepted, not sent yet). An unknown type returns code 2001. |
| `description` | body | string | no | Label so you can tell subscriptions apart. Example: `Production — failed fulfillments`. |
| `timeout_ms` | body | integer | no | Per-attempt timeout, 1000-30000 ms. Default 10000. Deliveries never wait longer than 10 seconds, so values above 10000 behave like 10000. Cannot be changed after creation. Example: `10000`. |

**`events`**

```json
[
  "fulfillment.failed"
]
```

#### Example request

```bash
curl -X POST 'https://api.merchfox.com/api/v1/webhooks/me' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>' \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://api.example.com/hooks/merchfox","events":["fulfillment.failed"],"description":"Production — failed fulfillments","timeout_ms":10000}'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.id` | string | Subscription id (`wsub_` + ULID). |
| `data.url` | string | Delivery URL. |
| `data.events` | string[] | Subscribed event types. |
| `data.description` | string | Your label; empty string when not set. |
| `data.active` | bool | Always true for a new subscription. |
| `data.created_at` | string | Creation time. |
| `data.secret` | string | Signing secret, 64 hex characters. Returned only here — store it securely. |
| `data.warning` | string | Reminder that the secret is shown once. |

#### Response: Success — secret returned ONCE (HTTP 200)

The `secret` (64 hex characters) is shown exactly once. Save it now — it verifies the `X-Mfx-Signature` header on every delivery. It cannot be retrieved or rotated later; if you lose it, delete and re-create the subscription.

```json
{
  "code": 0,
  "data": {
    "id": "wsub_01HXYZ123ABC456DEF789GHI",
    "url": "https://api.example.com/hooks/merchfox",
    "events": [
      "fulfillment.failed"
    ],
    "description": "Production — failed fulfillments",
    "active": true,
    "created_at": "2026-05-18T08:30:00.000Z",
    "secret": "5f2c9a7e1b3d4f6a8c0e2a4c6e8a0c2e4a6c8e0a2c4e6a8c0e2a4c6e8a0c2e4a",
    "warning": "This secret is shown ONCE — save it now. Use it to verify the X-Mfx-Signature header on every webhook delivery."
  },
  "message": "Success",
  "request_id": "wh1b2c3d4e5f6b01"
}
```

#### Response: Error — invalid URL (HTTP 200)

The URL must start with https://, have no username/password and resolve only to public addresses. Errors from this service come back as HTTP 200 with the error in `code`.

```json
{
  "code": 2001,
  "data": null,
  "message": "url must use https://",
  "request_id": "wh1b2c3d4e5f6b02"
}
```

#### Response: Error — unknown event type (HTTP 200)

An event type outside the allowed list. See the `events` parameter.

```json
{
  "code": 2001,
  "data": null,
  "message": "unknown event type: order.deleted",
  "request_id": "wh1b2c3d4e5f6b03"
}
```

<a id="update_webhook"></a>

### Update webhook

`PATCH /api/v1/webhooks/me/{id}`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `update_webhook`

Change the URL, events, description or `active` flag. Send only the fields you want to change. `active: false` pauses deliveries — events that fire while paused are dropped, not held for later. Setting `active` back to true does not lift a mute (`muted_until`). The secret and `timeout_ms` cannot be changed here.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Subscription id from List or Create (starts with wsub_). Example: `wsub_01JA2B3C4D5E6F7G8H9J0K1M2N`. |
| `url` | body | string | no | New HTTPS URL. Same rules as on create. Example: `https://api.example.com/hooks/v2`. |
| `events` | body | array | no | New events array. Must contain at least one entry. |
| `description` | body | string | no | New description. Example: `Updated label`. |
| `active` | body | boolean | no | false pauses deliveries, true resumes them. Does not delete the subscription. Example: `true`. |

**`events`**

```json
[
  "fulfillment.failed"
]
```

#### Example request

```bash
curl -X PATCH 'https://api.merchfox.com/api/v1/webhooks/me/{id}' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>' \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://api.example.com/hooks/v2","events":["fulfillment.failed"],"description":"Updated label","active":true}'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.id` | string | Subscription id (`wsub_` + ULID). |
| `data.url` | string | Delivery URL. |
| `data.events` | string[] | Subscribed event types. |
| `data.description` | string | Your label; empty string when not set. |
| `data.active` | bool | Your on/off switch. |
| `data.failure_count` | int | Failed attempts since the last successful delivery. |
| `data.last_success_at` | string | Last successful delivery. Omitted until there is one. |
| `data.last_failure_at` | string | Last failed attempt. Omitted after a success. |
| `data.muted_until` | string | Deliveries are paused until this time after 50 failures within 5 minutes. Omitted when never muted; may be in the past. |
| `data.api_version` | string | Payload version, always "v1". |
| `data.created_at` | string | Creation time. |
| `data.updated_at` | string | Last change. |

#### Response: Success — events updated (HTTP 200)

Only the fields you sent are touched. Note: `secret` is NEVER returned on update — it stays the same as when the subscription was created.

```json
{
  "code": 0,
  "data": {
    "id": "wsub_01HXYZ123ABC456DEF789GHI",
    "url": "https://api.example.com/hooks/merchfox",
    "events": [
      "fulfillment.failed"
    ],
    "description": "Production — failed fulfillments",
    "active": true,
    "failure_count": 0,
    "api_version": "v1",
    "created_at": "2026-05-18T08:30:00.000Z",
    "updated_at": "2026-05-18T09:15:42.812Z"
  },
  "message": "Success",
  "request_id": "wh1b2c3d4e5f6c01"
}
```

#### Response: Error — subscription not found (HTTP 200)

The id does not exist or belongs to another seller — both look the same.

```json
{
  "code": 2002,
  "data": null,
  "message": "webhook not found",
  "request_id": "wh1b2c3d4e5f6c02"
}
```

<a id="delete_webhook"></a>

### Delete webhook

`DELETE /api/v1/webhooks/me/{id}`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `delete_webhook`
- Destructive: cannot be undone.

Permanently deletes a subscription and its secret. Pending retries for it are dropped. To stop deliveries for a while instead, use Update webhook with `active: false`.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Subscription id to delete. Example: `wsub_01JA2B3C4D5E6F7G8H9J0K1M2N`. |

#### Example request

```bash
curl -X DELETE 'https://api.merchfox.com/api/v1/webhooks/me/{id}' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.id` | string | The deleted subscription id. |
| `data.deleted` | bool | Always true. |

#### Response: Success — deleted (HTTP 200)

Hard delete. Pending retries are dropped. Use Update webhook with active=false if you might want it back.

```json
{
  "code": 0,
  "data": {
    "id": "wsub_01HXYZ123ABC456DEF789GHI",
    "deleted": true
  },
  "message": "Success",
  "request_id": "wh1b2c3d4e5f6d01"
}
```

#### Response: Error — subscription not found (HTTP 200)

```json
{
  "code": 2002,
  "data": null,
  "message": "webhook not found",
  "request_id": "wh1b2c3d4e5f6d02"
}
```

## Mockup

<a id="mockup_list_templates"></a>

### List templates

`GET /api/v1/mockup/templates`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `mockup_list_templates`

Your saved designs, newest first, as a plain array (no paging). A template's `id` is the `templateId` for design-first lines in **Quote order** (`{ templateId, sku, quantity }`); its `variantIds` (full view) are the variants it can be ordered in — map them to SKUs with **List blueprint variants**. Use `view=list` for a light list (counts and a thumbnail, no `variantIds`). Team members need designs, catalog or orders read access.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `view` | query | string | no | `list` for the light list view; omit for the full view (layers, mockups, variantIds). Example: `list`. |

#### Example request

```bash
curl -X GET 'https://api.merchfox.com/api/v1/mockup/templates' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | []object | A JSON array; each element has the fields below. |
| `data[].id` | string | Template id — the `templateId` for design-first lines in Quote order. |
| `data[].userId` | string | Owner. |
| `data[].blueprintId` | string | Product type the design is for. |
| `data[].title` | string | Design name. |
| `data[].variantIds` | string[] | Variants the design can be ordered in (full view only). Map them to SKUs with List blueprint variants. |
| `data[].productId` | string | Linked catalog product, when one exists. |
| `data[].designConfig` | object | Editor settings (full view); only `garmentColor` in the list view. |
| `data[].layers` | []object | Full view: the artwork and text layers per print side (position, size, asset URL, text style). |
| `data[].mockups` | []object | Full view: rendered mockups — `id`, `variantId`, `printSide`, `mockupUrl`, `isPrimary`. |
| `data[].layerCount` | int | List view only: number of layers. |
| `data[].mockupCount` | int | List view only: number of mockups. |
| `data[].preview` | object | List view only: `mode` and `thumbUrl` for a thumbnail. |
| `data[].createdAt` | string | ISO-8601 creation time. |
| `data[].updatedAt` | string | ISO-8601 last update. |

#### Response: Success (view=list) (HTTP 200)

A plain array, newest first. The full view (default) adds `variantIds`, `designConfig`, `layers` and `mockups`.

```json
{
  "code": 0,
  "data": [
    {
      "id": "6710c0ffa1b2c3d4e5f60501",
      "userId": "018f2a1b-4c5d-7e6f-8a9b-0c1d2e3f4a5b",
      "blueprintId": "6650b0c1d2e3f4a5b6c7d801",
      "title": "Galaxy Cat Unisex Tee",
      "designConfig": {
        "garmentColor": "#ffffff"
      },
      "layerCount": 2,
      "mockupCount": 3,
      "preview": {
        "mode": "mockup",
        "thumbUrl": "https://cdn.example.com/mockups/6710c0ff-front.jpg"
      },
      "createdAt": "2026-10-10T08:00:00.000Z",
      "updatedAt": "2026-10-14T04:00:00.000Z"
    }
  ],
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

## Shopify

<a id="shopify_install"></a>

### Begin install (OAuth)

`GET /api/v1/shopify/install`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `shopify_install`

Returns the Shopify OAuth authorize URL as `url`. Send the seller's browser there — Shopify asks for consent and then redirects to /shopify/callback. This endpoint does not redirect itself. The link is single-use and expires after 10 minutes. **The seller must be signed in to the MerchFox seller portal in that same browser** — otherwise Shopify still shows the consent screen, but the store is not connected and the browser lands on the portal with `shopify_error=session_mismatch`. A server-only integration therefore cannot finish a connection by itself; hand the URL to the seller.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `shop` | query | string | yes | Shopify store domain in the form `<handle>.myshopify.com`. Example: `galaxy-cat-tees.myshopify.com`. |
| `store_name` | query | string | no | Optional display name for the store in MerchFox (max 100 characters). Example: `Galaxy Cat Tees`. |

#### Example request

```bash
curl -X GET 'https://api.merchfox.com/api/v1/shopify/install' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.url` | string | Shopify OAuth authorize URL. Send the seller's browser here; single-use, expires after 10 minutes. |

#### Response: Success — authorize URL (HTTP 200)

Send the seller's browser to `url`. Shopify then redirects to /shopify/callback. Single-use; expires after 10 minutes.

```json
{
  "code": 0,
  "data": {
    "url": "https://shopify.com/admin/oauth/authorize?client_id=YOUR_APP_CLIENT_ID&scope=read_fulfillments,write_fulfillments,read_merchant_managed_fulfillment_orders,write_merchant_managed_fulfillment_orders,read_orders,read_products&redirect_uri=https%3A%2F%2Fapi.merchfox.com%2Fapi%2Fv1%2Fshopify%2Fcallback&state=eyJzIjoi...&shop=galaxy-cat-tees.myshopify.com"
  },
  "message": "Success",
  "request_id": "sh1b2c3d4e5f6a01"
}
```

#### Response: Invalid shop domain (HTTP 400)

```json
{
  "code": 2001,
  "data": null,
  "message": "shop must be a *.myshopify.com domain",
  "request_id": "sh1b2c3d4e5f6a02"
}
```

<a id="shopify_list_stores"></a>

### List connected stores

`GET /api/v1/shopify/stores`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `shopify_list_stores`

All Shopify stores connected to the seller account, with their order-sync settings. `status` is ACTIVE, or DISCONNECTED after the app was uninstalled in the Shopify admin (reconnect with Begin install). Orders from every ACTIVE store are imported automatically — through Shopify webhooks and an hourly check, and the first sync goes back 90 days. When the last order sync failed, `last_sync_error` explains it in plain words (for example that the store must be reconnected); it is null otherwise.

#### Parameters

No parameters.

#### Example request

```bash
curl -X GET 'https://api.merchfox.com/api/v1/shopify/stores' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.id` | string | Connected-store id (UUID). Use it in the /shopify/stores/{id} paths. |
| `data.mongo_id` | string | Internal record id — the `shop_id` Order Platform uses for this store. Omitted in the rare case it is missing. |
| `data.shop_domain` | string | Shopify shop domain — `<handle>.myshopify.com`. |
| `data.store_name` | string\|null | Display name given at install (`store_name`), if any. |
| `data.shop_name` | string\|null | Shop name reported by Shopify. |
| `data.status` | enum | ACTIVE \| DISCONNECTED. DISCONNECTED means the app was uninstalled in the Shopify admin: orders stop syncing; connect the store again with Begin install. |
| `data.auto_push_to_fulfillment` | bool | New Shopify orders are pushed to fulfillment automatically. |
| `data.auto_sync_tracking` | bool | Tracking is pushed back to Shopify automatically (also required by Push tracking). |
| `data.default_fulfillment_service_key` | string | Fulfillment service used when no shipping-method mapping matches; empty = the system default. |
| `data.shipping_method_mapping` | object | Shopify shipping-method label → MerchFox fulfillment service key. Labels match case-insensitively, exactly or as a prefix of the order's shipping method. |
| `data.last_sync_at` | string\|null | ISO-8601 time of the last sync activity, or null. |
| `data.last_sync_error` | string\|null | Plain-language reason the last order sync failed (for example that the store must be reconnected), or null. |

#### Response: Success — one connected store (HTTP 200)

```json
{
  "code": 0,
  "data": [
    {
      "id": "0192f0a4-6b1c-7d2e-8f3a-4b5c6d7e8f90",
      "mongo_id": "6702f3c1a4e5d6f789012345",
      "shop_domain": "galaxy-cat-tees.myshopify.com",
      "store_name": "Galaxy Cat Tees",
      "shop_name": "Galaxy Cat Tees",
      "status": "ACTIVE",
      "auto_push_to_fulfillment": true,
      "auto_sync_tracking": true,
      "default_fulfillment_service_key": "",
      "shipping_method_mapping": {
        "Express Shipping": "PRIORITY"
      },
      "last_sync_at": "2026-05-13T18:20:00.000Z",
      "last_sync_error": null
    }
  ],
  "message": "Success",
  "request_id": "sh1b2c3d4e5f6c01"
}
```

#### Response: Store needs re-authorization (HTTP 200)

The last order sync failed because Shopify rejected the stored token. Reconnect the store via Begin install. Treat `last_sync_error` as display text: check null / non-null rather than parsing it.

```json
{
  "code": 0,
  "data": [
    {
      "id": "0192f0a4-6b1c-7d2e-8f3a-4b5c6d7e8f91",
      "mongo_id": "6702f3c1a4e5d6f789012346",
      "shop_domain": "old-store.myshopify.com",
      "store_name": null,
      "shop_name": "Old Store",
      "status": "ACTIVE",
      "auto_push_to_fulfillment": true,
      "auto_sync_tracking": true,
      "default_fulfillment_service_key": "",
      "shipping_method_mapping": {},
      "last_sync_at": "2026-04-30T09:00:00.000Z",
      "last_sync_error": "Shopify rejected this store's access token. Reconnect the store."
    }
  ],
  "message": "Success",
  "request_id": "sh1b2c3d4e5f6c02"
}
```

#### Response: Empty — no stores connected (HTTP 200)

```json
{
  "code": 0,
  "data": [],
  "message": "Success",
  "request_id": "sh1b2c3d4e5f6c03"
}
```

<a id="shopify_get_store"></a>

### Get connected store

`GET /api/v1/shopify/stores/{id}`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `shopify_get_store`

One connected store, with the same fields as List connected stores. 404 when the store does not exist or belongs to another seller.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Connected-store `id` from List connected stores. |

#### Example request

```bash
curl -X GET 'https://api.merchfox.com/api/v1/shopify/stores/{id}' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.id` | string | Connected-store id (UUID). Use it in the /shopify/stores/{id} paths. |
| `data.mongo_id` | string | Internal record id — the `shop_id` Order Platform uses for this store. Omitted in the rare case it is missing. |
| `data.shop_domain` | string | Shopify shop domain — `<handle>.myshopify.com`. |
| `data.store_name` | string\|null | Display name given at install (`store_name`), if any. |
| `data.shop_name` | string\|null | Shop name reported by Shopify. |
| `data.status` | enum | ACTIVE \| DISCONNECTED. DISCONNECTED means the app was uninstalled in the Shopify admin: orders stop syncing; connect the store again with Begin install. |
| `data.auto_push_to_fulfillment` | bool | New Shopify orders are pushed to fulfillment automatically. |
| `data.auto_sync_tracking` | bool | Tracking is pushed back to Shopify automatically (also required by Push tracking). |
| `data.default_fulfillment_service_key` | string | Fulfillment service used when no shipping-method mapping matches; empty = the system default. |
| `data.shipping_method_mapping` | object | Shopify shipping-method label → MerchFox fulfillment service key. Labels match case-insensitively, exactly or as a prefix of the order's shipping method. |
| `data.last_sync_at` | string\|null | ISO-8601 time of the last sync activity, or null. |
| `data.last_sync_error` | string\|null | Plain-language reason the last order sync failed (for example that the store must be reconnected), or null. |

#### Response: Success (HTTP 200)

```json
{
  "code": 0,
  "data": {
    "id": "0192f0a4-6b1c-7d2e-8f3a-4b5c6d7e8f90",
    "mongo_id": "6702f3c1a4e5d6f789012345",
    "shop_domain": "galaxy-cat-tees.myshopify.com",
    "store_name": "Galaxy Cat Tees",
    "shop_name": "Galaxy Cat Tees",
    "status": "ACTIVE",
    "auto_push_to_fulfillment": true,
    "auto_sync_tracking": true,
    "default_fulfillment_service_key": "",
    "shipping_method_mapping": {
      "Express Shipping": "PRIORITY"
    },
    "last_sync_at": "2026-05-13T18:20:00.000Z",
    "last_sync_error": null
  },
  "message": "Success",
  "request_id": "sh1b2c3d4e5f6d01"
}
```

#### Response: Not found / not yours (HTTP 404)

```json
{
  "code": 2002,
  "data": null,
  "message": "Store not found",
  "request_id": "sh1b2c3d4e5f6d02"
}
```

<a id="shopify_update_store_settings"></a>

### Update store sync settings

`PATCH /api/v1/shopify/stores/{id}/settings`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `shopify_update_store_settings`

Changes how orders from this Shopify store are handled. Send only the fields to change. 404 when the store does not exist or belongs to another seller; 400 with the reason when a value is invalid (nothing is saved then).

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Connected-store `id`. |
| `auto_push_to_fulfillment` | body | boolean | no | Push new Shopify orders to fulfillment automatically. Example: `true`. |
| `auto_sync_tracking` | body | boolean | no | Send tracking back to Shopify automatically (also required by Push tracking). Example: `true`. |
| `default_fulfillment_service_key` | body | string | no | Fulfillment service used when no shipping-method mapping matches: ECONOMY, FIRST_CLASS, RUSH, PRIORITY, EXPEDITE or OVERNIGHT; empty string = system default (FIRST_CLASS). Example: `FIRST_CLASS`. |
| `shipping_method_mapping` | body | object | no | Replaces the whole map of Shopify shipping-method label → fulfillment service key (same keys as above, not empty). At most 50 labels; each 1–100 characters and not starting with "$". Send {} or null to clear it. |

**`shipping_method_mapping`**

```json
{
  "Express Shipping": "PRIORITY",
  "Overnight": "OVERNIGHT"
}
```

#### Example request

```bash
curl -X PATCH 'https://api.merchfox.com/api/v1/shopify/stores/{id}/settings' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>' \
  -H 'Content-Type: application/json' \
  -d '{"auto_push_to_fulfillment":true,"auto_sync_tracking":true,"default_fulfillment_service_key":"FIRST_CLASS","shipping_method_mapping":{"Express Shipping":"PRIORITY","Overnight":"OVERNIGHT"}}'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.updated` | bool | True when the settings were saved. |

#### Response: Success (HTTP 200)

```json
{
  "code": 0,
  "data": {
    "updated": true
  },
  "message": "Success",
  "request_id": "sh1b2c3d4e5f6e21"
}
```

#### Response: Invalid mapping value (HTTP 400)

```json
{
  "code": 2001,
  "data": null,
  "message": "shipping_method_mapping[\"Express Shipping\"] must be one of: ECONOMY, FIRST_CLASS, RUSH, PRIORITY, EXPEDITE, OVERNIGHT",
  "request_id": "sh1b2c3d4e5f6e22"
}
```

#### Response: Not found / not yours (HTTP 404)

```json
{
  "code": 2002,
  "data": null,
  "message": "Store not found",
  "request_id": "sh1b2c3d4e5f6e23"
}
```

<a id="shopify_push_tracking"></a>

### Push tracking to Shopify

`POST /api/v1/shopify/stores/{id}/tracking`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `shopify_push_tracking`

Marks the Shopify order as shipped with this tracking number, and Shopify emails the buyer its shipping notification. Only the first open fulfillment order of the Shopify order is fulfilled (all of its remaining items) — orders split across several locations are fulfilled only partly. `shopifyOrderId` may be the numeric id or the full `gid://shopify/Order/…` id. There is no automatic retry of a FAILED push; call again. Only runs when the store has `auto_sync_tracking` on; otherwise the call is recorded with `syncStatus: "SKIPPED"`. A Shopify-side failure still answers 200 with `syncStatus: "FAILED"` and a plain-language reason in `syncError` (Shopify's own validation message is included when it rejects the fulfillment). 404 when the store does not exist or belongs to another seller.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Connected-store `id`. |
| `orderId` | body | string | yes | MerchFox order id the tracking belongs to (kept on the sync record). Example: `7a2c8d9e-1f3b-4a5c-9d6e-0f8b1c2d3e4f`. |
| `shopifyOrderId` | body | string | yes | Shopify order id (numeric id from the Shopify admin / webhook). Example: `5432109876543`. |
| `trackingNumber` | body | string | yes | Carrier tracking number (max 100 characters). Example: `9400111899223197428347`. |
| `carrier` | body | string | yes | Carrier name as Shopify should show it, e.g. USPS, UPS, FedEx, DHL (max 50 characters). Example: `USPS`. |

#### Example request

```bash
curl -X POST 'https://api.merchfox.com/api/v1/shopify/stores/{id}/tracking' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>' \
  -H 'Content-Type: application/json' \
  -d '{"orderId":"7a2c8d9e-1f3b-4a5c-9d6e-0f8b1c2d3e4f","shopifyOrderId":"5432109876543","trackingNumber":"9400111899223197428347","carrier":"USPS"}'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data._id` | string | Internal document id. |
| `data.id` | string | Tracking-sync record id (UUID). |
| `data.storeId` | string | Connected-store id. |
| `data.orderId` | string | Echo of the MerchFox order id. |
| `data.shopifyOrderId` | string | Echo of the Shopify order id. |
| `data.trackingNumber` | string | Echo of the tracking number. |
| `data.carrier` | string | Echo of the carrier. |
| `data.syncStatus` | enum | SYNCED (Shopify accepted it) \| FAILED (see syncError) \| SKIPPED (auto_sync_tracking is off). |
| `data.syncError` | string\|null | Plain-language reason when syncStatus = FAILED (includes Shopify's validation message when it rejected the fulfillment), else null. |
| `data.syncedAt` | string | ISO-8601 time of this attempt. |
| `data.createdAt` | string | ISO-8601 time the record was written. |
| `data.updatedAt` | string | ISO-8601 time of the last change. |

#### Response: Success — pushed to Shopify (HTTP 200)

Shopify accepted the fulfillment and emails the buyer its shipping notification.

```json
{
  "code": 0,
  "data": {
    "_id": "6710f8a1a1b2c3d4e5f60401",
    "id": "0192f1b2-3c4d-7e5f-8a6b-7c8d9e0f1a2b",
    "storeId": "0192f0a4-6b1c-7d2e-8f3a-4b5c6d7e8f90",
    "orderId": "7a2c8d9e-1f3b-4a5c-9d6e-0f8b1c2d3e4f",
    "shopifyOrderId": "5432109876543",
    "trackingNumber": "9400111899223197428347",
    "carrier": "USPS",
    "syncStatus": "SYNCED",
    "syncError": null,
    "syncedAt": "2026-05-14T16:40:00.000Z",
    "createdAt": "2026-05-14T16:40:00.000Z",
    "updatedAt": "2026-05-14T16:40:00.000Z"
  },
  "message": "Success",
  "request_id": "sh1b2c3d4e5f6f01"
}
```

#### Response: Skipped — auto sync tracking is off (HTTP 200)

Nothing is sent to Shopify while the store has `auto_sync_tracking` off; the attempt is still recorded.

```json
{
  "code": 0,
  "data": {
    "id": "0192f1b2-3c4d-7e5f-8a6b-7c8d9e0f1a2c",
    "storeId": "0192f0a4-6b1c-7d2e-8f3a-4b5c6d7e8f90",
    "orderId": "7a2c8d9e-1f3b-4a5c-9d6e-0f8b1c2d3e4f",
    "shopifyOrderId": "5432109876543",
    "trackingNumber": "9400111899223197428347",
    "carrier": "USPS",
    "syncStatus": "SKIPPED",
    "syncError": null,
    "syncedAt": "2026-05-14T16:40:00.000Z"
  },
  "message": "Success",
  "request_id": "sh1b2c3d4e5f6f02"
}
```

#### Response: Store not found / not yours (HTTP 404)

```json
{
  "code": 2002,
  "data": null,
  "message": "Store not found",
  "request_id": "sh1b2c3d4e5f6f03"
}
```

<a id="shopify_disconnect_store"></a>

### Disconnect store

`DELETE /api/v1/shopify/stores/{id}`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `shopify_disconnect_store`
- Destructive: cannot be undone.

Removes the Shopify store from the seller account and asks Shopify to delete the order webhooks MerchFox registered on it (best effort, after the response), so new Shopify orders stop syncing. Orders already in MerchFox are not affected. To remove the app from Shopify itself, uninstall it in the Shopify admin. Idempotent: an unknown or already-removed store also answers `{ "disconnected": true }`; a store owned by another seller answers 404.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Connected-store `id`. |

#### Example request

```bash
curl -X DELETE 'https://api.merchfox.com/api/v1/shopify/stores/{id}' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.disconnected` | bool | Always true on 200 — also for an unknown or already-removed store. |

#### Response: Success (HTTP 200)

Also returned for an unknown or already-removed store (idempotent).

```json
{
  "code": 0,
  "data": {
    "disconnected": true
  },
  "message": "Success",
  "request_id": "sh1b2c3d4e5f7001"
}
```

#### Response: Not yours (HTTP 404)

```json
{
  "code": 2002,
  "data": null,
  "message": "Store not found",
  "request_id": "sh1b2c3d4e5f7003"
}
```

## Users

<a id="users_me"></a>

### Get user profile

`GET /api/v1/users/me`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `users_me`

The calling user's account: name, business and address details, `roles`, and — for a team member — the effective seller-team `permissions` and `apiKeyScope` (the API keys whose orders they may work on; null = all orders). Called with an API key, this is the shop owner's account. Different from /seller/202311/me/profile, which returns the seller business.

#### Parameters

No parameters.

#### Example request

```bash
curl -X GET 'https://api.merchfox.com/api/v1/users/me' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data._id` | string | Internal document id. Use `id` instead. |
| `data.id` | string | User id (UUID). |
| `data.email` | string | Sign-in email (lower-cased). |
| `data.firstName` | string | First name. Absent when never set. |
| `data.lastName` | string | Last name. Absent when never set. |
| `data.businessName` | string | Business name. Absent when never set. |
| `data.phone` | string | Phone number. Absent when never set. |
| `data.addressLine1` | string | Address line 1. Absent when never set. |
| `data.addressLine2` | string | Address line 2. Absent when never set. |
| `data.city` | string | City. Absent when never set. |
| `data.state` | string | State or region. Absent when never set. |
| `data.zipCode` | string | ZIP / postal code. Absent when never set. |
| `data.country` | string | ISO 3166-1 alpha-2 country code. Absent when never set. |
| `data.roles` | []string | `SELLER` for a shop owner, `SELLER_TEAM` for a team member. |
| `data.tier` | enum | Effective plan tier FREE \| PRO \| ENTERPRISE (includes any active override). |
| `data.tierOverride` | string\|null | Tier set by MerchFox for a period, if any. |
| `data.tierOverrideExpiresAt` | string\|null | When that override ends. |
| `data.tierOverrideReason` | string\|null | Why it was set. |
| `data.isActive` | bool | False once the account is deactivated or the member revoked. |
| `data.totpEnabled` | bool | True when two-factor authentication is on. |
| `data.createdById` | string | Team members only: the shop owner's user id. Absent for owners. |
| `data.onboardingSurvey` | object | Answers from the sign-up survey, when filled in. |
| `data.createdAt` | string | ISO 8601 account creation time. |
| `data.permissions` | []string | Team members: effective seller-team permissions (see Guides → Access & permissions). Shop owners: normally empty — an owner (and any API key) has full access to the shop. |
| `data.sensitivePermissions` | []string | Always empty for sellers and team members. |
| `data.apiKeyScope` | []string\|null | Team members: ids of the API keys whose orders they may work on; null = all orders. Always null for shop owners. |

#### Response: Success — shop owner (or an API key, which acts as the owner) (HTTP 200)

```json
{
  "code": 0,
  "data": {
    "_id": "6650f1a2b3c4d5e6f7a8b9c0",
    "id": "018f2a1b-4c5d-7e6f-8a9b-0c1d2e3f4a5b",
    "email": "casey@galaxy-cat-tees.com",
    "firstName": "Casey",
    "lastName": "Lin",
    "businessName": "Galaxy Cat Tees LLC",
    "phone": "+1 212 555 1234",
    "addressLine1": "123 Main St",
    "city": "New York",
    "state": "NY",
    "zipCode": "10001",
    "country": "US",
    "roles": [
      "SELLER"
    ],
    "tier": "PRO",
    "isActive": true,
    "totpEnabled": true,
    "createdAt": "2026-03-02T15:04:05.000Z",
    "permissions": [],
    "sensitivePermissions": [],
    "apiKeyScope": null
  },
  "message": "Success",
  "request_id": "us1b2c3d4e5f6a01"
}
```

#### Response: Success — team member limited to one API key (HTTP 200)

Wallet, labels, Order Platform, store, analytics and support permissions are removed while a member is limited to API-key orders.

```json
{
  "code": 0,
  "data": {
    "_id": "6650f1a2b3c4d5e6f7a8b9d1",
    "id": "018f2a1b-4c5d-7e6f-8a9b-0c1d2e3f4a6c",
    "email": "ops@galaxy-cat-tees.com",
    "firstName": "Jordan",
    "lastName": "Diaz",
    "roles": [
      "SELLER_TEAM"
    ],
    "tier": "PRO",
    "isActive": true,
    "totpEnabled": false,
    "createdById": "018f2a1b-4c5d-7e6f-8a9b-0c1d2e3f4a5b",
    "createdAt": "2026-09-20T10:00:00.000Z",
    "permissions": [
      "seller_orders:read",
      "seller_orders:write",
      "seller_catalog:read",
      "seller_designs:read"
    ],
    "sensitivePermissions": [],
    "apiKeyScope": [
      "6650a1b2c3d4e5f6a7b8c9d0"
    ]
  },
  "message": "Success",
  "request_id": "us1b2c3d4e5f6a02"
}
```

<a id="users_update_profile"></a>

### Update user profile

`PATCH /api/v1/users/me/profile`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `users_update_profile`

Partial update of the calling user's profile; send only the fields to change (at least one), each as a string — `null` is not accepted. Email cannot be changed here. Unknown fields are rejected. Values are trimmed. `phone` and `country` cannot be cleared once set (an empty string fails their format check); the other fields can be cleared with `""`. Returns the updated profile (with the stored `tier` — read Get user profile for the effective one). An API key updates the shop owner's profile.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `firstName` | body | string | no | First name, 1-50 chars. Example: `Casey`. |
| `lastName` | body | string | no | Last name, 1-50 chars. Example: `Lin`. |
| `businessName` | body | string | no | Business name, up to 100 chars. Example: `Acme Merch Co.`. |
| `phone` | body | string | no | 7-20 chars: digits, spaces, + - ( ) . Example: `+1 555 000 0000`. |
| `addressLine1` | body | string | no | Up to 200 chars. Example: `123 Main St`. |
| `addressLine2` | body | string | no | Up to 200 chars. Example: `Suite 100`. |
| `city` | body | string | no | Up to 100 chars. Example: `New York`. |
| `state` | body | string | no | State or region, up to 100 chars (free text; use the 2-letter code for US addresses). Example: `NY`. |
| `zipCode` | body | string | no | ZIP / postal code, up to 20 chars. Example: `10001`. |
| `country` | body | string | no | ISO 3166-1 alpha-2 in UPPERCASE (e.g. US) — lowercase is rejected. Example: `US`. |

#### Example request

```bash
curl -X PATCH 'https://api.merchfox.com/api/v1/users/me/profile' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>' \
  -H 'Content-Type: application/json' \
  -d '{"firstName":"Casey","lastName":"Lin","businessName":"Acme Merch Co.","phone":"+1 555 000 0000","addressLine1":"123 Main St","addressLine2":"Suite 100","city":"New York","state":"NY","zipCode":"10001","country":"US"}'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data._id` | string | Internal document id. Use `id` instead. |
| `data.id` | string | User id (UUID). |
| `data.email` | string | Sign-in email (lower-cased). |
| `data.firstName` | string | First name. Absent when never set. |
| `data.lastName` | string | Last name. Absent when never set. |
| `data.businessName` | string | Business name. Absent when never set. |
| `data.phone` | string | Phone number. Absent when never set. |
| `data.addressLine1` | string | Address line 1. Absent when never set. |
| `data.addressLine2` | string | Address line 2. Absent when never set. |
| `data.city` | string | City. Absent when never set. |
| `data.state` | string | State or region. Absent when never set. |
| `data.zipCode` | string | ZIP / postal code. Absent when never set. |
| `data.country` | string | ISO 3166-1 alpha-2 country code. Absent when never set. |
| `data.roles` | []string | `SELLER` for a shop owner, `SELLER_TEAM` for a team member. |
| `data.tier` | enum | Stored plan tier (an active override is not applied here — read GET /users/me for the effective tier). |
| `data.createdAt` | string | ISO 8601 account creation time. |

#### Response: Success (HTTP 200)

```json
{
  "code": 0,
  "data": {
    "_id": "6650f1a2b3c4d5e6f7a8b9c0",
    "id": "018f2a1b-4c5d-7e6f-8a9b-0c1d2e3f4a5b",
    "email": "casey@galaxy-cat-tees.com",
    "firstName": "Casey",
    "lastName": "Lin",
    "businessName": "Galaxy Cat Tees LLC",
    "phone": "+1 212 555 1234",
    "addressLine1": "123 Main St",
    "city": "New York",
    "state": "NY",
    "zipCode": "10001",
    "country": "US",
    "roles": [
      "SELLER"
    ],
    "tier": "PRO",
    "createdAt": "2026-03-02T15:04:05.000Z"
  },
  "message": "Success",
  "request_id": "us1b2c3d4e5f6a03"
}
```

#### Response: Invalid field (HTTP 400)

```json
{
  "code": 2001,
  "data": null,
  "message": "Country must be a 2-letter ISO code (e.g. US, GB)",
  "request_id": "us1b2c3d4e5f6a04"
}
```

## Shipping

<a id="label_create"></a>

### Create a shipping label

`POST /api/v1/labels/shipments`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `label_create`

Create a USPS label from your own system.

**Draft or buy.** By default the label is a **draft**: nothing is charged and someone confirms it in the portal under Order Label. Add `"buy": true` to buy it with no manual step: the response comes back with `status: "queued"` and the label is bought moments later. The wallet is charged once the carrier has created the label, for the final price, which can differ slightly from the quote. Poll **Get a shipping label** until it is `ready` (or `failed`).

**Balance check.** Even a draft is only created when your wallet can cover the quoted price (plus the refundable label deposit on wallets without an overdraft). Otherwise nothing is created and the call answers HTTP 402 with code 2006 and the amounts needed.

**IP allowlist (optional).** A key with no IPs works from any address, including `buy`. To lock it to your servers, add their addresses under My Account → API keys → Manage IPs; from then on calls from any other address are refused.

**Where it ships from.** Send `warehouseId` to use an address saved under Order Label → Warehouses (the short path — set it once, never repeat it), or send a full `from` object with the same fields as `to`.

If the `warehouseId` is missing or does not match one of your warehouses, the `from` address is used instead and the response carries a `warning` saying so — so a mistyped id does not silently ship from somewhere you did not intend. Send both and the warehouse wins. Send neither, or an unknown id with no `from`, and the request is refused.

**Units.** Weight is ounces and sizes are inches unless you say otherwise: add `"weightUnit": "lb"` or `"sizeUnit": "cm"`. Numbers may be sent as numbers or strings — `16` and `"16"` both work.

**Purchase pacing.** A key may make **5 purchases in a burst, then 30 per minute** (labels bought with `buy: true` or confirmed, and order submits share this budget). Over that the API answers HTTP 429 with code 4002, `Retry-After` and `data.retry_after_seconds`; nothing is charged. Creating drafts is not limited by this. All label calls also share the general limit of 120 requests per minute per IP address (code 4001).

**Retries.** Send an `Idempotency-Key` **header** (any unique string per order — your order number works well). If the call times out and you retry with the same key you get the original label back instead of a second one — and a second charge.

**Validation.** Problems come back together in `data.errors`, not one per call, so a body with four mistakes tells you all four at once. The API checks that address fields are present and that `state` is a 2-letter code; an address the carrier cannot use is refused at quote or purchase time with HTTP 500, code 5001, and a message naming the address.

**Response.** `data` is the label. When a `warning` applies (unknown `warehouseId`), `data` is `{ "label": {…}, "warning": "…" }` instead.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `service` | body | string | yes | Shipping product: `ground_advantage` (cheapest, 2–5 days), `priority` (1–3 days), or `priority_express` (overnight to 2 days). Example: `ground_advantage`. |
| `warehouseId` | body | string | no | Id of a warehouse saved under Order Label → Warehouses. If it is missing or unknown, `from` is used instead. Example: `68a1f2c3d4e5f60718293a4b`. |
| `to` | body | object | yes | Delivery address: `name`, `street1`, `city`, `state` (2-letter code), `zip`. Optional `street2` and `country` (defaults to US). |
| `from` | body | json | no | Origin address, same fields as `to`. Required when you do not send a `warehouseId`, and used as the fallback if the id turns out to be unknown. |
| `parcel` | body | object | yes | Package: `weight`, `length`, `width`, `height` — all greater than zero. Optional `weightUnit` (`oz` default, or `lb`) and `sizeUnit` (`in` default, or `cm`). |
| `reference` | body | string | no | Your own order number, at most 100 characters (trimmed). Returned as `reference` by create, get and list, so you can match a label to an order later. Example: `ORDER-1234`. |
| `buy` | body | boolean | no | true queues the label for purchase right away (charged once bought). Omit (or false) to create a draft and confirm it later — here with **Buy a draft label**, or in the portal. Example: `false`. |

**`to`**

```json
{
  "name": "Jane Doe",
  "street1": "1 Main St",
  "city": "Austin",
  "state": "TX",
  "zip": "78701"
}
```

**`parcel`**

```json
{
  "weight": 16,
  "length": 10,
  "width": 8,
  "height": 4
}
```

#### Example request

```bash
curl -X POST 'https://api.merchfox.com/api/v1/labels/shipments' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>' \
  -H 'Content-Type: application/json' \
  -d '{"service":"ground_advantage","warehouseId":"68a1f2c3d4e5f60718293a4b","to":{"name":"Jane Doe","street1":"1 Main St","city":"Austin","state":"TX","zip":"78701"},"parcel":{"weight":16,"length":10,"width":8,"height":4},"reference":"ORDER-1234","buy":false}'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.id` | string | Label id — use it to get, confirm or cancel the label. |
| `data.status` | enum | pending (draft) \| queued \| buying \| ready \| failed \| cancelling \| cancelled \| disputed. |
| `data.reference` | string | Your `reference`, if you sent one. |
| `data.service` | string | ground_advantage \| priority \| priority_express. |
| `data.trackingNumber` | string | Present once the label is `ready`. |
| `data.labelUrl` | string | Printable PDF. Present once the label is `ready`. |
| `data.price` | string | Total charged (or to be charged) in USD, e.g. "8.45". |
| `data.createdAt` | string | RFC 3339 creation time. |

#### Response: Bought (queued) (HTTP 200)

With `buy: true`. Poll Get a shipping label until `ready`.

```json
{
  "code": 0,
  "data": {
    "id": "6710a1b2c3d4e5f601234567",
    "status": "queued",
    "reference": "ORDER-1001",
    "service": "ground_advantage",
    "price": "8.45",
    "createdAt": "2026-10-16T14:02:11Z"
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: Draft (HTTP 200)

Without `buy`. Confirm it later.

```json
{
  "code": 0,
  "data": {
    "id": "6710a1b2c3d4e5f601234568",
    "status": "pending",
    "reference": "ORDER-1002",
    "service": "ground_advantage",
    "price": "8.45",
    "createdAt": "2026-10-16T14:02:30Z"
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: With a warning (HTTP 200)

The `warehouseId` was unknown, so `from` was used. `data` then wraps the label.

```json
{
  "code": 0,
  "data": {
    "label": {
      "id": "6710a1b2c3d4e5f601234569",
      "status": "pending",
      "service": "ground_advantage",
      "price": "8.45",
      "createdAt": "2026-10-16T14:03:00Z"
    },
    "warning": "warehouseId \"68a1f2c3d4e5f60718293a00\" was not found, so the \"from\" address was used instead"
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: Fields need fixing (HTTP 400)

Every problem is listed in `data.errors` at once.

```json
{
  "code": 2001,
  "data": {
    "errors": [
      "parcel.weight must be greater than 0",
      "to.state must be the 2-letter code, e.g. CA"
    ]
  },
  "message": "Some fields need fixing.",
  "request_id": "3f9a1c2b7d4e5f61"
}
```

#### Response: Wallet cannot cover it (HTTP 402)

Nothing is created or charged. Top up and try again.

```json
{
  "code": 2006,
  "data": {
    "total": 8.45,
    "walletBalance": 5,
    "sufficient": false,
    "holdAmount": 4.84,
    "requiredTotal": 13.29,
    "allowNegativeBalance": false
  },
  "message": "Insufficient wallet balance. Need $13.29 (includes $4.84 refundable deposit hold).",
  "request_id": "3f9a1c2b7d4e5f62"
}
```

#### Response: Buying too fast (HTTP 429)

More than 5 purchases in a burst or 30 a minute on one API key. Wait `retry_after_seconds` (also in `Retry-After`); nothing was charged.

```json
{
  "code": 4002,
  "data": {
    "retry_after_seconds": 8
  },
  "message": "Purchase rate limit exceeded",
  "request_id": "3f9a1c2b7d4e5f63"
}
```

<a id="label_get"></a>

### Get a shipping label

`GET /api/v1/labels/shipments/{id}`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `label_get`

Status moves `pending` (draft) → `queued` → `buying` → `ready`, or ends in `failed` if the purchase did not go through (nothing is charged for a failed label; the reason is shown in the portal). Once `ready` the response carries `trackingNumber` and `labelUrl` (the printable PDF). A cancelled label moves `ready` → `cancelling` → `cancelled` (or `disputed`, see **Cancel a label**).

Poll every few seconds after buying; a label is usually `ready` within a minute. There is no webhook for label status — the tracking webhook covers parcel movement after the label is printed.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | The label id returned when it was created. Example: `68b3c1d2e4f5a60718293c7d`. |

#### Example request

```bash
curl -X GET 'https://api.merchfox.com/api/v1/labels/shipments/{id}' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.id` | string | Label id — use it to get, confirm or cancel the label. |
| `data.status` | enum | pending (draft) \| queued \| buying \| ready \| failed \| cancelling \| cancelled \| disputed. |
| `data.reference` | string | Your `reference`, if you sent one. |
| `data.service` | string | ground_advantage \| priority \| priority_express. |
| `data.trackingNumber` | string | Present once the label is `ready`. |
| `data.labelUrl` | string | Printable PDF. Present once the label is `ready`. |
| `data.price` | string | Total charged (or to be charged) in USD, e.g. "8.45". |
| `data.createdAt` | string | RFC 3339 creation time. |

#### Response: Ready (HTTP 200)

```json
{
  "code": 0,
  "data": {
    "id": "6710a1b2c3d4e5f601234567",
    "status": "ready",
    "reference": "ORDER-1001",
    "service": "ground_advantage",
    "trackingNumber": "9400100000000000000000",
    "labelUrl": "https://cdn.example.com/labels/6710a1b2c3d4e5f601234567.pdf",
    "price": "8.45",
    "createdAt": "2026-10-16T14:02:11Z"
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: Not found (HTTP 404)

```json
{
  "code": 2002,
  "data": null,
  "message": "No label with that id.",
  "request_id": "3f9a1c2b7d4e5f66"
}
```

<a id="label_warehouses_list"></a>

### List warehouses

`GET /api/v1/labels/warehouses`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `label_warehouses_list`

Your saved origin addresses. Use an `id` from here as `warehouseId` when creating a label.

Warehouses are created and edited in the portal under **Order Label → Warehouses** — set one up once and it is yours to reuse. This endpoint exists so your integration can read the id without anyone copying it by hand.

#### Parameters

No parameters.

#### Example request

```bash
curl -X GET 'https://api.merchfox.com/api/v1/labels/warehouses' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.items` | []object | Saved origins (up to 50), newest first. |
| `data.items[].id` | string | Warehouse id — send it as `warehouseId`. |
| `data.items[].name` | string | Your name for it. |
| `data.items[].description` | string | Your note. Omitted when empty. |
| `data.items[].address` | object | Origin address. |
| `data.items[].address.name` | string | Full name or company. |
| `data.items[].address.street1` | string | Street address. |
| `data.items[].address.street2` | string | Suite or unit. Omitted when empty. |
| `data.items[].address.city` | string | City. |
| `data.items[].address.state` | string | 2-letter state code. |
| `data.items[].address.zip` | string | ZIP code. |
| `data.items[].address.country` | string | Country code, "US". |
| `data.items[].service` | string | Default service display name, e.g. "USPS Ground Advantage" (labels created by API use the service you send). |
| `data.items[].serviceToken` | string | Its code, e.g. "usps_ground_advantage". |
| `data.items[].parcel` | object | Default box (portal only; API calls send their own parcel). |
| `data.items[].parcel.weight` | string | Weight, as a string. |
| `data.items[].parcel.massUnit` | string | "oz" or "lb". |
| `data.items[].parcel.length` | string | Length, as a string. |
| `data.items[].parcel.width` | string | Width, as a string. |
| `data.items[].parcel.height` | string | Height, as a string. |
| `data.items[].parcel.distanceUnit` | string | "in" or "cm". |
| `data.items[].isDefault` | bool | True for the default warehouse. Omitted otherwise. |
| `data.items[].createdAt` | string | RFC 3339 creation time. |
| `data.items[].updatedAt` | string | RFC 3339 time of the last change. |

#### Response: Success (HTTP 200)

```json
{
  "code": 0,
  "data": {
    "items": [
      {
        "id": "68a1f2c3d4e5f60718293a4b",
        "name": "Main warehouse",
        "address": {
          "name": "Example Goods LLC",
          "street1": "100 Example Ave",
          "city": "Columbus",
          "state": "OH",
          "zip": "43004",
          "country": "US"
        },
        "service": "USPS Ground Advantage",
        "serviceToken": "usps_ground_advantage",
        "parcel": {
          "weight": "16",
          "massUnit": "oz",
          "length": "10",
          "width": "8",
          "height": "4",
          "distanceUnit": "in"
        },
        "isDefault": true,
        "createdAt": "2026-08-01T10:00:00Z",
        "updatedAt": "2026-08-01T10:00:00Z"
      }
    ]
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

<a id="label_scanform_create"></a>

### Create a SCAN form

`POST /api/v1/labels/scan-forms`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `label_scanform_create`

Build the manifest sheet for a batch. Returns `pdfUrl` — print it and hand it to the driver.

**Mailing date is what you tell USPS**, i.e. the day you hand the parcels over. It does **not** decide which labels go on the form; the `trackingNumbers` you send do. A label bought Monday and shipped Wednesday belongs on Wednesday’s form.

**A tracking number can only be on one form.** Numbers already manifested, not yet purchased, or not yours are reported back in `rejected` with a reason, and the rest still go on the form.

The sender address is editable rather than read from the warehouse, because an address USPS accepts on a label can still need a tweak to read correctly on a manifest.

Only labels that are `ready` and yours can go on a form, at most 500 per form. If USPS refuses the batch, nothing is saved and the call answers HTTP 500 with code 5001 and the reason — fix it and call again.

Past forms are listed — and reprinted — in the portal under **Order Label → SCAN Forms**.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `warehouseId` | body | string | no | Origin this batch ships from. Used for the record; the address below is what prints. Example: `68a1f2c3d4e5f60718293a4b`. |
| `mailingDate` | body | string | no | YYYY-MM-DD — the day you hand the parcels to USPS. Defaults to today (UTC). Example: `2026-08-20`. |
| `senderName` | body | string | yes | Name printed as the sender. Example: `Ecombiz LLC`. |
| `street1` | body | string | yes | Sender street. Example: `4738 Gateway Cir`. |
| `street2` | body | string | no | Suite or unit. Example: `N240`. |
| `city` | body | string | yes | Sender city. Example: `Kettering`. |
| `state` | body | string | yes | Sender state, 2-letter code. Example: `OH`. |
| `zip` | body | string | yes | Sender ZIP (5 or 9 digits). Example: `45440`. |
| `trackingNumbers` | body | array | yes | The labels to put on this form. Up to 500. |

**`trackingNumbers`**

```json
[
  "9400111899223100000001",
  "9400111899223100000002"
]
```

#### Example request

```bash
curl -X POST 'https://api.merchfox.com/api/v1/labels/scan-forms' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>' \
  -H 'Content-Type: application/json' \
  -d '{"warehouseId":"68a1f2c3d4e5f60718293a4b","mailingDate":"2026-08-20","senderName":"Ecombiz LLC","street1":"4738 Gateway Cir","street2":"N240","city":"Kettering","state":"OH","zip":"45440","trackingNumbers":["9400111899223100000001","9400111899223100000002"]}'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.form` | object | The SCAN form. |
| `data.form.id` | string | SCAN form id. |
| `data.form.warehouseId` | string | Warehouse you sent, if any. |
| `data.form.warehouse` | string | Its name, when known. |
| `data.form.mailingDate` | string | YYYY-MM-DD. |
| `data.form.trackingNumbers` | string[] | Labels on the form. |
| `data.form.labelCount` | int | How many. |
| `data.form.manifestNumber` | string | USPS manifest number. |
| `data.form.pdfUrl` | string | Printable form for the driver. |
| `data.form.status` | enum | ready \| incomplete (filed with USPS but the PDF could not be stored — contact support with the manifest number). |
| `data.form.error` | string | Problem detail, when any. |
| `data.form.createdAt` | string | RFC 3339 creation time. |
| `data.form.canDelete` | bool | Whether the form can be deleted in the portal. |
| `data.form.canRetry` | bool | Whether the portal can retry storing the PDF. |
| `data.rejected` | []object | Numbers left off the form, with the reason. |
| `data.rejected[].trackingNumber` | string | The number. |
| `data.rejected[].reason` | string | e.g. "Not found in your labels.", "Not purchased yet.", "Already on a SCAN form." |

#### Response: Success (HTTP 200)

Numbers that cannot go on the form are listed in `rejected`; the rest are on it.

```json
{
  "code": 0,
  "data": {
    "form": {
      "id": "68b3c1d2e4f5a60718293c7d",
      "warehouseId": "68a1f2c3d4e5f60718293a4b",
      "warehouse": "Main warehouse",
      "mailingDate": "2026-10-17",
      "trackingNumbers": [
        "9400100000000000000000",
        "9400100000000000000001"
      ],
      "labelCount": 2,
      "manifestNumber": "9475700000000000000000",
      "pdfUrl": "https://cdn.example.com/scan-forms/68b3c1d2e4f5a60718293c7d.pdf",
      "status": "ready",
      "createdAt": "2026-10-17T09:00:00Z",
      "canDelete": false,
      "canRetry": false
    },
    "rejected": [
      {
        "trackingNumber": "9400100000000000000009",
        "reason": "Already on a SCAN form."
      }
    ]
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: Nothing eligible (HTTP 400)

```json
{
  "code": 2001,
  "data": null,
  "message": "None of those tracking numbers can go on a SCAN form.",
  "request_id": "3f9a1c2b7d4e5f69"
}
```

<a id="label_tracking_register"></a>

### Start tracking parcels

`POST /api/v1/labels/tracking/register`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `label_tracking_register`

Turn on delivery updates for your labels. **Up to 100 numbers per call** — send more in further calls.

**Available on request.** Package tracking is enabled per account — contact support to turn it on. Until then every tracking call answers `403`; **Check tracking access** tells you which applies.

Only your own labels can be registered. Numbers already being tracked are counted in `alreadyRegistered`, so calling this twice is safe; numbers that cannot be tracked are listed in `skipped` with a reason. If **none** of the numbers are labels you bought here, the whole call is refused with code 2001. Registering is free.

Once registered, updates arrive on their own. Set a webhook below rather than polling.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `trackingNumbers` | body | array | yes | Tracking numbers from your labels. Up to 100. |

**`trackingNumbers`**

```json
[
  "9400111899223100000001"
]
```

#### Example request

```bash
curl -X POST 'https://api.merchfox.com/api/v1/labels/tracking/register' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>' \
  -H 'Content-Type: application/json' \
  -d '{"trackingNumbers":["9400111899223100000001"]}'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.registered` | int | Numbers newly tracked. |
| `data.alreadyRegistered` | int | Numbers that were already tracked. |
| `data.skipped` | []object | Numbers that could not be tracked. |
| `data.skipped[].trackingNumber` | string | The number. |
| `data.skipped[].reason` | string | e.g. "Not found in your labels.", "Not accepted for tracking." |

#### Response: Success (HTTP 200)

```json
{
  "code": 0,
  "data": {
    "registered": 2,
    "alreadyRegistered": 1,
    "skipped": [
      {
        "trackingNumber": "9400100000000000000009",
        "reason": "Not accepted for tracking."
      }
    ]
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: Tracking not enabled (HTTP 403)

Package tracking is enabled per account on request.

```json
{
  "code": 1003,
  "data": null,
  "message": "Tracking updates are not enabled for your account. Please contact support.",
  "request_id": "3f9a1c2b7d4e5f64"
}
```

<a id="label_tracking_get"></a>

### Get parcel tracking

`GET /api/v1/labels/tracking/{trackingNumber}`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `label_tracking_get`

Latest status and the event history. `status` is the carrier’s own wording, which changes more often; `statusCategory` is a fixed list you can switch on:

- `LABEL_CREATED` — label exists, USPS has not scanned it yet
- `ACCEPTED` — USPS has the parcel
- `IN_TRANSIT` — moving through the USPS network
- `OUT_FOR_DELIVERY` — on the truck for delivery today
- `DELIVERED` — delivered (`delivered` is also `true`)
- `EXCEPTION` — something needs attention: an alert, return to sender, undeliverable
- `AWAITING_UPDATE` — no information from USPS yet

New categories may be added; treat an unknown one like `AWAITING_UPDATE`.

Returns 404 until the number has been registered.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `trackingNumber` | path | string | yes | A tracking number you registered. Example: `9400111899223100000001`. |

#### Example request

```bash
curl -X GET 'https://api.merchfox.com/api/v1/labels/tracking/{trackingNumber}' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.trackingNumber` | string | The tracking number. |
| `data.status` | string | Carrier wording of the latest status. Omitted until there is one. |
| `data.statusCategory` | enum | LABEL_CREATED \| ACCEPTED \| IN_TRANSIT \| OUT_FOR_DELIVERY \| DELIVERED \| EXCEPTION \| AWAITING_UPDATE. Treat an unknown value like AWAITING_UPDATE. |
| `data.events` | []object | Scan history, newest first. |
| `data.events[].status` | string | Carrier wording. |
| `data.events[].location` | string | Where it happened, when known. |
| `data.events[].datetime` | string | When it happened, when known. |
| `data.expectedDeliveryDate` | string | Carrier estimate, YYYY-MM-DD, when known. |
| `data.expectedDeliveryTime` | string | Carrier time estimate, when known. |
| `data.delivered` | bool | True once delivered. |
| `data.registeredAt` | string | When tracking was turned on (not included in webhook payloads). |
| `data.updatedAt` | string | Last update received (not included in webhook payloads). |

#### Response: In transit (HTTP 200)

```json
{
  "code": 0,
  "data": {
    "trackingNumber": "9400100000000000000000",
    "status": "Arrived at USPS Regional Facility",
    "statusCategory": "IN_TRANSIT",
    "events": [
      {
        "status": "Arrived at USPS Regional Facility",
        "location": "AUSTIN TX",
        "datetime": "2026-10-17T08:15:00Z"
      },
      {
        "status": "Accepted at USPS Origin Facility",
        "location": "COLUMBUS OH",
        "datetime": "2026-10-16T19:40:00Z"
      }
    ],
    "expectedDeliveryDate": "2026-10-19",
    "delivered": false,
    "registeredAt": "2026-10-16T14:05:00Z",
    "updatedAt": "2026-10-17T08:16:40Z"
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: Not registered (HTTP 404)

```json
{
  "code": 2002,
  "data": null,
  "message": "This label is not registered for tracking yet.",
  "request_id": "3f9a1c2b7d4e5f6a"
}
```

#### Response: Tracking not enabled (HTTP 403)

Package tracking is enabled per account on request.

```json
{
  "code": 1003,
  "data": null,
  "message": "Tracking updates are not enabled for your account. Please contact support.",
  "request_id": "3f9a1c2b7d4e5f64"
}
```

<a id="label_tracking_lookup"></a>

### Get tracking for many parcels

`POST /api/v1/labels/tracking/lookup`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `label_tracking_lookup`

Latest status for **up to 100** of your registered numbers in one call. Same fields as **Get parcel tracking**, in `items`.

Numbers that are not registered (or not yours) come back in `notRegistered` instead of failing the whole call, so you can register them and try again. Repeats and blank entries are ignored.

This reads the status we already hold, so it is free and safe to call on a schedule — but a webhook is still the better way to hear about changes.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `trackingNumbers` | body | array | yes | Registered tracking numbers. Up to 100. |

**`trackingNumbers`**

```json
[
  "9400111899223100000001",
  "9400111899223100000002"
]
```

#### Example request

```bash
curl -X POST 'https://api.merchfox.com/api/v1/labels/tracking/lookup' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>' \
  -H 'Content-Type: application/json' \
  -d '{"trackingNumbers":["9400111899223100000001","9400111899223100000002"]}'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.items` | []object | Tracking for each registered number. |
| `data.items[].trackingNumber` | string | The tracking number. |
| `data.items[].status` | string | Carrier wording of the latest status. Omitted until there is one. |
| `data.items[].statusCategory` | enum | LABEL_CREATED \| ACCEPTED \| IN_TRANSIT \| OUT_FOR_DELIVERY \| DELIVERED \| EXCEPTION \| AWAITING_UPDATE. Treat an unknown value like AWAITING_UPDATE. |
| `data.items[].events` | []object | Scan history, newest first. |
| `data.items[].events[].status` | string | Carrier wording. |
| `data.items[].events[].location` | string | Where it happened, when known. |
| `data.items[].events[].datetime` | string | When it happened, when known. |
| `data.items[].expectedDeliveryDate` | string | Carrier estimate, YYYY-MM-DD, when known. |
| `data.items[].expectedDeliveryTime` | string | Carrier time estimate, when known. |
| `data.items[].delivered` | bool | True once delivered. |
| `data.items[].registeredAt` | string | When tracking was turned on (not included in webhook payloads). |
| `data.items[].updatedAt` | string | Last update received (not included in webhook payloads). |
| `data.notRegistered` | string[] | Numbers you sent that are not registered (or not yours). |

#### Response: Success (HTTP 200)

```json
{
  "code": 0,
  "data": {
    "items": [
      {
        "trackingNumber": "9400100000000000000000",
        "status": "Arrived at USPS Regional Facility",
        "statusCategory": "IN_TRANSIT",
        "events": [
          {
            "status": "Arrived at USPS Regional Facility",
            "location": "AUSTIN TX",
            "datetime": "2026-10-17T08:15:00Z"
          },
          {
            "status": "Accepted at USPS Origin Facility",
            "location": "COLUMBUS OH",
            "datetime": "2026-10-16T19:40:00Z"
          }
        ],
        "expectedDeliveryDate": "2026-10-19",
        "delivered": false,
        "registeredAt": "2026-10-16T14:05:00Z",
        "updatedAt": "2026-10-17T08:16:40Z"
      }
    ],
    "notRegistered": [
      "9400100000000000000005"
    ]
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: Tracking not enabled (HTTP 403)

Package tracking is enabled per account on request.

```json
{
  "code": 1003,
  "data": null,
  "message": "Tracking updates are not enabled for your account. Please contact support.",
  "request_id": "3f9a1c2b7d4e5f64"
}
```

<a id="label_tracking_list"></a>

### List tracked parcels

`GET /api/v1/labels/tracking`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `label_tracking_list`

Every number you have registered, most recently updated first, with its latest status. Returns `items`, `total`, `page` and `pageSize` — the last two are the values actually used, since out-of-range ones are adjusted rather than refused.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `q` | query | string | no | Part of a tracking number. |
| `page` | query | integer | no | Default 1. |
| `pageSize` | query | integer | no | Default 20, max 100. |

#### Example request

```bash
curl -X GET 'https://api.merchfox.com/api/v1/labels/tracking' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.items` | []object | Tracked parcels, most recently updated first. |
| `data.items[].trackingNumber` | string | The tracking number. |
| `data.items[].status` | string | Carrier wording of the latest status. Omitted until there is one. |
| `data.items[].statusCategory` | enum | LABEL_CREATED \| ACCEPTED \| IN_TRANSIT \| OUT_FOR_DELIVERY \| DELIVERED \| EXCEPTION \| AWAITING_UPDATE. Treat an unknown value like AWAITING_UPDATE. |
| `data.items[].events` | []object | Scan history, newest first. |
| `data.items[].events[].status` | string | Carrier wording. |
| `data.items[].events[].location` | string | Where it happened, when known. |
| `data.items[].events[].datetime` | string | When it happened, when known. |
| `data.items[].expectedDeliveryDate` | string | Carrier estimate, YYYY-MM-DD, when known. |
| `data.items[].expectedDeliveryTime` | string | Carrier time estimate, when known. |
| `data.items[].delivered` | bool | True once delivered. |
| `data.items[].registeredAt` | string | When tracking was turned on (not included in webhook payloads). |
| `data.items[].updatedAt` | string | Last update received (not included in webhook payloads). |
| `data.total` | int | Matching parcels. |
| `data.page` | int | Page used. |
| `data.pageSize` | int | Page size used. |

#### Response: Success (HTTP 200)

```json
{
  "code": 0,
  "data": {
    "items": [
      {
        "trackingNumber": "9400100000000000000000",
        "status": "Arrived at USPS Regional Facility",
        "statusCategory": "IN_TRANSIT",
        "events": [
          {
            "status": "Arrived at USPS Regional Facility",
            "location": "AUSTIN TX",
            "datetime": "2026-10-17T08:15:00Z"
          },
          {
            "status": "Accepted at USPS Origin Facility",
            "location": "COLUMBUS OH",
            "datetime": "2026-10-16T19:40:00Z"
          }
        ],
        "expectedDeliveryDate": "2026-10-19",
        "delivered": false,
        "registeredAt": "2026-10-16T14:05:00Z",
        "updatedAt": "2026-10-17T08:16:40Z"
      }
    ],
    "total": 1,
    "page": 1,
    "pageSize": 20
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: Tracking not enabled (HTTP 403)

Package tracking is enabled per account on request.

```json
{
  "code": 1003,
  "data": null,
  "message": "Tracking updates are not enabled for your account. Please contact support.",
  "request_id": "3f9a1c2b7d4e5f64"
}
```

<a id="label_tracking_access"></a>

### Check tracking access

`GET /api/v1/labels/tracking/access`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `label_tracking_access`

Whether package tracking is enabled for your account: `{"enabled": true}` or `{"enabled": false}`. Answers `200` whether or not it is enabled.

Tracking is enabled on request — contact support if you need it.

#### Parameters

No parameters.

#### Example request

```bash
curl -X GET 'https://api.merchfox.com/api/v1/labels/tracking/access' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.enabled` | bool | Whether package tracking is enabled for your account. |

#### Response: Enabled (HTTP 200)

```json
{
  "code": 0,
  "data": {
    "enabled": true
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: Not enabled (HTTP 200)

```json
{
  "code": 0,
  "data": {
    "enabled": false
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

<a id="label_webhook_set"></a>

### Set your tracking webhook

`PUT /api/v1/labels/webhook-endpoint`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `label_webhook_set`

We POST every tracking update for your parcels to this URL, so you never have to poll.

**Available on request.** Package tracking is enabled per account — contact support to turn it on. Until then every tracking call answers `403`; **Check tracking access** tells you which applies.

**HTTPS only, and it must be reachable from the internet.** No username/password in the URL; localhost, internal hostnames and private addresses are refused, and redirects are not followed.

**What arrives.** A JSON `POST` with headers `MerchFox-Event` (`tracking.updated`, or `tracking.test` for a test), `MerchFox-Delivery` (unique id — use it to ignore a repeat) and `MerchFox-Signature`. Body:

```json
{
  "event": "tracking.updated",
  "data": {
    "trackingNumber": "9400111899223100000001",
    "status": "Arrived at USPS Regional Facility",
    "statusCategory": "IN_TRANSIT",
    "events": [{ "status": "Arrived at USPS Regional Facility", "location": "AUSTIN TX", "datetime": "2026-10-01T08:15:00Z" }],
    "expectedDeliveryDate": "2026-10-03",
    "delivered": false
  }
}
```

Answer with any `2xx` quickly; anything else is retried.

**Verifying a delivery.** Each request carries `MerchFox-Signature: t=<unix>,v1=<hex>`. Compute HMAC-SHA256 of `` `${t}.${rawBody}` `` with your secret and compare — against the **raw body**, before parsing, since re-serialising JSON changes the bytes and the signature will not match.

**The secret is shown once**, on first save or when you set `rotateSecret`. Saving a new URL does not change it, so verification code you already deployed keeps working.

Each attempt waits up to 10 seconds. A failed delivery is retried after 30 s, 2 min, 10 min, 30 min, 2 h, 6 h and 6 h (8 attempts, about 15 hours); after 20 failed attempts in a row the endpoint is switched off and further updates are dropped until you turn it back on. Saving the URL again re-enables it and resets the failure count.

Delivery health, pausing and removing the endpoint live in the portal under **Order Label → Tracking**.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `url` | body | string | yes | Your HTTPS endpoint. Example: `https://your-server.com/merchfox/tracking`. |
| `rotateSecret` | body | boolean | no | true issues a new signing secret and returns it once. Old secret stops working immediately. Example: `false`. |

#### Example request

```bash
curl -X PUT 'https://api.merchfox.com/api/v1/labels/webhook-endpoint' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>' \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://your-server.com/merchfox/tracking","rotateSecret":false}'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.configured` | bool | Always true after saving. |
| `data.endpoint` | object | Your webhook. |
| `data.endpoint.url` | string | Delivery URL. |
| `data.endpoint.enabled` | bool | Whether deliveries are on. Saving turns it on. |
| `data.endpoint.lastStatus` | int | HTTP status of the last attempt, when any. |
| `data.endpoint.lastError` | string | Error of the last attempt, when any. |
| `data.endpoint.lastDeliveredAt` | string | Last successful delivery, when any. |
| `data.endpoint.failureStreak` | int | Failed attempts in a row; 20 switches the endpoint off. |
| `data.endpoint.createdAt` | string | When the webhook was first saved. |
| `data.endpoint.secret` | string | Signing secret (`whsec_` + 64 hex). Only on first save or with rotateSecret — store it securely. |

#### Response: First save — secret shown once (HTTP 200)

```json
{
  "code": 0,
  "data": {
    "configured": true,
    "endpoint": {
      "url": "https://api.example.com/merchfox/tracking",
      "enabled": true,
      "createdAt": "2026-10-16T14:10:00Z",
      "secret": "whsec_5f2c9a7e1b3d4f6a8c0e2a4c6e8a0c2e4a6c8e0a2c4e6a8c0e2a4c6e8a0c2e4a"
    }
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: URL refused (HTTP 400)

```json
{
  "code": 2001,
  "data": null,
  "message": "That URL cannot be used — must start with https://",
  "request_id": "3f9a1c2b7d4e5f6b"
}
```

#### Response: Tracking not enabled (HTTP 403)

Package tracking is enabled per account on request.

```json
{
  "code": 1003,
  "data": null,
  "message": "Tracking updates are not enabled for your account. Please contact support.",
  "request_id": "3f9a1c2b7d4e5f64"
}
```

<a id="label_webhook_test"></a>

### Send a test delivery

`POST /api/v1/labels/webhook-endpoint/test`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `label_webhook_test`

Queues a signed sample payload (`tracking.test`) so you can confirm your receiver works before a real parcel depends on it. Arrives within about a minute. A paused endpoint does not receive it; with no URL saved the call answers code 2001.

#### Parameters

No parameters.

#### Example request

```bash
curl -X POST 'https://api.merchfox.com/api/v1/labels/webhook-endpoint/test' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.queued` | bool | Always true — the test delivery is on its way. |

#### Response: Queued (HTTP 200)

```json
{
  "code": 0,
  "data": {
    "queued": true
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: No URL saved (HTTP 400)

```json
{
  "code": 2001,
  "data": null,
  "message": "Save a webhook URL first.",
  "request_id": "3f9a1c2b7d4e5f6c"
}
```

<a id="label_quote"></a>

### Price a label

`POST /api/v1/labels/shipments/quote`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `label_quote`

What a label would cost, without creating anything. Nothing is stored and nothing is charged.

Same body as creating a label, minus `buy` and `reference` — so you can price a shipment by changing the URL rather than learning a second format.

The response includes `price` (postage plus handling fee, as a string), `requiredTotal` (price plus any refundable deposit), and `payable`: whether your wallet can currently cover it. Use it to decide between buying now and topping up first, instead of finding out from a failed purchase.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `service` | body | string | yes | `ground_advantage`, `priority`, or `priority_express`. Example: `ground_advantage`. |
| `warehouseId` | body | string | no | Saved origin. If missing or unknown, `from` is used instead. Example: `68a1f2c3d4e5f60718293a4b`. |
| `to` | body | object | yes | Delivery address — same fields as on create. |
| `from` | body | json | no | Origin address. Required when there is no usable `warehouseId`. |
| `parcel` | body | object | yes | Package — same fields as on create. |

**`to`**

```json
{
  "name": "Jane Doe",
  "street1": "1 Main St",
  "city": "Austin",
  "state": "TX",
  "zip": "78701"
}
```

**`parcel`**

```json
{
  "weight": 16,
  "length": 10,
  "width": 8,
  "height": 4
}
```

#### Example request

```bash
curl -X POST 'https://api.merchfox.com/api/v1/labels/shipments/quote' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>' \
  -H 'Content-Type: application/json' \
  -d '{"service":"ground_advantage","warehouseId":"68a1f2c3d4e5f60718293a4b","to":{"name":"Jane Doe","street1":"1 Main St","city":"Austin","state":"TX","zip":"78701"},"parcel":{"weight":16,"length":10,"width":8,"height":4}}'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.service` | string | The service priced. |
| `data.price` | string | Postage plus handling fee in USD, e.g. "8.45". |
| `data.payable` | bool | Whether the wallet can cover it right now. |
| `data.requiredTotal` | string | What the wallet must hold: price plus any refundable deposit. Omitted if the balance could not be checked. |
| `data.depositHold` | string | Refundable deposit set aside with the purchase (wallets without overdraft). Only when above zero. |
| `data.warning` | string | Only when `warehouseId` was unknown and `from` was used. |

#### Response: Success (HTTP 200)

```json
{
  "code": 0,
  "data": {
    "service": "ground_advantage",
    "price": "8.45",
    "payable": true,
    "requiredTotal": "13.29",
    "depositHold": "4.84"
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: Fields need fixing (HTTP 400)

Every problem is listed in `data.errors` at once.

```json
{
  "code": 2001,
  "data": {
    "errors": [
      "parcel.weight must be greater than 0",
      "to.state must be the 2-letter code, e.g. CA"
    ]
  },
  "message": "Some fields need fixing.",
  "request_id": "3f9a1c2b7d4e5f61"
}
```

#### Response: Address refused by the carrier (HTTP 500)

```json
{
  "code": 5001,
  "data": null,
  "message": "The destination (ship to) address could not be used. Please check that address and try again.",
  "request_id": "3f9a1c2b7d4e5f65"
}
```

<a id="label_list"></a>

### List labels

`GET /api/v1/labels/shipments`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `label_list`

Your labels, newest first, as a plain array of at most 100 (no paging — narrow the result with filters). To find a label by your own order number, use the `reference` returned on create or filter by `tracking`.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `status` | query | string | no | Exact status: `pending`, `queued`, `buying`, `ready`, `failed`, `cancelling`, `cancelled`, `disputed`. Example: `ready`. |
| `tracking` | query | string | no | Part of a tracking number (case-insensitive). |
| `orderId` | query | string | no | A label id (the `id` returned on create) — not your `reference`. Example: `68b3c1d2e4f5a60718293c7d`. |
| `warehouseId` | query | string | no | Only labels shipped from this warehouse. |
| `from` | query | string | no | Created on or after this UTC day, YYYY-MM-DD. An invalid date is ignored. |
| `to` | query | string | no | Created on or before this UTC day, YYYY-MM-DD. An invalid date is ignored. |

#### Example request

```bash
curl -X GET 'https://api.merchfox.com/api/v1/labels/shipments' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | []object | A JSON array; each element has the fields below. |
| `data[].id` | string | Label id — use it to get, confirm or cancel the label. |
| `data[].status` | enum | pending (draft) \| queued \| buying \| ready \| failed \| cancelling \| cancelled \| disputed. |
| `data[].reference` | string | Your `reference`, if you sent one. |
| `data[].service` | string | ground_advantage \| priority \| priority_express. |
| `data[].trackingNumber` | string | Present once the label is `ready`. |
| `data[].labelUrl` | string | Printable PDF. Present once the label is `ready`. |
| `data[].price` | string | Total charged (or to be charged) in USD, e.g. "8.45". |
| `data[].createdAt` | string | RFC 3339 creation time. |

#### Response: Success (HTTP 200)

A plain array, newest first, at most 100.

```json
{
  "code": 0,
  "data": [
    {
      "id": "6710a1b2c3d4e5f601234567",
      "status": "ready",
      "reference": "ORDER-1001",
      "service": "ground_advantage",
      "trackingNumber": "9400100000000000000000",
      "labelUrl": "https://cdn.example.com/labels/6710a1b2c3d4e5f601234567.pdf",
      "price": "8.45",
      "createdAt": "2026-10-16T14:02:11Z"
    }
  ],
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

<a id="label_confirm"></a>

### Buy a draft label

`POST /api/v1/labels/shipments/{id}/confirm`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `label_confirm`

Buys a label you created without `buy`: it moves from `pending` to `queued`, is bought moments later, and the wallet is charged once the carrier has created it. Poll **Get a shipping label** until `ready`.

This is the other half of the draft flow: create from your system, review, then confirm — either here or in the portal. Only a `pending` label can be confirmed; confirming again answers code 2001 "This label is already confirmed."

HTTP 402 (code 2006) means the wallet cannot cover it. The draft stays, so top up and call this again. Purchases are paced per API key like **Create a shipping label** (HTTP 429, code 4002).

The response is the detailed label view (addresses, parcel, `total` as a number), which has more fields than create/get.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | The label id. Example: `68b3c1d2e4f5a60718293c7d`. |

#### Example request

```bash
curl -X POST 'https://api.merchfox.com/api/v1/labels/shipments/{id}/confirm' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.id` | string | Label id. |
| `data.status` | enum | Same values as on the label. |
| `data.service` | string | Display name, e.g. "USPS Ground Advantage". |
| `data.servicelevelToken` | string | Service code, e.g. "usps_ground_advantage". |
| `data.warehouseId` | string | Origin warehouse, when one was used. |
| `data.warehouseName` | string | Its name. |
| `data.total` | number | Total in USD. |
| `data.depositHold` | number | Refundable deposit held with the purchase, when any. |
| `data.zone` | string | USPS zone for this origin and destination. |
| `data.trackingNumber` | string | Once bought. |
| `data.labelUrl` | string | Printable PDF once bought; blank while cancelling or after cancel. |
| `data.errorMessage` | string | Why a purchase or cancel failed. |
| `data.tracked` | bool | Whether delivery updates are on for this tracking number. |
| `data.addressFrom` | object | Origin address. |
| `data.addressFrom.name` | string | Full name or company. |
| `data.addressFrom.street1` | string | Street address. |
| `data.addressFrom.street2` | string | Suite or unit. Omitted when empty. |
| `data.addressFrom.city` | string | City. |
| `data.addressFrom.state` | string | 2-letter state code. |
| `data.addressFrom.zip` | string | ZIP code. |
| `data.addressFrom.country` | string | Country code, "US". |
| `data.addressTo` | object | Delivery address. |
| `data.addressTo.name` | string | Full name or company. |
| `data.addressTo.street1` | string | Street address. |
| `data.addressTo.street2` | string | Suite or unit. Omitted when empty. |
| `data.addressTo.city` | string | City. |
| `data.addressTo.state` | string | 2-letter state code. |
| `data.addressTo.zip` | string | ZIP code. |
| `data.addressTo.country` | string | Country code, "US". |
| `data.parcel` | object | Package. |
| `data.parcel.weight` | string | Weight, as a string. |
| `data.parcel.massUnit` | string | "oz" or "lb". |
| `data.parcel.length` | string | Length, as a string. |
| `data.parcel.width` | string | Width, as a string. |
| `data.parcel.height` | string | Height, as a string. |
| `data.parcel.distanceUnit` | string | "in" or "cm". |
| `data.messages` | string[] | Holds your `reference`. |
| `data.createdAt` | string | RFC 3339 creation time. |
| `data.updatedAt` | string | RFC 3339 time of the last change. |

#### Response: Queued for purchase (HTTP 200)

```json
{
  "code": 0,
  "data": {
    "id": "6710a1b2c3d4e5f601234567",
    "status": "queued",
    "service": "USPS Ground Advantage",
    "servicelevelToken": "usps_ground_advantage",
    "warehouseId": "68a1f2c3d4e5f60718293a4b",
    "warehouseName": "Main warehouse",
    "total": 8.45,
    "zone": "5",
    "tracked": false,
    "addressFrom": {
      "name": "Example Goods LLC",
      "street1": "100 Example Ave",
      "city": "Columbus",
      "state": "OH",
      "zip": "43004",
      "country": "US"
    },
    "addressTo": {
      "name": "Jane Sample",
      "street1": "1 Main St",
      "city": "Austin",
      "state": "TX",
      "zip": "78701",
      "country": "US"
    },
    "parcel": {
      "weight": "16",
      "massUnit": "oz",
      "length": "10",
      "width": "8",
      "height": "4",
      "distanceUnit": "in"
    },
    "messages": [
      "ORDER-1001"
    ],
    "createdAt": "2026-10-16T14:02:11Z",
    "updatedAt": "2026-10-16T14:03:00Z"
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: Already confirmed (HTTP 400)

```json
{
  "code": 2001,
  "data": null,
  "message": "This label is already confirmed.",
  "request_id": "3f9a1c2b7d4e5f67"
}
```

#### Response: Wallet cannot cover it (HTTP 402)

Nothing is created or charged. Top up and try again.

```json
{
  "code": 2006,
  "data": {
    "total": 8.45,
    "walletBalance": 5,
    "sufficient": false,
    "holdAmount": 4.84,
    "requiredTotal": 13.29,
    "allowNegativeBalance": false
  },
  "message": "Insufficient wallet balance. Need $13.29 (includes $4.84 refundable deposit hold).",
  "request_id": "3f9a1c2b7d4e5f62"
}
```

#### Response: Buying too fast (HTTP 429)

More than 5 purchases in a burst or 30 a minute on one API key. Wait `retry_after_seconds` (also in `Retry-After`); nothing was charged.

```json
{
  "code": 4002,
  "data": {
    "retry_after_seconds": 8
  },
  "message": "Purchase rate limit exceeded",
  "request_id": "3f9a1c2b7d4e5f63"
}
```

<a id="label_cancel"></a>

### Cancel a label

`POST /api/v1/labels/shipments/{id}/cancel`

- Auth: API key (`X-Mfx-App-Key` + `X-Mfx-App-Secret`)
- Explorer id: `label_cancel`

A label not yet bought (`pending`, `queued`) is simply closed — nothing was charged.

A purchased label (`ready`) is sent to the carrier to be voided. Status goes to `cancelling`, then:
- `cancelled` — voided; the full amount is credited back to the wallet;
- `disputed` — the carrier accepted the void with a fee: 10% is kept and 90% refunded;
- back to `ready` — the carrier refused (for example the parcel was already scanned).

A label that is `buying` or `failed` cannot be cancelled (code 2001). Cancelling a label that is already cancelling, cancelled or disputed returns it unchanged. The response is the detailed label view.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | The label id. Example: `68b3c1d2e4f5a60718293c7d`. |

#### Example request

```bash
curl -X POST 'https://api.merchfox.com/api/v1/labels/shipments/{id}/cancel' \
  -H 'X-Mfx-App-Key: <YOUR_API_KEY>' \
  -H 'X-Mfx-App-Secret: <YOUR_API_SECRET>'
```

#### Response fields

| Field | Type | Description |
| --- | --- | --- |
| `code` | int | Application status code. 0 = success; non-zero = error (1xxx auth, 2xxx request, 4xxx rate limit, 5xxx server). Most errors also carry the matching HTTP status (2001 with 400, 2002 with 404, 4001 with 429), but API-key management and webhook endpoints answer every error with HTTP 200 — always branch on `code`. |
| `message` | string | "Success" on success, the error reason on failure. |
| `request_id` | string | Correlation id, also sent in the `X-Request-Id` response header. Include it when contacting support. |
| `data` | object | Endpoint-specific payload. See nested fields below. |
| `data.id` | string | Label id. |
| `data.status` | enum | Same values as on the label. |
| `data.service` | string | Display name, e.g. "USPS Ground Advantage". |
| `data.servicelevelToken` | string | Service code, e.g. "usps_ground_advantage". |
| `data.warehouseId` | string | Origin warehouse, when one was used. |
| `data.warehouseName` | string | Its name. |
| `data.total` | number | Total in USD. |
| `data.depositHold` | number | Refundable deposit held with the purchase, when any. |
| `data.zone` | string | USPS zone for this origin and destination. |
| `data.trackingNumber` | string | Once bought. |
| `data.labelUrl` | string | Printable PDF once bought; blank while cancelling or after cancel. |
| `data.errorMessage` | string | Why a purchase or cancel failed. |
| `data.tracked` | bool | Whether delivery updates are on for this tracking number. |
| `data.addressFrom` | object | Origin address. |
| `data.addressFrom.name` | string | Full name or company. |
| `data.addressFrom.street1` | string | Street address. |
| `data.addressFrom.street2` | string | Suite or unit. Omitted when empty. |
| `data.addressFrom.city` | string | City. |
| `data.addressFrom.state` | string | 2-letter state code. |
| `data.addressFrom.zip` | string | ZIP code. |
| `data.addressFrom.country` | string | Country code, "US". |
| `data.addressTo` | object | Delivery address. |
| `data.addressTo.name` | string | Full name or company. |
| `data.addressTo.street1` | string | Street address. |
| `data.addressTo.street2` | string | Suite or unit. Omitted when empty. |
| `data.addressTo.city` | string | City. |
| `data.addressTo.state` | string | 2-letter state code. |
| `data.addressTo.zip` | string | ZIP code. |
| `data.addressTo.country` | string | Country code, "US". |
| `data.parcel` | object | Package. |
| `data.parcel.weight` | string | Weight, as a string. |
| `data.parcel.massUnit` | string | "oz" or "lb". |
| `data.parcel.length` | string | Length, as a string. |
| `data.parcel.width` | string | Width, as a string. |
| `data.parcel.height` | string | Height, as a string. |
| `data.parcel.distanceUnit` | string | "in" or "cm". |
| `data.messages` | string[] | Holds your `reference`. |
| `data.createdAt` | string | RFC 3339 creation time. |
| `data.updatedAt` | string | RFC 3339 time of the last change. |

#### Response: Void requested (HTTP 200)

A bought label moves to `cancelling`; poll until `cancelled` (or `disputed`).

```json
{
  "code": 0,
  "data": {
    "id": "6710a1b2c3d4e5f601234567",
    "status": "cancelling",
    "service": "USPS Ground Advantage",
    "servicelevelToken": "usps_ground_advantage",
    "warehouseId": "68a1f2c3d4e5f60718293a4b",
    "warehouseName": "Main warehouse",
    "total": 8.45,
    "zone": "5",
    "tracked": false,
    "addressFrom": {
      "name": "Example Goods LLC",
      "street1": "100 Example Ave",
      "city": "Columbus",
      "state": "OH",
      "zip": "43004",
      "country": "US"
    },
    "addressTo": {
      "name": "Jane Sample",
      "street1": "1 Main St",
      "city": "Austin",
      "state": "TX",
      "zip": "78701",
      "country": "US"
    },
    "parcel": {
      "weight": "16",
      "massUnit": "oz",
      "length": "10",
      "width": "8",
      "height": "4",
      "distanceUnit": "in"
    },
    "messages": [
      "ORDER-1001"
    ],
    "createdAt": "2026-10-16T14:02:11Z",
    "updatedAt": "2026-10-16T14:03:00Z",
    "trackingNumber": "9400100000000000000000"
  },
  "message": "Success",
  "request_id": "202203070749000101890810281E8C70B7"
}
```

#### Response: Cannot be cancelled (HTTP 400)

```json
{
  "code": 2001,
  "data": null,
  "message": "This label cannot be cancelled.",
  "request_id": "3f9a1c2b7d4e5f68"
}
```
