EasyPod Partner API
Place orders, query the EasyPod 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>.
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:
- /llms.txt — concise index of every endpoint
- /llms-full.txt — the complete API v1 reference in a single file
# 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, 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-...{
"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. 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" }]
}]
}'{
"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 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"]
}]
}'{
"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
}
}
}/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-...{
"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": "..." }
]
}
}/api/v1/ordersList 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"{
"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 }
}
}/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, 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"
}]
}'{
"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 }
}
}Shops
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./api/v1/shopsList 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{
"success": true,
"data": {
"items": [
{
"id": "3f1c9a20-...",
"name": "Acme Threads",
"currency": "USD",
"createdAt": "2026-07-20T10:00:00Z",
"isDefault": true
}
]
}
}/api/v1/shopsCreate 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" }'{
"success": true,
"data": {
"id": "3f1c9a20-...",
"name": "Acme Threads",
"currency": "USD",
"createdAt": "2026-07-20T10:00:00Z",
"isDefault": false
}
}/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)" }'{
"success": true,
"data": {
"id": "3f1c9a20-...",
"name": "Acme Threads (US)",
"currency": "USD",
"createdAt": "2026-07-20T10:00:00Z",
"isDefault": true
}
}/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-...{
"success": true,
"data": {
"id": "3f1c9a20-...",
"name": "Acme Threads",
"currency": "USD",
"createdAt": "2026-07-20T10:00:00Z",
"isDefault": false
}
}/api/v1/balanceWallet 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{
"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 |
| Shipped | already debited | poll for tracking updates |
| Cancelled | never debited | submit new order if needed |
| Rejected | never debited | read 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.
| Code | HTTP | Meaning |
|---|---|---|
| UNAUTHORIZED | 401 | Missing or invalid JWT (empty body — key off the status) |
| FORBIDDEN | 403 | JWT valid but missing required scope |
| VALIDATION_ERROR | 400 | Service-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_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 |
| DEFAULT_SIZE_UNAVAILABLE | 400 | No size sent and no default print size is configured for this SKU + placement |
| PRINT_METHOD_NOT_ALLOWED | 400 | printMethod isn't offered for this SKU + placement |
| EMBROIDERY_MANAGED | 400 | Stitch-priced embroidery is managed in the dealer portal only. 400 for printMethod Embroidery; 409 when replacing a design that has embroidery attached. |
| LABEL_FETCH_FAILED | 400 | We couldn't download your shippingLabel.labelUrl (also LABEL_FORMAT_INVALID, LABEL_TOO_LARGE, LABEL_NORMALIZE_FAILED) |
| SHOP_NOT_FOUND | 422 | shopId isn't one of your shops (404 on /shops/{id}) |
| 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 |
| ORDER_NOT_MUTABLE | 409 | Designs can only be changed while the order is ApprovalPending |
| NOT_FOUND | 404 | Resource doesn't exist or isn't visible to this credential |
| RATE_LIMITED | 429 | Per-credential rate limit hit. Key off the HTTP status; back off to the next minute. |
{
"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.