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-test.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-test.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-test.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-test.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.
Placement codes are derived from the catalog's mockup zones (width1..width5, plus gang_sheet). Real-world meaning (Front, Back, Pocket…) varies per garment — pick a placement by its dimensions and the position you see in the mockup, not by guessing from the code.
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": "Placement 1",
"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": "Placement 2",
"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.
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[].designs[]array- One or more designs, each with url + placement + widthInches + heightInches.
items[].designs[].printMethodstring- Optional. Print method code (e.g. DTF) from GET /models/{id}.placements[].printMethods[]. Omit to auto-derive from the variation + placement.
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.
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.
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
}]
}]
}'{
"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), 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 |
| 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.