Merch Factory API

Send print on demand orders from your own website, app or ERP, read their status and tracking, and get a signed webhook the moment something changes. JSON over HTTPS, one API key, examples in curl, Node.js, Python and PHP.

Base URL https://www.merchfactory.in/api/v1Version v1Updated 23 October 2026

Introduction

The API is for brands that sell on their own website, app or marketplace tool and want Merch Factory to print, pack and ship those orders automatically.

You design products once in the Merch Factory editor and save them. Each saved product gets one SKU per size. Your system sends an order with those SKUs and the customer's address; we price it, pick a courier, charge your prepaid wallet and start production, exactly as if you had placed it in the dashboard. When the order is printed, shipped, delivered or returned, we tell your server through a webhook.

If you sell on Shopify, WooCommerce or Wix, use those integrations instead: they do all of this without code. The API is for everything else.

You canEndpoint
List your products and their SKUsGET /v1/products
See which couriers deliver to a pincode, and their priceGET /v1/couriers
Create and pay for an orderPOST /v1/orders
Read an order, its status and trackingGET /v1/orders/{id}
Cancel an order before printing startsPOST /v1/orders/{id}/cancel
Receive order and stock updatesWebhooks (set up in the dashboard)

Quickstart

From zero to your first order in four steps.

  1. Save your products. Create your designs in the editor (dashboard → New product) and save them. Only saved products can be ordered through the API.
  2. Create an API key. In the dashboard open Connectors → REST API and press Create key. Copy it once, it is not shown again. Store it as an environment variable on your server, for example MF_API_KEY.
  3. Get your SKUs. Call GET /v1/products. Map each of your own products and sizes to a Merch Factory SKU such as MF-7kQpL2xVr9TzWm4BnC1d-L (kids sizes are age ranges: MF-7kQpL2xVr9TzWm4BnC1d-7-8Yr). SKUs never change, so map them once and store them.
  4. Send an order. When a customer buys, call POST /v1/orders with the SKUs and their address. Keep enough balance in your wallet: the order is paid from it at once.
curl https://www.merchfactory.in/api/v1/products?limit=5 \
  -H "Authorization: Bearer $MF_API_KEY"

Authentication

Every request needs an API key in the Authorization header. Keys look like mf_live_ followed by 32 letters and digits.

Header
Authorization: Bearer mf_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  • Full access keys can read everything and create and cancel orders. Read only keys can only read; a write with one returns 403 FORBIDDEN.
  • A brand can have up to 5 keys. Create one per website or tool, so you can revoke one without breaking the others.
  • A key works for one brand only. Orders it places are charged to that brand's wallet and recorded under the team member who created it. If that person leaves the brand, the key stops working.
  • Revoking a key in the dashboard stops it within 30 seconds.

Keep keys on your server. Never put a key in a mobile app, browser JavaScript or a public repository: anyone holding a full-access key can place orders from your wallet. If a key leaks, revoke it in the dashboard and create a new one.

Requests and pagination

  • Send JSON with Content-Type: application/json. Bodies are limited to 64 KB.
  • Responses are JSON. Single objects come back as { "data": { … } }, lists as { "data": [ … ], "has_more": true, "next_cursor": "…" }.
  • Every response has an X-Request-Id header. Quote it if you contact support.
  • Times are ISO 8601 in UTC. Money is in Indian rupees (INR) as a number, for example 1349.12.
  • To read the next page of a list, pass the previous page's next_cursor as starting_after. When has_more is false you have everything.
Paging through all products
GET /v1/products?limit=100
GET /v1/products?limit=100&starting_after=<next_cursor from the previous page>

Errors

Errors use normal HTTP status codes and always have the same shape, with a stable code your program can check and a message you can show to a person.

Example
HTTP/1.1 402 Payment Required
X-Request-Id: req_Ii_D5DKDFwUq

