API v1

EasyPod Partner API

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

60 req/min/key Per-dealer 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.customhub.io/api/PartnerAuthentication/auth. Token TTL: 24h.
  3. 3Call https://api.customhub.io/api/v1/* with Authorization: Bearer <jwt>.

Building with an AI agent?

This documentation is also published as machine-readable text following the llms.txt standard — point any coding agent (Claude, ChatGPT, Cursor, …) at these to load the full contract:

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

Catalog

The catalog exposes the EasyPod 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.

Placements have a stable code (width1..width5, width7, width8, plus gang_sheet) and a human label — the tenant's configured placement title (e.g. Front, Pocket, Back, Left Sleeve, Right Sleeve). 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-...
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": "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" }
        ]
      }
    ]
  }
}
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.
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. selfShipped: true requires the orders_selfship scope (else 422 SELF_SHIP_NOT_ENABLED); no label object is sent.
shopIduuid
Optional. Quote in the context of one of your shops. Must be a shop you own, else 422 SHOP_NOT_FOUND. Omitted → your default shop is used for pricing context; unlike create, quoting never provisions a shop.
itemsarrayrequired
Line items; at least one. Each carries sku or variationId, quantity, and designs (placement + widthInches + heightInches, all required for quote; URL is not required).

Quoting does not check your wallet. Neither does POST /orders — orders are always accepted as ApprovalPending, and an underfunded order waits there until the wallet covers it. Check headroom 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 — abridged
{
  "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 a successful create with the same id + identical body returns the original response; a different body for that id returns 409 EXTERNAL_ORDER_ID_CONFLICT. A create that failed (4xx/5xx, no order created) is not cached — fix the problem and retry with the same externalOrderId.

Parameters

externalOrderIdstringrequired
1–100 chars. Your order id; also the idempotency key for this dealer.
shopIduuid
Optional. Assigns the order to one of your shops (see the Shops section). Must be a shop you own, else 422 SHOP_NOT_FOUND. Omitted → the order lands in your default shop (auto-provisioned if you have none).
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, plus optional widthInches + heightInches (send both or neither). Send trimmed PNG/JPEG; we download and re-host it.
items[].designs[].widthInches / heightInchesnumber
Optional, both-or-neither, 0.01–100. A size you send must fit the placement max, else 400 PRINT_DIMENSIONS_EXCEED_PLACEMENT_MAX. Omit both and we derive the size from the SKU's per-size default, keeping the image's aspect ratio and shrinking it to fit the placement max (never rejected for size).
items[].designs[].placementstring
Where to print. Accepts the placement code (width1..width5, width7, width8, gang_sheet) OR its title from GET /models/{id}.placements[].label (e.g. "Front", "Pocket", "Back", "Left Sleeve", "Right Sleeve") — 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. On tenants with stitch-priced embroidery, Embroidery can't be set through the API (400 EMBROIDERY_MANAGED); it's only added in the dealer portal.
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 or JPEG; ZPL is not accepted), ≤ 500 chars, ≤ 25 MB. We download it at submit, convert it to a 4x6 300 DPI PNG and host it ourselves. If the download or conversion fails, the order is rejected (400 LABEL_*).

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 download the label at submit, convert it to a 4x6 PNG and host it ourselves, so the URL only has to be reachable at submit time (PDF, PNG or JPEG, HTTPS, ≤500 chars, ≤25 MB; ZPL is not accepted). If we can't fetch or convert it, the order is rejected with a LABEL_* error and you can retry with the same externalOrderId. 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"]
    }]
  }'
Response 201 Created — abridged
{
  "success": true,
  "data": {
    "orderId":         "9b4f7e2a-...",
    "orderNumber":     "shopify-1234",
    "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 its owning shop (shopId + shopName), 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.

Each item carries options[] — its resolved {name, value} option rows (Size, Color) in display order — and personalization, the buyer's personalization text from a marketplace listing (e.g. Etsy), or null. These matter most for orders imported from a connected store rather than created through this API.

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",
    "shopId":          "3f1c9a20-...",
    "shopName":        "Acme Threads",
    "items": [
      {
        "id":       "...",
        "sku":      "BC-3001-L-BLACK",
        "quantity": 1,
        "options": [
          { "name": "Size",  "value": "L" },
          { "name": "Color", "value": "Black" }
        ],
        "personalization": null,
        "designs": [ /* re-hosted url, placement, widthInches, heightInches */ ],
        "proofs":  [ /* ... */ ]
      }
    ],
    "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 ≥), by your own externalOrderId for lookup after a network retry, or by shopId to list one shop's orders. Each row carries the owning shopId + shopName.

Parameters

pageinteger
Defaults to 1.
pageSizeinteger
Defaults to 50. Clamped 1..100.
sinceiso8601
Filter orders with CreatedAt ≥ this timestamp. When omitted, only the last 60 days are listed (externalOrderId lookups are not windowed).
statusstring
One of ApprovalPending, Approved, Shipped, Cancelled, Rejected. Anything else → 400 VALIDATION_ERROR.
externalOrderIdstring
Exact match — lookup after a retry.
shopIduuid
Optional. Filter to orders in a single shop you own (see the Shops section).
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",
        "shopId":          "3f1c9a20-...",
        "shopName":        "Acme Threads",
        "status":          "Approved",
        "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, optional widthInches + heightInches (both or neither; omitted → derived from the SKU default and shrunk to fit), 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":    "width3",
      "widthInches":  11.0,
      "heightInches": 14.0,
      "printMethod":  "DTF"
    }]
  }'
