CustomHub Partner API
Place orders, query the CustomHub catalog, and track shipments programmatically. REST + JSON over HTTPS, base URL https://api.customhub.io.
Overview & getting started
Three steps to your first order.
- 1Create an API key in Settings → API Keys. The secret is shown only once — save it now.
- 2Exchange
apiKey+secretfor a JWT athttps://id.customhub.io/api/PartnerAuthentication/auth. Token TTL: 24h. - 3Call
https://api.customhub.io/api/v1/*withAuthorization: Bearer <jwt>.
# 1. Get a token (24h TTL)
TOKEN=$(curl -sS -X POST https://id.customhub.io/api/PartnerAuthentication/auth \
-H 'Content-Type: application/json' \
-d '{"apiKey":"ek_live_...","secretKey":"esk_live_..."}' \
| jq -r .accessToken)
# 2. List the catalog
curl -H "Authorization: Bearer $TOKEN" \
https://api.customhub.io/api/v1/models?pageSize=5/api/PartnerAuthentication/authExchange credentials for a JWT
Exchange your apiKey + secret for a 24-hour bearer token. The JWT carries your dealer id; every subsequent call is auto-scoped to your dealer's shops.
Posted to Identity (https://id.customhub.io), not the API host.
Parameters
apiKeystringrequired- The ek_live_ prefixed key from Settings → API Keys.
secretKeystringrequired- The esk_live_ prefixed secret shown once at key creation.
Re-exchange when the token nears expiry — there's no separate refresh-token flow in v1.
curl -X POST https://id.customhub.io/api/PartnerAuthentication/auth \
-H 'Content-Type: application/json' \
-d '{
"apiKey": "ek_live_...",
"secretKey":"esk_live_..."
}'{
"accessToken": "eyJhbGc...",
"refreshToken": "...",
"expired": "2026-05-19T19:00:00+00:00"
}Catalog
sku you submit on POST /orders./api/v1/modelsList models
Requires scope products_r
Page through every active model in the catalog. Use search to filter by model name or code.
Each row carries a dealer-specific pricing summary — the lowest ("from") price across the model's variations on your price list. Full per-variation pricing is on GET /models/{id}.
Parameters
pageinteger- Defaults to 1. 1-indexed.
pageSizeinteger- Defaults to 50. Clamped to 1..100.
searchstring- Optional. Matches against Model.Description and Model.Code.
The top-level listPrice / price fields are tenant-wide defaults and are often 0 — read your real numbers from pricing. Each from* value is the cheapest across the model's variations on your price list; pricing is null only when none of the model's variations are priced for your account.
curl -H "Authorization: Bearer $TOKEN" \
"https://api.customhub.io/api/v1/models?page=1&pageSize=50&search=bella"{
"success": true,
"data": {
"models": [
{
"id": "9b4f7e2a-...",
"code": "BC3001",
"name": "Bella Canvas 3001 Unisex Shirt",
"brand": "Bella+Canvas",
"primaryImageUrl": "https://cdn.../bc3001-front.jpg",
"listPrice": 0,
"price": 0,
"pricing": {
"currency": "USD",
"fromUnitPrice": 6.49,
"fromListPrice": 6.49,
"fromBlankCost": 3.99,
"pricedVariationCount": 188
},
"variationCount": 188,
"isFeatured": false
}
],
"pagination": {
"page": 1, "pageSize": 50, "totalCount": 27, "hasMore": false
}
}
}/api/v1/models/{id}Get a model
Requires scope products_r
Returns the full model with every orderable variation SKU, all images, supported placements, and product copy (material, care, sizing, …).
Use variations[].sku in items[].sku on POST /orders, and placements[].code in designs[].placement.
Every variation carries your exact pricing (blank cost, handling fee, unit/list price) and every placement lists its printMethods with your exact per-placement print charge — enough to compute the full item cost without quoting: blankCost + printMethods[].price (per printed placement) + handlingFee.
Parameters
iduuidrequired- Model.Id GUID from the list response.
Placements have a stable code (width1..width5, plus gang_sheet) and a human label — the tenant's configured placement title (e.g. Front, Pocket, Back). In designs[].placement you may send either the code or that label (case-insensitive) — both resolve to the same placement. Real-world meaning varies per garment, so pick a placement by its dimensions and the position you see in the mockup.
Computing your cost — an item's all-in production cost is variations[].pricing.blankCost + the printMethods[].price of each placement you print + handlingFee. Example above: blank 5.68 + one front DTF print 2.50 = 8.18. These are the same numbers the order engine bills at approval, so you can seed your cost table straight from the catalog. priceSource tells you how firm the number is: dealer (an explicit price for that placement + method), dealer_default (the placement's default), dealer_location_fallback (cheapest configured method at that placement), or global (tenant-wide fallback). Shipping is quoted separately — see POST /orders/quote.
curl -H "Authorization: Bearer $TOKEN" \
https://api.customhub.io/api/v1/models/9b4f7e2a-...{
"success": true,
"data": {
"id": "9b4f7e2a-...",
"code": "BC3001",
"name": "Bella Canvas 3001 Unisex Shirt",
"brand": "Bella+Canvas",
"material": "100% Airlume combed and ring-spun cotton, 32 single 4.2 oz.",
"fitType": "Unisex retail fit",
"images": [ "https://cdn.../bc3001-1.jpg" ],
"categories":[ { "id": "...", "name": "T-Shirts" } ],
"variations": [
{
"id": "...",
"sku": "BC-3001-L-BLACK",
"isActive": true,
"variationDescription": "BELLA CANVAS Unisex T-shirt L",
"variation2Description": "Black",
"variation2Hex": "#000000",
"images": [ "https://cdn.../bc3001-black-front.jpg" ],
"pricing": {
"currency": "USD",
"unitPrice": 8.18,
"listPrice": 8.18,
"blankCost": 5.68,
"handlingFee": 0.0
}
}
],
"placements": [
{
"code": "width1", "label": "Front",
"maxWidthInches": 12, "maxHeightInches": 12,
"printMethods": [
{ "code": "DTF", "label": "DTF - Direct to Film", "price": 2.50, "priceSource": "dealer" },
{ "code": "DTG", "label": "Direct to Garment", "price": 2.50, "priceSource": "dealer_location_fallback" }
]
},
{
"code": "width2", "label": "Pocket",
"maxWidthInches": 4, "maxHeightInches": 4,
"printMethods": [
{ "code": "DTF", "label": "DTF - Direct to Film", "price": 2.50, "priceSource": "dealer" }
]
}
]
}
}Orders
ApprovalPending state. An admin reviews and approves; on approval the wallet is debited at the locked computedCost returned in the create response./api/v1/orders/quoteGet a cost estimate
Requires scope orders_r
Returns the same computedCost shape that POST /orders returns, without persisting an order, reserving an externalOrderId, ingesting designs, or running the wallet headroom check.
Use it to show your customer the total at checkout before committing.
Parameters
shippingAddressobjectrequired- Same shape as on POST /orders — affects shipping calc.
isRushboolean- Default false. Triggers the rush surcharge.
requestedShippingServicestring- Carrier service hint.
selfShippedboolean- Default false. When true, returns a production-only quote (shippingCost 0, box/handling fee retained) — for when you intend to supply your own label via shippingLabel on POST /orders. Quoting does not require the orders_selfship scope or a label object.
itemsarrayrequired- Line items; at least one. Each carries sku or variationId, quantity, and designs (placement + dims; URL is not required for quote).
The quote endpoint does not return INSUFFICIENT_BALANCE. Check wallet headroom separately via GET /api/v1/balance.
curl -X POST https://api.customhub.io/api/v1/orders/quote \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"shippingAddress": { "name":"Jane Doe","street1":"123 Main St","city":"Brooklyn","state":"NY","zip":"11201","country":"US" },
"isRush": false,
"items": [{
"variationId": "9b4f7e2a-...",
"quantity": 1,
"designs": [{ "placement":"width1", "widthInches":11, "heightInches":11, "printMethod":"DTF" }]
}]
}'{
"success": true,
"data": {
"computedCost": {
"currency": "USD",
"itemsSubtotal": 21.00,
"shippingCost": 4.20,
"grandTotal": 25.70,
"perItem": [{ "sku": "BC-3001-L-BLACK", "quantity": 1, "unitCost": 21.00 }]
}
}
}/api/v1/ordersCreate order
Requires scope orders_w
Submit a new order. Returns the locked cost breakdown so you can show your customer the total before approval.
Idempotency: externalOrderId doubles as the per-dealer idempotency key. Retrying with the same id + identical body returns the original response. A different body for the same id returns 409 EXTERNAL_ORDER_ID_CONFLICT.
Parameters
externalOrderIdstringrequired- 1–100 chars. Your order id; also the idempotency key for this dealer.
shippingAddressobjectrequired- name, street1, city, state, zip, country (ISO 3166-1 alpha-2), email, phone.
billingAddressobject- Optional. If omitted, the shipping address is copied.
customerobject- name, email, phone — surfaced in the admin queue and order notifications.
itemsarrayrequired- At least one line item. Each item references a ModelVariation by sku.
items[].skustring- A ModelVariation.Sku from GET /models/{id}. Either sku or variationId is required.
items[].variationIduuid- Stable ModelVariation.Id from GET /models/{id}.variations[].id. Wins over sku when both are sent.
items[].quantityintegerrequired- 1..10000
items[].imageUrlstring- Optional listing image (your marketplace/storefront product photo). HTTPS only, ≤ 2048 chars. Stored by reference, shown as the listing thumbnail on the packing slip. NOT printed.
items[].designs[]array- One or more designs — the artwork we print. Each has url + placement + widthInches + heightInches. Send trimmed PNG/JPEG; we download and re-host it.
items[].designs[].placementstring- Where to print. Accepts the placement code (width1..width5, gang_sheet) OR its title from GET /models/{id}.placements[].label (e.g. "Front", "Pocket", "Back") — case-insensitive.
items[].designs[].printMethodstring- Optional. Print method code (e.g. DTF) from GET /models/{id}.placements[].printMethods[]. Omit to auto-derive from the variation + placement.
items[].proofs[]array- Optional. Proof/mockup preview URLs (the garment mockup shown to production staff and on the packing slip). NOT printed. HTTPS only, max 10 per item, ≤ 500 chars each. Stored by reference (not re-hosted) — keep the URLs stable and publicly reachable; no expiring presigned links.
isGiftboolean- Default false.
isRushboolean- Default false. Triggers the rush surcharge per tenant config.
notesstring- Free-form text persisted on the order — visible to admins, not customers.
shippingLabelobject- Bring Your Own Label (BYOL). Optional. Supply a pre-purchased carrier label and EasyPod ships with it instead of buying one. Requires the orders_selfship scope — otherwise 422 SELF_SHIP_NOT_ENABLED. Postage is then $0 (box/handling fee still applies).
shippingLabel.carrierstring- Required if shippingLabel present. ≤ 100 chars (e.g. "USPS").
shippingLabel.servicestring- Optional service name, e.g. "GROUND ADVANTAGE". ≤ 100 chars.
shippingLabel.trackingNumberstring- Required if shippingLabel present. ≤ 100 chars. Returned via GET /orders/{id}.shipments[].
shippingLabel.labelUrlstring- Required if shippingLabel present. Absolute HTTPS URL to the label file (PDF/PNG/ZPL), ≤ 500 chars. Stored by reference, not re-hosted — keep it stable and reachable for the production team.
Design files — we download from your URL, validate format (PNG/JPEG only in v1), and re-host on our storage. The URL must be HTTPS and publicly reachable at submit time. Max 100 MB.
Bring Your Own Label — include shippingLabel (requires the orders_selfship scope) to ship with your own pre-purchased label (see the BYOL example tab above). You provide a URL to your already-hosted label file in labelUrl — there is no binary file upload; we reference the URL as-is and print from it at packing, so keep it stable and reachable (PDF/PNG/ZPL, HTTPS, ≤500 chars). Any carrier is accepted (it is free-text — e.g. UniUni). EasyPod skips buying a carrier label and charges $0 postage (the box/handling fee still applies, since we still produce and pack the order).
curl -X POST https://api.customhub.io/api/v1/orders \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"externalOrderId": "shopify-1234",
"shippingAddress": {
"name": "Jane Doe",
"street1": "123 Main St",
"city": "Brooklyn",
"state": "NY",
"zip": "11201",
"country": "US",
"phone": "+1-555-0100",
"email": "[email protected]"
},
"customer": { "name": "Jane Doe", "email": "[email protected]" },
"items": [{
"sku": "BC-3001-L-BLACK",
"quantity": 1,
"designs": [{
"url": "https://cdn.partner.com/designs/abc.png",
"placement": "width1",
"widthInches": 11.0,
"heightInches": 11.0
}],
"proofs": ["https://cdn.partner.com/mockups/abc-front.jpg"]
}]
}'{
"success": true,
"data": {
"orderId": "9b4f7e2a-...",
"orderNumber": "PO-12345",
"externalOrderId": "shopify-1234",
"status": "ApprovalPending",
"createdAt": "2026-05-18T10:00:05Z",
"computedCost": {
"currency": "USD",
"itemsSubtotal": 21.00,
"shippingCost": 4.20,
"shippingCategoryFee":0.50,
"discountAmount": 0.00,
"rushFee": 0.00,
"grandTotal": 25.70
}
}
}/api/v1/orders/{orderId}Retrieve an order
Requires scope orders_r
Returns the full order with current addresses, items (with their re-hosted design URLs and any submitted proof URLs), all shipments, the locked cost breakdown, rejection details (if applicable), and a sanitized event log.
Parameters
orderIduuidrequired- The orderId returned by POST /orders.
curl -H "Authorization: Bearer $TOKEN" \
https://api.customhub.io/api/v1/orders/9b4f7e2a-...{
"success": true,
"data": {
"orderId": "9b4f7e2a-...",
"externalOrderId": "shopify-1234",
"productionStatus":"Approved",
"items": [ /* ... */ ],
"shipments": [
{
"shipmentId": "...",
"carrier": "USPS",
"trackingNumber": "9400111...",
"trackingUrl": "https://tools.usps.com/go/TrackConfirmAction?tLabels=...",
"shippedAt": "2026-05-19T11:34:00Z"
}
],
"computedCost": { /* ... */ },
"events": [
{ "at": "2026-05-18T10:00:05Z", "type": "created", "by": "partner" },
{ "at": "2026-05-18T17:12:00Z", "type": "approved", "by": "admin" },
{ "at": "2026-05-19T11:34:00Z", "type": "shipped", "by": "system", "shipmentId": "..." }
]
}
}/api/v1/ordersList orders
Requires scope orders_r
Page through your orders. Filter by status, by since (createdAt ≥), or by your own externalOrderId for lookup after a network retry.
Parameters
pageinteger- Defaults to 1.
pageSizeinteger- Defaults to 50. Clamped 1..100.
sinceiso8601- Filter orders with CreatedAt ≥ this timestamp.
statusstring- Any of ApprovalPending, Approved, Rejected, Cancelled, InProduction, Shipped.
externalOrderIdstring- Exact match — lookup after a retry.
curl -H "Authorization: Bearer $TOKEN" \
"https://api.customhub.io/api/v1/orders?page=1&pageSize=50&since=2026-05-01T00:00:00Z&status=Shipped"{
"success": true,
"data": {
"orders": [
{
"orderId": "9b4f7e2a-...",
"externalOrderId": "shopify-1234",
"status": "Progressing Shipment",
"productionStatus":"Approved",
"itemCount": 1,
"total": 25.70,
"createdAt": "2026-05-18T10:00:05Z",
"lastShippedAt": "2026-05-19T11:34:00Z"
}
],
"pagination": { "page": 1, "pageSize": 50, "totalCount": 133, "hasMore": true }
}
}/api/v1/orders/{orderId}Cancel an order
Requires scope orders_w
Cancels an order only while it is in ApprovalPending — i.e. before an admin has approved it.
Idempotent: cancelling an already-cancelled order returns the current state with 200. After admin approval, contact your account team.
Parameters
orderIduuidrequired- The orderId returned by POST /orders.
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
https://api.customhub.io/api/v1/orders/9b4f7e2a-...{
"success": true,
"data": {
"orderId": "9b4f7e2a-...",
"status": "Cancelled",
"cancelledAt": "2026-05-18T17:15:00Z"
}
}/api/v1/orders/{orderId}/items/{itemId}/designsAdd or replace designs
Requires scope orders_w
Add designs to an existing item while the order is still ApprovalPending. A design whose placement is already used replaces the existing one.
The order cost is recomputed and returned (placements are priced independently). This does not re-check wallet balance — the order already cleared that at create. Send at most one design per placement; after admin approval you get 409 ORDER_NOT_MUTABLE.
Parameters
orderIduuidrequired- The orderId returned by POST /orders.
itemIduuidrequired- The item id from GET /orders/{orderId}.items[].id.
designsarrayrequired- One or more designs: url + placement + widthInches + heightInches + optional printMethod.
Discover valid placement codes and their supported print methods via GET /api/v1/models/{id} — each placement lists maxWidthInches/maxHeightInches and a printMethods[] array.
curl -X POST https://api.customhub.io/api/v1/orders/9b4f7e2a-.../items/1c2d3e4f-.../designs \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"designs": [{
"url": "https://cdn.partner.com/designs/back.png",
"placement": "width2",
"widthInches": 11.0,
"heightInches": 14.0,
"printMethod": "DTF"
}]
}'{
"success": true,
"data": {
"orderId": "9b4f7e2a-...",
"itemId": "1c2d3e4f-...",
"designs": [
{ "placement": "width1", "url": "https://cdn.../front.png", "widthInches": 11.0, "heightInches": 11.0, "printMethod": "DTF", "designWidthInches": 11.0 },
{ "placement": "width2", "url": "https://cdn.../back.png", "widthInches": 11.0, "heightInches": 14.0, "printMethod": "DTF", "designWidthInches": 11.0 }
],
"computedCost": { "currency": "USD", "itemsSubtotal": 28.00, "shippingCost": 4.20, "grandTotal": 32.70 }
}
}/api/v1/balanceWallet balance
Requires scope orders_r
Returns the caller dealer's current balance, credit limit, and remaining headroom. Call this before POST /orders if you want to know in advance whether the order will pass the headroom check (INSUFFICIENT_BALANCE).
balance > 0 means the dealer owes that amount; balance < 0 means there is credit on file.headroom = creditLimit − balance.
curl -H "Authorization: Bearer $TOKEN" \
https://api.customhub.io/api/v1/balance{
"success": true,
"data": {
"dealerId": "5b3b...",
"currency": "USD",
"balance": 1240.50,
"creditLimit": 2500.00,
"headroom": 1259.50
}
}Order lifecycle
Until webhooks ship (planned Phase 3), poll GET /api/v1/orders/{id} to discover state transitions. The events[] array on the response is the authoritative ordered history.
| State | Wallet | Your action |
|---|---|---|
| ApprovalPending | not debited | poll, or DELETE to retract |
| Approved | debited at locked grand total | poll |
| InProduction | already debited | no API cancel possible |
| Shipped | already debited | poll for tracking updates |
| Cancelled | never debited | submit new order if needed |
| Rejected | never debited | read rejection.reason, submit fixed order |
Error codes
Errors come back in a stable envelope. The error.code value is stable across releases — adding a new code is non-breaking; renaming one is breaking.
| Code | HTTP | Meaning |
|---|---|---|
| UNAUTHORIZED | 401 | Missing or invalid JWT |
| FORBIDDEN | 403 | JWT valid but missing required scope |
| VALIDATION_ERROR | 400 | Request body / query failed validation |
| SELF_SHIP_NOT_ENABLED | 422 | shippingLabel sent without the orders_selfship scope |
| SKU_NOT_FOUND | 400 | SKU isn't a known ModelVariation in this tenant's catalog |
| PLACEMENT_NOT_SUPPORTED_FOR_SKU | 400 | Placement code not configured for this model |
| PRINT_DIMENSIONS_EXCEED_PLACEMENT_MAX | 400 | widthInches / heightInches exceeds the placement's max |
| DESIGN_FETCH_FAILED | 400 | We couldn't download your design URL |
| DESIGN_FORMAT_INVALID | 400 | Unsupported format. v1 accepts PNG and JPEG only. |
| DESIGN_TOO_LARGE | 400 | Design file exceeds 100 MB |
| INSUFFICIENT_BALANCE | 402 | Wallet headroom check failed. Top up + retry with a different externalOrderId. |
| EXTERNAL_ORDER_ID_CONFLICT | 409 | Same externalOrderId replayed with a different body |
| ORDER_CREATE_IN_PROGRESS | 409 | A concurrent request with the same externalOrderId is in flight |
| ORDER_NOT_CANCELLABLE | 400 | Order is past ApprovalPending — contact account team |
| NOT_FOUND | 404 | Resource doesn't exist or isn't visible to this credential |
| RATE_LIMITED | 429 | Per-credential rate limit hit. Honor Retry-After. |
{
"success": false,
"error": {
"code": "SKU_NOT_FOUND",
"message": "one or more skus are not found or not orderable",
"details": [{ "field": "items[0].sku" }]
}
}Limits & conventions
Ready to integrate? Create your first key in Settings → API Keys.