{
  "error": {
    "code": "INSUFFICIENT_BALANCE",
    "message": "Your wallet balance is too low for this order: it needs ₹1,349.12 and ₹820.00 is available. Recharge the wallet and send the order again.",
    "request_id": "req_Ii_D5DKDFwUq"
  }
}
StatusCodeWhat to do
400VALIDATION_ERRORA field is missing or wrong. The message names the field, for example customer.phone.
400INVALID_JSONThe body is not valid JSON.
401UNAUTHORIZEDNo key, a mistyped key or a revoked key.
402INSUFFICIENT_BALANCERecharge the wallet and send the same order again.
403FORBIDDENRead-only key used for a write, or the key's creator left the brand.
403ORDERS_DISABLEDAPI orders are switched off in Connectors → REST API.
403AGREEMENT_REQUIREDAccept the merchant agreement in the dashboard.
404NOT_FOUNDNo such order or product for this brand.
409DUPLICATE_ORDER_REFThis order_ref was already used by an order placed another way. Use a new one.
409ORDER_IN_PROGRESSThe same order is being placed by another request. Fetch it in a few seconds.
409SHIPPING_CHANGEDCourier prices moved while placing. Nothing was charged; send it again.
409CANNOT_CANCELThe order is past PLACED.
413BODY_TOO_LARGESend less than 64 KB.
422UNKNOWN_SKUA SKU does not belong to one of your saved products.
422INVALID_ITEMSA product is inactive, the size does not exist, or a design needs updating in the editor.
422COURIER_NOT_AVAILABLEThe courier you named does not deliver there (only with courier_fallback: false).
422SHIPPING_TOO_EXPENSIVEEvery courier costs more than your shipping limit.
422COD_NOT_AVAILABLEAdd payout details in Settings → Payout, or send it prepaid.
422ORDER_REJECTEDAnother check failed (stock, print quality). The message says which.
429RATE_LIMITEDToo many requests. Wait for the Retry-After seconds.
429DAILY_LIMIT_REACHEDYour orders-per-day limit is used up.
503SHIPPING_UNAVAILABLECourier prices could not be fetched. Nothing was charged; retry in a minute.
500INTERNAL_ERROROur side failed. Nothing was changed; retry.

For 429 and 503 responses, read the Retry-After header (seconds). Retrying any order request is always safe, see safe retries.

Rate limits

Limits protect your wallet and our couriers from runaway scripts. A normal store never comes close.

LimitValue
Requests per key120 per minute, all endpoints together
Order creation per key30 per minute
Courier lookups per key30 per minute
Orders per brand per day (India time)300 by default; set up to 2,000 in Connectors → REST API
Cancellations per brand per day200
Items per order20 product/size lines, up to 500 pieces each
Wrong keys from one IP address20 in 10 minutes, then that address is blocked for 10 minutes

Safe retries

Every order carries your own reference, order_ref, which must be unique within your brand. Merch Factory turns it into the order's id, so the same order_ref can never create two orders or two charges.

If a request times out or your server restarts mid-call, send exactly the same request again. If the first one succeeded you get the existing order back with 200 and "duplicate": true; if it did not, the order is created now with 201. Use your store's own order number as order_ref.

A cancelled order keeps its order_ref. To order the same thing again, use a new reference such as WEB-1042-2.

The product object

A design you saved in the editor, printed on one garment and colour.

id
string
Merch Factory product id.
name
string
The name you gave the design.
base_product
string | null
The blank it is printed on, for example "Men's 180 GSM Half Sleeve Round Neck T-shirt".
color
string | null
Garment colour.
status
"active" | "inactive"
Only active products can be ordered. Inactive ones have no variants.
collection
string | null
Collection name, if you use collections.
variants
array
One entry per size: { "sku": "MF-<id>-L", "size": "L" }. Use the SKU in orders.
created_at, updated_at
string | null
ISO 8601 times.

List products

