# Express + PrintedSimply

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

Stand up a Node backend that forwards orders and verifies real signed webhooks. Plain REST with a thin fetch helper — no SDK required, live on a $0 test key.

$0 sandbox key

Idempotent orders

Signed webhooks

1. Install Dependencies

```
npm install express dotenv
npm install -D typescript @types/express tsx
```

Add your API key to `.env`:

```
# .env
PRINTEDSIMPLY_API_KEY=ps_test_YOUR_KEY
PRINTEDSIMPLY_WEBHOOK_SECRET=whsec_YOUR_SECRET
PORT=3000
```

2. Create the Server

Products, pricing, and order creation in a single file. The webhook router is mounted before `express.json()`, so it still sees the raw body it verifies:

```
// server.ts
import "dotenv/config";
import express from "express";
import { webhooks } from "./webhooks";

const app = express();
const BASE_URL = "https://portal.printedsimply.com/api/v1";

// Webhooks first. A delivery is verified over its raw bytes, so the webhook
// route must read them before any JSON parser turns the body into an object.
app.use(webhooks);
app.use(express.json());

// Thin REST helper — Node 18+ has a global fetch, no SDK needed.
async function mf(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) {
    // Errors come back as { status: "error", error: { code, message } }.
    const error: any = new Error(json.error?.message ?? "PrintedSimply API error");
    error.status = res.status;
    throw error;
  }
  // Success payloads are wrapped as { status, data, request_id }.
  return json.data;
}

// List products
app.get("/products", async (_req, res) => {
  const { products } = await mf("/products");
  res.json(products);
});

// Get pricing estimate
app.post("/estimate", async (req, res) => {
  const estimate = await mf("/pricing/estimate", {
    method: "POST",
    body: JSON.stringify({
      items: req.body.items,
      shipping_method: req.body.shipping_method ?? "standard",
    }),
  });
  res.json(estimate);
});

// Create order
app.post("/orders", async (req, res) => {
  // reference is your own order id. It is stamped on the order, and it makes
  // the Idempotency-Key: a retry of the same order returns the first result
  // instead of placing a second one.
  const { reference, items, address_to, shipping_method = "standard" } = req.body;
  if (!reference) {
    res.status(400).json({ error: "reference (your order id) is required" });
    return;
  }
  try {
    const order = await mf("/order", {
      method: "POST",
      headers: { "Idempotency-Key": `order-${reference}` },
      body: JSON.stringify({
        reference,
        items,
        address_to,
        shipping: { method: shipping_method },
      }),
    });
    res.json(order);
  } catch (err: any) {
    res.status(err.status ?? 500).json({ error: err.message });
  }
});

// Get order status
app.get("/orders/:id", async (req, res) => {
  const order = await mf(`/order/${req.params.id}`);
  res.json(order);
});

app.listen(Number(process.env.PORT), () => {
  console.log(`Server running on port ${process.env.PORT}`);
});
```

3. Handle Webhooks

Create `webhooks.ts` beside `server.ts`. It 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`.

```
// webhooks.ts
import crypto from "crypto";
import express from "express";

export const webhooks = express.Router();

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.
const handled = new Set<string>();

webhooks.post(
  "/webhooks/printedsimply",
  // The raw bytes, exactly as signed. If req.body is not a Buffer, a JSON
  // parser ran first: mount this router before app.use(express.json()).
  express.raw({ type: "application/json" }),
  (req, res) => {
    const rawBody = Buffer.isBuffer(req.body) ? req.body.toString("utf8") : "";
    const signature = req.get("X-PS-Signature-V2");
    if (!verifyWebhook(rawBody, signature, process.env.PRINTEDSIMPLY_WEBHOOK_SECRET!)) {
      res.status(401).send("Invalid signature");
      return;
    }

    const eventId = req.get("X-PS-Event-Id") ?? "";
    if (handled.has(eventId)) {
      res.sendStatus(200);
      return;
    }

    // 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("Shipped!", event.data.order_id, event.data.tracking_number);
        // Update your database
        break;
      case "order.delivered":
        console.log("Delivered!", event.data.order_id);
        break;
      // Answer 200 to events you do not handle too: anything else is a failed delivery.
    }

    handled.add(eventId);
    res.sendStatus(200);
  }
);
```

4. Run It

```
npx tsx server.ts
```

Test with curl: `curl http://localhost:3000/products`

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.