Response 200 — abridged
{
  "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": "width3", "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 }
  }
}
Organize

Shops

A shop is a lightweight container you create to segment your orders by your own client or brand (e.g. one shop per storefront). Every order belongs to exactly one shop. Pass shopId on POST /orders (and /orders/quote) to target one; omit it and the order lands in your default shop — auto-created on your first order if you have none. Shops never widen visibility — you only ever see your own dealer's data.
GET/api/v1/shops

List shops

Requires scope shops_r

Returns every shop you own, earliest first. The one with isDefault: true is used when an order omits shopId.

curl -H "Authorization: Bearer $TOKEN" \
  https://api.customhub.io/api/v1/shops
Response 200
{
  "success": true,
  "data": {
    "items": [
      {
        "id":        "3f1c9a20-...",
        "name":      "Acme Threads",
        "currency":  "USD",
        "createdAt": "2026-07-20T10:00:00Z",
        "isDefault": true
      }
    ]
  }
}
POST/api/v1/shops

Create a shop

Requires scope shops_w

Create a shop to group orders. The first shop you create (or the one auto-provisioned on your first order) becomes your default. Max 100 shops per dealer.

Parameters

namestringrequired
1–200 chars. Display name for the shop.
currencystring
Optional. ISO 4217 code. Defaults to USD.

Errors: 422 SHOP_NAME_REQUIRED, 422 INVALID_CURRENCY, 422 SHOP_LIMIT_REACHED (100-shop cap), 403 FORBIDDEN (no linked dealer).

curl -X POST https://api.customhub.io/api/v1/shops \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{ "name": "Acme Threads", "currency": "USD" }'
Response 201 Created
{
  "success": true,
  "data": {
    "id":        "3f1c9a20-...",
    "name":      "Acme Threads",
    "currency":  "USD",
    "createdAt": "2026-07-20T10:00:00Z",
    "isDefault": false
  }
}
PATCH/api/v1/shops/{id}

Rename a shop

Requires scope shops_w

Update a shop's display name. Returns the updated shop.

Parameters

iduuidrequired
The shop id from GET /api/v1/shops.
namestringrequired
1–200 chars. New display name.

404 SHOP_NOT_FOUND if the id isn't one of your shops.

curl -X PATCH https://api.customhub.io/api/v1/shops/3f1c9a20-... \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{ "name": "Acme Threads (US)" }'
Response 200
{
  "success": true,
  "data": {
    "id":        "3f1c9a20-...",
    "name":      "Acme Threads (US)",
    "currency":  "USD",
    "createdAt": "2026-07-20T10:00:00Z",
    "isDefault": true
  }
}
DELETE/api/v1/shops/{id}

Delete a shop

Requires scope shops_w

Soft-deletes a shop. Orders already placed in it are unaffected. If it was your default, the next-earliest shop becomes the new default. You must always keep at least one shop.

Parameters

iduuidrequired
The shop id from GET /api/v1/shops.

422 CANNOT_DELETE_LAST_SHOP if it's your only shop; 404 SHOP_NOT_FOUND if the id isn't one of your shops.

curl -X DELETE -H "Authorization: Bearer $TOKEN" \
  https://api.customhub.io/api/v1/shops/3f1c9a20-...
Response 200 — the soft-deleted shop
{
  "success": true,
  "data": {
    "id":        "3f1c9a20-...",
    "name":      "Acme Threads",
    "currency":  "USD",
    "createdAt": "2026-07-20T10:00:00Z",
    "isDefault": false
  }
}
GET/api/v1/balance

Wallet balance

Requires scope orders_r

Returns the caller dealer's current balance, credit limit, and remaining headroom. Order creation never rejects for balance: an order your wallet can't cover is still accepted as ApprovalPending and is held there until it's funded. Call this to know in advance whether an order will be approvable.

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
Shippedalready debitedpoll for tracking updates
Cancellednever debitedsubmit new order if needed
Rejectednever debitedread rejection.message, 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 (empty body — key off the status)
FORBIDDEN403JWT valid but missing required scope
VALIDATION_ERROR400Service-layer validation failed. Body schema errors (missing fields, out-of-range values) return 400 in ASP.NET ProblemDetails shape ({ errors: {...} }) instead of this envelope.
SELF_SHIP_NOT_ENABLED422shippingLabel sent without the orders_selfship scope
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
DEFAULT_SIZE_UNAVAILABLE400No size sent and no default print size is configured for this SKU + placement
PRINT_METHOD_NOT_ALLOWED400printMethod isn't offered for this SKU + placement
EMBROIDERY_MANAGED400Stitch-priced embroidery is managed in the dealer portal only. 400 for printMethod Embroidery; 409 when replacing a design that has embroidery attached.
LABEL_FETCH_FAILED400We couldn't download your shippingLabel.labelUrl (also LABEL_FORMAT_INVALID, LABEL_TOO_LARGE, LABEL_NORMALIZE_FAILED)
SHOP_NOT_FOUND422shopId isn't one of your shops (404 on /shops/{id})
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
ORDER_NOT_MUTABLE409Designs can only be changed while the order is ApprovalPending
NOT_FOUND404Resource doesn't exist or isn't visible to this credential
RATE_LIMITED429Per-credential rate limit hit. Key off the HTTP status; back off to the next minute.
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. Excess returns HTTP 429 — key off the status (don't rely on a body or Retry-After) and back off to the next minute window.
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
Successful creates cached 90 days, then garbage-collected. Failed creates are not cached.

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