GET/v1/productsAny key
Query parameters
limit
integer
1 to 100. Default 50.
starting_after
string
next_cursor from the previous page.
Response 200
{
  "data": [
    {
      "id": "7kQpL2xVr9TzWm4BnC1d",
      "name": "Sunset Club Tee",
      "base_product": "Men's 180 GSM Half Sleeve Round Neck T-shirt",
      "color": "Beige",
      "status": "active",
      "collection": null,
      "created_at": "2026-10-13T12:49:11.503Z",
      "updated_at": "2026-10-14T10:19:26.830Z",
      "variants": [
        { "sku": "MF-7kQpL2xVr9TzWm4BnC1d-S", "size": "S" },
        { "sku": "MF-7kQpL2xVr9TzWm4BnC1d-M", "size": "M" },
        { "sku": "MF-7kQpL2xVr9TzWm4BnC1d-L", "size": "L" }
      ]
    }
  ],
  "has_more": true,
  "next_cursor": "7kQpL2xVr9TzWm4BnC1d"
}

Retrieve a product

GET/v1/products/{id}Any key

Returns { "data": <product> }, or 404 NOT_FOUND.

List couriers

Which couriers deliver to a pincode right now and what each costs. Useful to show delivery options at your checkout or to pick a courier name for an order.

GET/v1/couriers?pincode=560038Any key
Query parameters
pincode
stringrequired
6-digit Indian pincode.
payment_method
"prepaid" | "cod"
Default prepaid. Courier prices for COD can be higher.
weight_kg
number
Parcel weight. Default 0.5. Orders use the real weight of the garments.
order_value
number
Declared value in INR. Default 1000.
Response 200
{
  "data": [
    { "name": "Shadowfax Surface", "price_inr": 69.16, "estimated_days": 5, "mode": "Surface", "rating": 1.17 },
    { "name": "Delhivery Surface", "price_inr": 72.16, "estimated_days": 5, "mode": "Surface", "rating": 3.4 },
    { "name": "Blue Dart Air", "price_inr": 118.0, "estimated_days": 2, "mode": "Air", "rating": 4.1 }
  ],
  "city": "Bangalore",
  "state": "Karnataka"
}

Prices include GST and are what an order would be charged for shipping today. They can change between this call and the order; the order always uses the price at the moment it is placed. Ekart, Amazon Shipping and India Post / Speed Post are not offered through the API, so they are never listed or picked.

How a courier is chosen

Orders take an optional courier field:

  • Leave it out to use your brand's default rule, set in Connectors → REST API (cheapest unless you change it).
  • A rule: "cheapest", "fastest" (fewest estimated days, then price) or "best_rated" (highest courier rating, then price).
  • A courier name, as returned by GET /v1/couriers. Matching ignores case: "Delhivery" picks the cheapest Delhivery service, "Delhivery Air" only that one.

If the courier you named does not deliver to that pincode, the order uses your default rule instead and the response says "courier_fallback_used": true. Send "courier_fallback": false to get a 422 COURIER_NOT_AVAILABLE error instead.

If you set a highest shipping price in the dashboard, couriers above it are never chosen; when every courier is above it the order is refused with SHIPPING_TOO_EXPENSIVE. Once a parcel ships, the order shows the courier that actually carried it, which can differ when our hub reroutes a parcel.

The order object

id
string
Merch Factory order id. Starts with Oapi for orders created through the API.
order_ref
string
Your reference. You can use it anywhere an order id is accepted.
status
string
source
string
api, dashboard, shopify, woocommerce, wix or ai_connector.
payment_method
"prepaid" | "cod"
How the customer pays you.
order_value
number
Declared value; for COD, the amount the courier collects.
amount_charged_inr
number
What your wallet paid: production, print and shipping, with GST.
items[]
array
product_id, sku, name, size, quantity.
shipping
object
courier, mode, awb and tracking_url (null until shipped) and charge_inr.
customer
object
name, city, state, pincode. Phone, email and street address are never returned.
timeline[]
array
Every status with its time: { "status": "PLACED", "at": "…" }.
cancellation
object | null
reason and cancelled_by when cancelled.
created_at, updated_at
string
ISO 8601 times.

Order statuses

