Build a print-on-demand storefront with the App Router and server actions. Your API key stays server-side; orders go out as plain REST — no SDK required, live on a $0 test key.
No SDK required — Next.js runs on Node 18+, which has a global fetch. Add your API key to .env.local:
# .env.local
PRINTEDSIMPLY_API_KEY=ps_test_YOUR_KEY
PRINTEDSIMPLY_WEBHOOK_SECRET=whsec_YOUR_SECRETCreate a shared REST client:
// lib/mf.ts
// No SDK required — a tiny fetch wrapper around the REST API.
const BASE_URL = "https://portal.printedsimply.com/api/v1";
async function mfFetch(path: string, init?: RequestInit) {
const res = await fetch(`${BASE_URL}${path}`, {
...init,
headers: {
"x-api-key": process.env.PRINTEDSIMPLY_API_KEY!,
"Content-Type": "application/json",
...init?.headers,
},
});
const json = await res.json();
if (!res.ok) {
throw new Error(json.error?.message ?? "PrintedSimply API request failed");
}
// Every response is wrapped as { status, data, request_id }.
return json.data;
}
export const mf = {
products: {
list: () => mfFetch("/products"),
},
orders: {
// The same Idempotency-Key on a retry returns the first result instead
// of placing a second order.
create: (order: unknown, idempotencyKey: string) =>
mfFetch("/order", {
method: "POST",
headers: { "Idempotency-Key": idempotencyKey },
body: JSON.stringify(order),
}),
get: (id: string) => mfFetch(`/order/${id}`),
},
};Fetch the product catalog in a server component — no loading spinners needed.
// app/products/page.tsx
import { mf } from "@/lib/mf";
export default async function ProductsPage() {
const { products } = await mf.products.list();
return (
<div className="grid grid-cols-3 gap-4 p-8">
{products.map((product: any) => (
<div key={product.sku} className="border rounded-lg p-4">
<h3 className="font-bold">{product.name}</h3>
<p className="text-gray-600">{product.sku}</p>
<a href={`/order?sku=${product.sku}`} className="text-blue-600">
Order →
</a>
</div>
))}
</div>
);
}Use a server action to submit orders. The API key stays on the server — nothing is exposed to the client — and your order id becomes the Idempotency-Key, so a double submit places one order.
// app/order/actions.ts
"use server";
import { mf } from "@/lib/mf";
export async function createOrder(formData: FormData) {
// Your own order id, rendered into the form as a hidden field (for
// example crypto.randomUUID() when the page renders). It is stamped on the
// order and makes the Idempotency-Key, so a double submit places one order.
const reference = formData.get("reference");
if (typeof reference !== "string" || !reference) {
throw new Error("reference (your order id) is required");
}
const order = await mf.orders.create({
reference,
items: [
{
sku: formData.get("sku") as string,
quantity: Number(formData.get("quantity")),
print_file: formData.get("print_file") as string,
},
],
address_to: {
name: formData.get("name") as string,
address1: formData.get("address1") as string,
city: formData.get("city") as string,
state: formData.get("state") as string,
zip: formData.get("zip") as string,
country: "US",
},
shipping: { method: "standard" },
}, `order-${reference}`);
return order;
}Receive real-time updates when orders ship or are delivered. The route verifies X-PS-Signature-V2 over the raw body and rejects a stale timestamp, so a captured delivery cannot be replayed; skips an X-PS-Event-Id it has already handled; and reads the order from the event's data.
// app/api/webhooks/printedsimply/route.ts
import crypto from "crypto";
const TOLERANCE_SECONDS = 300;
// header is the raw value of X-PS-Signature-V2, e.g. "t=1754222400,v1=abc123..."
// During a secret rotation the header carries one v1 per live secret
// ("t=...,v1=<new>,v1=<old>"), so collect them all and accept ANY match.
// A missing header (undefined or null) is refused like a malformed one.
function verifyWebhook(rawBody: string, header: string | null | undefined, secret: string): boolean {
let t: string | null = null;
const v1s: string[] = [];
for (const part of (header || '').split(',')) {
const i = part.indexOf('=');
if (i <= 0) continue;
const key = part.slice(0, i).trim();
const value = part.slice(i + 1).trim();
if (key === 't') t = value;
else if (key === 'v1') v1s.push(value);
}
if (!/^\d+$/.test(t || '') || v1s.length === 0) return false;
if (!v1s.every((v) => /^[0-9a-f]+$/i.test(v))) return false;
// Reject a captured request replayed outside the window. This check is the
// reason to prefer V2 — a body-only signature never expires.
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
if (age > TOLERANCE_SECONDS) return false;
// The timestamp is signed along with the body, so it cannot be swapped out.
const expected = crypto
.createHmac('sha256', secret)
.update(t + '.' + rawBody)
.digest('hex');
// timingSafeEqual requires equal-length buffers
return v1s.some(
(v) =>
v.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(v), Buffer.from(expected))
);
}
// Delivery is at-least-once: a retry or a redelivery sends the same event
// again, with the same X-PS-Event-Id. Skip an id you have already handled.
// In production, keep handled ids in your database under a unique index;
// this Set stands in for it (and does not survive a cold start).
const handled = new Set<string>();
export async function POST(req: Request) {
// The raw body, exactly as signed: read it as text before parsing it.
const rawBody = await req.text();
const signature = req.headers.get("x-ps-signature-v2");
if (!verifyWebhook(rawBody, signature, process.env.PRINTEDSIMPLY_WEBHOOK_SECRET!)) {
return new Response("Invalid signature", { status: 401 });
}
const eventId = req.headers.get("x-ps-event-id") ?? "";
if (handled.has(eventId)) return new Response("OK", { status: 200 });
// Every delivery is { id, event, timestamp, data }; the order is in data.
const event = JSON.parse(rawBody);
switch (event.event) {
case "order.shipped":
console.log("Order shipped!", event.data.order_id, event.data.tracking_number);
// Update your database, notify the customer, etc.
break;
case "order.delivered":
console.log("Order delivered!", event.data.order_id);
break;
// Answer 200 to events you do not handle too: anything else is a failed delivery.
}
handled.add(eventId);
return new Response("OK", { status: 200 });
}// app/order/[id]/page.tsx
import { mf } from "@/lib/mf";
export default async function OrderStatusPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const order = await mf.orders.get(id);
return (
<div className="p-8 max-w-lg mx-auto">
<h1 className="text-2xl font-bold mb-4">Order {id}</h1>
<div className="space-y-2">
<p><strong>Status:</strong> {order.status}</p>
<p><strong>Tracking:</strong> {order.tracking_number ?? "Pending"}</p>
<p><strong>Carrier:</strong> {order.tracking_carrier ?? "—"}</p>
</div>
</div>
);
}All event types, retry policies, and signature verification.
Every endpoint with request/response examples.
Safe retries, $0 to test
Send an Idempotency-Key on POST /order so a retry never double-charges. On an ps_test_ key the full order lifecycle runs for $0.