API v1

CustomHub Partner API

Place orders, query the CustomHub catalog, and track shipments programmatically. REST + JSON over HTTPS, base URL https://api.customhub.io.

60 req/min/key Per-key idempotency Admin-gated approvals camelCase, ISO 8601, USD

Overview & getting started

Three steps to your first order.

  1. 1Create an API key in Settings → API Keys. The secret is shown only once — save it now.
  2. 2Exchange apiKey + secret for a JWT at https://id-test.customhub.io/api/PartnerAuthentication/auth. Token TTL: 24h.
  3. 3Call https://api.customhub.io/api/v1/* with Authorization: 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
POST/api/PartnerAuthentication/auth

Exchange 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_..."
  }'
Response
{
  "accessToken": "eyJhbGc...",
  "refreshToken": "...",
  "expired": "2026-05-19T19:00:00+00:00"
}
Models

Catalog

The catalog exposes the CustomHub tenant model catalog — products like "Bella Canvas 3001 Unisex Shirt" or "Comfort Colors 1566 Sweatshirt". Each model has many variations (size × color combos); each variation has the orderable sku you submit on POST /orders.
GET/api/v1/models

List 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"
Response 200
{
  "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
    }
  }
}
GET/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-...
Response 200 — abridged
{
  "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" }
        ]
      }
    ]
  }
}
Submit & manage

Orders

All orders are submitted in the ApprovalPending state. An admin reviews and approves; on approval the wallet is debited at the locked computedCost returned in the create response.
POST/api/v1/orders/quote

Get 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" }]
    }]
  }'
Response 200
{
  "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 }]
    }
  }
}
POST/api/v1/orders

Create 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
      }]
    }]
  }'
Response 201 Created
{
  "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
    }
  }
}
GET/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-...
Response 200 — abridged
{
  "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": "..." }
    ]
  }
}
GET/api/v1/orders

List 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"
Response 200 — abridged
{
  "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 }
  }
}
DELETE/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-...
Response 200
{
  "success": true,
  "data": {
    "orderId":     "9b4f7e2a-...",
    "status":      "Cancelled",
    "cancelledAt": "2026-05-18T17:15:00Z"
  }
}
POST/api/v1/orders/{orderId}/items/{itemId}/designs

Add 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"
    }]
  }'
Response 200
{
  "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 }
  }
}
GET/api/v1/balance

Wallet 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
Response 200
{
  "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.

StateWalletYour action
ApprovalPendingnot debitedpoll, or DELETE to retract
Approveddebited at locked grand totalpoll
InProductionalready debitedno API cancel possible
Shippedalready debitedpoll for tracking updates
Cancellednever debitedsubmit new order if needed
Rejectednever debitedread 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.

CodeHTTPMeaning
UNAUTHORIZED401Missing or invalid JWT
FORBIDDEN403JWT valid but missing required scope
VALIDATION_ERROR400Request body / query failed validation
SKU_NOT_FOUND400SKU isn't a known ModelVariation in this tenant's catalog
PLACEMENT_NOT_SUPPORTED_FOR_SKU400Placement code not configured for this model
PRINT_DIMENSIONS_EXCEED_PLACEMENT_MAX400widthInches / heightInches exceeds the placement's max
DESIGN_FETCH_FAILED400We couldn't download your design URL
DESIGN_FORMAT_INVALID400Unsupported format. v1 accepts PNG and JPEG only.
DESIGN_TOO_LARGE400Design file exceeds 100 MB
INSUFFICIENT_BALANCE402Wallet headroom check failed. Top up + retry with a different externalOrderId.
EXTERNAL_ORDER_ID_CONFLICT409Same externalOrderId replayed with a different body
ORDER_CREATE_IN_PROGRESS409A concurrent request with the same externalOrderId is in flight
ORDER_NOT_CANCELLABLE400Order is past ApprovalPending — contact account team
NOT_FOUND404Resource doesn't exist or isn't visible to this credential
RATE_LIMITED429Per-credential rate limit hit. Honor Retry-After.
Error envelope
{
  "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

Rate limit
60 requests / minute / API key. Returns 429 with Retry-After.
Max items / order
500 line items.
Max designs / order
50 (counted across all items).
Design file
HTTPS only, max 100 MB, PNG or JPEG, magic-byte validated.
JSON casing
camelCase for both request and response.
Times
ISO 8601 with offset.
Money
Numbers in major units (12.50 = $12.50). USD only in v1.
Country
ISO 3166-1 alpha-2 (US, TR, GB).
Idempotency TTL
Cached responses kept 90 days, then garbage-collected.

Ready to integrate? Create your first key in Settings → API Keys.

EasyPod Partner API — Documentation