StatusMeaning
PLACEDPaid and waiting for production. Can still be cancelled.
PROCESSINGPrinting has started. Can no longer be cancelled.
SHIPPEDWith the courier. awb and tracking_url are set.
NDRA delivery attempt failed. Handle it in the dashboard under NDR.
DELIVEREDThe customer has the parcel. COD money follows in your remittance.
RETURNEDReturn to origin (RTO) received back at Merch Factory.
CANCELLEDCancelled before printing. The wallet was refunded in full.
LOSTDeclared lost by the courier.

Create an order

Creates the order and pays for it from your wallet at once, with the same checks and prices as an order placed in the dashboard.

POST/v1/ordersFull-access key
Body
order_ref
stringrequired
Your unique reference, 1 to 40 characters: letters, digits, spaces and - _ . / #. See safe retries.
payment_method
"prepaid" | "cod"required
COD needs payout details on your brand. You pay us the same either way; COD money collected by the courier is sent to you.
order_value
numberrequired
What the customer pays you, in INR. Used as the declared value and, for COD, the amount collected.
customer.name
stringrequired
Up to 80 characters.
customer.phone
stringrequired
10-digit Indian mobile number. +91, 0 and spaces are removed.
customer.email
stringrequired
Customer email, stored on the order like in the dashboard checkout.
customer.address1
stringrequired
House, street, area. Up to 120 characters.
customer.address2
stringrequired
Landmark. Couriers need it; up to 120 characters.
customer.pincode
stringrequired
6 digits. City and state are filled in from it.
items[]
arrayrequired
1 to 20 lines. Each is { "sku", "quantity" } or { "product_id", "size", "quantity" }. Send one of the two per line, not both. Quantity 1 to 500. Repeated SKUs are merged.
courier
string
A rule or a courier name. See how a courier is chosen.
courier_fallback
boolean
Default true. False returns an error instead of using another courier.
curl https://www.merchfactory.in/api/v1/orders \
  -H "Authorization: Bearer $MF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "order_ref": "WEB-1042",
    "payment_method": "prepaid",
    "order_value": 999,
    "courier": "cheapest",
    "customer": {
      "name": "Asha Rao",
      "phone": "9876543210",
      "email": "[email protected]",
      "address1": "12, 4th Cross, Indiranagar",
      "address2": "Near CMH Hospital",
      "pincode": "560038"
    },
    "items": [
      { "sku": "MF-7kQpL2xVr9TzWm4BnC1d-L", "quantity": 1 },
      { "product_id": "Hs3FdX8bQe0YtUz6RkPa", "size": "XL", "quantity": 2 }
    ]
  }'
Response
HTTP/1.1 201 Created
X-Request-Id: req_Ab12Cd34Ef56

{
  "data": {
    "id": "Oapi5c1e0b9a7d24f86e3b10c9d2",
    "order_ref": "WEB-1042",
    "status": "PLACED",
    "source": "api",
    "created_at": "2026-10-23T07:24:18.642Z",
    "updated_at": "2026-10-23T07:24:18.642Z",
    "payment_method": "prepaid",
    "order_value": 999,
    "amount_charged_inr": 1349.12,
    "items": [
      { "product_id": "7kQpL2xVr9TzWm4BnC1d", "sku": "MF-7kQpL2xVr9TzWm4BnC1d-L", "name": "Sunset Club Tee", "size": "L", "quantity": 1 },
      { "product_id": "Hs3FdX8bQe0YtUz6RkPa", "sku": "MF-Hs3FdX8bQe0YtUz6RkPa-XL", "name": "Night Owl", "size": "XL", "quantity": 2 }
    ],
    "shipping": { "courier": "Shadowfax Surface", "mode": "Surface", "awb": null, "tracking_url": null, "charge_inr": 69.16 },
    "customer": { "name": "Asha Rao", "city": "Bangalore", "state": "Karnataka", "pincode": "560038" },
    "timeline": [{ "status": "PLACED", "at": "2026-10-23T07:24:18.587Z" }],
    "cancellation": null
  },
  "duplicate": false,
  "courier_fallback_used": false
}
  • 201: the order was created and paid. 200 with "duplicate": true: this order_ref was already used by an earlier API call, nothing new was charged.
  • Prices are always worked out by Merch Factory. Do not send prices; the wallet is charged the production cost of the items plus shipping.
  • Before charging, we check stock for every size and colour, that each design is still current and sharp enough to print, and that your wallet covers the total. If any check fails, nothing is charged.
  • Placing an order usually takes 3 to 8 seconds because design files are copied into the order. Use a timeout of at least 60 seconds.

