Common issues and how to resolve them. If you still need help, contact us at developers@printedsimply.com.
401 Unauthorizedmissing_api_key or invalid_api_keyx-api-key or Authorization: Bearer (a bare api-key header is not read)ps_test_, live keys with ps_live_Set the header correctly in your request:
curl https://portal.printedsimply.com/api/v1/products \
-H "x-api-key: ps_test_YOUR_KEY_HERE"
# Equivalent — the same credential as a Bearer token:
# -H "Authorization: Bearer ps_test_YOUR_KEY_HERE"
# Common mistake — a header name we do not read:
# -H "api-key: ps_test_..." ← WRONG429 Too Many Requestsrate_limit_exceededX-RateLimit-Remaining header on responses to monitor your usageX-RateLimit-Reset to know when the window resetsImplement exponential backoff with jitter:
async function fetchWithRetry(url: string, options: RequestInit, maxRetries = 3) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(url, options);
if (response.status === 429) {
// Retry-After is in seconds, on both kinds of 429. (X-RateLimit-Reset is
// a Unix timestamp in milliseconds, not seconds.)
const retryAfter = Number(response.headers.get("Retry-After"));
const waitMs = retryAfter > 0
? retryAfter * 1000
: Math.pow(2, attempt) * 1000 + Math.random() * 1000;
console.log(`Rate limited. Retrying in ${Math.round(waitMs / 1000)}s...`);
await new Promise((resolve) => setTimeout(resolve, waitMs));
continue;
}
return response;
}
throw new Error("Max retries exceeded");
}express.json()Verify X-PS-Signature-V2 over the raw body on every webhook. A missing or malformed header, a stale timestamp or a signature of the wrong length returns false; it never throws, so a bad request is a 401 rather than a 500 that is retried:
import crypto from "crypto";
import express from "express";
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))
);
}
// Register this route BEFORE app.use(express.json()): the signature covers the
// raw bytes, and a JSON parser that runs first leaves an object behind.
app.post("/webhooks/printedsimply", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = Buffer.isBuffer(req.body) ? req.body.toString("utf8") : "";
if (!verifyWebhook(rawBody, req.get("X-PS-Signature-V2"), process.env.PRINTEDSIMPLY_WEBHOOK_SECRET!)) {
res.status(401).send("Invalid signature");
return;
}
// Every delivery is { id, event, timestamp, data }; the order is in data.
// Skip an X-PS-Event-Id you have already handled: delivery is at-least-once.
const event = JSON.parse(rawBody);
console.log(event.event, event.data.order_id);
res.sendStatus(200);
});POST /v1/artwork/validate checks a file against it| Product | Min DPI | Max print size | Bleed | Formats |
|---|---|---|---|---|
| Die-Cut Sticker | 300 | 17" x 17" | 0.125" | PNG, PDF, SVG |
| Kiss-Cut Sticker | 300 | 17" x 17" | 0.125" | PNG, PDF, SVG |
| Sticker Sheet | 300 | 8.5" x 11" | 0.125" | PNG, PDF |
| Roll Sticker | 300 | 4" x 4" | 0.0625" | PNG, PDF |
| Vinyl Decal | 150 | 48" x 48" | 0.25" | PNG, PDF, SVG, TIFF |
| Wall Decal | 150 | 96" x 96" | 0.25" | PNG, PDF, SVG, TIFF |
| Magnet | 300 | 12" x 12" | 0.125" | PNG, PDF |
| Poster | 150 | 36" x 48" | 0.125" | PNG, PDF, TIFF |
Always validate artwork before creating an order:
curl -X POST https://portal.printedsimply.com/api/v1/artwork/validate \
-H "Content-Type: application/json" \
-H "x-api-key: ps_test_YOUR_KEY" \
-d '{
"file_url": "https://example.com/design.png",
"product_type": "sticker-die-cut",
"width_px": 900,
"height_px": 900,
"dpi": 300,
"file_size_bytes": 2500000
}'created→in_production→shipped→deliveredGET /api/v1/order/{id} at reasonable intervals (no more than once per minute).GET /api/v1/order/{id}/events endpoint to see the full event timeline for an order.Orders can be cancelled up until they enter in_production (the created and paid states). Once an order enters production, it cannot be cancelled through the API. Contact support for orders already in production.
The official SDKs and command-line tool (including packages such as @printedsimply/sdk and @printedsimply/cli) are still in development and are not available on npm yet. Any install, update, or version commands referencing them will not work today.
You can use the PrintedSimply REST API directly over HTTP — see the REST and authentication sections above for troubleshooting requests. If you would like early access to the SDKs or CLI, get in touch.
Still stuck? Error reference · Full API docs · Email us at developers@printedsimply.com