Integration guide · Quickstart · Reference · Order API
POD Mfg Order API: orders, tracking and billing
For developers at companies that send us their own print orders over the API, with custom art per order, billed once a night. It covers the three jobs your system does with us: send orders, receive tracking, and manage billing. If you sell our catalog products from your own store instead, start with the integration guide.
Overview
Your servers send us print orders over HTTPS; we check the art, charge your saved card once a night, make and ship each order, and send tracking back by signed webhook.
Each order line names a blank garment (our supplier SKU), a print method, one print-ready file per print location, and a mockup image. Each order carries a US ship-to address.
- You send the order with
POST /orders. We answer at once with the price. - We fetch and check your files from your approved file host.
- At midnight US Eastern we charge your saved card once for every order accepted that day.
- We make and ship it. Your customer gets the parcel under your packing-slip name.
- Tracking comes back on the
order.shippedwebhook, and onGET /orders/{id}.
| Base URL | https://app.podmfg.com/api/v1 (all paths below are relative to it) |
| Test mode | Test keys (podmfg_test_...) use the same URL. Orders are stored and answered, never charged, never made. |
| Live mode | Live keys (podmfg_live_...). Every accepted order is billed and made. |
| Format | JSON. Times in ISO 8601 UTC. Money in integer cents (2800 = $28.00 USD). |
| Versioning | Additive only within v1. Ignore fields you don’t recognize. |
| Coverage | US ship-to addresses only. |
Status of this guide. The core API (keys, orders, webhooks, test mode) is live today. The order line format for print files, file replacement, and the nightly batch endpoints are being built now and are marked (in build). Field names may still change slightly before go-live; we’ll send the final reference with your test key.
Access and security
Every order call must carry a valid key and come from an IP address we have approved; anything else is refused.
API key. Send Authorization: Bearer <key> on every request. Keys are issued by POD Mfg staff and handed over through a secure channel, never email. Keep them on your servers only.
IP allowlist. Order, file and batch endpoints answer only to your approved addresses. A call from anywhere else gets 403 and is logged. The catalog read endpoints are not IP-locked.
- What we need: one or two fixed outbound IP addresses for your servers.
- On Google Cloud: route your outbound traffic through Cloud NAT with a reserved static IP, and send us that address. Google’s shared Cloud IP ranges cover every Google Cloud customer, so we can’t approve them as a whole. Each approved range can be no wider than a /16.
File-host allowlist. We download print files and mockups only from host names you register with us (for example cdn.example.com). Exact host names, no wildcards, https:// only, no redirects to other hosts.
Idempotency. Send an Idempotency-Key header (a UUID) on every POST. A retry with the same key and body returns the first answer (Idempotent-Replayed: true), never a second order. Keys are kept 24 hours.
Rate limit. 10 requests a second per key, bursts of 50. A 429 carries Retry-After. Every answer carries X-Request-Id: log it and quote it to us.
curl https://app.podmfg.com/api/v1/me -H "Authorization: Bearer $PODMFG_API_KEY"
Sending an order
Send one POST /orders per customer order, when your customer pays; each line is a custom line naming the blank, the print method, the files per location and a mockup. (in build)
curl -X POST https://app.podmfg.com/api/v1/orders \
-H "Authorization: Bearer $PODMFG_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"external_order_id": "CM-100231",
"shipping_method": "standard",
"ship_to": {
"name": "Jane Smith", "address1": "123 Main St", "address2": null,
"city": "Irvine", "state": "CA", "postal_code": "92618",
"country": "US", "phone": "+19495550100"
},
"packing_slip": { "from_name": "Your Brand", "message": "Thanks for your order!" },
"line_items": [{
"external_line_id": "1",
"quantity": 2,
"custom": {
"blank_sku": "PC61-Black-L",
"print_method": "dtg",
"mockup_url": "https://cdn.example.com/mock/100231-1.png",
"placements": [
{ "location": "front", "file_url": "https://cdn.example.com/art/100231-front.png" },
{ "location": "back", "file_url": "https://cdn.example.com/art/100231-back.png" }
]
}
}]
}'
| Field | Rules |
|---|---|
external_order_id |
Your order number. Unique per mode; a repeat answers 409 duplicate_external_order_id. |
shipping_method |
standard (default) or expedited. |
ship_to |
Name, street, city, state, postal_code, country (US only), phone. We need nothing else about your customer. |
packing_slip.from_name |
The name printed on the packing slip. Optional message. |
external_line_id |
Your line id. It comes back on webhooks and tracking. |
blank_sku |
Our supplier SKU (style-color-size, for example PC61-Black-L). Unknown: 422 unknown_blank. Size or color not offered: 422. |
print_method |
dtg or dtf. All Over Print (sublimation, cut and sew) uses a different flow; ask us before sending it. |
placements[].location |
A location key for that method: front, back, left_chest, sleeves, … GET /print_locations?method=dtg lists them with each print box size. One file per location. |
placements[].file_url |
https://, on your approved file host. PNG, PDF or SVG, up to 50 MB. Transparent background, sized to the print box at 300 DPI or better. |
mockup_url |
https://, on your approved file host. PNG or JPG, up to 50 MB. |
Prices are fixed when we accept the order. Each line is priced from your price sheet. A blank, location or method with no price answers 422 price_not_set; we never guess a price.
Check before you send. POST /orders/estimate takes the same body, runs every check and returns the price and shipping, without creating anything.
Catalog. GET /catalog lists the blanks you can order, with SKUs, colors, sizes and your price.
Order statuses, cancels and file fixes
An order can be cancelled or edited until the midnight Eastern charge; after that it is in production and the API answers 409 order_in_production.
| Status | Meaning | You can |
|---|---|---|
processing_art |
We are downloading and checking your files (seconds to minutes). | Cancel |
needs_art |
A file is missing, unreachable, or not a usable image. Not billed until fixed. | Replace the file, cancel |
pending_batch |
Accepted and priced. Charged at the next midnight Eastern. | Cancel, edit ship-to |
payment_failed |
The nightly charge was declined. Held until paid. | Cancel |
paid |
Charged. Production starts. | Read only |
in_production |
Being printed. | Read only |
shipped |
Left our dock. Tracking on the webhook. | Read only |
cancelled |
Cancelled by you or by us. | Read only |
Low-resolution art is not rejected. If a file is below the print resolution, our art team upscales it and the order stays on schedule. You only hear about art when a file can’t be used at all.
Replacing a file (in build). Before the cutoff, send a new file URL for one placement; the order goes back to processing_art.
POST /orders/{id}/lines/{external_line_id}/placements/{location}/file
{ "file_url": "https://cdn.example.com/art/100231-front-v2.png" }
Other order calls.
GET /orders/{id}andGET /orders?external_order_id=CM-100231: status, price, tracking.POST /orders/{id}/cancel: before the cutoff. A paid order is not cancellable through the API; contact us.PATCH /orders/{id}with a newship_to: before the cutoff. (in build)
Tracking
Every parcel we ship sends you an order.shipped webhook with the carrier, tracking number and tracking link; the same tracking is on the order.
How it works.
- After the nightly charge, the order goes to production, then we pack and ship it.
- You get one
order.shippedevent per parcel. A large order can ship in more than one box; each event lists theline_itemsin that box. - Mark those lines fulfilled in your system and send the tracking link to your customer.
- When every line has shipped, the order’s
statusisshipped.
Example: order.shipped.
{
"id": "evt_3Lw...",
"type": "order.shipped",
"created_at": "2026-11-12T18:22:05Z",
"livemode": true,
"api_version": "v1",
"data": {
"order_id": "ord_9kP1...",
"external_order_id": "CM-100231",
"shipment": {
"carrier": "USPS",
"tracking_number": "9400111899223344556677",
"tracking_url": "(the carrier's tracking link)",
"shipped_at": "2026-11-12T18:20:00Z",
"line_items": [{ "external_line_id": "1", "quantity": 2 }]
},
"order_status": "shipped"
}
}
Reading tracking from the order. GET /orders/{id} (or GET /orders?external_order_id=...) returns shipments[]: one entry per parcel with carrier, tracking_number, tracking_url, shipped_at and its line_items. Use it to check an order or to catch up after downtime.
Missed a webhook? GET /events?type=order.shipped&since=... lists every shipment event of the last 30 days, exactly as sent.
Test it. With a test key, POST /orders/{id}/simulate/ship sends a real order.shipped event with a fake tracking number.
Webhooks
We POST a signed JSON event to your webhook URL whenever an order or batch changes; answer any 2xx within 10 seconds.
| Event | When | Key fields |
|---|---|---|
order.created |
We received the order. | order_id, external_order_id |
order.accepted |
Files checked, priced, waiting for tonight’s charge. (in build) | order |
order.art_rejected |
A file can’t be used (missing, unreachable, not an image). (in build) | external_line_id, location, reason |
order.paid |
Charged in the nightly batch. | order |
order.in_production |
Sent to the printer. | order |
order.shipped |
A parcel left. One event per parcel. | carrier, tracking_number, tracking_url, line_items |
order.on_hold |
Held by us (address check, garment out of stock). | reason |
order.cancelled |
Cancelled by you or by us. | refunded_amount |
batch.paid |
The nightly charge succeeded. (in build) | batch_id, amount, order ids |
batch.payment_failed |
The nightly charge was declined. Nothing is made until paid. (in build) | batch_id, failure_message, next_retry_at |
Verify every event. The PODMFG-Signature header is t=<unix seconds>,v1=<hex>, where the hex is HMAC-SHA256 of "<t>.<raw body>" keyed with your whsec_... secret. Verify against the raw body before parsing, and reject a t more than 5 minutes from your clock.
const crypto = require("crypto");
function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(String(header || "").split(",").map(p => p.split("=")));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return typeof parts.v1 === "string" && parts.v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
}
Delivery rules.
- Retries: after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h and 24 h (9 tries over about 3 days).
- At least once: the same event can arrive twice. Dedupe on
id. - Not in order: use
order_statusand the times, or fetch the order. - Backfill:
GET /events?since=...&type=...lists the last 30 days of events, exactly as sent.
Billing
We charge your saved card once a night, at 00:00 US Eastern, for every order accepted that day; no payment means no production.
Nightly charge. (in build)
- Orders in
pending_batchbefore midnight Eastern are charged together in one payment. - On success, each order moves to
paid, goes to production, and you getorder.paidper order and onebatch.paid. - On a decline, every order in the batch stays held, we email your billing contact and send
batch.payment_failed. We retry at the next midnight, as soon as the card is updated, or when our team presses retry. Nothing of yours is made while a batch is unpaid. - The card is saved on a secure Stripe page we send to your billing contact. We never see the card number.
Managing your card.
- Your billing contact gets a secure Stripe link to save the card. We never see the card number.
- To change the card, ask us for a new link, or use the link in any payment-failed email. Saving a new card retries an unpaid batch straight away.
- Card changes, failed charges and receipts go to your billing contact by email. Tell us if that person changes.
What each charge covers. Every order in the batch: the items at your prices, plus that order’s shipping. GET /orders/{id} shows subtotal, shipping_total and total; the batch statement adds them up.
Cancels and refunds. Cancel before midnight Eastern and the order is never charged. After the charge the order is in production and can’t be cancelled through the API; contact us about a problem order and we’ll credit or refund it.
Statements. (in build)
GET /batches: every nightly charge, newest first.GET /batches/{id}: the orders in it, the amount per order, and the Stripe receipt.batch.paidandbatch.payment_failedwebhooks tell your system the moment a charge succeeds or fails.
Prices. One price per item from your price sheet: the blank, plus each print location. Sizes 2XL and up cost more on the blank. We send the full sheet, SKU by SKU, with your test key. The price on each order is fixed when we accept it.
Shipping. Worked out per box from our carriers’ rates, by packed weight and speed, and shown on the order and the estimate.
| Speed | shipping_method |
Notes |
|---|---|---|
| Standard | standard |
Default. |
| Expedited | expedited |
Priority service. |
Sales tax. Not charged on API client orders.
Errors, endpoints and what we need from you
Errors come back as JSON with a stable code; retry only 429 and 503, with the same Idempotency-Key.
{ "error": { "type": "invalid_request_error", "code": "unknown_blank",
"message": "PC61-Purple-L is not in the catalog.",
"param": "line_items[0].custom.blank_sku", "request_id": "req_..." } }
| Status | Code | What to do |
|---|---|---|
| 401 | api_key_missing, api_key_invalid |
Check the key and test vs live. |
| 403 | ip_not_allowed |
Call from an approved IP, or send us the new address. |
| 403 | missing_scope |
The key lacks that permission. Ask us. |
| 409 | duplicate_external_order_id |
Already sent. Fetch it; don’t resend. |
| 409 | idempotency_key_reused |
One key per request. Reuse only for an exact retry. |
| 409 | order_in_production |
Past the cutoff. Contact us. |
| 422 | unknown_blank |
Check the SKU against GET /catalog. |
| 422 | price_not_set |
No price for that blank, location or method. Ask us. |
| 422 | file_host_not_allowed |
File URL isn’t on your approved hosts. |
| 422 | address_invalid, country_not_supported |
Fix the address. US only. |
| 429 | rate_limited |
Wait Retry-After seconds. |
| 503 | payments_unavailable, shipping_unavailable |
Retry with the same Idempotency-Key. |
Endpoints.
| Endpoint | Purpose | IP-locked |
|---|---|---|
GET /me |
Who this key belongs to | No |
GET /catalog |
Blanks, colors, sizes, your prices | No |
GET /print_locations?method= |
Locations and print box sizes | Yes |
POST /orders/estimate |
Check and price without creating | Yes |
POST /orders |
Create an order | Yes |
GET /orders/{id}, GET /orders?external_order_id= |
Status and tracking | Yes |
PATCH /orders/{id} |
Edit ship-to before the cutoff (in build) | Yes |
POST /orders/{id}/cancel |
Cancel before the cutoff | Yes |
POST /orders/{id}/lines/{line}/placements/{loc}/file |
Replace a file (in build) | Yes |
GET /batches, GET /batches/{id} |
Nightly charges (in build) | Yes |
GET /events |
Webhook backfill, 30 days | Yes |
Test mode. With a test key, nothing is charged or made. Simulate calls move a test order along: POST /orders/{id}/simulate/ship and .../simulate/cancel; .../simulate/batch runs a nightly charge (in build).
What we need from you to go live.
- One or two fixed outbound IP addresses (Cloud NAT static IP on Google Cloud).
- The host names your print files and mockups live on.
- Your webhook URL for live events, and one for test events.
- Whether you’ll send our supplier SKUs or your own (if your own, send the list to map).
- Typical file format and size, and which locations you print.
- Rough orders per day and your target go-live date.
- The name and return address for the packing slip.
- Who at your company should get the link to save the card.