Retrieve an order

GET/v1/orders/{id or order_ref}Any key

Returns { "data": <order> }. Pass our id or your order_ref (URL-encode it if it contains # or /). Orders placed outside the API can be read too.

List orders

GET/v1/ordersAny key
Query parameters
limit
integer
1 to 100. Default 25.
starting_after
string
next_cursor (an order id) from the previous page.

All of your brand's orders, newest first, whatever their source. For updates as they happen, use webhooks instead of polling this list.

Cancel an order

POST/v1/orders/{id or order_ref}/cancelFull-access key
Body (optional)
reason
string
Up to 300 characters. Shown on the order in the dashboard.
curl -X POST https://www.merchfactory.in/api/v1/orders/WEB-1042/cancel \
  -H "Authorization: Bearer $MF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "Customer changed their mind" }'

Only orders that are still PLACED can be cancelled. The full amount goes back to your wallet at once and the response includes refund_inr. Once printing has started the request returns 409 CANNOT_CANCEL.

Webhooks

Instead of asking us for updates, let us call you. When an order or a blank you use changes, we POST a signed JSON event to your server.

  1. Open Connectors → REST API → Webhooks and press Add webhook.
  2. Enter a public https:// address on your server, choose the events and whether you want updates for orders sent through the API only or for all your orders.
  3. Copy the signing secret (whsec_…) into your server settings and use Send test to check your endpoint answers.

A brand can have up to 3 webhook addresses. Your endpoint must answer with any 2xx status within 8 seconds. Do slow work after answering. Redirects are not followed.

Event types

EventSent whendata
order.processingPrinting starts (the order can no longer be cancelled).order
order.shippedHanded to the courier. Includes awb and tracking_url.order
order.deliveredDelivered to the customer.order
order.rto_initiatedDelivery failed and the parcel is on its way back (RTO).order
order.rto_deliveredThe returned parcel reached Merch Factory.order
stock.lowA blank colour/size used by your active products is running low.items[]
stock.out_of_stockIt ran out. New orders for those SKUs are refused until it is back.items[]
stock.back_in_stockA blank that was low or out is fully available again.items[]
pingYou pressed Send test.message

Order events carry the same order object as GET /v1/orders/{id}. While an RTO is on its way back the order's status still reads SHIPPED; it becomes RETURNED when our warehouse logs the parcel. Stock events list every affected SKU of yours in one event, so you can pause those products in your store.

stock.out_of_stock
{
  "id": "evt_2f049b5bfb434fe097d904b3",
  "type": "stock.out_of_stock",
  "created_at": "2026-10-23T07:28:10.649Z",
  "data": {
    "items": [
      { "sku": "MF-7kQpL2xVr9TzWm4BnC1d-XL", "product_id": "7kQpL2xVr9TzWm4BnC1d", "product_name": "Sunset Club Tee", "color": "Beige", "size": "XL", "level": "out_of_stock" }
    ]
  }
}

Payload and headers

Example request to your server
POST /merch-factory/webhook HTTP/1.1
Content-Type: application/json
User-Agent: MerchFactory-Webhooks/1.0
MF-Event: order.shipped
MF-Event-Id: evt_e744e9f0b1f9aa07141656be
MF-Delivery-Attempt: 1
MF-Signature: t=1792720528,v1=5f2b9c…(64 hex characters)

{
  "id": "evt_e744e9f0b1f9aa07141656be",
  "type": "order.shipped",
  "created_at": "2026-10-23T07:25:28.555Z",
  "data": {
    "order": {
      "id": "Oapi5c1e0b9a7d24f86e3b10c9d2",
      "order_ref": "WEB-1042",
      "status": "SHIPPED",
      "shipping": {
        "courier": "Delhivery Surface",
        "awb": "1490811234567",
        "tracking_url": "https://shiprocket.co/tracking/1490811234567",
        "mode": "Surface",
        "charge_inr": 72.16
      },
      "...": "the same order object as GET /orders/{id}"
    }
  }
}
HeaderMeaning
MF-EventThe event type, the same as type in the body.
MF-Event-IdUnique id of this event, the same as id in the body. Retries keep the same id.
MF-Delivery-Attempt1 for the first try, then 2, 3 …
MF-Signaturet=<unix seconds>,v1=<signature>. See below.

Each event is sent once per webhook. If you see the same MF-Event-Id twice (because your server answered too slowly the first time), ignore the second one.

Verifying signatures

Check every webhook before trusting it, so nobody else can send you fake order updates.

  1. Read t and v1 from the MF-Signature header.
  2. Compute HMAC-SHA256 of t + "." + raw request body with your whsec_… secret, as lowercase hex.
  3. Compare it with v1 using a constant-time comparison, and refuse requests whose t is more than 5 minutes old.

Use the raw body bytes exactly as received. Parsing the JSON and serialising it again changes the bytes and the check fails.

import crypto from 'node:crypto'
import express from 'express'

const app = express()
const SECRET = process.env.MF_WEBHOOK_SECRET // whsec_…

// Use the raw body: re-serialising the JSON changes the bytes and breaks the signature.
app.post('/merch-factory/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('MF-Signature') || ''
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
  const expected = crypto.createHmac('sha256', SECRET).update(`${parts.t}.${req.body}`).digest('hex')
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300
  const valid = parts.v1?.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
  if (!valid || !fresh) return res.sendStatus(400)

  const event = JSON.parse(req.body)
  // Store event.id; skip it if you have already processed it.
  res.sendStatus(200) // answer fast, do slow work afterwards
  handle(event)
})

