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.
npm install express dotenv
npm install -D typescript @types/express tsxAdd your API key to .env:
# .env
PRINTEDSIMPLY_API_KEY=ps_test_YOUR_KEY
PRINTEDSIMPLY_WEBHOOK_SECRET=whsec_YOUR_SECRET
PORT=3000Products, 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}`);
});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);
}
);npx tsx server.tsTest 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.