@easypod/sdk
v0.1.1
Published
TypeScript SDK for the EasyPod / CustomHub Partner API — print-on-demand fulfillment: catalog, quotes, orders, balance.
Maintainers
Readme
@easypod/sdk
TypeScript SDK for the EasyPod / CustomHub Partner API — print-on-demand fulfillment. Enumerate the catalog, quote costs, submit orders, track fulfillment, check your wallet. Zero runtime dependencies (Node 18+ fetch).
npm i @easypod/sdkQuickstart
import { EasyPod } from '@easypod/sdk';
const easypod = new EasyPod({
apiKey: process.env.EASYPOD_API_KEY!, // ek_live_...
secretKey: process.env.EASYPOD_SECRET_KEY!,// esk_live_...
});
const { models } = await easypod.models.list({ search: 'tee' });
const detail = await easypod.models.get(models[0].id); // variations (SKUs) + placementsGet keys in the dealer portal under Settings → API Keys. Recommended scopes: products_r + orders_r + orders_w.
Placing an order
import { EasyPod, EasyPodError, retryExternalOrderId } from '@easypod/sdk';
// 1. Quote first (scope: orders_r) — no order is persisted. Quote REQUIRES explicit
// print dimensions (create can omit them); defaults live on models.get()
// variations[].defaultPrintSizes.
const { computedCost } = await easypod.orders.quote({
shippingAddress: { name: 'Jane Doe', street1: '123 Main St', city: 'Brooklyn', state: 'NY', zip: '11201', country: 'US' },
items: [{ variationId: 'f47ac10b-...', quantity: 1, designs: [{ placement: 'width1', widthInches: 11, heightInches: 11 }] }],
});
// 2. Create (scope: orders_w). externalOrderId is YOUR id — it doubles as the idempotency key.
const order = await easypod.orders.create({
externalOrderId: 'shopify-1234',
shippingAddress: { name: 'Jane Doe', street1: '123 Main St', city: 'Brooklyn', state: 'NY', zip: '11201', country: 'US' },
items: [{
variationId: 'f47ac10b-...', // prefer variationId (stable) over sku
quantity: 1,
designs: [{
url: 'https://cdn.example.com/art.png', // PNG/JPEG, HTTPS — downloaded + re-hosted at submit
placement: 'width1', // use placements[].code from models.get()
}],
}],
});
// order.status === 'ApprovalPending' — your wallet is NOT debited until a tenant
// admin approves, and then at exactly order.computedCost.grandTotal (cost is locked).
// 3. Poll until it ships (no webhooks yet).
const detail = await easypod.orders.get(order.orderId); // events[], shipments[], rejectionIdempotency — read this before retrying
externalOrderId is the idempotency key (unique per dealer, 90-day memory):
- Same id, same body → the original response replays byte-for-byte. Safe.
- Failures are cached too. After fixing the cause (e.g. topping up after
INSUFFICIENT_BALANCE), retry with a new id:retryExternalOrderId('shopify-1234', 1) → 'shopify-1234-r1'. The thrownEasyPodErrorsetsrequiresNewExternalOrderId: truewhen this applies. ORDER_CREATE_IN_PROGRESS(409) → a previous attempt is still running. Don't switch ids; pollorders.list({ externalOrderId })to discover the outcome.
The SDK auto-retries orders.create only on RATE_LIMITED, NETWORK_ERROR, and INTERNAL_ERROR — always with the same id, which is safe by contract. Everything else surfaces immediately.
Error handling
try {
await easypod.orders.create(req);
} catch (err) {
if (err instanceof EasyPodError) {
switch (err.code) { // typed union, autocompletes
case 'INSUFFICIENT_BALANCE': /* top up, then new externalOrderId */ break;
case 'SKU_NOT_FOUND': /* re-check models.get() */ break;
case 'RATE_LIMITED': /* err.retryAfterSeconds */ break;
case 'ORDER_CREATE_IN_PROGRESS': /* poll orders.list({externalOrderId}) */ break;
}
}
}Server codes come verbatim from the Partner API (stable contract). Four SDK-only codes cover the rest: API_ERROR (non-envelope server body, see err.raw), INVALID_RESPONSE, NETWORK_ERROR, SDK_USAGE_ERROR.
Surface
| Call | Endpoint | Scope |
|---|---|---|
| models.list({ page?, pageSize?, search? }) | GET /api/v1/models | products_r |
| models.get(modelId) | GET /api/v1/models/{id} | products_r |
| orders.quote(req) | POST /api/v1/orders/quote | orders_r |
| orders.create(req) | POST /api/v1/orders | orders_w |
| orders.list(params) | GET /api/v1/orders | orders_r |
| orders.get(orderId) | GET /api/v1/orders/{id} | orders_r |
| orders.cancel(orderId) | DELETE /api/v1/orders/{id} | orders_w |
| orders.addDesigns(orderId, itemId, req) | POST /api/v1/orders/{id}/items/{itemId}/designs | orders_w |
| balance.get() | GET /api/v1/balance | orders_r |
Known limitations (v1 API)
- No webhooks yet — poll
orders.list({ since });orders.get().events[]is the audit trail. - Quote requires explicit
widthInches/heightInchesper design (create can omit them to use SKU defaults). The SDK rejects dims-less quotes locally with a pointer todefaultPrintSizes. FORBIDDENcan mean "no shop" — besides missing scopes, a dealer account without a shop cannot quote or order.- Designs: PNG/JPEG only, ≤ 100 MB each, ≤ 50 designs per order.
- No test mode — orders are real, but land in
ApprovalPending(no charge until an admin approves;orders.cancelworks until then). - Rate limit: 60 requests/min per credential.
- Never use this SDK in a browser — it throws at construction if it detects one (your
secretKeywould be public).
Config
new EasyPod({
apiKey, secretKey,
baseUrl: 'https://api.customhub.io', // default; other CustomHub tenants override
identityUrl: 'https://id.customhub.io', // default (production identity)
timeoutMs: 30_000,
maxRetries: 3,
userAgent: 'my-storefront/1.0',
fetch: customFetch,
});