Your store is on Shopify
Connect Shopify
- We publish your products to your Shopify store.
- Paid Shopify orders reach us on their own.
- We mark each order fulfilled in Shopify with tracking.
- No developer needed: a few minutes in Shopify's admin.
Integration guide
You sell. We make and ship every order, and send the tracking back. This guide takes you from your first sign-in to your first live order, whether your store is on Shopify or any other website or system.
There are two ways to send orders to POD Mfg. Pick the one that matches where your store lives. Everything after the order (production, shipping, tracking) is the same.
Your store is on Shopify
Any other website or system
On Shopify? Follow Connect your Shopify store and skip the rest of this page. The API steps below are for every other kind of store.
Setting up the API connection, step by step. Names in bold are exactly what you'll see in POD Mfg at app.podmf.com.
Where: app.podmf.com/start-store
Fill in the start form with your business details and choose Our own site or system. Confirm your email with the link we send, then sign in at app.podmf.com (with Google, or the password you set from our sign-in email). No waiting list.
The person who signs up is the brand's owner. Only an owner can connect a store, add the card and make API keys.
Where: Shown the first time you sign in
Read the terms to the end, tick the box, and press Accept and continue. Nothing works through the API until an owner has accepted the current terms.
Where: Dashboard → Developers & API → Connect via API
This is your control panel for the API connection. Press Connect via API. A Setup checklist appears and ticks itself off as you go.
Where: Developers & API → Card for production charges → Add card
Each live order is charged to this card when it comes in. The card is saved on a secure Stripe page: Stripe keeps it and we never see the number. Use Replace card to change it later.
Where: Dashboard → Design Studio, then Products → New product
Put your art on a garment in the Design Studio and approve it. Our team checks every file before anything is printed.
On Products, press New product, pick the approved design, choose colors and sizes, and enter your Price (what your customer pays you). You can set a different price per size.
Your price is yours to set. What you pay us per item is shown as You pay in the Product mapping table on the Developers & API page, next to your retail price.
Where: Products → your product → Publish
Press Publish. Only published products can be ordered; Pause takes a product off sale.
Products that carry names or numbers (team personalization) can't be ordered through the API yet. Ask your account manager about those.
Where: Developers & API → Make a key
Give the key a name (for example "Website"), set Mode to Test (never charged or made), and press Make key.
Copy the key straight away: it is shown once. Test keys start with podmfg_test_. Test orders are never charged and never made, so your developer can try anything.
Where: Developers & API → Make a key → Copy setup instructions for your developer
This button copies a short note with the web address of the API, the docs, and the steps. If you just made the test key it includes it too; otherwise send the key separately.
Send your developer that note and a link to this page (Part 2 is written for them). Send keys through a password manager or a secure channel, not in a public chat.
Where: Setup checklist → Place one now, or API orders → Create a test order
Your developer will send test orders from your website. You can also make one yourself. On a test order the Test tools let you Mark shipped (fake tracking), Fail payment or Cancel, to see what your system does when that happens to a real order.
Where: Developers & API → Webhook endpoints → Add endpoint
A webhook endpoint is a web address on your system where we post news about your orders: paid, on hold, shipped with tracking, cancelled. Your developer gives you the address, or adds it for you.
Pick Test orders while you build, then add one for Live orders (https only). The endpoint's secret is shown once: give it to your developer. Send test event checks it's listening.
Where: Setup checklist → Make your live key
When the checklist shows a card on file, at least one published product and a webhook endpoint, Make a key offers Live. Make a live key (it starts with podmfg_live_) and give it to your developer securely.
From the first live order on, every order is charged to your card and made for real.
Before you make the live key, check:
You can follow every order on the Developers & API page under API orders, and see each charge under Charges for API orders. Your customer pays you on your website; POD Mfg charges you, not your customer.
Per order: what you pay per item (the You pay column on Product mapping, with quantity discounts applied on the real order) plus shipping. The amount is fixed when the order arrives. Test orders are never charged.
How sales tax applies to API orders is still being finalised with our finance team. Ask your account manager before you rely on the current charges for your own tax records.
The order waits as payment failed and we email your billing contact. We try the card again after 1, 6 and 24 hours, then hold the order. Replace the card and press Retry payment. Nothing is made until it's paid. If your bank asks to confirm the payment, save the card again with Replace card, then press Retry payment.
We hold an order, without charging it, when a person should check the address (no house number, US territories, military mail) or the garment is out of stock at our supplier. Our team releases it, or you can cancel it. Addresses we can't ship to are refused straight away so you can fix them.
An order can be cancelled until it goes into production, and a paid one is refunded in full. Once production has started it can't be cancelled through the API: contact us about that order.
US addresses only for now. Shipping is the cheapest service unless your website asks for the fastest one. The shipping amount is set when the order arrives.
Everything you need to connect a store to POD Mfg. The full detail is in the API reference, the 15-minute quickstart (full cURL, Node and Python samples), the OpenAPI 3.1 spec and the Postman collection (Bruno and Insomnia import it too).
https://app.podmf.com/api/v1 (paths below are relative to it)Authorization: Bearer <key> on every request. Keep keys on your server, never in a web page.podmfg_test_... test mode: orders are stored and answered but never charged and never made. podmfg_live_... live mode. Same base URL; the key decides the mode. A test key never sees live data and the other way round.currency (2800 is $28.00 USD). Ids are prefixed (prd_, var_, ord_, shp_, evt_, whk_).?limit=50&starting_after=<id> answers { data, has_more, next_cursor }.v1 changes are additive only: ignore fields you don't know.422 personalization_unsupported).Check your key:
curl https://app.podmf.com/api/v1/me \
-H "Authorization: Bearer $PODMFG_API_KEY" GET /products: your published products, each with variants (a color and size) carrying our
id (var_...), our sku, your external_sku, retail_price,
wholesale_price and in_stock. Use ?updated_since= to sync changes. Images are signed
links that expire after 24 hours: re-fetch rather than store them.
variant_id / sku against your
products, or set your own SKU on ours with PATCH /variants/{id} { "external_sku": "..." }
(or on the Developers page, Product mapping, which also downloads the whole catalog as CSV with every id).
POST /orders when your customer pays. Each line names exactly one of
variant_id, sku or external_sku. Always send an Idempotency-Key
and your external_order_id (unique per brand and mode). Answers 201 with the order's
status and charge. A live order is charged now: paid, or payment_failed
(retried automatically), or on_hold (not charged until released). A test order answers
paid with charge.status: simulated.
order.shipped, and react to
order.paid, order.payment_failed, order.on_hold and order.cancelled.
curl -X POST https://app.podmf.com/api/v1/orders \
-H "Authorization: Bearer $PODMFG_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"external_order_id": "1042",
"shipping_method": "standard",
"ship_to": { "name": "Jane Smith", "address1": "123 Main St", "city": "Irvine",
"state": "CA", "postal_code": "92618", "country": "US" },
"line_items": [
{ "variant_id": "var_...", "quantity": 2, "external_line_id": "li_1" }
]
}'
Optional: POST /orders/estimate prices a cart (wholesale plus shipping) without creating anything.
GET /orders/{id} and GET /orders?external_order_id= read an order back.
POST /orders/{id}/cancel cancels before production (full refund if paid).
POST /orders/{id}/retry_payment charges a failed order again after the owner fixes the card.
shipping_method is standard (cheapest, the default) or expedited (fastest).
Add an endpoint on the Developers page (Webhook endpoints) or with POST /webhook_endpoints { "url": "...", "enabled_events": ["*"] }. The answer's secret (whsec_...) is shown
once; POST /webhook_endpoints/{id}/rotate_secret replaces it (the old one stops at once). A test key makes a
test endpoint, which gets only test-order events; live endpoints get live events and must be https://. Product
events go to both. Every endpoint must be a public address; we don't follow redirects.
| Event | When | What to do |
|---|---|---|
order.paid | The charge succeeded (or simulated, in test). | Note it; production follows. |
order.shipped | A parcel left, with carrier, tracking_number, tracking_url and its line_items. One per parcel: two boxes, two events. | Mark those lines fulfilled with tracking. |
order.payment_failed | The card was declined: failure_code, failure_message, next_retry_at. | Flag it for the owner; we retry on our own. |
order.on_hold | Held, with a reason such as address_review, blank_out_of_stock or payment_failed_final. | Show it to the owner; check the address with your customer. |
order.cancelled | Cancelled (by you or by us), with refunded_amount. | Cancel it in your system. |
order.created, order.in_production | Accepted; sent to production. | Optional status updates. |
product.published, product.updated, product.unpublished | The owner changed a product. | Re-sync that product. |
Each delivery is a POST of { id, type, created_at, livemode, api_version, data } with headers
PODMFG-Signature, PODMFG-Event-Id and PODMFG-Event-Type. Order events carry
order_status and the whole order object.
PODMFG-Signature: t=<unix seconds>,v1=<hex>, where the hex is HMAC-SHA256 of "<t>.<raw body>" keyed with the endpoint's whole whsec_... secret. Use the raw bytes before JSON parsing, compare in constant time, and refuse a t more than 5 minutes from your clock. Full samples: quickstart, step 5.2xx within 10 seconds counts. Queue the work, then answer.POST /events/{id}/resend) can send it again.id. Delivery is at least once, so the same event can arrive twice.order_status and the times in data, or fetch the order.GET /events?since=<ISO time>&type=<type> lists the last 30 days of events exactly as sent, to backfill after downtime or instead of a webhook.PATCH /webhook_endpoints/{id} {"status":"active"}).// Node: check PODMFG-Signature on the RAW body before you trust a webhook.
const [t, ...sigs] = header.split(",").map((p) => p.trim().split("="));
// secret = the endpoint's whole whsec_... string
const expected = crypto.createHmac("sha256", secret)
.update(`${t[1]}.`).update(rawBody).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(t[1])) <= 300;
const ok = fresh && sigs.some(([k, v]) => k === "v1" && v.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(v), Buffer.from(expected))); Test vector: secret whsec_test, t=1700000000, body {"a":1} gives v1=38877139021993b830af32feea6e18a8da83eb2f6e49ee50bd9e4cf4ca4d3789.
With a test key, build the whole flow end to end. After you create a test order, make things happen to it:
POST /orders/{id}/simulate/ship (a parcel with TEST... tracking, sends order.shipped),
.../simulate/payment_failed (sends order.payment_failed; then retry_payment sends
order.paid) and .../simulate/cancel (as if we cancelled it, sends order.cancelled).
Simulations refuse a live key (403 test_mode_only). Send test event on the Developers page sends a signed
ping to one endpoint: ignore event types you don't know.
Idempotency-Key (a UUID) on every POST. A timeout? Send the same request with the same key: you get the first answer (Idempotent-Replayed: true), never a second order or charge. Keys last 24 hours; a request that failed frees its key.X-RateLimit-Limit and X-RateLimit-Remaining; a 429 has Retry-After. Repeated bad keys from one address are paused for a while.X-Request-Id. Log it, and quote it when you contact us. The Developers page's API request log shows the last 30 days of calls.Errors always have this shape, with the right HTTP status:
{
"error": {
"type": "invalid_request_error",
"code": "variant_not_found",
"message": "Variant var_123 does not exist or is not published.",
"param": "line_items[0].variant_id",
"request_id": "req_..."
}
} whsec_ secret. Test and live endpoints have different secrets.POST sends an Idempotency-Key; retries reuse it.external_order_id is your real, unique order number.variant_id, sku or external_sku).payment_failed and on_hold (tell the owner) and order.shipped (one per parcel).GET /events runs after any downtime.| Status | Code | What it means | What to do |
|---|---|---|---|
| 401 | api_key_missing, api_key_invalid | No key, a mistyped key, or a revoked one. | Send Authorization: Bearer <key>. Check you didn't paste a test key where a live one belongs, or the other way round. |
| 403 | terms_required, missing_scope, test_mode_only | An owner hasn't accepted the updated terms, the key isn't allowed to do this, or a simulate call used a live key. | Owner signs in and accepts; or make a key with the right permissions; simulate only with a test key. |
| 409 | duplicate_external_order_id | You already sent an order with this order number (in this mode). The message names the existing ord_ id. | Don't resend: fetch that order. Your order numbers must be unique. |
| 409 | idempotency_key_reused, idempotency_key_in_use | The same Idempotency-Key with a different body, or while the first request is still running. | One key per order attempt; reuse it only to retry the exact same request. |
| 409 | order_not_cancellable | The order is already in production. | Contact us about that order. |
| 422 | address_invalid | We can't ship to that address (bad state code, ZIP, or missing street or city). | Ask your customer to correct it, then send the order again. |
| 422 | country_not_supported | The ship-to country isn't US. | US addresses only for now. |
| 422 | variant_not_found, sku_not_found, external_sku_not_found | The item isn't one of your published variants. | Refresh your product list; check the SKU mapping. |
| 422 | personalization_unsupported | That product carries names or numbers. | Not orderable through the API yet. |
| 402 | payment_failed | Only from retry_payment: the card was declined again. | Owner replaces the card, then retry. |
| 429 | rate_limited | More than 10 requests a second (bursts of 50) for one key. | Wait for Retry-After seconds, then continue. |
| 503 | payments_unavailable, shipping_unavailable | Card payments or shipping quotes are offline for a moment. A live order isn't accepted when we can't charge it. | Retry later with the same Idempotency-Key. |
A declined card on POST /orders is not an error: the order is created (201) with status: payment_failed and we retry it. Every code is listed in the API reference.
https://, with a valid certificate. We don't follow redirects: use the final address.400? Your signature check may be reading parsed JSON instead of the raw body, or using an old secret after a rotation.GET /events.No. Test-key orders are never charged and never sent to production. Only a live key makes real orders.
With the same Idempotency-Key you got the first answer back. With the same external_order_id the second one was refused (409 duplicate_external_order_id). Either way there is one order and one charge.
Sales tax on API orders is being finalised. Check with your account manager before you rely on today's charges.
Not yet. A non-US address is refused with 422 country_not_supported.
Until the order goes into production, yes: POST /orders/{id}/cancel refunds it in full. After that, contact us (409 order_not_cancellable).
order.delivered can be subscribed to but isn't sent today. Use the tracking_url from order.shipped.
Yes. A brand can have a Shopify store connected and an API connection at the same time. Shopify orders come in through Shopify; everything else through the API.
Your POD Mfg account manager is your first contact for setup, billing and orders. You can also reach our team through
the contact box on our request access page. Developers with API questions can email
privacy@podmf.com. Include the X-Request-Id (req_...) of the
call, or the ord_ id of the order, so we can find it.