Integration guide · Quickstart · Reference · Catalog API
Your first order in 15 minutes
The POD Mfg Partner API lets your own site or system sell products you designed in POD Mfg: read them, send orders, and hear back when they ship. This walk-through uses a test key the whole way: test orders are never charged and never made, so try anything.
Base URL https://app.podmf.com/api/v1. JSON only. Money is whole cents (2800 is $28.00).
1. Get a test key (2 minutes)
- Sign in at app.podmf.com as an owner of your brand and open Developers & API.
- Press Connect via API, then under Make a key choose Test and press Make key.
- Copy the key (it starts with
podmfg_test_). We show it once and keep only a fingerprint.
Check it works:
export PODMFG_API_KEY=podmfg_test_... # your test key
curl https://app.podmf.com/api/v1/me \
-H "Authorization: Bearer $PODMFG_API_KEY" Keep keys on your server, never in a web page. Every answer carries X-Request-Id; tell us that id if something looks wrong.
2. List your products (2 minutes)
Publish at least one product in POD Mfg first. Each variant (a color and size) has our id, our SKU and room for your own SKU.
curl "https://app.podmf.com/api/v1/products?limit=5" \
-H "Authorization: Bearer $PODMFG_API_KEY" {
"object": "list",
"data": [
{
"id": "prd_...",
"title": "Team Logo Performance Tee",
"variants": [
{ "id": "var_...", "sku": "PMG-ST350-NAV-M-...", "external_sku": null,
"options": { "color": "Navy", "size": "M" },
"retail_price": 2800, "wholesale_price": 1450, "currency": "USD", "in_stock": true }
]
}
],
"has_more": false,
"next_cursor": null
}
Map these into your store: an order can name a variant by variant_id, our sku, or your
external_sku (set it on the Developers page or with PATCH /variants/{id}). The
Developers page also downloads your whole catalog, with every id, as a CSV.
3. Create an order (3 minutes)
Send an Idempotency-Key with every POST: if your request times out, send it again with the
same key and you get the first answer, never a second order. With a test key the order answers
"status": "paid" and "charge": { "status": "simulated" }.
cURL:
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", "address2": "Apt 4", "city": "Irvine", "state": "CA", "postal_code": "92618", "country": "US" }, "line_items": [ { "variant_id": "var_...", "quantity": 2, "external_line_id": "li_1" } ] }' Node.js:
// Node 18 or later (built-in fetch and crypto).
const res = await fetch("https://app.podmf.com/api/v1/orders", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PODMFG_API_KEY}`,
"Content-Type": "application/json",
// The same key on a retry returns the first answer: never a second order or charge.
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
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" }],
}),
});
const order = await res.json();
if (!res.ok) throw new Error(`${order.error.code}: ${order.error.message} (${order.error.request_id})`);
console.log(order.id, order.status, order.total); Python:
import os, uuid, requests
r = requests.post(
"https://app.podmf.com/api/v1/orders",
headers={
"Authorization": f"Bearer {os.environ['PODMFG_API_KEY']}",
# The same key on a retry returns the first answer: never a second order or charge.
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"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"}],
},
)
order = r.json()
if not r.ok:
raise RuntimeError(f"{order['error']['code']}: {order['error']['message']} ({order['error']['request_id']})")
print(order["id"], order["status"], order["total"]) external_order_id is your order number: we refuse a second order with the same one
(409 duplicate_external_order_id). Errors always look like
{ "error": { "type", "code", "message", "param", "request_id" } }.
4. Receive a webhook (5 minutes)
Tell us where to send events. A test endpoint gets only test events, so it can be a development server (it must be reachable from the internet).
curl -X POST https://app.podmf.com/api/v1/webhook_endpoints \
-H "Authorization: Bearer $PODMFG_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://your-site/podmfg/webhooks", "enabled_events": ["*"] }'
# The answer's "secret" (whsec_...) is shown once: save it as PODMFG_WEBHOOK_SECRET. Then make your test order ship. In real life this happens when the factory hands the parcel to the carrier; here a test key (or the Mark shipped button on the Developers page) makes it happen now, with fake tracking:
# Make the test order ship (fake tracking): your endpoint gets order.shipped.
curl -X POST https://app.podmf.com/api/v1/orders/ord_.../simulate/ship \
-H "Authorization: Bearer $PODMFG_API_KEY"
# Also: .../simulate/payment_failed (then /retry_payment) and .../simulate/cancel Your endpoint receives, within a few seconds:
{
"id": "evt_...",
"type": "order.shipped",
"created_at": "2026-10-14T18:22:05Z",
"livemode": false,
"api_version": "v1",
"data": {
"order_id": "ord_...",
"external_order_id": "1042",
"shipment": {
"id": "shp_...",
"carrier": "USPS",
"service": "Ground Advantage",
"tracking_number": "TEST4831907726150394",
"tracking_url": "https://...",
"shipped_at": "2026-10-14T18:20:00Z",
"line_items": [{ "external_line_id": "li_1", "variant_id": "var_...", "quantity": 2 }]
},
"order_status": "shipped"
}
} - Answer any
2xxwithin 10 seconds; anything else is retried for about 3 days (1 m, 5 m, 30 m, 2 h, 6 h, 12 h, 24 h, 24 h). - An event can arrive twice: dedupe on its
id. Order isn't guaranteed: useorder_status. - One
order.shippedper parcel: an order in two boxes sends two. - Missed some?
GET /events?since=...lists the last 30 days, and the Developers page can resend any event.
5. Verify the signature (3 minutes)
Every delivery has a PODMFG-Signature header: t=<unix seconds>,v1=<hex>, where
the hex is the HMAC-SHA256 of "<t>.<raw body>" keyed with your endpoint's whole
whsec_... secret. Check it on the raw body (the exact bytes, before JSON parsing),
compare in constant time, and refuse a t more than 5 minutes from your clock. While you replace a
secret, accept both the old and the new one.
Node.js (Express):
const crypto = require("node:crypto");
const express = require("express");
const app = express();
function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.create(null);
const sigs = [];
for (const p of String(header || "").split(",")) {
const [k, v] = p.trim().split("=");
if (k === "t") parts.t = v;
if (k === "v1") sigs.push(v);
}
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(`${parts.t}.`)
.update(rawBody)
.digest("hex");
return sigs.some(
(s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)),
);
}
// express.raw: verify the exact bytes we sent, before any JSON parsing.
app.post("/podmfg/webhooks", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body, req.get("PODMFG-Signature"), process.env.PODMFG_WEBHOOK_SECRET)) {
return res.status(400).send("bad signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// Dedupe on event.id, queue the work, answer at once (within 10 seconds).
res.sendStatus(204);
}); Python (Flask):
import hashlib, hmac, os, time
from flask import Flask, request
app = Flask(__name__)
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
t, sigs = None, []
for part in (header or "").split(","):
k, _, v = part.strip().partition("=")
if k == "t":
t = v
elif k == "v1":
sigs.append(v)
if t is None or not t.isdigit() or abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(s, expected) for s in sigs)
@app.post("/podmfg/webhooks")
def webhook():
# request.get_data(): the exact bytes we sent, before any JSON parsing.
if not verify(request.get_data(), request.headers.get("PODMFG-Signature"),
os.environ["PODMFG_WEBHOOK_SECRET"]):
return "bad signature", 400
event = request.get_json()
# Dedupe on event["id"], queue the work, answer at once (within 10 seconds).
return "", 204 cURL, to test your handler:
# Sign a test request to your own handler running on your machine.
SECRET='whsec_...'
BODY='{"id":"evt_test","type":"ping","created_at":"2026-10-14T18:22:05Z","livemode":false,"api_version":"v1","data":{}}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -X POST http://localhost:3000/podmfg/webhooks \
-H "Content-Type: application/json" \
-H "PODMFG-Event-Id: evt_test" -H "PODMFG-Event-Type: ping" \
-H "PODMFG-Signature: t=$TS,v1=$SIG" \
--data-binary "$BODY"
A known answer to test your code: secret whsec_test, t=1700000000, body
{"a":1} gives
v1=38877139021993b830af32feea6e18a8da83eb2f6e49ee50bd9e4cf4ca4d3789.
6. Go live
- On the Developers page: add a card (each live order is charged to it), publish your products, keep a webhook endpoint (add a live one for live events).
- Make a live key (
podmfg_live_) and swap it in. Live orders are charged at once and made exactly like a Shopify order. - A declined card leaves the order
payment_failed: we retry after 1, 6 and 24 hours and email you. Nothing unpaid is ever made.
Everything else (cancelling, estimates, the full order object, every error code) is in the API reference. Prefer clicking to typing? Import the Postman collection (Bruno and Insomnia read it too). The integration guide has the business owner's side, a go-live checklist and troubleshooting.
Questions: privacy@podmf.com.