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.
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 can | Endpoint |
|---|---|
| List your products and their SKUs | GET /v1/products |
| See which couriers deliver to a pincode, and their price | GET /v1/couriers |
| Create and pay for an order | POST /v1/orders |
| Read an order, its status and tracking | GET /v1/orders/{id} |
| Cancel an order before printing starts | POST /v1/orders/{id}/cancel |
| Receive order and stock updates | Webhooks (set up in the dashboard) |
Quickstart
From zero to your first order in four steps.
- Save your products. Create your designs in the editor (dashboard → New product) and save them. Only saved products can be ordered through the API.
- 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. - Get your SKUs. Call
GET /v1/products. Map each of your own products and sizes to a Merch Factory SKU such asMF-7kQpL2xVr9TzWm4BnC1d-L(kids sizes are age ranges:MF-7kQpL2xVr9TzWm4BnC1d-7-8Yr). SKUs never change, so map them once and store them. - Send an order. When a customer buys, call
POST /v1/orderswith 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.
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-Idheader. 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_cursorasstarting_after. Whenhas_moreis false you have everything.
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.
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"
}
}| Status | Code | What to do |
|---|---|---|
| 400 | VALIDATION_ERROR | A field is missing or wrong. The message names the field, for example customer.phone. |
| 400 | INVALID_JSON | The body is not valid JSON. |
| 401 | UNAUTHORIZED | No key, a mistyped key or a revoked key. |
| 402 | INSUFFICIENT_BALANCE | Recharge the wallet and send the same order again. |
| 403 | FORBIDDEN | Read-only key used for a write, or the key's creator left the brand. |
| 403 | ORDERS_DISABLED | API orders are switched off in Connectors → REST API. |
| 403 | AGREEMENT_REQUIRED | Accept the merchant agreement in the dashboard. |
| 404 | NOT_FOUND | No such order or product for this brand. |
| 409 | DUPLICATE_ORDER_REF | This order_ref was already used by an order placed another way. Use a new one. |
| 409 | ORDER_IN_PROGRESS | The same order is being placed by another request. Fetch it in a few seconds. |
| 409 | SHIPPING_CHANGED | Courier prices moved while placing. Nothing was charged; send it again. |
| 409 | CANNOT_CANCEL | The order is past PLACED. |
| 413 | BODY_TOO_LARGE | Send less than 64 KB. |
| 422 | UNKNOWN_SKU | A SKU does not belong to one of your saved products. |
| 422 | INVALID_ITEMS | A product is inactive, the size does not exist, or a design needs updating in the editor. |
| 422 | COURIER_NOT_AVAILABLE | The courier you named does not deliver there (only with courier_fallback: false). |
| 422 | SHIPPING_TOO_EXPENSIVE | Every courier costs more than your shipping limit. |
| 422 | COD_NOT_AVAILABLE | Add payout details in Settings → Payout, or send it prepaid. |
| 422 | ORDER_REJECTED | Another check failed (stock, print quality). The message says which. |
| 429 | RATE_LIMITED | Too many requests. Wait for the Retry-After seconds. |
| 429 | DAILY_LIMIT_REACHED | Your orders-per-day limit is used up. |
| 503 | SHIPPING_UNAVAILABLE | Courier prices could not be fetched. Nothing was charged; retry in a minute. |
| 500 | INTERNAL_ERROR | Our 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.
| Limit | Value |
|---|---|
| Requests per key | 120 per minute, all endpoints together |
| Order creation per key | 30 per minute |
| Courier lookups per key | 30 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 day | 200 |
| Items per order | 20 product/size lines, up to 500 pieces each |
| Wrong keys from one IP address | 20 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.
idnamebase_productcolorstatuscollectionvariants{ "sku": "MF-<id>-L", "size": "L" }. Use the SKU in orders.created_at, updated_atList products
/v1/productsAny keylimitstarting_after{
"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
/v1/products/{id}Any keyReturns { "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.
/v1/couriers?pincode=560038Any keypincodepayment_methodweight_kgorder_value{
"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
idorder_refstatussourcepayment_methodorder_valueamount_charged_inritems[]product_id, sku, name, size, quantity.shippingcourier, mode, awb and tracking_url (null until shipped) and charge_inr.customername, city, state, pincode. Phone, email and street address are never returned.timeline[]{ "status": "PLACED", "at": "…" }.cancellationreason and cancelled_by when cancelled.created_at, updated_atOrder statuses
| Status | Meaning |
|---|---|
PLACED | Paid and waiting for production. Can still be cancelled. |
PROCESSING | Printing has started. Can no longer be cancelled. |
SHIPPED | With the courier. awb and tracking_url are set. |
NDR | A delivery attempt failed. Handle it in the dashboard under NDR. |
DELIVERED | The customer has the parcel. COD money follows in your remittance. |
RETURNED | Return to origin (RTO) received back at Merch Factory. |
CANCELLED | Cancelled before printing. The wallet was refunded in full. |
LOST | Declared 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.
/v1/ordersFull-access keyorder_refpayment_methodorder_valuecustomer.namecustomer.phonecustomer.emailcustomer.address1customer.address2customer.pincodeitems[]{ "sku", "quantity" } or { "product_id", "size", "quantity" }. Send one of the two per line, not both. Quantity 1 to 500. Repeated SKUs are merged.couriercourier_fallbackcurl 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 }
]
}'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.200with"duplicate": true: thisorder_refwas 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
/v1/orders/{id or order_ref}Any keyReturns { "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
/v1/ordersAny keylimitstarting_afterAll 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
/v1/orders/{id or order_ref}/cancelFull-access keyreasoncurl -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.
- Open Connectors → REST API → Webhooks and press Add webhook.
- 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. - 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
| Event | Sent when | data |
|---|---|---|
order.processing | Printing starts (the order can no longer be cancelled). | order |
order.shipped | Handed to the courier. Includes awb and tracking_url. | order |
order.delivered | Delivered to the customer. | order |
order.rto_initiated | Delivery failed and the parcel is on its way back (RTO). | order |
order.rto_delivered | The returned parcel reached Merch Factory. | order |
stock.low | A blank colour/size used by your active products is running low. | items[] |
stock.out_of_stock | It ran out. New orders for those SKUs are refused until it is back. | items[] |
stock.back_in_stock | A blank that was low or out is fully available again. | items[] |
ping | You 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.
{
"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
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}"
}
}
}| Header | Meaning |
|---|---|
MF-Event | The event type, the same as type in the body. |
MF-Event-Id | Unique id of this event, the same as id in the body. Retries keep the same id. |
MF-Delivery-Attempt | 1 for the first try, then 2, 3 … |
MF-Signature | t=<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.
- Read
tandv1from theMF-Signatureheader. - Compute HMAC-SHA256 of
t + "." + raw request bodywith yourwhsec_…secret, as lowercase hex. - Compare it with
v1using a constant-time comparison, and refuse requests whosetis 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:
- Use a Read only key while you build the product mapping and order-status screens.
- Place one real order to your own address with a full-access key and a test
order_refsuch asTEST-1. - Cancel it right away with
POST /v1/orders/TEST-1/cancel. While it is stillPLACEDthe full amount returns to your wallet. - 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
| Date | Change |
|---|---|
| 23 Oct 2026 | Webhooks for order processing, shipped, delivered, RTO and stock levels, with signed requests and automatic retries. |
| 23 Oct 2026 | v1 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.