Customnia Partner API
Customnia is a print house in Long Island City, NY and Compton, CA. Stores, apps and marketplaces send us orders their customers already paid for; we print or embroider them and ship them, in the store's name if asked. This API is how orders get to us and how you learn what happens to them.
Ordering for yourself, not for a store's customers? See Order by API: sign up by email, retail prices, pay by card.
- Base URL
https://api.customnia.com/v1- Format
- Requests and answers are JSON; designs are uploaded as
multipart/form-data. - Version
- 1, November 2026
Every field and error: API reference · machine-readable openapi.yaml (generate a client, import into Postman) · changelog
Authentication
Every request carries your API key. Keys are issued by Customnia; write to hello@customnia.com. Keep the key on your server; never in a browser or a mobile app.
curl https://api.customnia.com/v1/catalog \
-H "Authorization: Bearer cn_live_…"
Test mode
You also get a test key, cn_test_…. Build and test your integration with it: every call works and is checked and priced like a real one, but orders placed with it are kept apart, never printed, packed or charged. Their numbers start with TEST- and they carry "test": true. A test key sees only test orders, the live key only real ones.
POST/orders/{id}/simulate — test key only: {"status": "shipped"} moves a test order the way production would (printing, ready, shipped with a test UPS tracking number, delivered, cancelled), and the order.updated webhook follows within a minute.
Designs and mockups are the same in both modes: a design made with the test key can be ordered with the live key.
Errors and limits
| Status | Meaning |
|---|---|
| 401 | No key or a wrong one. |
| 404 | Not found, or not yours. |
| 409 | Not possible now: printing has started, the order is being placed, the design has no print file yet. The body says which (error). |
| 422 | Invalid input: {"message": "…", "errors": {"field": ["…"]}}. |
| 429 | More than 240 requests a minute. Wait and retry. |
Money and times
- Money is in US cents:
1360is $13.60. - Times are ISO 8601 with the time zone of the print shop.
- Prices are your wholesale prices; they are in the catalog and in every quote and order.
- Production takes 4 business days at the print shop (weekends and US holidays excluded); orders after 3 PM count from the next business day. Each order says when it is
ready_by. - We ship to US addresses only, usually by UPS Ground.
Catalog
GET/catalog — blanks you can order, their colors and sizes, your price for every color × size, photos and where the print sits on them. Changes rarely: cache it for an hour.
{
"currency": "USD", "production_days": 4,
"shipping": {"first_item": 650, "additional_item": 200, "countries": ["US"]},
"locations": [{"id": 1, "name": "NYC"}, {"id": 2, "name": "LA"}],
"blanks": [{
"id": 4, "name": "Unisex Next Level Cotton T-Shirt", "category": "T-Shirts", "decoration": "print",
"print_area": {"width_in": 14, "height_in": 16, "width_px": 2100, "height_px": 2400, "dpi": 150},
"sizes": [{"id": 3, "name": "M"}, …],
"colors": [{"id": 1, "name": "White", "hex": "#FFFFFF", "photo": "https://…jpg",
"photo_print_area": {"preview_x": 160, "preview_y": 115, "preview_width": 280, "preview_height": 320},
"prices": {"3": 1360, …},
"back_photo": "https://…jpg", "back_print_price": 320}, …],
"back_print_area": {"width_in": 14, "height_in": 16, "width_px": 2100, "height_px": 2400, "dpi": 150}
}, …]
}
Filters: /catalog?category=HOODIE&color=Black (also brand, size, label; each takes an id or a name, case aside). The answer always carries filters with every value they can take. GET/catalog/{blank_id} is one blank.
Each blank also has brand, sku, labels, printing_speeds, has_front, has_back, platen (print area in pixels on every blank); each color sku, dark, text_hex, print_location_id, same_day.
photo_print_area is in a 600 × 600 frame laid over the photo: scale it to the photo's size to place a preview.
Blanks with a back_print_area are printed on the back too. A back print adds the color's back_print_price to each piece; back_photo and back_photo_print_area work like the front ones.
Designs
A design is your artwork on one blank. Order items refer to it; every color of the blank can be ordered with it.
POST/designs — blank_id and file: a PNG, at least 600 × 600 px; for a full print use the blank's print_area.width_px × height_px. Transparent background prints best. Add back_file for the back (blanks with a back_print_area only), or send only back_file for a back-only print. Without either you get an empty design to open in the designer.
curl https://api.customnia.com/v1/designs -H "Authorization: Bearer cn_live_…" \
-F blank_id=4 -F file=@sunset.png -F back_file=@logo.png
201 {"id": 57, "blank_id": 4, "ready": true, "sides": ["front", "back"],
"print_file_url": "https://…png", "back_print_file_url": "https://…png",
"mockup_url_template": "https://api.customnia.com/v1/designs/57/mockups/{color_id}.jpg",
"back_mockup_url_template": "https://api.customnia.com/v1/designs/57/mockups/{color_id}.jpg?side=back"}
GET/designs/{id} — the same; ready turns true once the design has a print file (after the designer's Save).
GET/designs?page=1&per_page=25 — your designs, newest first, with meta.total and meta.last_page. customized_blank_ids=172945,172944 finds designs by their id in the old design API; blank_id narrows to one blank.
Designs from layers
If you have your own editor, send its layers instead of a PNG: a fabric.js canvas per side, the way Customnia's designers keep them. We render the print files and previews from them; ready turns true when the print file exists.
POST /v1/designs
{"blank_id": 4, "color_id": 1, "notes": "Left chest",
"front_layers": {"version": "5.3.0", "objects": [{"type": "image", "uuid": "u-1", "src": "https://…/logo-light.png",
"left": 140, "top": 20, "width": 1000, "height": 1000, "scaleX": 0.2, "scaleY": 0.2}]},
"back_layers": null,
"alternative": {"front": {"u-1": {"src": "https://…/logo-dark.png"}}}}
201 {"id": 58, "customized_blank_id": 173010, "blank_id": 4, "color_id": 1,
"color": {"id": 1, "name": "White", "hex": "#FFFFFF", "dark": false}, "print_location_id": 11,
"origin": "api", "test": false, "ready": false, "editable": true, "sides": ["front"],
"layers": {"front": {"canvas": {…}, "alternative": {"u-1": {"src": "…dark.png"}}, "fabric_version": 5, "attached_files": {}}, "back": null},
"previews": {"front": null, "back": null},
"print_files": {"front": {"main": null, "alternative": null, "clear_background": null, "ready_print": null, "ready_print_alternative": null}, "back": null},
"print_file_url": null, "back_print_file_url": null, …}
- The layers sit in the 600 × 600 frame of the blank's photo in that color; the print area is the color's
photo_print_area. alternative: by objectuuid(orid), what the object becomes on a garment of the other darkness:{"src": …}for a picture,{"color": …}for text.- A side with no objects is not printed; a design with no objects at all is refused (422).
PATCH/designs/{id} — only what you send changes: front_layers / back_layers (null removes the side), alternative, color_id, notes; or a new picture for a side as file / back_file (multipart POST /designs/{id} with _method=PATCH). A side that changes gets a new print file. Once the design is in a placed order: 409 design_locked — copy it instead.
Copy and claim
POST/designs/{id}/copy — {"blank_id": 6, "color_id": 2} (blank_id defaults to the design's own): a new design of yours on that blank and color. Layers are scaled into the new print area; between light and dark colors objects with an alternative swap to it. POST/designs/{id}/layers/copy takes the same and answers {"front": {"canvas": …, "alternative": …}, "back": null} without saving anything.
POST/designs/claim — {"customized_blank_ids": [172945, 172944]}: designs you made through the old design API become yours here, keeping their id (customized_blank_id). A design that another partner has, or that carries another partner's mark, a Customnia customer's design, or a design in another store's order cannot be claimed. Claiming again returns the same design. Live key only.
{"claimed": [{"id": 61, "customized_blank_id": 172945, "origin": "claimed", …}],
"rejected": [{"customized_blank_id": 172944, "error": "taken", "message": "The design belongs to another partner."}]}
Replace a picture
POST/designs/media/replace — a picture changed in your media library, changed in your designs. On a light garment the light variant is the object's picture and dark its alternative; the other way round on a dark one. new given means that variant changes (null removes the alternative). Without design_ids every design of yours with the picture changes.
POST/designs/text/replace — a text changed at once in your designs: from: {text, font} finds the words (the font only when given), to: {text, color_light, color_dark} is what they become. On a light garment color_light is the fill and color_dark its alternative; the other way round on a dark one. Without design_ids every design of yours with the words changes.
{"replacements": [{"design_ids": [57, 58],
"light": {"current": "https://…/logo-light.png", "new": "https://…/logo2-light.png"},
"dark": {"current": "https://…/logo-dark.png", "new": "https://…/logo2-dark.png"}}]}
→ {"updated": [57, 58]}
A test key reads every design of yours but changes only designs made with a test key.
Mockups
GET/designs/{id}/mockups/{color_id}.jpg — the design on the photo of the blank in that color, 1000 × 1000 JPEG, made once and kept; ?side=back for the back. Download it and host it yourself (e.g. upload it to Shopify); the URL needs your key.
Designer link
POST/designs/{id}/editor-link — {"return_url": "https://your.app/back", "email": "seller@…", "brand_name": "Brooklyn Tees"} → {"url": "…", "expires_in": 43200}. Send your user to url: our designer opens with this design; after Save the browser goes to return_url. Then fetch the design again for its new print file.
Quotes
POST/quotes — what an order would cost, before you charge anyone.
{"items": [{"design_id": 57, "color_id": 1, "size_id": 3, "quantity": 2}]}
→ {"items": [{… "price": 1360}], "totals": {"items": 2720, "shipping": 850, "total": 3570, "currency": "USD"}}
Orders
POST/orders — an order your customer already paid for. reference is your own order id and makes the call safe to retry: the same reference returns the order you already placed (200) instead of a new one (201).
{
"reference": "shopify:brooklyn-tees:1001",
"label": "#1001",
"seller": {"name": "Brooklyn Tees", "domain": "brooklyn-tees.myshopify.com", "packing_name": "Brooklyn Tees",
"logo_url": "https://brooklyn-tees.com/logo.png", "packing_note": "Thanks for shopping small!"},
"customer": {"email": "jamie@example.com"},
"shipping_address": {"first_name": "Jamie", "last_name": "Rivera", "address1": "350 5th Ave", "city": "New York",
"state": "NY", "zip": "10118", "country": "US", "phone": "+12125550123"},
"items": [{"design_id": 57, "color_id": 1, "size_id": 3, "quantity": 2}],
"warehouse_id": 1,
"notes": "Gift"
}
seller.packing_nameis the name on the shipping label and the parcel when your account ships white label (no Customnia branding).logo_urlandpacking_notego on the packing slip; we keep a copy of the logo, and if it does not load the order goes on without it.warehouse_id: 1 New York, 2 Los Angeles. If left out, orders to AK, AZ, CA, CO, HI, ID, MT, NM, NV, OR, UT, WA and WY are made in Los Angeles, the rest in New York.- Placing an order means you will pay its total; Customnia invoices partners as agreed.
The answer is the order:
{
"id": "CFK3625MM", "reference": "shopify:brooklyn-tees:1001", "test": false, "status": "received",
"ready_by": "2026-11-06T18:00:00-05:00", "location": "NYC",
"can_cancel": true, "can_change_address": true,
"items": [{"design_id": 57, "color_id": 1, "color": "White", "size_id": 3, "size": "M", "quantity": 2}],
"totals": {"items": 2720, "shipping": 850, "total": 3570, "currency": "USD"},
"shipments": []
}
GET/orders/{id} — by our number (CFK3625MM) or by your reference.
Statuses
| status | Meaning |
|---|---|
received | In the production queue. |
printing | Printing has started; it can no longer be cancelled. |
ready | Made and packed, waiting for the carrier. |
shipped | Handed to the carrier; shipments has the tracking number. |
delivered | The carrier delivered it. |
cancelled | Cancelled before printing (by you or by us). |
A shipment: {"carrier": "UPS", "service": "Ground", "tracking_number": "1Z…", "tracking_url": "https://…", "shipped_at": "…", "delivered_at": null}.
Cancel and change the address
POST/orders/{id}/cancel — {"reason": "customer"}. Works while can_cancel is true; otherwise 409 printing_started and the order is made.
PATCH/orders/{id}/address — the same fields as shipping_address. Works while can_change_address is true; otherwise 409 too_late.
Webhooks
Give us a URL and we POST order.updated whenever an order's status, ready-by date, tracking or delivery changes. Answer 2xx; otherwise we retry after 1, 5, 15 and 60 minutes, then after 3, 6 and 12 hours.
POST https://your.app/webhooks/customnia
X-Customnia-Event: order.updated
X-Customnia-Delivery: 1842
X-Customnia-Signature: t=1793580000,v1=5f2c…
{"event": "order.updated", "sent_at": "…", "order": { …the order as above… }}
Check the signature: HMAC-SHA256 of t + "." + raw body with your webhook secret must equal v1; reject old t values.
// PHP
[$t, $v1] = sscanf($header, 't=%d,v1=%s');
$ok = hash_equals(hash_hmac('sha256', $t . '.' . $rawBody, $secret), $v1) && abs(time() - $t) < 300;
// Node
const [t, v1] = header.match(/t=(\d+),v1=(\w+)/).slice(1);
const ok = crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(
crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')));
Checking for changes
GET/orders?updated_since=2026-11-01T00:00:00Z&limit=100 — orders that changed since then, newest first. Use it as a safety net next to webhooks (e.g. every 10 minutes), or instead of them.
Connect an AI agent (MCP)
The API is also an MCP server at https://api.customnia.com/mcp (Streamable HTTP), for AI agents and coding tools: Claude Code, Cursor, VS Code and others that take a server URL with a header. It uses your API key (without one it only offers buyers' sign-up), sees exactly what your key sees and calls the same endpoints, with the same limits.
claude mcp add --transport http customnia https://api.customnia.com/mcp \ --header "Authorization: Bearer cn_test_…"
// Cursor: ~/.cursor/mcp.json
{"mcpServers": {"customnia": {"url": "https://api.customnia.com/mcp",
"headers": {"Authorization": "Bearer cn_test_…"}}}}
Tools: search_catalog, get_blank, create_design (from a PNG URL or layers), get_design, list_designs, copy_design, get_mockup (the image itself), designer_link, quote_order, get_order, list_orders, cancel_order, change_order_address; with a test key also place_order and simulate_order; with the live key claim_designs. Stores' real orders are not placed through MCP: place them from your server with POST /orders. Buyers' keys (see Order by API) add get_account, setup_payments and a live place_order. Resources: the API summary, openapi.yaml, the migration steps and the ordering steps.
Start with the test key. Give the agent the live key only for reading orders and designs, cancelling, or claiming old designs.
Moving from the old design API
The old API at design.customnia.com/api (and its copy on designer.customnia.com) had no keys and is being retired. Everything it did is here, with your key. Claim your existing designs first (POST /designs/claim with their ids); after that they are reachable by their new id, and customized_blank_id stays the id the checkout and your database already use.
Moving a whole project? Step-by-step instructions (Markdown, written so a coding agent can follow them): find the old calls, claim designs, replace calls and fields, test, cut over.
| Old | New |
|---|---|
GET|POST /api/blanks, GET /api/blanks/filters | GET /catalog (+ filters); categories → category_id / category / category_code on each blank, printingSpeeds / labels / brands → filters |
GET /api/blanks/{id} | GET /catalog/{blank_id} |
POST /api/blanks/{id}/customize-blanks {cb: {blank_printing_location_id, front_layers, back_layers}, alternative, notes} | POST /designs {blank_id, print_location_id (or color_id), front_layers, back_layers, alternative, notes} |
GET /api/customize-blanks/{id} | GET /designs/{id} |
POST /api/customize-blanks/list {items} | GET /designs?customized_blank_ids=… |
PUT /api/customize-blanks/{id} | PATCH /designs/{id} (only the sides sent change; 409 design_locked instead of 400) |
POST /api/customize-blanks/{id}/copy {blank_id, color_id} | POST /designs/{id}/copy — the copy is a design of yours (id, customized_blank_id) |
POST /api/customize-blanks/{id}/layers/copy | POST /designs/{id}/layers/copy; layers.front.main → front.canvas, layers.front.alternative → front.alternative |
POST /api/customize-blanks/media/replace | POST /designs/media/replace; customize_blanks → design_ids (or none: all of yours), is_changed_light + light_current + light_new → light: {current, new}, same for dark |
POST /api/customize-blanks/text/replace | POST /designs/text/replace; customize_blanks → design_ids (or none: all of yours), from and to unchanged; the answer is updated (your design ids) instead of changed |
Fields
| Old | New |
|---|---|
id (customized_blank) | customized_blank_id; the design's own id is id |
blank_printing_location_id, blank_printing_location.color_model.{id, hex, dart} | print_location_id, color.{id, hex, dark}, color_id |
front_artwork.fabric_layers (a JSON string) | layers.front.canvas (an object) |
front_artwork.alternative_layer, .attached_files, .fabric_version | layers.front.alternative, .attached_files, .fabric_version (5 when empty) |
front_artwork.preview_image | previews.front |
front_artwork.platen_image, .alternative_platen_image, .readyprint_main | print_file_url (= print_files.front.main), print_files.front.alternative, print_files.front.ready_print |
back_artwork.* | layers.back, previews.back, back_print_file_url, print_files.back |
notes | notes (sent at the top level, not in cb) |
blank: mode_preview, platen_front_width, brand_name, images, sizes[].size, colors[].dart, printingLocations[] (id, color_id, preview_front_url, layout_front) | preview_mode, platen.width_px, brand, images, sizes[].name, colors[].dark, per color print_location_id, photo, photo_print_area, back_photo, back_photo_print_area |
blank cost, colors[].cost, sizes[].cost, front_printing_cost, store margin | your price per color × size in colors[].prices and back_print_price; Customnia's retail "from" price in retail_price_from |
Questions, a key, a webhook secret: hello@customnia.com.