You can see or change the secret any time with Signing secret on the webhook. After changing it, the old secret stops verifying at once.

Retries and removal

If your endpoint does not answer 2xx within 8 seconds, we try again after 1 minute, 5 minutes, 15 minutes, 30 minutes, 1, 2, 3 and 4 hours, then every 6 hours, until 24 hours after the event. The dashboard shows each delivery and its last answer under Recent deliveries.

If every delivery to an address fails for 24 hours in a row, the webhook is removed and the brand sees a notice in the dashboard. Fix your server and add the address again. One successful delivery resets the clock.

We send at most 60 events a minute to one address; anything above that follows a minute later.

Testing your integration

There is no separate sandbox: the API works on your real brand and wallet. A safe way to test:

  1. Use a Read only key while you build the product mapping and order-status screens.
  2. Place one real order to your own address with a full-access key and a test order_ref such as TEST-1.
  3. Cancel it right away with POST /v1/orders/TEST-1/cancel. While it is still PLACED the full amount returns to your wallet.
  4. Point a webhook at a request inspector or your staging server and press Send test.

Every write and every failed request appears under Recent activity on the REST API page for 30 days.

OpenAPI spec

A machine-readable description of every endpoint is at https://www.merchfactory.in/api/v1/openapi.json (OpenAPI 3.1). Import it into Postman, Insomnia or a code generator to get a ready client.

Changelog

DateChange
23 Oct 2026Webhooks for order processing, shipped, delivered, RTO and stock levels, with signed requests and automatic retries.
23 Oct 2026v1 released: API keys, products, couriers, create, read, list and cancel orders.

Changes inside v1 only ever add fields or endpoints. Anything that would break an existing integration will come as v2, announced in advance.

Support

Stuck? Open a ticket in the dashboard under Help → Integrations with the X-Request-Id of the call, or contact us. For what the API can do for your business, see the print on demand API overview.