# Next.js + PrintedSimply

Source: https://portal.printedsimply.com/docs/quickstarts/nextjs

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.

$0 sandbox key

Idempotent orders

Signed webhooks

1. Set Up

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_SECRET
```

Create 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}`),
  },
};
```

2. List Products

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 &rarr;
          </a>
        </div>
      ))}
    </div>
  );
}
```

3. Create Orders with Server Actions

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;
}
```

4. Handle Webhooks

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 });
}
```

5. Check Order Status

```
// 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>
  );
}
```

[Webhook events](https://portal.printedsimply.com/docs/webhooks)

All event types, retry policies, and signature verification.

[API reference](https://portal.printedsimply.com/docs/api-reference)

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.
