Request access

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.

  1. You send the order with POST /orders. We answer at once with the price.
  2. We fetch and check your files from your approved file host.
  3. At midnight US Eastern we charge your saved card once for every order accepted that day.
  4. We make and ship it. Your customer gets the parcel under your packing-slip name.
  5. Tracking comes back on the order.shipped webhook, and on GET /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} and GET /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 new ship_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.

  1. After the nightly charge, the order goes to production, then we pack and ship it.
  2. You get one order.shipped event per parcel. A large order can ship in more than one box; each event lists the line_items in that box.
  3. Mark those lines fulfilled in your system and send the tracking link to your customer.
  4. When every line has shipped, the order’s status is shipped.

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_status and 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_batch before midnight Eastern are charged together in one payment.
  • On success, each order moves to paid, goes to production, and you get order.paid per order and one batch.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.paid and batch.payment_failed webhooks 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.