# PrintedSimply — full descriptor > Custom printed products from our own plant, ordered and tracked over a REST API or an MCP server. The agent makes the artwork, a person approves it, and we make the thing. Custom stickers, decals, magnets and posters, printed in one US plant. A run of one is an order: every product's minimum_quantity is 1. A test key runs the same order through a $0 sandbox from placed to delivered. Every status change arrives as a signed webhook, on a test order as on a live one. Each artwork file is checked at the size it prints before a live order is charged. An order line can declare how its artwork was made and who approved it, and the order and its webhooks carry that declaration. Stickers, decals, magnets and posters, made in our own US plant, in about 2 days — ordered over a REST API with one key. Sign up at https://portal.printedsimply.com/signup for a test key (ps_test_*) and a live key (ps_live_*). A test key runs the whole order lifecycle in a $0 sandbox with real signed webhooks and never charges; a live key charges the card on file and sends the order to production. Keys created before the PrintedSimply rename start with mf_test_ or mf_live_ and keep working. ## API (base https://portal.printedsimply.com/api/v1) - Auth: send your key in the `x-api-key` header on every request. - Writes accept an `Idempotency-Key` header; a retry with the same key never places a second order. - GET /products — the catalog: what our plant makes, with all-in prices. - POST /orders/quote — a signed, expiring price for an order. - POST /order — place an order. - GET /order/{id} — status, line items and tracking. - GET /order/{id}/events — the order's history. - POST /webhooks — subscribe an endpoint to order events, HMAC-signed. - Reference: https://portal.printedsimply.com/docs/api-reference - OpenAPI: https://portal.printedsimply.com/api/openapi.json ## MCP server Order from Claude, Cursor, ChatGPT or any MCP client with the same key, no code. - Endpoint: https://portal.printedsimply.com/api/mcp (streamable HTTP, nothing to install). Send the key in the `x-api-key` header or as `Authorization: Bearer`; an OAuth client is pointed at the sign-in by the server's 401. - No key yet: https://portal.printedsimply.com/api/mcp?profile=visitor needs none. It searches the catalog, prices an order, checks artwork and places a $0 sandbox order on a demo account, then hands the person a link to finish on the dashboard. - How to connect: https://portal.printedsimply.com/docs/quickstarts/mcp (a config to paste: https://portal.printedsimply.com/mcp-config.json) - Discovery: https://portal.printedsimply.com/.well-known/mcp (the server card: https://portal.printedsimply.com/.well-known/mcp/server-card.json), and the AI Catalog at https://portal.printedsimply.com/.well-known/ai-catalog.json (SEP-2127), naming the card at https://portal.printedsimply.com/api/mcp/server-card - Tools (28); call whoami first to see whether the key is a test key or a live one: - search_catalog: Search the catalog - get_product: Get a product - get_product_options: List custom-size options - recommend_products: Recommend products - estimate_shipping: Estimate shipping - get_calendar: Get the plant calendar - quote_order: Quote an order - estimate_pricing: Estimate a price - create_mockup: Render a product mockup - validate_artwork: Check a print file - create_order: Place an order - reorder: Place an order again - get_order_status: Get an order's status - track_order: Track a shipment - list_orders: List orders - cancel_order: Cancel an order - get_order_events: Get an order's events - update_order_address: Change an order's address - update_order_shipping: Change an order's shipping - add_order_item: Add an item to an order - update_order_item: Change an item's quantity - cancel_order_item: Cancel one order item - whoami: Check this key - check_setup: Check account setup - list_assets: List uploaded files - get_asset: Get an uploaded file - upload_asset: Upload a file - delete_asset: Delete an uploaded file ## Instructions for LLMs - Test first: build and run the integration with a test key (ps_test_*). A test order runs the whole lifecycle and is never charged, printed or shipped; POST /order/{id}/simulate moves it on. - Quote before ordering: POST /orders/quote returns the price and a quote_token; send the quote_token on POST /order and the order is charged the quoted total. - PrintedSimply absorbs card processing and carrier peak postage costs; neither adds a customer fee to a new order. - Buying for a date: read GET /calendar first. order_by gives the last day to order by each shipping method for the holidays, and peak the carrier's informational cost window; for any other day send deliver_by on POST /orders/quote and read deliver_by_check.latest_order_day. advisories lists the carrier delays in effect; a quote into a destination one touches says deliver_by_check.status at_risk, with the advisory in delivery_risk. - Always send an Idempotency-Key on POST /order (your own order id works), so a retry returns the first order instead of placing a second. - Verify X-PS-Signature-V2 (t=,v1=.">) with the endpoint's secret before acting on a webhook, refuse an old t, and skip an X-PS-Event-Id you have already handled. Each X-PS-* header is also sent as its deprecated X-MF-* alias, with the same value; read X-PS-* first. - Never put a key in a browser, a mobile app or a URL: call the API from a server. ## Place an order curl -X POST https://portal.printedsimply.com/api/v1/order \ -H "x-api-key: ps_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-$(date +%s)" \ -d '{ "items": [ { "sku": "sticker-die-cut-3x3", "quantity": 100, "artwork_url": "https://printedsimply.com/samples/sticker-3x3.png" } ], "address_to": { "name": "Jane Doe", "address1": "123 Main St", "city": "Austin", "state": "TX", "zip": "78701", "country": "US" }, "shipping": { "method": "standard" } }' The order's reference is its Idempotency-Key, and its artwork is a sample file this site serves. A test key places it for $0 and returns order_id (test_ord_...) and tracking_url. A product our plant does not make is refused with 422 sku_not_available before anything is priced or charged. ## Webhook events - order.created - order.paid - order.in_production - order.shipped - order.delivered - order.cancelled - order.failed - order.updated - order.quality_check - order.issue - order.approval_requested - order.approval_approved - order.approval_declined - order.approval_expired - product.updated - catalog.synced - webhook.disabled ## Documentation (https://portal.printedsimply.com/docs) Every docs page is also Markdown: append `.md` to its URL (`/docs.md` for the overview), or ask for the page with `Accept: text/markdown`. ### Start - [Overview](https://portal.printedsimply.com/docs.md): What the API does, where to start, and your first request. - [curl quickstart](https://portal.printedsimply.com/docs/quickstarts/curl.md): Key to shipped sandbox order in one terminal, no SDK. - [TypeScript quickstart](https://portal.printedsimply.com/docs/quickstarts/typescript.md): The same order from Node with fetch: nothing to install. - [Python quickstart](https://portal.printedsimply.com/docs/quickstarts/python.md): The same order from Python with the standard library: nothing to install. - [All quickstarts](https://portal.printedsimply.com/docs/quickstarts.md): Every first-order guide on one page, by framework and by language. - [PHP quickstart](https://portal.printedsimply.com/docs/quickstarts/php.md): The same order from PHP with the curl extension: nothing to install with Composer. - [MCP quickstart](https://portal.printedsimply.com/docs/quickstarts/mcp.md): Point Claude, Cursor, or ChatGPT at the hosted MCP server and order in plain English. - [Next.js quickstart](https://portal.printedsimply.com/docs/quickstarts/nextjs.md): App Router and server actions: the order from a server component, the key never in the browser. - [Express quickstart](https://portal.printedsimply.com/docs/quickstarts/express.md): A minimal Node server that forwards orders and verifies signed webhooks. - [Flask quickstart](https://portal.printedsimply.com/docs/quickstarts/flask.md): The order and the webhook check from Python with requests; the same in Django or FastAPI. - [Rails quickstart](https://portal.printedsimply.com/docs/quickstarts/rails.md): A service class, a controller and routes, with HMAC webhook verification. - [Go quickstart](https://portal.printedsimply.com/docs/quickstarts/go.md): A lightweight HTTP client and handlers for high-throughput order processing. ### Guides - [Webhooks](https://portal.printedsimply.com/docs/webhooks.md): Subscribe, verify the HMAC signature, handle retries, and replay a failed delivery. - [Error scenarios](https://portal.printedsimply.com/docs/error-scenarios.md): What happens when a card fails, artwork is rejected, or a webhook endpoint is down. - [Troubleshooting](https://portal.printedsimply.com/docs/troubleshooting.md): The common failures, how to tell them apart, and the fix for each. ### Reference - [API reference](https://portal.printedsimply.com/docs/api-reference.md): Every v1 endpoint with its method, path, and summary, generated from the OpenAPI spec. - [Error codes](https://portal.printedsimply.com/docs/errors.md): Every error code the API can return, with its HTTP status and an actionable fix. - [Rate limits](https://portal.printedsimply.com/docs/rate-limits.md): Per-plan quotas, the headers on every response, and how to back off honestly. - [Changelog](https://portal.printedsimply.com/docs/api-changelog.md): Dated API versions, what changed in each, the breaking-change notice period, and every platform release. ### Concepts - [Reliability contract](https://portal.printedsimply.com/docs/reliability.md): Idempotency, pagination, signing, retries, and limits — written as promises. ## More - Create keys: https://portal.printedsimply.com/dashboard/api-keys (sign up: https://portal.printedsimply.com/signup) - Pricing: https://portal.printedsimply.com/pricing - Status: https://portal.printedsimply.com/status - Status as JSON, no key: GET https://portal.printedsimply.com/api/status (the status page's numbers: overall and per-service tiers, uptime and latency; the page explains the fields and the request limit) - API changelog feeds, the newest 50 dated changes (https://portal.printedsimply.com/docs/api-changelog lists them all): https://portal.printedsimply.com/docs/api-changelog.rss (RSS) or https://portal.printedsimply.com/docs/api-changelog.json (JSON Feed) - Platform release feeds, the newest 50 platform releases (https://portal.printedsimply.com/docs/api-changelog#platform-releases lists them all): https://portal.printedsimply.com/docs/platform-releases.rss (RSS) or https://portal.printedsimply.com/docs/platform-releases.json (JSON Feed) - Product feed, our plant's products in OpenAI's product feed format for ChatGPT (JSONL): https://portal.printedsimply.com/openai-product-feed.jsonl --- # The documentation, in full Every docs page follows, in the order the docs read. Every docs page is also Markdown: append `.md` to its URL (`/docs.md` for the overview), or ask for the page with `Accept: text/markdown`. Linked rather than included here: - https://portal.printedsimply.com/docs/api-reference.md: every operation in full — parameters, bodies, responses, errors and a sample in four languages — generated from the OpenAPI document named above, which is read for the one operation a task needs. - https://portal.printedsimply.com/docs/api-changelog.md: the dated history of the API and of the platform, which an integration needs when it pins a version, not before its first order. --- # The PrintedSimply API, end to end Source: https://portal.printedsimply.com/docs Anything a person can do on this site, your code or your assistant can do with one key: read the catalog, get a price, place the order, and be told what happened. Every operation is a versioned REST endpoint, on the same key. [Get an API key](https://portal.printedsimply.com/signup) ## Start here [Quickstart](https://portal.printedsimply.com/docs/quickstarts) Sign up, reveal a test key, and place a $0 sandbox order, in curl, TypeScript, Python, PHP and more. [API reference](https://portal.printedsimply.com/docs/api-reference) Every v1 endpoint, generated from the OpenAPI document the SDKs are built from. ## Your first request Send it with a `ps_test_` key in the `x-api-key` header and it runs the whole order for $0, all the way to delivered, with real signed webhooks. `POST /v1/order` ```bash curl -X POST https://portal.printedsimply.com/api/v1/order \ -H "x-api-key: ps_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-$(date +%s)" \ -d '{ "items": [ { "sku": "sticker-die-cut-3x3", "quantity": 100, "artwork_url": "https://printedsimply.com/samples/sticker-3x3.png" } ], "address_to": { "name": "Jane Doe", "address1": "123 Main St", "city": "Austin", "state": "TX", "zip": "78701", "country": "US" }, "shipping": { "method": "standard" } }' ``` ## What we put in writing The behaviour you are allowed to depend on. It is a page, not a sentence in a sales deck. [Reliability contract](https://portal.printedsimply.com/docs/reliability) Idempotency, pagination, signing, retries and limits, written as promises with the numbers in them. ## Events, signed and replayable Register an endpoint and we POST an HMAC-signed payload on every event. Each request carries an `X-PS-Signature` header (`sha256=`), delivery retries **up to 7 attempts over about 10 hours**, and any failed delivery can be replayed from the dashboard. [Read the webhooks guide](https://portal.printedsimply.com/docs/webhooks) Event families - `order.*` — created, paid, in_production, shipped, delivered, cancelled, failed, updated ## Machine-readable Everything a person reads here is published for a program to read too. The OpenAPI document is what the reference page, both SDKs and the Postman collection are generated from. [OpenAPI 3.1](https://portal.printedsimply.com/api/openapi.json) The machine-readable contract every artifact on this site is derived from. [Postman collection](https://portal.printedsimply.com/api/postman-collection) The same operations, importable into Postman in one click. [llms.txt](https://portal.printedsimply.com/llms.txt) The machine-readable index of this documentation and the spec. [llms-full.txt](https://portal.printedsimply.com/llms-full.txt) These docs as one Markdown file, the API reference and changelog linked rather than included. Any page is Markdown at its URL plus .md. --- # curl quickstart Source: https://portal.printedsimply.com/docs/quickstarts/curl Quickstart · Any language One shell, five commands, a $0 sandbox order on its way. About 4 minutes $0 sandbox key Ends with a sandbox order on its way ## Step 1: Create a sandbox key In the dashboard, open API keys and create a key in Test mode. It starts with ps_test_ — test keys are free, never charge a card, and never reach a print facility. You can create one before adding a payment method. [Create a test key](https://portal.printedsimply.com/dashboard/api-keys?env=test) shell: ```bash export PRINTEDSIMPLY_API_KEY=ps_test_YOUR_KEY ``` A live key (ps_live_) takes the same requests. A real order also needs a card on file and your own artwork: Going live, at the end of this page, has the four changes. ## Step 2: Pick a SKU Every response is the same envelope: status, data, and request_id. Quote the request_id in any support thread and we can find the exact call. Each product's sku is what an order names. shell: ```bash curl -s "https://portal.printedsimply.com/api/v1/products?limit=3" \ -H "x-api-key: $PRINTEDSIMPLY_API_KEY" ``` data.products: ```json [ { "sku": "sticker-die-cut-3x3", "name": "Die-Cut Sticker, 3×3 in", "category": "stickers" }, { "sku": "wall-decal-24x36", "name": "Wall Decal, 24×36 in", "category": "decals" }, { "sku": "magnet-3x3", "name": "Magnet, 3×3 in", "category": "magnets" } ] ``` Rows carry more than these three fields, the price of one unit among them; POST /v1/orders/quote prices a whole order. The order below names the first row's sticker as sticker-die-cut-3x3: a SKU is matched whatever its case. ## Step 3: Place the order The order's reference doubles as its Idempotency-Key: replaying the same key with the same payload returns the original order instead of creating a second one; replaying it with a different payload is rejected with 422 rather than silently charging twice. A new order needs a new reference as well as a new key: one reference is one order for 30 days, so the same reference under another key is refused with 409 duplicate_reference, naming the order that has it. shell: ```bash curl -X POST https://portal.printedsimply.com/api/v1/order \ -H "x-api-key: $PRINTEDSIMPLY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-$(date +%s)" \ -d '{ "items": [ { "sku": "sticker-die-cut-3x3", "quantity": 100, "artwork_url": "https://printedsimply.com/samples/sticker-3x3.png" } ], "address_to": { "name": "Jane Doe", "address1": "123 Main St", "city": "Austin", "state": "TX", "zip": "78701", "country": "US" }, "shipping": { "method": "standard" } }' ``` response: ```json { "status": "success", "data": { "order_id": "test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk", "portal_order_id": "ord_wVK9ZiG-VYiPrlSeBUBmrUVk", "tracking_url": "https://printedsimply.com/track/eyJ2IjoxLCJ0IjoidHJhY2tpbmciLCJ0b2siOiJ0cmtfQXF1SkZxRExEa2lzSzZKaC1IQUJIejlMIn0.199c0c58721512fcccd784902c422ca4e827c6d09b7e495bbb4db9b06ae62003", "test_mode": true, "lifecycle": "simulated", "charged": "$0.00", "artwork_check": { "lines": [ { "item_index": 0, "sku": "sticker-die-cut-3x3", "status": "passed", "width_px": 1200, "height_px": 1200, "print_size_in": [ 3, 3 ], "effective_dpi": 400, "min_dpi": 300, "findings": [] } ], "would_refuse": false } }, "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a" } ``` ## Step 4: Skip the wait Move the sandbox order straight to shipped instead of waiting for the schedule. Put the order_id from the response in ORDER_ID first. shell: ```bash ORDER_ID=test_ord_YOUR_ORDER_ID # data.order_id from the response curl -X POST "https://portal.printedsimply.com/api/v1/order/$ORDER_ID/simulate" \ -H "x-api-key: $PRINTEDSIMPLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"target_state": "shipped"}' ``` response: ```json { "status": "success", "data": { "order_id": "test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk", "portal_order_id": "ord_wVK9ZiG-VYiPrlSeBUBmrUVk", "status": "shipped", "simulated": true }, "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a" } ``` Left alone, a sandbox order moves on by itself: a scheduled job takes it to in_production, shipped and delivered over about five minutes, firing the same signed webhooks at each step. simulate does not wait for that schedule. It takes in_production, shipped, delivered, cancelled or issue_flagged, and only on a test key. An order the schedule has already delivered answers 400 already_terminal; to rehearse the step again, place a new order with a new reference and Idempotency-Key (the sample uses one value for both; the same key replays the delivered order, and the same reference under a new key is refused 409 duplicate_reference). ## Step 5: Open the tracking link and watch it deliver The create response carries tracking_url. Open it in a browser, then read the order: once it has shipped, tracking carries the sandbox tracking number (TRK-TEST-…) and its carrier. Simulate delivered, or wait, to watch it deliver. shell: ```bash curl -s "https://portal.printedsimply.com/api/v1/order/$ORDER_ID" \ -H "x-api-key: $PRINTEDSIMPLY_API_KEY" ``` Sandbox orders run the real lifecycle on a compressed clock: created → in_production → shipped → delivered, firing genuinely HMAC-signed webhooks at every transition through the same delivery pipeline live orders use. Leave the tracking page open. ## Going live The sandbox order above never charges, prints or ships. A real one takes four changes. ### Add a card in Billing A live order is charged to the card on file when it is placed. Without one, POST /v1/order answers 402 no_payment_method and nothing is placed. [Open Billing](https://portal.printedsimply.com/dashboard/billing) ### Create a live key On the API keys page, create a key in Live mode. It starts with ps_live_ and is shown in full once, so store it where your server reads secrets, never in a browser. [Create a live key](https://portal.printedsimply.com/dashboard/api-keys?env=live) ### Send the same request with your own artwork Swap in the live key, your customer's address and your artwork: an https link to the file (PNG, PDF or SVG), or the id POST /v1/assets returns, as print_file. A live order's artwork is checked before anything is charged: a line with no artwork answers 422 artwork_required, and a file that cannot be printed answers 422 artwork_not_printable. ### Subscribe a live-mode webhook A subscription in test mode receives sandbox events only. For real orders, subscribe your endpoint in live mode (or all) on the Webhooks page or with POST /v1/webhooks, and verify X-PS-Signature-V2 on every delivery. [Open Webhooks](https://portal.printedsimply.com/dashboard/webhooks) ## Scopes this page needs A key created without an explicit scope list gets all of them. If you scope the key down, grant at least these: - `products:read` - `orders:write` - `orders:read` ## Order placed. What's next? - [Subscribe to webhooks](https://portal.printedsimply.com/docs/webhooks) Get order.shipped pushed to you instead of polling — signed, replayable, deduped. - [Understand the limits](https://portal.printedsimply.com/docs/rate-limits) Per-plan rate and daily limits, the headers we return, and how to back off honestly. - [Browse the API reference](https://portal.printedsimply.com/docs/api-reference) Every v1 endpoint, with its required scopes and response shape. - [Try another quickstart](https://portal.printedsimply.com/docs/quickstarts) The same order in curl, TypeScript, Python or PHP, or from your framework. --- # TypeScript quickstart Source: https://portal.printedsimply.com/docs/quickstarts/typescript Quickstart · TypeScript / Node fetch, an idempotent order, a tracking link — nothing to install. About 5 minutes $0 sandbox key Ends with a sandbox order on its way ## Step 1: Create a sandbox key In the dashboard, open API keys and create a key in Test mode. It starts with ps_test_ — test keys are free, never charge a card, and never reach a print facility. You can create one before adding a payment method. [Create a test key](https://portal.printedsimply.com/dashboard/api-keys?env=test) shell: ```bash export PRINTEDSIMPLY_API_KEY=ps_test_YOUR_KEY ``` A live key (ps_live_) takes the same requests. A real order also needs a card on file and your own artwork: Going live, at the end of this page, has the four changes. ## Step 2: Pick a SKU Every response is the same envelope: status, data, and request_id. Quote the request_id in any support thread and we can find the exact call. Each product's sku is what an order names. Save it as products.ts and run node products.ts: Node 22.18 and later run TypeScript as it is, and fetch is built in, so there is nothing to install. The files have no type annotations, so on Node 18 to 22.17 the same text runs as products.mjs. products.ts: ```typescript const apiKey = process.env.PRINTEDSIMPLY_API_KEY if (!apiKey) throw new Error("Set PRINTEDSIMPLY_API_KEY to your test key (ps_test_…) first") const res = await fetch("https://portal.printedsimply.com/api/v1/products?limit=3", { headers: { "x-api-key": apiKey }, signal: AbortSignal.timeout(30_000), }) const { data, error } = await res.json() if (!res.ok) throw new Error(`${res.status} ${error.code}: ${error.message}`) for (const product of data.products) console.log(product.sku, "-", product.name) ``` data.products: ```json [ { "sku": "sticker-die-cut-3x3", "name": "Die-Cut Sticker, 3×3 in", "category": "stickers" }, { "sku": "wall-decal-24x36", "name": "Wall Decal, 24×36 in", "category": "decals" }, { "sku": "magnet-3x3", "name": "Magnet, 3×3 in", "category": "magnets" } ] ``` Rows carry more than these three fields, the price of one unit among them; POST /v1/orders/quote prices a whole order. The order below names the first row's sticker as sticker-die-cut-3x3: a SKU is matched whatever its case. ## Step 3: Place the order The order's reference doubles as its Idempotency-Key: send the same request again and you get this order back, never a second one; the same key with a different body is refused with 422. A new order needs a new reference as well as a new key: one reference is one order for 30 days, so the same reference under another key is refused with 409 duplicate_reference, naming the order that has it. Run it as you ran products.ts: node order.ts. order.ts: ```typescript const apiKey = process.env.PRINTEDSIMPLY_API_KEY if (!apiKey) throw new Error("Set PRINTEDSIMPLY_API_KEY to your test key (ps_test_…) first") const order = { "items": [ { "sku": "sticker-die-cut-3x3", "quantity": 100, "artwork_url": "https://printedsimply.com/samples/sticker-3x3.png" } ], "address_to": { "name": "Jane Doe", "address1": "123 Main St", "city": "Austin", "state": "TX", "zip": "78701", "country": "US" }, "shipping": { "method": "standard" } } const res = await fetch("https://portal.printedsimply.com/api/v1/order", { method: "POST", headers: { "x-api-key": apiKey, "Content-Type": "application/json", "Idempotency-Key": `order-${Date.now()}`, }, body: JSON.stringify(order), signal: AbortSignal.timeout(30_000), }) const { data, error } = await res.json() if (!res.ok) throw new Error(`${res.status} ${error.code}: ${error.message}`) // data.order_id is the order (test_ord_… on a test key); data.tracking_url is its tracking page. console.log(data.order_id, data.charged) console.log(data.tracking_url) ``` response: ```json { "status": "success", "data": { "order_id": "test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk", "portal_order_id": "ord_wVK9ZiG-VYiPrlSeBUBmrUVk", "tracking_url": "https://printedsimply.com/track/eyJ2IjoxLCJ0IjoidHJhY2tpbmciLCJ0b2siOiJ0cmtfQXF1SkZxRExEa2lzSzZKaC1IQUJIejlMIn0.199c0c58721512fcccd784902c422ca4e827c6d09b7e495bbb4db9b06ae62003", "test_mode": true, "lifecycle": "simulated", "charged": "$0.00", "artwork_check": { "lines": [ { "item_index": 0, "sku": "sticker-die-cut-3x3", "status": "passed", "width_px": 1200, "height_px": 1200, "print_size_in": [ 3, 3 ], "effective_dpi": 400, "min_dpi": 300, "findings": [] } ], "would_refuse": false } }, "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a" } ``` ## Step 4: Skip the wait Move the sandbox order straight to shipped instead of waiting for the schedule: node ship.ts test_ord_… with the order_id order.ts printed. ship.ts: ```typescript const apiKey = process.env.PRINTEDSIMPLY_API_KEY if (!apiKey) throw new Error("Set PRINTEDSIMPLY_API_KEY to your test key (ps_test_…) first") const orderId = process.argv[2] // the order_id order.ts printed: test_ord_… const res = await fetch(`https://portal.printedsimply.com/api/v1/order/${orderId}/simulate`, { method: "POST", headers: { "x-api-key": apiKey, "Content-Type": "application/json" }, body: JSON.stringify({ target_state: "shipped" }), signal: AbortSignal.timeout(30_000), }) const { data, error } = await res.json() if (!res.ok) throw new Error(`${res.status} ${error.code}: ${error.message}`) console.log(data.order_id, data.status) ``` response: ```json { "status": "success", "data": { "order_id": "test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk", "portal_order_id": "ord_wVK9ZiG-VYiPrlSeBUBmrUVk", "status": "shipped", "simulated": true }, "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a" } ``` Left alone, a sandbox order moves on by itself: a scheduled job takes it to in_production, shipped and delivered over about five minutes, firing the same signed webhooks at each step. simulate does not wait for that schedule. It takes in_production, shipped, delivered, cancelled or issue_flagged, and only on a test key. An order the schedule has already delivered answers 400 already_terminal; to rehearse the step again, place a new order with a new reference and Idempotency-Key (the sample uses one value for both; the same key replays the delivered order, and the same reference under a new key is refused 409 duplicate_reference). ## Step 5: Open the tracking link and watch it deliver order.ts printed the tracking link. Open it, then read the order: once it has shipped, tracking carries the sandbox tracking number (TRK-TEST-…) and its carrier. Simulate delivered, or wait, to watch it deliver. track.ts: ```typescript const apiKey = process.env.PRINTEDSIMPLY_API_KEY if (!apiKey) throw new Error("Set PRINTEDSIMPLY_API_KEY to your test key (ps_test_…) first") const orderId = process.argv[2] const res = await fetch(`https://portal.printedsimply.com/api/v1/order/${orderId}`, { headers: { "x-api-key": apiKey }, signal: AbortSignal.timeout(30_000), }) const { data, error } = await res.json() if (!res.ok) throw new Error(`${res.status} ${error.code}: ${error.message}`) console.log(data.status, data.tracking.number, data.tracking.carrier) console.log(data.tracking.page_url) ``` Sandbox orders run the real lifecycle on a compressed clock: created → in_production → shipped → delivered, firing genuinely HMAC-signed webhooks at every transition through the same delivery pipeline live orders use. Leave the tracking page open. ## Going live The sandbox order above never charges, prints or ships. A real one takes four changes. ### Add a card in Billing A live order is charged to the card on file when it is placed. Without one, POST /v1/order answers 402 no_payment_method and nothing is placed. [Open Billing](https://portal.printedsimply.com/dashboard/billing) ### Create a live key On the API keys page, create a key in Live mode. It starts with ps_live_ and is shown in full once, so store it where your server reads secrets, never in a browser. [Create a live key](https://portal.printedsimply.com/dashboard/api-keys?env=live) ### Send the same request with your own artwork Swap in the live key, your customer's address and your artwork: an https link to the file (PNG, PDF or SVG), or the id POST /v1/assets returns, as print_file. A live order's artwork is checked before anything is charged: a line with no artwork answers 422 artwork_required, and a file that cannot be printed answers 422 artwork_not_printable. ### Subscribe a live-mode webhook A subscription in test mode receives sandbox events only. For real orders, subscribe your endpoint in live mode (or all) on the Webhooks page or with POST /v1/webhooks, and verify X-PS-Signature-V2 on every delivery. [Open Webhooks](https://portal.printedsimply.com/dashboard/webhooks) ## Scopes this page needs A key created without an explicit scope list gets all of them. If you scope the key down, grant at least these: - `products:read` - `orders:write` - `orders:read` ## Order placed. What's next? - [Subscribe to webhooks](https://portal.printedsimply.com/docs/webhooks) Get order.shipped pushed to you instead of polling — signed, replayable, deduped. - [Understand the limits](https://portal.printedsimply.com/docs/rate-limits) Per-plan rate and daily limits, the headers we return, and how to back off honestly. - [Browse the API reference](https://portal.printedsimply.com/docs/api-reference) Every v1 endpoint, with its required scopes and response shape. - [Try another quickstart](https://portal.printedsimply.com/docs/quickstarts) The same order in curl, TypeScript, Python or PHP, or from your framework. --- # Python quickstart Source: https://portal.printedsimply.com/docs/quickstarts/python Quickstart · Python The standard library, an idempotent order, a tracking link — nothing to install. About 5 minutes $0 sandbox key Ends with a sandbox order on its way ## Step 1: Create a sandbox key In the dashboard, open API keys and create a key in Test mode. It starts with ps_test_ — test keys are free, never charge a card, and never reach a print facility. You can create one before adding a payment method. [Create a test key](https://portal.printedsimply.com/dashboard/api-keys?env=test) shell: ```bash export PRINTEDSIMPLY_API_KEY=ps_test_YOUR_KEY ``` A live key (ps_live_) takes the same requests. A real order also needs a card on file and your own artwork: Going live, at the end of this page, has the four changes. ## Step 2: Pick a SKU Every response is the same envelope: status, data, and request_id. Quote the request_id in any support thread and we can find the exact call. Each product's sku is what an order names. Save it as products.py and run python3 products.py: urllib is in the standard library, so there is nothing to install (Python 3.8 and later). On a python.org install on macOS, run its Install Certificates command once if urllib reports CERTIFICATE_VERIFY_FAILED. products.py: ```python import json import os import urllib.error import urllib.request request = urllib.request.Request( "https://portal.printedsimply.com/api/v1/products?limit=3", headers={"x-api-key": os.environ["PRINTEDSIMPLY_API_KEY"]}, ) try: with urllib.request.urlopen(request, timeout=30) as res: data = json.load(res)["data"] except urllib.error.HTTPError as err: error = json.load(err)["error"] raise SystemExit(f"{err.code} {error['code']}: {error['message']}") for product in data["products"]: print(product["sku"], "-", product["name"]) ``` data.products: ```json [ { "sku": "sticker-die-cut-3x3", "name": "Die-Cut Sticker, 3×3 in", "category": "stickers" }, { "sku": "wall-decal-24x36", "name": "Wall Decal, 24×36 in", "category": "decals" }, { "sku": "magnet-3x3", "name": "Magnet, 3×3 in", "category": "magnets" } ] ``` Rows carry more than these three fields, the price of one unit among them; POST /v1/orders/quote prices a whole order. The order below names the first row's sticker as sticker-die-cut-3x3: a SKU is matched whatever its case. ## Step 3: Place the order The order's reference doubles as its Idempotency-Key: send the same request again and you get this order back, never a second one; the same key with a different body is refused with 422. A new order needs a new reference as well as a new key: one reference is one order for 30 days, so the same reference under another key is refused with 409 duplicate_reference, naming the order that has it. Run it as you ran products.py: python3 order.py. order.py: ```python import json import os import urllib.error import urllib.request import uuid order = { "items": [ { "sku": "sticker-die-cut-3x3", "quantity": 100, "artwork_url": "https://printedsimply.com/samples/sticker-3x3.png" } ], "address_to": { "name": "Jane Doe", "address1": "123 Main St", "city": "Austin", "state": "TX", "zip": "78701", "country": "US" }, "shipping": { "method": "standard" } } request = urllib.request.Request( "https://portal.printedsimply.com/api/v1/order", method="POST", data=json.dumps(order).encode(), headers={ "x-api-key": os.environ["PRINTEDSIMPLY_API_KEY"], "Content-Type": "application/json", "Idempotency-Key": f"order-{uuid.uuid4().hex}", }, ) try: with urllib.request.urlopen(request, timeout=30) as res: data = json.load(res)["data"] except urllib.error.HTTPError as err: error = json.load(err)["error"] raise SystemExit(f"{err.code} {error['code']}: {error['message']}") # data["order_id"] is the order (test_ord_... on a test key); data["tracking_url"] is its tracking page. print(data["order_id"], data["charged"]) print(data["tracking_url"]) ``` response: ```json { "status": "success", "data": { "order_id": "test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk", "portal_order_id": "ord_wVK9ZiG-VYiPrlSeBUBmrUVk", "tracking_url": "https://printedsimply.com/track/eyJ2IjoxLCJ0IjoidHJhY2tpbmciLCJ0b2siOiJ0cmtfQXF1SkZxRExEa2lzSzZKaC1IQUJIejlMIn0.199c0c58721512fcccd784902c422ca4e827c6d09b7e495bbb4db9b06ae62003", "test_mode": true, "lifecycle": "simulated", "charged": "$0.00", "artwork_check": { "lines": [ { "item_index": 0, "sku": "sticker-die-cut-3x3", "status": "passed", "width_px": 1200, "height_px": 1200, "print_size_in": [ 3, 3 ], "effective_dpi": 400, "min_dpi": 300, "findings": [] } ], "would_refuse": false } }, "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a" } ``` ## Step 4: Skip the wait Move the sandbox order straight to shipped instead of waiting for the schedule: python3 ship.py test_ord_… with the order_id order.py printed. ship.py: ```python import json import os import urllib.error import urllib.request import sys order_id = sys.argv[1] # the order_id order.py printed: test_ord_... request = urllib.request.Request( f"https://portal.printedsimply.com/api/v1/order/{order_id}/simulate", method="POST", data=json.dumps({"target_state": "shipped"}).encode(), headers={"x-api-key": os.environ["PRINTEDSIMPLY_API_KEY"], "Content-Type": "application/json"}, ) try: with urllib.request.urlopen(request, timeout=30) as res: data = json.load(res)["data"] except urllib.error.HTTPError as err: error = json.load(err)["error"] raise SystemExit(f"{err.code} {error['code']}: {error['message']}") print(data["order_id"], data["status"]) ``` response: ```json { "status": "success", "data": { "order_id": "test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk", "portal_order_id": "ord_wVK9ZiG-VYiPrlSeBUBmrUVk", "status": "shipped", "simulated": true }, "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a" } ``` Left alone, a sandbox order moves on by itself: a scheduled job takes it to in_production, shipped and delivered over about five minutes, firing the same signed webhooks at each step. simulate does not wait for that schedule. It takes in_production, shipped, delivered, cancelled or issue_flagged, and only on a test key. An order the schedule has already delivered answers 400 already_terminal; to rehearse the step again, place a new order with a new reference and Idempotency-Key (the sample uses one value for both; the same key replays the delivered order, and the same reference under a new key is refused 409 duplicate_reference). ## Step 5: Open the tracking link and watch it deliver order.py printed the tracking link. Open it, then read the order: once it has shipped, tracking carries the sandbox tracking number (TRK-TEST-…) and its carrier. Simulate delivered, or wait, to watch it deliver. track.py: ```python import json import os import urllib.error import urllib.request import sys order_id = sys.argv[1] request = urllib.request.Request( f"https://portal.printedsimply.com/api/v1/order/{order_id}", headers={"x-api-key": os.environ["PRINTEDSIMPLY_API_KEY"]}, ) try: with urllib.request.urlopen(request, timeout=30) as res: data = json.load(res)["data"] except urllib.error.HTTPError as err: error = json.load(err)["error"] raise SystemExit(f"{err.code} {error['code']}: {error['message']}") print(data["status"], data["tracking"]["number"], data["tracking"]["carrier"]) print(data["tracking"]["page_url"]) ``` Sandbox orders run the real lifecycle on a compressed clock: created → in_production → shipped → delivered, firing genuinely HMAC-signed webhooks at every transition through the same delivery pipeline live orders use. Leave the tracking page open. ## Going live The sandbox order above never charges, prints or ships. A real one takes four changes. ### Add a card in Billing A live order is charged to the card on file when it is placed. Without one, POST /v1/order answers 402 no_payment_method and nothing is placed. [Open Billing](https://portal.printedsimply.com/dashboard/billing) ### Create a live key On the API keys page, create a key in Live mode. It starts with ps_live_ and is shown in full once, so store it where your server reads secrets, never in a browser. [Create a live key](https://portal.printedsimply.com/dashboard/api-keys?env=live) ### Send the same request with your own artwork Swap in the live key, your customer's address and your artwork: an https link to the file (PNG, PDF or SVG), or the id POST /v1/assets returns, as print_file. A live order's artwork is checked before anything is charged: a line with no artwork answers 422 artwork_required, and a file that cannot be printed answers 422 artwork_not_printable. ### Subscribe a live-mode webhook A subscription in test mode receives sandbox events only. For real orders, subscribe your endpoint in live mode (or all) on the Webhooks page or with POST /v1/webhooks, and verify X-PS-Signature-V2 on every delivery. [Open Webhooks](https://portal.printedsimply.com/dashboard/webhooks) ## Scopes this page needs A key created without an explicit scope list gets all of them. If you scope the key down, grant at least these: - `products:read` - `orders:write` - `orders:read` ## Order placed. What's next? - [Subscribe to webhooks](https://portal.printedsimply.com/docs/webhooks) Get order.shipped pushed to you instead of polling — signed, replayable, deduped. - [Understand the limits](https://portal.printedsimply.com/docs/rate-limits) Per-plan rate and daily limits, the headers we return, and how to back off honestly. - [Browse the API reference](https://portal.printedsimply.com/docs/api-reference) Every v1 endpoint, with its required scopes and response shape. - [Try another quickstart](https://portal.printedsimply.com/docs/quickstarts) The same order in curl, TypeScript, Python or PHP, or from your framework. --- # Your first order, one request away. Source: https://portal.printedsimply.com/docs/quickstarts Grab a $0 sandbox key and place a real, idempotent order against the live API — then watch it ship on real signed webhooks. Pick your stack below. Every framework guide is plain REST, no SDK required. $0 sandbox key Idempotent order creation Real signed webhooks [Get an API key](https://portal.printedsimply.com/signup) ## From API key to a tracked order The whole loop runs on a test key, for $0, before you ever touch a production order. Step 1 ### Grab a test key Create a free account and copy a ps_test_ key. Sandbox orders cost $0 and never charge a card. Step 2 ### Place an order One authenticated POST to /order — validated, priced, and routed. Send an Idempotency-Key so retries never double-charge. Step 3 ### Watch it ship The sandbox runs the full lifecycle on a compressed clock, firing real HMAC-signed webhooks at every status change. ## By framework Each guide places a $0 sandbox order and verifies a signed webhook in your framework of choice. Prefer another language? Any HTTP client works against the REST API. ### [Next.js](https://portal.printedsimply.com/docs/quickstarts/nextjs) TypeScript App Router with server actions. Keep your API key server-side and stream order status into React. Get started ### [Express](https://portal.printedsimply.com/docs/quickstarts/express) Node.js A minimal Node server that forwards orders and verifies signed webhooks with a thin fetch helper. Get started ### [Flask](https://portal.printedsimply.com/docs/quickstarts/flask) Python Order management with the requests library. Works the same with Django or FastAPI. Get started ### [Rails](https://portal.printedsimply.com/docs/quickstarts/rails) Ruby A service class, controller, and routes with HMAC webhook verification built in. Get started ### [Go](https://portal.printedsimply.com/docs/quickstarts/go) Go A lightweight HTTP client and handlers for high-throughput order processing. Get started ## By language The same first order — a sandbox key, a real SKU, a tracking link that delivers — in curl, TypeScript, Python or PHP. TypeScript runs on fetch and Python on the standard library, with nothing to install; PHP needs only its curl extension, nothing from Composer. ### [curl quickstart](https://portal.printedsimply.com/docs/quickstarts/curl) Any language Place your first PrintedSimply sandbox order with curl. A free ps_test_ key, one POST to /v1/order, and a tracking link that delivers while you watch. About 4 minutes ### [TypeScript quickstart](https://portal.printedsimply.com/docs/quickstarts/typescript) TypeScript / Node Place your first PrintedSimply sandbox order from TypeScript with fetch: no install. A free ps_test_ key, one POST to /v1/order, and a tracking link that delivers while you watch. About 5 minutes ### [Python quickstart](https://portal.printedsimply.com/docs/quickstarts/python) Python Place your first PrintedSimply sandbox order from Python with the standard library: no install. A free ps_test_ key, one POST to /v1/order, and a tracking link that delivers while you watch. About 5 minutes ### [PHP quickstart](https://portal.printedsimply.com/docs/quickstarts/php) PHP Place your first PrintedSimply sandbox order from PHP with the curl extension: no Composer, no SDK. A free ps_test_ key, one POST to /v1/order, and a tracking link that delivers while you watch. About 5 minutes ### [MCP quickstart](https://portal.printedsimply.com/docs/quickstarts/mcp) Claude, Cursor or ChatGPT Connect PrintedSimply to Claude, Cursor or ChatGPT over MCP and ask it to place a sandbox order. No install, no SDK: one config block and a tracking link. About 3 minutes ## Need the full reference? Every endpoint and the OpenAPI spec. [API reference](https://portal.printedsimply.com/docs/api-reference) --- # PHP quickstart Source: https://portal.printedsimply.com/docs/quickstarts/php Quickstart · PHP The curl extension, an idempotent order, a tracking link — nothing to install with Composer. About 5 minutes $0 sandbox key Ends with a sandbox order on its way ## Step 1: Create a sandbox key In the dashboard, open API keys and create a key in Test mode. It starts with ps_test_ — test keys are free, never charge a card, and never reach a print facility. You can create one before adding a payment method. [Create a test key](https://portal.printedsimply.com/dashboard/api-keys?env=test) shell: ```bash export PRINTEDSIMPLY_API_KEY=ps_test_YOUR_KEY ``` A live key (ps_live_) takes the same requests. A real order also needs a card on file and your own artwork: Going live, at the end of this page, has the four changes. ## Step 2: Pick a SKU Every response is the same envelope: status, data, and request_id. Quote the request_id in any support thread and we can find the exact call. Each product's sku is what an order names. Save it as products.php and run php products.php. It needs only PHP's curl extension (php -m lists curl when it is there; on Debian and Ubuntu it is the php-curl package), so there is nothing to install with Composer (PHP 8.1 and later). products.php: ```php ["x-api-key: $apiKey"], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, ]); $raw = curl_exec($ch); if ($raw === false) { fwrite(STDERR, 'Request failed: ' . curl_error($ch) . "\n"); exit(1); } $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $reply = json_decode($raw, true); if ($status >= 400) { $error = $reply['error'] ?? []; fwrite(STDERR, sprintf( "%d %s: %s (request_id %s)\n", $status, $error['code'] ?? 'error', $error['message'] ?? $raw, $reply['request_id'] ?? 'none' )); exit(1); } $data = $reply['data']; foreach ($data['products'] as $product) { echo $product['sku'], ' - ', $product['name'], "\n"; } ``` data.products: ```json [ { "sku": "sticker-die-cut-3x3", "name": "Die-Cut Sticker, 3×3 in", "category": "stickers" }, { "sku": "wall-decal-24x36", "name": "Wall Decal, 24×36 in", "category": "decals" }, { "sku": "magnet-3x3", "name": "Magnet, 3×3 in", "category": "magnets" } ] ``` Rows carry more than these three fields, the price of one unit among them; POST /v1/orders/quote prices a whole order. The order below names the first row's sticker as sticker-die-cut-3x3: a SKU is matched whatever its case. ## Step 3: Place the order The order's reference doubles as its Idempotency-Key: send the same request again and you get this order back, never a second one; the same key with a different body is refused with 422. A new order needs a new reference as well as a new key: one reference is one order for 30 days, so the same reference under another key is refused with 409 duplicate_reference, naming the order that has it. Run it as you ran products.php: php order.php. order.php: ```php $order, CURLOPT_HTTPHEADER => [ "x-api-key: $apiKey", 'Content-Type: application/json', 'Idempotency-Key: order-' . bin2hex(random_bytes(8)), ], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, ]); $raw = curl_exec($ch); if ($raw === false) { fwrite(STDERR, 'Request failed: ' . curl_error($ch) . "\n"); exit(1); } $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $reply = json_decode($raw, true); if ($status >= 400) { $error = $reply['error'] ?? []; fwrite(STDERR, sprintf( "%d %s: %s (request_id %s)\n", $status, $error['code'] ?? 'error', $error['message'] ?? $raw, $reply['request_id'] ?? 'none' )); exit(1); } $data = $reply['data']; // $data['order_id'] is the order (test_ord_... on a test key); $data['tracking_url'] is its tracking page. echo $data['order_id'], ' ', $data['charged'], "\n"; echo $data['tracking_url'], "\n"; ``` response: ```json { "status": "success", "data": { "order_id": "test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk", "portal_order_id": "ord_wVK9ZiG-VYiPrlSeBUBmrUVk", "tracking_url": "https://printedsimply.com/track/eyJ2IjoxLCJ0IjoidHJhY2tpbmciLCJ0b2siOiJ0cmtfQXF1SkZxRExEa2lzSzZKaC1IQUJIejlMIn0.199c0c58721512fcccd784902c422ca4e827c6d09b7e495bbb4db9b06ae62003", "test_mode": true, "lifecycle": "simulated", "charged": "$0.00", "artwork_check": { "lines": [ { "item_index": 0, "sku": "sticker-die-cut-3x3", "status": "passed", "width_px": 1200, "height_px": 1200, "print_size_in": [ 3, 3 ], "effective_dpi": 400, "min_dpi": 300, "findings": [] } ], "would_refuse": false } }, "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a" } ``` The requests use only the curl and json extensions, so the same calls work inside a WooCommerce plugin or any other PHP application; there, throw or log the error instead of exiting. The files here run from the command line so you can watch the order go through first. ## Step 4: Skip the wait Move the sandbox order straight to shipped instead of waiting for the schedule: php ship.php test_ord_… with the order_id order.php printed. ship.php: ```php json_encode(['target_state' => 'shipped']), CURLOPT_HTTPHEADER => ["x-api-key: $apiKey", 'Content-Type: application/json'], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, ]); $raw = curl_exec($ch); if ($raw === false) { fwrite(STDERR, 'Request failed: ' . curl_error($ch) . "\n"); exit(1); } $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $reply = json_decode($raw, true); if ($status >= 400) { $error = $reply['error'] ?? []; fwrite(STDERR, sprintf( "%d %s: %s (request_id %s)\n", $status, $error['code'] ?? 'error', $error['message'] ?? $raw, $reply['request_id'] ?? 'none' )); exit(1); } $data = $reply['data']; echo $data['order_id'], ' ', $data['status'], "\n"; ``` response: ```json { "status": "success", "data": { "order_id": "test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk", "portal_order_id": "ord_wVK9ZiG-VYiPrlSeBUBmrUVk", "status": "shipped", "simulated": true }, "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a" } ``` Left alone, a sandbox order moves on by itself: a scheduled job takes it to in_production, shipped and delivered over about five minutes, firing the same signed webhooks at each step. simulate does not wait for that schedule. It takes in_production, shipped, delivered, cancelled or issue_flagged, and only on a test key. An order the schedule has already delivered answers 400 already_terminal; to rehearse the step again, place a new order with a new reference and Idempotency-Key (the sample uses one value for both; the same key replays the delivered order, and the same reference under a new key is refused 409 duplicate_reference). ## Step 5: Open the tracking link and watch it deliver order.php printed the tracking link. Open it, then read the order: once it has shipped, tracking carries the sandbox tracking number (TRK-TEST-…) and its carrier. Simulate delivered, or wait, to watch it deliver. track.php: ```php ["x-api-key: $apiKey"], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, ]); $raw = curl_exec($ch); if ($raw === false) { fwrite(STDERR, 'Request failed: ' . curl_error($ch) . "\n"); exit(1); } $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $reply = json_decode($raw, true); if ($status >= 400) { $error = $reply['error'] ?? []; fwrite(STDERR, sprintf( "%d %s: %s (request_id %s)\n", $status, $error['code'] ?? 'error', $error['message'] ?? $raw, $reply['request_id'] ?? 'none' )); exit(1); } $data = $reply['data']; echo $data['status'], ' ', $data['tracking']['number'], ' ', $data['tracking']['carrier'], "\n"; echo $data['tracking']['page_url'], "\n"; ``` Sandbox orders run the real lifecycle on a compressed clock: created → in_production → shipped → delivered, firing genuinely HMAC-signed webhooks at every transition through the same delivery pipeline live orders use. Leave the tracking page open. ## Going live The sandbox order above never charges, prints or ships. A real one takes four changes. ### Add a card in Billing A live order is charged to the card on file when it is placed. Without one, POST /v1/order answers 402 no_payment_method and nothing is placed. [Open Billing](https://portal.printedsimply.com/dashboard/billing) ### Create a live key On the API keys page, create a key in Live mode. It starts with ps_live_ and is shown in full once, so store it where your server reads secrets, never in a browser. [Create a live key](https://portal.printedsimply.com/dashboard/api-keys?env=live) ### Send the same request with your own artwork Swap in the live key, your customer's address and your artwork: an https link to the file (PNG, PDF or SVG), or the id POST /v1/assets returns, as print_file. A live order's artwork is checked before anything is charged: a line with no artwork answers 422 artwork_required, and a file that cannot be printed answers 422 artwork_not_printable. ### Subscribe a live-mode webhook A subscription in test mode receives sandbox events only. For real orders, subscribe your endpoint in live mode (or all) on the Webhooks page or with POST /v1/webhooks, and verify X-PS-Signature-V2 on every delivery. [Open Webhooks](https://portal.printedsimply.com/dashboard/webhooks) ## Scopes this page needs A key created without an explicit scope list gets all of them. If you scope the key down, grant at least these: - `products:read` - `orders:write` - `orders:read` ## Order placed. What's next? - [Subscribe to webhooks](https://portal.printedsimply.com/docs/webhooks) Get order.shipped pushed to you instead of polling — signed, replayable, deduped. - [Understand the limits](https://portal.printedsimply.com/docs/rate-limits) Per-plan rate and daily limits, the headers we return, and how to back off honestly. - [Browse the API reference](https://portal.printedsimply.com/docs/api-reference) Every v1 endpoint, with its required scopes and response shape. - [Try another quickstart](https://portal.printedsimply.com/docs/quickstarts) The same order in curl, TypeScript, Python or PHP, or from your framework. --- # MCP quickstart Source: https://portal.printedsimply.com/docs/quickstarts/mcp Quickstart · Claude, Cursor or ChatGPT Order from a chat with your assistant. No code at all. The agent makes the artwork, a person approves it, and we make the thing. Custom stickers, decals, magnets and posters, printed in one US plant. A run of one is an order: every product's minimum_quantity is 1. A test key runs the same order through a $0 sandbox from placed to delivered. Every status change arrives as a signed webhook, on a test order as on a live one. Each artwork file is checked at the size it prints before a live order is charged. An order line can declare how its artwork was made and who approved it, and the order and its webhooks carry that declaration. About 3 minutes $0 sandbox key Ends with a sandbox order on its way ## Step 1: Create a sandbox key In the dashboard, open API keys and create a key in Test mode. It starts with ps_test_ — test keys are free, never charge a card, and never reach a print facility. You can create one before adding a payment method. [Create a test key](https://portal.printedsimply.com/dashboard/api-keys?env=test) key: ``` ps_test_YOUR_KEY ``` A live key (ps_live_) takes the same requests. A real order also needs a card on file and your own artwork: Going live, at the end of this page, has the four changes. ## Step 2: Point your client at the MCP endpoint The server is remote and always on — nothing to install or run locally. Add it to your MCP client config and restart the client. config: ```json { "mcpServers": { "printedsimply": { "url": "https://portal.printedsimply.com/api/mcp", "headers": { "x-api-key": "ps_test_YOUR_KEY" } } } } ``` URL only: ``` https://portal.printedsimply.com/api/mcp?x-api-key=ps_test_YOUR_KEY ``` Use the URL-only form for clients whose connector UI has a URL field but no header field. Sandbox test keys only — never put a live key in a URL. No key yet? https://portal.printedsimply.com/api/mcp?profile=visitor needs none: it searches the catalog, prices an order, checks artwork and places a $0 sandbox order on a demo account, then hands you a link to finish on the dashboard. ## Step 3: See what it can do The server carries 28 tools, the ones for ordering what PrintedSimply makes: find a product, price it, check the artwork, place the order and follow it to the door. Your client lists them as soon as it connects. tools: ``` search_catalog Search the catalog get_product Get a product get_product_options List custom-size options recommend_products Recommend products estimate_shipping Estimate shipping get_calendar Get the plant calendar quote_order Quote an order estimate_pricing Estimate a price create_mockup Render a product mockup validate_artwork Check a print file create_order Place an order reorder Place an order again get_order_status Get an order's status track_order Track a shipment list_orders List orders cancel_order Cancel an order get_order_events Get an order's events update_order_address Change an order's address update_order_shipping Change an order's shipping add_order_item Add an item to an order update_order_item Change an item's quantity cancel_order_item Cancel one order item whoami Check this key check_setup Check account setup list_assets List uploaded files get_asset Get an uploaded file upload_asset Upload a file delete_asset Delete an uploaded file ``` Cancelling or changing an order before production is among these tools; get_order_status's actions says what an order still takes. The rest of your account (webhooks) is on the dashboard and the REST API, with the same key. ## Step 4: Ask for the order in plain English The assistant finds the SKU, prices it, checks the artwork and calls create_order for you. Ask it to run whoami first if you want it to see the key's mode, scopes and spend caps before it spends anything. prompt: ``` Find a die-cut sticker in the catalog, quote 100 of them shipped standard to Jane Doe, 123 Main St, Austin TX 78701, US, and place the order. ``` ## Step 5: Skip the wait The assistant reads each step with track_order as the sandbox moves the order on. To move it now, simulate it over REST with the same key and the order_id the assistant was given. shell: ```bash curl -X POST "https://portal.printedsimply.com/api/v1/order/test_ord_YOUR_ORDER_ID/simulate" \ -H "x-api-key: ps_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"target_state": "shipped"}' ``` Left alone, a sandbox order moves on by itself: a scheduled job takes it to in_production, shipped and delivered over about five minutes, firing the same signed webhooks at each step. simulate does not wait for that schedule. It takes in_production, shipped, delivered, cancelled or issue_flagged, and only on a test key. An order the schedule has already delivered answers 400 already_terminal; to rehearse the step again, place a new order with a new reference and Idempotency-Key (the sample uses one value for both; the same key replays the delivered order, and the same reference under a new key is refused 409 duplicate_reference). ## Step 6: Open the tracking link and watch it deliver create_order returns the tracking link straight into the conversation. Open it, then ask the assistant to check again: it calls track_order and reads you the new status, until it is delivered. Sandbox orders run the real lifecycle on a compressed clock: created → in_production → shipped → delivered, firing genuinely HMAC-signed webhooks at every transition through the same delivery pipeline live orders use. Leave the tracking page open. ## Going live The sandbox order above never charges, prints or ships. A real one takes four changes. ### Add a card in Billing A live order is charged to the card on file when it is placed. Without one, POST /v1/order answers 402 no_payment_method and nothing is placed. [Open Billing](https://portal.printedsimply.com/dashboard/billing) ### Create a live key On the API keys page, create a key in Live mode. It starts with ps_live_ and is shown in full once, so store it where your server reads secrets, never in a browser. [Create a live key](https://portal.printedsimply.com/dashboard/api-keys?env=live) ### Send the same request with your own artwork Swap in the live key, your customer's address and your artwork: an https link to the file (PNG, PDF or SVG), or the id POST /v1/assets returns, as print_file. A live order's artwork is checked before anything is charged: a line with no artwork answers 422 artwork_required, and a file that cannot be printed answers 422 artwork_not_printable. ### Subscribe a live-mode webhook A subscription in test mode receives sandbox events only. For real orders, subscribe your endpoint in live mode (or all) on the Webhooks page or with POST /v1/webhooks, and verify X-PS-Signature-V2 on every delivery. [Open Webhooks](https://portal.printedsimply.com/dashboard/webhooks) ## Scopes this page needs A key created without an explicit scope list gets all of them. If you scope the key down, grant at least these: - `orders:read` - `orders:write` - `products:read` - `pricing:read` - `assets:read` - `assets:write` - `artwork:write` - `mockups:write` ## Order placed. What's next? - [Subscribe to webhooks](https://portal.printedsimply.com/docs/webhooks) Get order.shipped pushed to you instead of polling — signed, replayable, deduped. - [Understand the limits](https://portal.printedsimply.com/docs/rate-limits) Per-plan rate and daily limits, the headers we return, and how to back off honestly. - [Browse the API reference](https://portal.printedsimply.com/docs/api-reference) Every v1 endpoint, with its required scopes and response shape. - [Try another quickstart](https://portal.printedsimply.com/docs/quickstarts) The same order in curl, TypeScript, Python or PHP, or from your framework. --- # 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 (
{products.map((product: any) => (

{product.name}

{product.sku}

Order →
))}
); } ``` 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=,v1="), 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(); 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 (

Order {id}

Status: {order.status}

Tracking: {order.tracking_number ?? "Pending"}

Carrier: {order.tracking_carrier ?? "—"}

); } ``` [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. --- # 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=,v1="), 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(); 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. --- # Flask + PrintedSimply Source: https://portal.printedsimply.com/docs/quickstarts/flask Build a Python order backend with Flask and the requests library. Plain REST, no SDK required — the same code works with Django or FastAPI, and runs on a $0 test key. $0 sandbox key Idempotent orders Signed webhooks 1. Install Dependencies ``` pip install requests flask python-dotenv ``` Add your API key to `.env`: ``` # .env PRINTEDSIMPLY_API_KEY=ps_test_YOUR_KEY PRINTEDSIMPLY_WEBHOOK_SECRET=whsec_YOUR_SECRET ``` 2. Create the App Products, pricing, and orders in a single file: ``` # app.py import os import requests from dotenv import load_dotenv from flask import Flask, request, jsonify from webhooks import webhooks load_dotenv() app = Flask(__name__) app.register_blueprint(webhooks) # No SDK required — call the REST API with the requests library. BASE_URL = "https://portal.printedsimply.com/api/v1" HEADERS = { "x-api-key": os.environ["PRINTEDSIMPLY_API_KEY"], "Content-Type": "application/json", } @app.route("/products") def list_products(): resp = requests.get(f"{BASE_URL}/products", headers=HEADERS) # Responses are wrapped as {"status", "data", "request_id"}. return jsonify(resp.json()["data"]["products"]) @app.route("/estimate", methods=["POST"]) def estimate(): data = request.json resp = requests.post( f"{BASE_URL}/pricing/estimate", headers=HEADERS, json={ "items": data["items"], "shipping_method": data.get("shipping_method", "standard"), }, ) return jsonify(resp.json()["data"]) @app.route("/orders", methods=["POST"]) def create_order(): data = request.json # 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. reference = data.get("reference") if not reference: return jsonify({"error": "reference (your order id) is required"}), 400 resp = requests.post( f"{BASE_URL}/order", headers={**HEADERS, "Idempotency-Key": f"order-{reference}"}, json={ "reference": reference, "items": data["items"], "address_to": data["address_to"], "shipping": {"method": data.get("shipping_method", "standard")}, }, ) if resp.status_code >= 400: return jsonify(resp.json().get("error", {"message": "Order failed"})), resp.status_code return jsonify(resp.json()["data"]) @app.route("/orders/") def get_order(order_id): resp = requests.get(f"{BASE_URL}/order/{order_id}", headers=HEADERS) return jsonify(resp.json()["data"]) ``` 3. Handle Webhooks Create `webhooks.py` beside `app.py` (which registers it). 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.py import json import os from flask import Blueprint, request import hmac import hashlib import time TOLERANCE_SECONDS = 300 # header is the raw X-PS-Signature-V2 value, e.g. "t=1754222400,v1=abc123..." # During a secret rotation the header carries one v1 per live secret # ("t=...,v1=,v1="), so collect them all and accept ANY match. # A missing header (None) is refused like a malformed one. def verify_webhook(raw_body: bytes, header: str, secret: str) -> bool: t, v1s = "", [] for part in (header or "").split(","): if "=" not in part: continue key, value = part.strip().split("=", 1) if key == "t": t = value elif key == "v1": v1s.append(value) # A Unix timestamp is ten digits. Refusing a longer one also keeps int() # from raising on a header padded past Python's digit limit. if not (t.isascii() and t.isdigit()) or len(t) > 12 or not v1s: return False # Reject a captured request replayed outside the window. This check is the # reason to prefer V2 — a body-only signature never expires. if abs(int(time.time()) - int(t)) > TOLERANCE_SECONDS: return False # The timestamp is signed along with the body, so it cannot be swapped out. expected = hmac.new( secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256, ).hexdigest() # Compared as bytes: compare_digest raises on a str with non-ASCII characters. return any(hmac.compare_digest(v1.encode(), expected.encode()) for v1 in v1s) webhooks = Blueprint("webhooks", __name__) # 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. handled = set() @webhooks.route("/webhooks/printedsimply", methods=["POST"]) def printedsimply_webhook(): # The raw bytes, exactly as signed — not request.json. raw_body = request.get_data() signature = request.headers.get("X-PS-Signature-V2") if not verify_webhook(raw_body, signature, os.environ["PRINTEDSIMPLY_WEBHOOK_SECRET"]): return "Invalid signature", 401 event_id = request.headers.get("X-PS-Event-Id", "") if event_id in handled: return "OK", 200 # Every delivery is {"id", "event", "timestamp", "data"}; the order is in data. event = json.loads(raw_body) order = event.get("data", {}) if event.get("event") == "order.shipped": tracking_number = order.get("tracking_number") print(f"Shipped! Order {order.get('order_id')}, tracking {tracking_number}") # Update your database elif event.get("event") == "order.delivered": print(f"Delivered! Order {order.get('order_id')}") # Answer 200 to events you do not handle too: anything else is a failed delivery. handled.add(event_id) return "OK", 200 ``` 4. Run It ``` flask run --debug ``` Test with curl: `curl http://localhost:5000/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. --- # Rails + PrintedSimply Source: https://portal.printedsimply.com/docs/quickstarts/rails Integrate PrintedSimply into a Rails app with a service class, controller, routes, and real HMAC webhook verification. Plain REST, no SDK required — live on a $0 test key. $0 sandbox key Idempotent orders Signed webhooks 1. Install Dependencies ``` # Gemfile gem "httparty" gem "dotenv-rails" ``` Add your API key to `.env`: ``` # .env PRINTEDSIMPLY_API_KEY=ps_test_YOUR_KEY PRINTEDSIMPLY_WEBHOOK_SECRET=whsec_YOUR_SECRET ``` 2. Create the API Client A service class that wraps all PrintedSimply API calls: ``` # app/services/printed_simply.rb class PrintedSimply BASE_URL = "https://portal.printedsimply.com/api/v1" def initialize(api_key = ENV["PRINTEDSIMPLY_API_KEY"]) @api_key = api_key end def products get("/products") end def product(sku) get("/products/#{sku}") end def estimate(items:, shipping_method: "standard") post("/pricing/estimate", { items: items, shipping_method: shipping_method, }) end # 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. def create_order(reference:, items:, address_to:, shipping_method: "standard") post("/order", { reference: reference, items: items, address_to: address_to, shipping: { method: shipping_method }, }, idempotency_key: "order-#{reference}") end def order(id) get("/order/#{id}") end private def headers { "x-api-key" => @api_key, "Content-Type" => "application/json" } end def get(path) HTTParty.get("#{BASE_URL}#{path}", headers: headers).parsed_response end def post(path, body, idempotency_key: nil) request_headers = headers request_headers = request_headers.merge("Idempotency-Key" => idempotency_key) if idempotency_key HTTParty.post("#{BASE_URL}#{path}", headers: request_headers, body: body.to_json).parsed_response end end ``` 3. Add the Controller ``` # app/controllers/api/orders_controller.rb class Api::OrdersController < ApplicationController skip_before_action :verify_authenticity_token def index_products render json: ps.products end def estimate result = ps.estimate( items: params[:items], shipping_method: params[:shipping_method] || "standard", ) render json: result end def create order = ps.create_order( reference: order_params.require(:reference), items: order_params[:items], address_to: order_params[:address_to], shipping_method: order_params[:shipping_method] || "standard", ) render json: order, status: :created rescue StandardError => e render json: { error: e.message }, status: :unprocessable_entity end def show render json: ps.order(params[:id]) end private def ps @ps ||= PrintedSimply.new end def order_params params.permit( :reference, :shipping_method, items: [:sku, :quantity, :print_file], address_to: [:name, :address1, :address2, :city, :state, :zip, :country], ) end end ``` Routes: ``` # config/routes.rb Rails.application.routes.draw do namespace :api do get "products", to: "orders#index_products" post "estimate", to: "orders#estimate" post "orders", to: "orders#create" get "orders/:id", to: "orders#show" end post "webhooks/printedsimply", to: "webhooks#printedsimply" end ``` 4. Handle Webhooks The controller 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`. The route is already in `config/routes.rb` above. ``` # app/controllers/webhooks_controller.rb class WebhooksController < ApplicationController skip_before_action :verify_authenticity_token, raise: false # 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. HANDLED = Set.new def printedsimply # The raw bytes, exactly as signed — not params. raw_body = request.raw_post signature = request.headers["X-PS-Signature-V2"] unless verify_webhook(raw_body, signature, ENV.fetch("PRINTEDSIMPLY_WEBHOOK_SECRET")) return head :unauthorized end event_id = request.headers["X-PS-Event-Id"].to_s return head :ok if HANDLED.include?(event_id) # Every delivery is { "id", "event", "timestamp", "data" }; the order is in data. event = JSON.parse(raw_body) order = event.fetch("data", {}) case event["event"] when "order.shipped" Rails.logger.info "Shipped! Order #{order['order_id']}, tracking #{order["tracking_number"]}" # Update your database when "order.delivered" Rails.logger.info "Delivered! Order #{order['order_id']}" end # Answer 200 to events you do not handle too: anything else is a failed delivery. HANDLED.add(event_id) head :ok end private TOLERANCE_SECONDS = 300 # header is the raw X-PS-Signature-V2 value, e.g. "t=1754222400,v1=abc123..." # During a secret rotation the header carries one v1 per live secret # ("t=...,v1=,v1="), so collect them all and accept ANY match. # A missing header (nil) is refused like a malformed one. def verify_webhook(raw_body, header, secret) t = "" v1s = [] header.to_s.split(",").each do |part| key, value = part.strip.split("=", 2) next if value.nil? if key == "t" t = value elsif key == "v1" v1s << value end end return false unless t.match?(/\A\d+\z/) && v1s.any? # Reject a captured request replayed outside the window. This check is the # reason to prefer V2 — a body-only signature never expires. return false if (Time.now.to_i - t.to_i).abs > TOLERANCE_SECONDS # The timestamp is signed along with the body, so it cannot be swapped out. expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{raw_body}") v1s.any? { |v1| ActiveSupport::SecurityUtils.secure_compare(v1, expected) } end end ``` 5. Run It ``` bundle install && rails server ``` Test with curl: `curl http://localhost:3000/api/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. --- # Go + PrintedSimply Source: https://portal.printedsimply.com/docs/quickstarts/go Build a high-throughput print-on-demand service on the PrintedSimply REST API. A lightweight HTTP client, handlers, and real signed-webhook verification — no SDK required, live on a $0 test key. $0 sandbox key Idempotent orders Signed webhooks 1. Initialize Project ``` go mod init myapp go get github.com/joho/godotenv ``` Add your API key to `.env`: ``` # .env PRINTEDSIMPLY_API_KEY=ps_test_YOUR_KEY PRINTEDSIMPLY_WEBHOOK_SECRET=whsec_YOUR_SECRET ``` 2. Create the API Client A lightweight HTTP client for the PrintedSimply API: ``` // ps/client.go package ps import ( "bytes" "encoding/json" "fmt" "io" "net/http" ) const baseURL = "https://portal.printedsimply.com/api/v1" type Client struct { apiKey string http *http.Client } func NewClient(apiKey string) *Client { return &Client{apiKey: apiKey, http: &http.Client{}} } func (c *Client) get(path string) (json.RawMessage, error) { req, _ := http.NewRequest("GET", baseURL+path, nil) req.Header.Set("x-api-key", c.apiKey) return c.do(req) } func (c *Client) post(path string, body any, idempotencyKey string) (json.RawMessage, error) { data, _ := json.Marshal(body) req, _ := http.NewRequest("POST", baseURL+path, bytes.NewReader(data)) req.Header.Set("x-api-key", c.apiKey) req.Header.Set("Content-Type", "application/json") if idempotencyKey != "" { req.Header.Set("Idempotency-Key", idempotencyKey) } return c.do(req) } func (c *Client) do(req *http.Request) (json.RawMessage, error) { resp, err := c.http.Do(req) if err != nil { return nil, err } defer resp.Body.Close() raw, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 400 { return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, raw) } return raw, nil } func (c *Client) ListProducts() (json.RawMessage, error) { return c.get("/products") } func (c *Client) GetProduct(sku string) (json.RawMessage, error) { return c.get("/products/" + sku) } // CreateOrder places an order. The same idempotencyKey on a retry returns the // first result instead of placing a second order. func (c *Client) CreateOrder(order map[string]any, idempotencyKey string) (json.RawMessage, error) { return c.post("/order", order, idempotencyKey) } func (c *Client) GetOrder(id string) (json.RawMessage, error) { return c.get("/order/" + id) } func (c *Client) Estimate(body map[string]any) (json.RawMessage, error) { return c.post("/pricing/estimate", body, "") } ``` 3. Add HTTP Handlers Products, orders, and a simple HTTP server. It also registers the webhook handler you add in the next step: ``` // main.go package main import ( "encoding/json" "log" "myapp/ps" "net/http" "os" "strings" "github.com/joho/godotenv" ) var client *ps.Client func main() { godotenv.Load() client = ps.NewClient(os.Getenv("PRINTEDSIMPLY_API_KEY")) http.HandleFunc("/products", handleProducts) http.HandleFunc("/orders", handleCreateOrder) http.HandleFunc("/orders/", handleGetOrder) http.HandleFunc("/webhooks/printedsimply", handleWebhook) // in webhook.go log.Println("Server running on :8080") log.Fatal(http.ListenAndServe(":8080", nil)) } func handleProducts(w http.ResponseWriter, r *http.Request) { data, err := client.ListProducts() if err != nil { http.Error(w, err.Error(), 500) return } w.Header().Set("Content-Type", "application/json") w.Write(data) } func handleCreateOrder(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "Method not allowed", 405) return } var body map[string]any json.NewDecoder(r.Body).Decode(&body) // 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. reference, _ := body["reference"].(string) if reference == "" { http.Error(w, "reference (your order id) is required", 400) return } data, err := client.CreateOrder(body, "order-"+reference) if err != nil { http.Error(w, err.Error(), 400) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(201) w.Write(data) } func handleGetOrder(w http.ResponseWriter, r *http.Request) { id := strings.TrimPrefix(r.URL.Path, "/orders/") data, err := client.GetOrder(id) if err != nil { http.Error(w, err.Error(), 500) return } w.Header().Set("Content-Type", "application/json") w.Write(data) } ``` 4. Handle Webhooks Create `webhook.go` beside `main.go`, in the same package. 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`. Run the package (`go run .`), so both files are built. ``` // webhook.go package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "io" "log" "net/http" "os" "strconv" "strings" "sync" "time" ) // Every delivery is {"id", "event", "timestamp", "data"}; the order is in data. type webhookEvent struct { ID string `json:"id"` Event string `json:"event"` Data struct { OrderID string `json:"order_id"` Reference string `json:"reference"` Status string `json:"status"` TrackingNumber string `json:"tracking_number"` TrackingURL string `json:"tracking_url"` } `json:"data"` } // 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 map stands in for it. var ( handledMu sync.Mutex handled = map[string]bool{} ) func handleWebhook(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // The raw bytes, exactly as signed. body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } signature := r.Header.Get("X-PS-Signature-V2") if !verifyWebhook(body, signature, os.Getenv("PRINTEDSIMPLY_WEBHOOK_SECRET")) { http.Error(w, "Invalid signature", http.StatusUnauthorized) return } eventID := r.Header.Get("X-PS-Event-Id") handledMu.Lock() seen := handled[eventID] handledMu.Unlock() if seen { w.WriteHeader(http.StatusOK) return } var event webhookEvent if err := json.Unmarshal(body, &event); err != nil { http.Error(w, "Bad payload", http.StatusBadRequest) return } switch event.Event { case "order.shipped": log.Printf("Shipped! Order %s, tracking %s", event.Data.OrderID, event.Data.TrackingNumber) // Update your database case "order.delivered": log.Printf("Delivered! Order %s", event.Data.OrderID) } // Answer 200 to events you do not handle too: anything else is a failed delivery. handledMu.Lock() handled[eventID] = true handledMu.Unlock() w.WriteHeader(http.StatusOK) } const toleranceSeconds = 300 // verifyWebhook checks the raw X-PS-Signature-V2 value, e.g. "t=1754222400,v1=abc123...". // During a secret rotation the header carries one v1 per live secret // ("t=...,v1=,v1="), so collect them all and accept ANY match. func verifyWebhook(rawBody []byte, header, secret string) bool { t := "" var v1s []string for _, part := range strings.Split(header, ",") { key, value, found := strings.Cut(strings.TrimSpace(part), "=") if !found { continue } if key == "t" { t = value } else if key == "v1" { v1s = append(v1s, value) } } ts, err := strconv.ParseInt(t, 10, 64) if err != nil || len(v1s) == 0 { return false } // Reject a captured request replayed outside the window. This check is the // reason to prefer V2 — a body-only signature never expires. age := time.Now().Unix() - ts if age > toleranceSeconds || age < -toleranceSeconds { return false } // The timestamp is signed along with the body, so it cannot be swapped out. mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(t + ".")) mac.Write(rawBody) expected := hex.EncodeToString(mac.Sum(nil)) for _, v1 := range v1s { // hmac.Equal is constant-time and returns false on a length mismatch. if hmac.Equal([]byte(v1), []byte(expected)) { return true } } return false } ``` 5. Run It ``` go run . ``` Test with curl: `curl http://localhost:8080/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. --- # Webhooks Source: https://portal.printedsimply.com/docs/webhooks Get HMAC-signed, real-time notifications the moment an order changes — no polling, and every delivery is replayable. ## The contract **Signed** Every delivery carries `X-PS-Signature-V2`: `t=,v1=`, an HMAC-SHA256 of `.` under your endpoint's secret. Refuse a `t` more than 5 minutes from your clock and compare in constant time — [the verifier](https://portal.printedsimply.com/docs/webhooks#signature-verification). **Retried** A timeout, a refused connection, a 5xx, 408 or 429 is retried, up to 7 attempts over about 10 hours, waiting between them: 1. 1 minute 2. 5 minutes 3. 15 minutes 4. 1 hour 5. 3 hours 6. 6 hours Any other 4xx is final. [Retry policy](https://portal.printedsimply.com/docs/webhooks#retry-policy) **Logged** Every attempt is kept with its status, its cause and the body we sent, in the [delivery log](https://portal.printedsimply.com/dashboard/webhooks/logs) and at `GET /v1/webhooks/deliveries`. **Replayable** Re-send any delivery (`POST /v1/webhooks/deliveries/{id}/redeliver`) or any event in your feed (`POST /v1/events/{eventId}/replay`), signed afresh, under the same event id. **One id per event** The envelope's `id`, sent as `X-PS-Event-Id`, is the same on every retry and replay. Store it and skip one you have handled. ## Your first event Three steps with the test key from the [quickstart](https://portal.printedsimply.com/docs/quickstarts/curl): subscribe, place and ship a $0 test order, read what arrives. Nothing is charged or printed. ### 1. Subscribe your handler Point `url` at your handler — a deployed one, or the one on your laptop through an HTTPS tunnel ([Local development](https://portal.printedsimply.com/docs/webhooks#local-development)). `mode: "test"` sends it sandbox events only; it is a test key's default, and a test key asking for live traffic is refused. curl: ```bash export PRINTEDSIMPLY_API_KEY=ps_test_... # the quickstart's test key curl -X POST https://portal.printedsimply.com/api/v1/webhooks \ -H "x-api-key: $PRINTEDSIMPLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url":"https://your-app.example.com/webhooks/printedsimply", "events":["order.created","order.shipped"], "mode":"test"}' ``` Response: ```json { "status": "success", "data": { "id": "whk_5Gm2Rk8Tq4Vw7Yb3Nc6Hd9Jf", "url": "https://your-app.example.com/webhooks/printedsimply", "events": [ "order.created", "order.shipped" ], "active": true, "mode": "test", "created_at": "2026-10-10T15:00:00.000Z", "secret_rotated_at": null, "previous_secret_expires_at": null, "rotation_pending": false, "inbox": false, "secret": "9c2e7f4a1b8d3e6f0a5c9b2d7e4f1a8c3b6d9e2f5a8c1b4d7e0f3a6c9b2e5d8f" }, "request_id": "req_3Lw8Pn5Tk2Xv9Qd6" } ``` `secret` is in this response and in no later one: store it now. Your handler verifies every delivery with it. ### 2. Make it happen Place the quickstart's $0 test order, and your endpoint receives `order.created`. Placed it before? Run it again: the sample sends a new Idempotency-Key each run and no `reference`, so it is a new order. A replayed key returns the first order, a reference sent twice is refused 409 `duplicate_reference`, and neither raises an event. Then ship it now rather than waiting for the sandbox's clock: curl: ```bash # The order_id your $0 test order answered with ORDER_ID=test_ord_8Kq2vX9mP4nRw7Tb5Lc3dEhA curl -X POST https://portal.printedsimply.com/api/v1/order/$ORDER_ID/simulate \ -H "x-api-key: $PRINTEDSIMPLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"target_state":"shipped"}' ``` ### 3. Read it `order.shipped` as your handler receives it — the body, and the request it arrives in: order.shipped: ```json { "id": "evt_Qm7Wd2Xk9Lp4Rt6Vb8Nc3Hs5", "event": "order.shipped", "timestamp": "2026-10-10T15:04:05.120Z", "data": { "event_id": "evt_Fz3Jh8Ty1Ku6Mp9Ga4Se2Wd7", "order_id": "test_ord_8Kq2vX9mP4nRw7Tb5Lc3dEhA", "portal_order_id": "ord_8Kq2vX9mP4nRw7Tb5Lc3dEhA", "provider": "printedsimply", "reference": "order-1001", "status": "shipped", "tracking_number": "TRK-TEST-7D4K20QX81MZ", "tracking_carrier": "USPS", "tracking_url": "https://portal.printedsimply.com/track/trk_Vb6Nq1Xe8Rm3Kt5Wz2Hc9Lp", "tracking": { "number": "TRK-TEST-7D4K20QX81MZ", "carrier": "USPS", "url": "https://portal.printedsimply.com/track/trk_Vb6Nq1Xe8Rm3Kt5Wz2Hc9Lp" }, "estimated_delivery": { "earliest": "2026-10-20", "latest": "2026-10-26", "ships_by": "2026-10-14", "is_estimate": true }, "test_mode": true } } ``` Request: ```http POST /webhooks/printedsimply HTTP/1.1 Content-Type: application/json X-PS-Signature: sha256=5c17b4986cab6c68e663dbbcc186883de1a5461bfd49a32e495c41e1fca2d17e X-PS-Signature-V2: t=1791644647,v1=9654f6cb155f348959b85ef125fe01a2ef6e75705b5b9618e4b426b59b49abfe X-PS-Timestamp: 1791644647 X-PS-Event: order.shipped X-PS-Event-Id: evt_Qm7Wd2Xk9Lp4Rt6Vb8Nc3Hs5 X-PS-Delivery-Attempt: 1 X-MF-Signature: sha256=5c17b4986cab6c68e663dbbcc186883de1a5461bfd49a32e495c41e1fca2d17e X-MF-Signature-V2: t=1791644647,v1=9654f6cb155f348959b85ef125fe01a2ef6e75705b5b9618e4b426b59b49abfe X-MF-Timestamp: 1791644647 X-MF-Event: order.shipped X-MF-Event-Id: evt_Qm7Wd2Xk9Lp4Rt6Vb8Nc3Hs5 X-MF-Delivery-Attempt: 1 ``` - **Dedupe on id** — the value of the `X-PS-Event-Id` header, and `event_id` in the delivery log and in `GET /v1/events`. It is the same on every retry and every replay. - `data.event_id` is an older id some order events still carry for receivers that read it. It is not the event's id: it differs from `id`, and not every event carries it, so dedupe on `id` alone. - `tracking_number`, `tracking_carrier` and `tracking_url` are the documented tracking fields; `tracking` holds the same three as an object, kept for receivers built on it. - `test_mode: true` — a sandbox order. A live order's events carry `false`. **Going live** is a key swap: send the same subscribe request with your `ps_live_` key and `"mode": "live"`, and that endpoint receives your real orders' events and never the sandbox's. ## Overview Webhooks let you subscribe to events that happen in your PrintedSimply account. When an event occurs, we send an HTTP `POST` request to the URL you configure, with a JSON payload describing the event. Every webhook request is signed twice. `X-PS-Signature-V2` (value `t=,v1=`) is an HMAC-SHA256 over `.`. Because the timestamp is part of the signed material, you can reject a request that was captured and replayed later — **verify this one**. `X-PS-Signature` (value `sha256=`) is the original body-only signature. It still ships on every request so existing integrations keep working, but a body-only signature stays valid forever and cannot detect a replay. Each delivery also includes `X-PS-Event`, `X-PS-Timestamp`, `X-PS-Delivery-Attempt`, and `X-PS-Event-Id` headers. The event id also appears as `id` in the payload, and is the same value across our retry attempts and any manual redelivery — store it and skip an id you have already processed, so a retry never runs your handler twice. Every `X-PS-*` header is also sent under its deprecated `X-MF-*` name with the same value (`X-MF-Signature-V2`, `X-MF-Event-Id` and the rest), so a receiver written before the PrintedSimply rename keeps verifying unchanged. Read `X-PS-*` in new code; the SDKs' verifiers read it first and fall back to the old name. ## Event types Order lifecycle | Event | Description | | --- | --- | | `order.created` | Fired when a new order is submitted | | `order.paid` | Fired when payment is successfully captured | | `order.in_production` | Fired when the order enters production at the fulfillment provider | | `order.shipped` | Fired when the order ships with tracking information | | `order.delivered` | Fired when the carrier confirms delivery | | `order.cancelled` | Fired when an order is cancelled | | `order.failed` | Fired when an order that was accepted will not be produced — `error_code` is `payment_failed` (a payment taken after the order was accepted was declined or never funded) | | `order.updated` | Fired when a placed order changes — a line added, a quantity changed, a line cancelled, the shipping service changed, the destination address changed, a letter reported missing was reshipped (`letter_reshipped`, carrying the replacement's ids), or the fulfilment provider assigned its own id to an order accepted under a pending id (`provider_order_id_assigned`: `order_id` is the new id and `changes.previous_order_id` the one it replaces, which keeps resolving). `changes.action` says which | | `order.quality_check` | Fired when the order enters quality inspection before shipping | | `order.issue` | Fired when fulfillment flags an issue with the order (hold, defect, or error) — `issue_reason` says why when the facility did: `artwork_unprintable`, `address_undeliverable`, `shipment_exception`, `on_hold` or `cancellation_pending`; it is absent when no reason fits. A test key raises it with POST /v1/order/{id}/simulate | | `order.approval_requested` | Fired when an order placed with `approval: "required"` is held for a person's approval — nothing is charged or sent to production. `approval_url` is the signed page the person approves or declines it on, with no account; it stops deciding at `expires_at` (72 hours), and the order is then cancelled | | `order.approval_approved` | Fired when a person approved a held order on its approval page and the order was placed and charged — `order.created` and `order.paid` fire beside it. `order_id` is the placed order's own id; `portal_order_id` never changes. `decided_by` is the name they typed, when they typed one | | `order.approval_declined` | Fired when a person declined a held order on its approval page: the order is cancelled and nothing was charged | | `order.approval_expired` | Fired when a held order's approval link expired with no decision: the order is cancelled and nothing was charged | The catalog and your endpoints The catalog and your webhook endpoints emit their own events too. Subscribe to any subset — pass the names in `events`, or `["*"]` for everything. | Family | Events | | --- | --- | | Catalog | `product.updated`, `catalog.synced` | | Webhooks | `webhook.disabled` | ## Setting up webhooks Register endpoints with your API key — no dashboard round trip, which is what lets your own code subscribe to the events its own orders produce. Everything below needs the `webhooks:manage` scope. The same subscriptions appear in the [dashboard](https://portal.printedsimply.com/dashboard/webhooks), so either surface can manage what the other created. curl: ```bash # Subscribe an endpoint. The response carries "secret" — shown once. # A test key subscribes to test traffic; with your ps_live_ key, send "mode":"live". curl -X POST https://portal.printedsimply.com/api/v1/webhooks \ -H "x-api-key: $PRINTEDSIMPLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url":"https://example.com/hooks/printedsimply", "events":["order.shipped","order.delivered"], "mode":"test"}' # Everything you are subscribed to (never the secrets) curl https://portal.printedsimply.com/api/v1/webhooks \ -H "x-api-key: $PRINTEDSIMPLY_API_KEY" # Pause without losing the subscription or its history curl -X PATCH https://portal.printedsimply.com/api/v1/webhooks/whk_5Gm2Rk8Tq4Vw7Yb3Nc6Hd9Jf \ -H "x-api-key: $PRINTEDSIMPLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"active":false}' ``` The signing secret is returned **exactly once**, on create and on rotation — every later read of a subscription omits it. If you lose it, rotate rather than re-read. Pausing with `active: false` holds what is already queued for the endpoint: nothing is sent, and the held events go out where their retry schedule left off once you set it back to `true`. Events raised while it is paused are not queued for it, so replay the ones you need from the event feed after resuming. An endpoint we switch off after repeated failures is held the same way. ### Requirements - The endpoint URL must use HTTPS, or http:// to localhost or 127.0.0.1 outside production - It must also be publicly reachable: delivery refuses any hostname that resolves to a loopback, private or link-local address, so an endpoint inside your own network never receives one - Your server must respond with a `2xx` status code within 10 seconds ## Test and live traffic Every subscription has a `mode`: `all` (the default for a live key), `test`, or `live`. A `test` endpoint receives sandbox events and nothing else, so the tunnel on your laptop never sees a real customer's order. A `live` endpoint is the mirror image: your production handler stops being woken every few minutes by whoever is working through the quickstart. A test key stays on its own side of that line. A subscription it creates is `test` unless it says otherwise, and asking for `live` or `all` answers 403 `live_key_required`. It can change, rotate or delete only test subscriptions, and its delivery log shows sandbox events and account events only. It can still list every subscription the account has. A live key manages all of them. curl: ```bash # A tunnel that only ever sees the sandbox curl -X POST https://portal.printedsimply.com/api/v1/webhooks \ -H "x-api-key: $PRINTEDSIMPLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url":"https://example.invalid/dev","events":["*"],"mode":"test"}' # Re-scope one you already have (live or all needs your ps_live_ key) curl -X PATCH https://portal.printedsimply.com/api/v1/webhooks/whk_5Gm2Rk8Tq4Vw7Yb3Nc6Hd9Jf \ -H "x-api-key: $PRINTEDSIMPLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"mode":"live"}' ``` What arrives: ```json // A sandbox order, on a mode:"test" or mode:"all" subscription { "id": "evt_5f...", "event": "order.created", "timestamp": "2026-08-08T14:03:11.204Z", "data": { "order_id": "test_ord_8Kq2vX9mP4nRw7Tb5Lc3dEhA", "portal_order_id": "ord_8Kq2vX9mP4nRw7Tb5Lc3dEhA", "status": "paid", "total_cents": 2800, "test_mode": true } } // A catalog sync — account-level, so it reaches every subscription // and carries no test_mode at all. { "id": "evt_a1...", "event": "catalog.synced", "timestamp": "2026-08-08T14:05:00.000Z", "data": { "provider": "merchfarm", "product_count": 412 } } ``` **Every order event carries `test_mode`** in its `data`, stamped from the same decision that routed it — so a handler on a `mode: "all"` subscription can still branch on arrival, and the flag can never disagree with which endpoints were chosen. Some events belong to neither side — a catalog sync (`catalog.synced`, `product.updated`). They are facts about your account rather than a rehearsal of anything, so they reach **every** subscription whatever its mode, and they carry no `test_mode` rather than an invented one. Subscriptions created before this field existed are `all`, which is exactly the traffic they receive today — nothing you are relying on stopped arriving. ## Signature verification Verify every webhook against the `X-PS-Signature-V2` header using the webhook secret from your dashboard. Its value is `t=,v1=`, where the hex digest is an HMAC-SHA256 of `.` — the timestamp, a literal dot, then the **raw** request body (not the parsed JSON). Reject the request if `t` is more than five minutes from your own clock, then compare the digests in constant time. Skipping the timestamp check leaves you replayable: the signature alone proves the body came from us, not that it was sent to you just now. The older `X-PS-Signature` header (`sha256=`, an HMAC-SHA256 of the raw body alone) still ships on every request and existing integrations remain supported — but it has no replay protection, so move to V2 when you can. Node.js: ```javascript const crypto = require('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=,v1="), so collect them all and accept ANY match. // A missing header (undefined or null) is refused like a malformed one. function verifyWebhook(rawBody, header, secret) { let t = null; const v1s = []; 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)) ); } ``` Python: ```python import hmac import hashlib import time TOLERANCE_SECONDS = 300 # header is the raw X-PS-Signature-V2 value, e.g. "t=1754222400,v1=abc123..." # During a secret rotation the header carries one v1 per live secret # ("t=...,v1=,v1="), so collect them all and accept ANY match. # A missing header (None) is refused like a malformed one. def verify_webhook(raw_body: bytes, header: str, secret: str) -> bool: t, v1s = "", [] for part in (header or "").split(","): if "=" not in part: continue key, value = part.strip().split("=", 1) if key == "t": t = value elif key == "v1": v1s.append(value) # A Unix timestamp is ten digits. Refusing a longer one also keeps int() # from raising on a header padded past Python's digit limit. if not (t.isascii() and t.isdigit()) or len(t) > 12 or not v1s: return False # Reject a captured request replayed outside the window. This check is the # reason to prefer V2 — a body-only signature never expires. if abs(int(time.time()) - int(t)) > TOLERANCE_SECONDS: return False # The timestamp is signed along with the body, so it cannot be swapped out. expected = hmac.new( secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256, ).hexdigest() # Compared as bytes: compare_digest raises on a str with non-ASCII characters. return any(hmac.compare_digest(v1.encode(), expected.encode()) for v1 in v1s) ``` ## Rotating the signing secret Rotate a webhook's signing secret with `POST /v1/webhooks/{id}/rotate-secret`, or from the dashboard. You get a new secret back once; the outgoing one stays valid for a grace window (24 hours by default, up to 7 days via `grace_hours`). No deliveries are lost and the subscription keeps its history — you never have to delete and recreate an endpoint. curl: ```bash # New secret now, old one still verifying for 24h (max 168) curl -X POST \ https://portal.printedsimply.com/api/v1/webhooks/whk_5Gm2Rk8Tq4Vw7Yb3Nc6Hd9Jf/rotate-secret \ -H "x-api-key: $PRINTEDSIMPLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"grace_hours": 24}' # Once the new secret is deployed, close the window early curl -X DELETE \ https://portal.printedsimply.com/api/v1/webhooks/whk_5Gm2Rk8Tq4Vw7Yb3Nc6Hd9Jf/rotate-secret \ -H "x-api-key: $PRINTEDSIMPLY_API_KEY" ``` During the window every delivery is signed with **both** secrets, and `X-PS-Signature-V2` carries one `v1` per secret — `t=,v1=,v1=`. Accept the request if **any** `v1` matches your secret; the examples above already do. Receivers still on the single-valued `X-PS-Signature` header get the outgoing secret's digest in `X-PS-Signature-Previous` for the duration of the window. Deploy the new secret to your endpoint, then close the window early with `DELETE /v1/webhooks/{id}/rotate-secret` (“Finish now” in the dashboard). Do that as soon as you can when you are rotating because a secret leaked — until the window closes, the old secret still verifies. ## Retry policy Each delivery waits up to 10 seconds for a `2xx` response. If it times out, the connection fails, or it returns a 5xx, 408 or 429, we retry — **up to 7 attempts over about 10 hours**, from a durable queue that a dispatcher drains every minute, so an endpoint that is down for a deploy still receives everything it missed. Any other `4xx` is treated as a permanent rejection and is not retried. | Attempt | Earliest, after the previous attempt | | --- | --- | | 1 (initial) | — | | 2 | 1 minute | | 3 | 5 minutes | | 4 | 15 minutes | | 5 | 1 hour | | 6 | 3 hours | | 7 (final) | 6 hours | You are notified either way a delivery ends — when the final attempt fails, and when the ladder stops early because the response can never succeed. Both mark the delivery failed and raise the same notice in your dashboard, debounced to one per endpoint per hour, so an endpoint answering `404` is not quieter than one answering `500`. You can inspect every attempt and **replay any failed delivery** on demand from the [webhook dashboard](https://portal.printedsimply.com/dashboard/webhooks) — a fresh signature is generated for the replay. An endpoint that keeps failing is eventually **disabled automatically**: after **20 consecutive events** end in permanent failure, the subscription is switched off and a `webhook.disabled` event is written to your account feed — announced there, in your dashboard notifications, and to any other active subscription, because the endpoint it is about can no longer hear it. Any successful delivery resets the count. Fix the endpoint, re-enable the subscription (which also resets the count), and replay what you missed from the [events feed](https://portal.printedsimply.com/dashboard/webhooks/events). ## Local development We only deliver to publicly reachable endpoints — `localhost`, loopback, and private IP ranges are refused at delivery time, which is what stops a webhook URL from being aimed at internal infrastructure. Registration says the same thing in the scheme: the endpoint URL must use HTTPS, or http:// to localhost or 127.0.0.1 outside production. Either way, the URL you are actually developing against is not one this API can reach. **No server yet?** Make your account's [hosted test inbox](https://portal.printedsimply.com/dashboard/webhooks/inbox): it receives your test events, never live ones, and keeps each exactly as an endpoint would receive it — headers, signature and raw body — with nothing to install and no tunnel. **Use an HTTPS tunnel.** Run your handler, give it a public HTTPS URL with a tunnel, and subscribe that URL with your test key and `mode: "test"` (“Test only” in the [dashboard](https://portal.printedsimply.com/dashboard/webhooks)). Deliveries reach your laptop signed exactly as production signs them, and no real customer's order ever does. When the tunnel's URL changes, update the subscription's `url` (`PATCH /v1/webhooks/{id}`) rather than making another. Tunnel: ```bash # 1. Your handler, running on your machine, e.g. # http://localhost:3000/webhooks/printedsimply # 2. A public HTTPS URL for it — either tool works cloudflared tunnel --url http://localhost:3000 # or: ngrok http 3000 # 3. Subscribe the URL it printed, for test traffic only export PRINTEDSIMPLY_API_KEY=ps_test_... curl -X POST https://portal.printedsimply.com/api/v1/webhooks \ -H "x-api-key: $PRINTEDSIMPLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url":"https://YOUR-TUNNEL-HOST/webhooks/printedsimply", "events":["*"], "mode":"test"}' ``` ### Or replay the delivery log with `printedsimply listen` Every attempt records its payload whether or not it succeeded. Point a subscription at a placeholder host you do not control (`https://example.invalid/webhooks`) and the events pile up in your log, bodies included. Our CLI reads that log and replays each new entry to your machine, with no tunnel. It is not published to npm yet; until it is, use a tunnel. Create that placeholder subscription with `mode: "test"` (see [Test and live traffic](https://portal.printedsimply.com/docs/webhooks#test-and-live)). Otherwise the log it reads — and therefore your laptop — fills with your production order traffic alongside the sandbox events you are actually debugging. Listen: ```bash export PRINTEDSIMPLY_API_KEY=ps_test_... # @printedsimply/cli reads PRINTEDSIMPLY_API_KEY; hand it the API host too export PRINTEDSIMPLY_BASE_URL=https://portal.printedsimply.com/api/v1 printedsimply listen --forward-to http://localhost:3000/webhooks/printedsimply ``` Output: ```bash Forwarded requests are signed with this local secret: whsec_2f1c9a... Listening for deliveries → http://localhost:3000/webhooks/printedsimply ✓ order.created → 200 ✓ order.shipped → 200 ✗ order.cancelled → 500 (handler rejected) ``` Filtering & replay: ```bash # Only one subscription, only two event types printedsimply listen --forward-to http://localhost:3000/webhooks/printedsimply \ --webhook-id whk_5Gm2Rk8Tq4Vw7Yb3Nc6Hd9Jf --events order.created,order.shipped # Re-send the five most recent deliveries on startup printedsimply listen --forward-to http://localhost:3000/webhooks/printedsimply --replay-last 5 # Pin the signing secret so it survives restarts printedsimply listen --forward-to http://localhost:3000/webhooks/printedsimply \ --secret whsec_mylocaldev ``` Forwarded requests carry the full production header set and are signed the same way, so the handler you debug locally is the handler that ships — no `if (isDev) skipVerification()` branch. The secret printed at startup is generated locally and is **not** your endpoint's real signing secret; a dev machine should never need production credentials to exercise a webhook handler. Set it as your webhook secret while developing, then swap in the real one when you deploy. Deliveries are forwarded in the order their events happened, so `order.created` reaches your handler before `order.shipped`. Each _event_ is forwarded once per run, not each attempt — the placeholder subscription fails by design, so one event is up to 7 rows in the log, and the CLI collapses them on `event_id` exactly as your production handler does. If your handler returns a 5xx, the failure is printed and the CLI moves on rather than looping on it and burying the next event. Use `--replay-last` to send it again. ## Debugging deliveries Every attempt is recorded with its response status and the payload that was sent. The [delivery log](https://portal.printedsimply.com/dashboard/webhooks/logs) shows all of it: expand a row to read the exact body, and hit **Retry Delivery** on a failed one to re-send it with a fresh signature. The same three operations are available over the API, so you can wire them into your own tooling: curl: ```bash # The delivery log — attempts and outcomes, no payloads curl https://portal.printedsimply.com/api/v1/webhooks/deliveries?success=false \ -H "x-api-key: $PRINTEDSIMPLY_API_KEY" # Every attempt at one event — your dedupe key, from our side curl https://portal.printedsimply.com/api/v1/webhooks/deliveries?event_id=evt_9pQ2m4Kd \ -H "x-api-key: $PRINTEDSIMPLY_API_KEY" # One delivery, payload included (DELIVERY_ID is a row's "id" above) curl https://portal.printedsimply.com/api/v1/webhooks/deliveries/$DELIVERY_ID \ -H "x-api-key: $PRINTEDSIMPLY_API_KEY" # Replay it with a fresh signature curl -X POST \ https://portal.printedsimply.com/api/v1/webhooks/deliveries/$DELIVERY_ID/redeliver \ -H "x-api-key: $PRINTEDSIMPLY_API_KEY" ``` Reading a failure - `status_code` of `null` — we never got a response: the endpoint timed out (10s), refused the connection, or failed TLS. - `401` or `403` — usually signature verification. Check that you are verifying the _raw_ body, and that you accept any `v1` in the header during a rotation. - Any other `4xx` — treated as a permanent rejection and not retried. Fix the handler, then replay. - Repeated `5xx` — retried, up to 7 attempts over about 10 hours. Each attempt is its own log row, sharing one `X-PS-Event-Id` — which the log reports as `event_id`, so three rows are visibly one event rather than three failures. ## Best practices - Always verify signatures — never process a webhook without validating the HMAC signature first. - Respond quickly — return a `200` immediately and process the event asynchronously (e.g. via a job queue). - Handle duplicates — deduplicate on the envelope's `id` (the `X-PS-Event-Id` header), which is stable across retries and replays. The delivery log reports it as `event_id`, so you can reconcile your dedupe store against ours. - Use the raw body — verify the signature against the raw request body, not parsed/re-serialised JSON. - Monitor delivery logs — check the dashboard regularly for failed deliveries and address endpoint issues promptly. - Subscribe selectively — only subscribe to the event types you need to reduce noise and processing overhead. [API reference](https://portal.printedsimply.com/docs/api-reference) · [Troubleshooting](https://portal.printedsimply.com/docs/troubleshooting) · Email us at developers@printedsimply.com --- # Error Scenarios Source: https://portal.printedsimply.com/docs/error-scenarios What happens when things go wrong — and exactly what to do about it. The samples below call the REST API with `fetch` (Node 18 and later, nothing to install), placing the same order every quickstart places, through this helper. Every call works with a test key (`ps_test_`) for $0. A declined card, unprintable artwork and a cancel production refuses happen only to a live order. ``` const API = "https://portal.printedsimply.com/api/v1" const order = { "items": [ { "sku": "sticker-die-cut-3x3", "quantity": 100, "artwork_url": "https://printedsimply.com/samples/sticker-3x3.png" } ], "address_to": { "name": "Jane Doe", "address1": "123 Main St", "city": "Austin", "state": "TX", "zip": "78701", "country": "US" }, "shipping": { "method": "standard" } } // Every call: the key in x-api-key, JSON both ways. Resolves to { data } on // success, or to { status, error } (the error envelope) when it is refused. async function api(method, path, body, idempotencyKey) { const res = await fetch(API + path, { method, headers: { 'x-api-key': process.env.PRINTEDSIMPLY_API_KEY, 'Content-Type': 'application/json', ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}), }, body: body === undefined ? undefined : JSON.stringify(body), }) const json = await res.json() return res.ok ? { data: json.data } : { status: res.status, error: json.error } } ``` ## Payment fails mid-order ### Cause Card declined, insufficient funds, or 3DS authentication required. ### API Response ``` { "status": "error", "error": { "code": "payment_failed", "message": "Card was declined.", "doc_url": "https://portal.printedsimply.com/docs/errors#payment_failed", "suggestion": "Update your payment method at printedsimply.com/dashboard/billing." }, "request_id": "b3f1c2de-9a47-4e0b-8f5d-2c6a1e7d9b40" } ``` ### What to Do - The order is NOT created — no charge occurs - If a charge was attempted and failed, any hold is automatically released - Check the error code: 'authentication_required' means the card needs 3DS verification. On a live order, error.details carries payment_intent and client_secret: approve it with Stripe.js handleNextAction, then resend the order with payment_intent and a new Idempotency-Key - Prompt your user to update their payment method or try a different card ### Code Example ``` const placed = await api('POST', '/order', order, order.reference) if (placed.error?.code === 'payment_failed') { // Nothing was charged: show the decline and ask for another card console.log('Payment failed:', placed.error.message) } if (placed.error?.code === 'authentication_required' && placed.error.details?.client_secret) { // Nothing was charged: the total is held as an authorization. const { details } = placed.error const stripe = await loadStripe(details.publishable_key) const { error: declined } = await stripe.handleNextAction({ clientSecret: details.client_secret }) if (!declined) { // The same order with payment_intent, under a new Idempotency-Key await api('POST', '/order', { ...order, payment_intent: details.payment_intent }, `${order.reference}-3ds`) } } ``` ## Rate limit exceeded ### Cause More requests in the window than your plan allows. Rate limiting is enforced per account, across all keys — Free 60 requests per 60-second sliding window. Issuing more keys does not raise the budget. ### API Response ``` HTTP/1.1 429 Too Many Requests Retry-After: 45 X-RateLimit-Limit: 60 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1711234567000 { "status": "error", "error": { "code": "rate_limit_exceeded", "message": "Rate limit exceeded. Try again later.", "doc_url": "https://portal.printedsimply.com/docs/errors#rate_limit_exceeded", "suggestion": "The limit is 60 requests per 60 seconds. Check the X-RateLimit-Reset header for when you can retry." }, "request_id": "b3f1c2de-9a47-4e0b-8f5d-2c6a1e7d9b40" } ``` ### What to Do - Wait the number of seconds in the Retry-After header before retrying. It is set on both 429s, including the daily quota, which X-RateLimit-Reset (the per-minute window, as a Unix timestamp in milliseconds) does not describe - Implement exponential backoff: wait 1s, then 2s, then 4s - Batch operations where possible (use /orders/bulk for multi-order submissions) - Contact us if you consistently need higher limits ### Code Example ``` async function fetchWithRetry(url, options, maxRetries = 3) { for (let i = 0; i < maxRetries; i++) { const res = await fetch(url, options) if (res.status === 429) { // Retry-After is in seconds, on both kinds of 429. const retryAfter = Number(res.headers.get('Retry-After')) const waitMs = retryAfter > 0 ? retryAfter * 1000 : Math.pow(2, i) * 1000 await new Promise(r => setTimeout(r, Math.max(waitMs, 1000))) continue } return res } throw new Error('Rate limit exceeded after retries') } ``` ## Webhook delivery fails ### Cause Your endpoint returned a non-2xx status, timed out (>10s), or was unreachable. ### API Response ``` // One failed attempt, as GET /v1/webhooks/deliveries?success=false lists it { "id": "cmg2k4r7v0003qz8hx1y5w2ab", "webhook_id": "whk_SV8d5wGm2kUnSwx_RhZfY9OE", "event": "order.shipped", "event_id": "evt_q8Tz2LmV0cXk4RbN7sWd1HjP", "status_code": 500, "success": false, "created_at": "2026-10-10T08:24:51.032Z" } ``` ### What to Do - PrintedSimply makes up to 7 attempts over about 10 hours, waiting 1 minute, 5 minutes, 15 minutes, 1 hour, 3 hours, then 6 hours between them - A 4xx other than 408 or 429 is treated as a permanent rejection and is not retried - After the final attempt fails, the delivery is marked as failed - Check your webhook delivery logs in Dashboard > Webhooks for details - Use the 'Retry' button to manually redeliver a failed webhook - Ensure your endpoint responds within 10 seconds and returns 2xx - Verify X-PS-Signature-V2 and answer 401 when it does not match: a 401 reads as a signature failure in the delivery log ### Code Example ``` const crypto = require('crypto') const express = require('express') const app = 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=,v1="), so collect them all and accept ANY match. // A missing header (undefined or null) is refused like a malformed one. function verifyWebhook(rawBody, header, secret) { let t = null; const v1s = []; 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. 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)) { return res.status(401).send('Invalid signature') } // Respond quickly, then do the work. Delivery is at-least-once: skip an // X-PS-Event-Id you have already handled. res.status(200).send('OK') processOrderUpdate(JSON.parse(rawBody)).catch(console.error) }) ``` ## Invalid artwork submitted ### Cause A live order's artwork cannot be printed as it is: nothing is at the link (404 or 410), the link returns something that is not an image, or the file has too few pixels for the print size. ### API Response ``` { "status": "error", "error": { "code": "artwork_not_printable", "message": "The artwork on one line of this order cannot be printed as it is, so the order was not placed and nothing was charged. details.problems says what each one needs.", "doc_url": "https://portal.printedsimply.com/docs/errors#artwork_not_printable", "suggestion": "details.problems names each line by item_index with a code: unreachable (the artwork URL answered 404 or 410), not_an_image (it returned a page or other non-image bytes) or low_resolution (too few pixels for the print size; the message says how many it needs). Nothing was charged and the Idempotency-Key is free: fix the files and send the order again. A test key (ps_test_*) is never refused for artwork; its response carries the same verdict in artwork_check.would_refuse.", "details": { "problems": [ { "code": "low_resolution", "item_index": 0, "message": "items[0] (sticker-die-cut-3x3): 216×216 px is 72 DPI at 3×3 in, below the 225 DPI a die-cut sticker can be printed from (300 recommended). It needs at least 675 px on the long side; 900 prints at the recommended resolution." } ], "problem_count": 1, "artwork_check": { "lines": [ { "item_index": 0, "sku": "sticker-die-cut-3x3", "status": "refused", "width_px": 216, "height_px": 216, "findings": [ { "code": "low_resolution", "severity": "error", "message": "items[0] (sticker-die-cut-3x3): 216×216 px is 72 DPI at 3×3 in, below the 225 DPI a die-cut sticker can be printed from (300 recommended). It needs at least 675 px on the long side; 900 prints at the recommended resolution." } ], "print_size_in": [ 3, 3 ], "effective_dpi": 72, "min_dpi": 300 } ] } } }, "request_id": "b3f1c2de-9a47-4e0b-8f5d-2c6a1e7d9b40" } ``` ### What to Do - The order is NOT placed and nothing is charged; the Idempotency-Key is free to send again once the file is fixed - details.problems names each line by item_index, with a code and what the file needs - A test key is never refused for artwork: its response carries the same verdict in artwork_check, with would_refuse, so rehearse with a test key - Check a file against the product before ordering with POST /artwork/validate - Upload a file with POST /assets and send its id as print_file, or link the file itself (PNG, PDF or SVG) as artwork_url ### Code Example ``` // Check the file against the product before ordering const check = await api('POST', '/artwork/validate', { file_url: order.items[0].artwork_url, product_type: order.items[0].sku, width_px: 1200, height_px: 1200, }) if (check.data && !check.data.valid) { console.log('Fix first:', check.data.checks.filter((c) => c.status === 'fail')) } const placed = await api('POST', '/order', order, order.reference) if (placed.error?.code === 'artwork_not_printable') { for (const problem of placed.error.details.problems) { console.log(`items[${problem.item_index}] ${problem.code}: ${problem.message}`) } } ``` ## Cancellation refused by production ### Cause The order is past the cancel window (it has gone into production), or the fulfillment system refused to cancel a live order. ### API Response ``` { "status": "error", "error": { "code": "cancellation_failed", "message": "Could not cancel order in production system.", "doc_url": "https://portal.printedsimply.com/docs/errors#cancellation_failed", "suggestion": "The order may have already entered production. Contact support if this persists." }, "request_id": "b3f1c2de-9a47-4e0b-8f5d-2c6a1e7d9b40" } ``` ### What to Do - A live order is cancelled with the fulfillment system first and refunded only if it accepts. A 502 cancellation_failed means it refused: the order is still live and nothing was refunded - An order can be cancelled only before it goes into production: pending_payment, paid or accepted. From in_production on, the cancel answers 409 not_cancellable without asking; one already cancelled answers 400 already_cancelled - A test order follows the same window, with nothing to refund - Contact support at support@printedsimply.com for a live order the fulfillment system would not cancel - Subscribe to 'order.in_production' to know when the cancel window has closed ### Code Example ``` // orderId: the order_id POST /order returned const { data: current } = await api('GET', `/order/${orderId}`) // Only an order not yet in production can be cancelled if (!['pending_payment', 'paid', 'accepted'].includes(current.status)) { console.log(`Cannot cancel — order is ${current.status}`) } else { const cancelled = await api('DELETE', `/order/${orderId}/cancel`) if (cancelled.error?.code === 'cancellation_failed') { // Still live, nothing refunded: production would not stop it console.log('Fulfillment refused the cancel — contact support') } else if (cancelled.data) { console.log('Order cancelled; a live order is refunded') } } ``` ## When a placed order is flagged An order the facility cannot finish moves to `issue_flagged` and sends `order.issue`. When the facility's status says why, the order and the event carry `issue_reason`. It is null on the order, and absent from the event, when no reason below fits. The flag clears when the facility moves the order on. | issue_reason | What happened | What to do | | --- | --- | --- | | `artwork_unprintable` | The facility could not print the file the order sent: it would not open, or its format, size or resolution does not fit the product. | Check the file with POST /v1/artwork/validate. A flagged order takes no changes, so cancel it with DELETE /v1/order/{id}/cancel and place it again with a fixed file, or contact support with the order id. | | `address_undeliverable` | The carrier or the facility cannot deliver to the shipping address: it could not be verified, or the parcel came back. | Confirm the address with the buyer. A flagged order takes no address change, so cancel it and place it again with the corrected address, or contact support with the order id. | | `shipment_exception` | The parcel is in trouble in transit: a failed delivery, a carrier exception, or a lost or damaged package. | Watch for the next status: the flag clears when the facility moves the order on. If it does not, contact support with the order id and the tracking number. | | `on_hold` | The facility is holding the order and it is not moving. | Wait for the next status: the flag clears when the order moves on. Contact support with the order id if it stays on hold. | | `cancellation_pending` | A cancellation or refund was requested or attempted at the facility and has not finished, so the order is neither running nor cancelled. | Wait for order.cancelled, or for the order to move on if the cancellation was refused. Do not place a replacement until one of them arrives. | ### Rehearse it with a test key Simulate the flag on a test order and the order takes the same status and `issue_reason` a live one would, and `order.issue` is sent to your test-mode webhooks. Leave out `issue_reason` to rehearse a flag with none. The sandbox clock stops on a flag; simulate a later state (for example `shipped`) to move the order on, or cancel it. A live key is refused with `test_mode_only`. ``` curl -X POST https://portal.printedsimply.com/api/v1/order/test_ord_abc123/simulate \ -H "x-api-key: ps_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"target_state": "issue_flagged", "issue_reason": "artwork_unprintable"}' ``` ``` // order.issue: branch on why, and keep a default for a flag with no reason if (event.event === 'order.issue') { switch (event.data.issue_reason) { case 'artwork_unprintable': await askCustomerForNewArtwork(event.data.order_id) break case 'address_undeliverable': await askCustomerToConfirmAddress(event.data.order_id) break default: // shipment_exception, on_hold, cancellation_pending, or no issue_reason await flagForReview(event.data.order_id) } } ``` --- # Troubleshooting guide Source: https://portal.printedsimply.com/docs/troubleshooting Common issues and how to resolve them. If you still need help, contact us at developers@printedsimply.com. ## Authentication failures ### Symptoms - All requests return `401 Unauthorized` - Error code `missing_api_key` or `invalid_api_key` ### Diagnosis - Verify you are using a supported header: `x-api-key` or `Authorization: Bearer` (a bare `api-key` header is not read) - Check key format: test keys start with `ps_test_`, live keys with `ps_live_` - Confirm the key has not been revoked in your [dashboard](https://portal.printedsimply.com/dashboard/api-keys) - Ensure no trailing whitespace or newlines in the key value ### Resolution 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_..." ← WRONG ``` ## Rate limit issues ### Symptoms - Requests return `429 Too Many Requests` - Error code `rate_limit_exceeded` ### Diagnosis - Check the `X-RateLimit-Remaining` header on responses to monitor your usage - Rate limiting is enforced per account, across all keys — Free 60 requests per 60-second sliding window. Issuing more keys does not raise the budget - Use `X-RateLimit-Reset` to know when the window resets ### Resolution Implement 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"); } ``` ## Webhook delivery problems ### Symptoms - Webhook events are not arriving at your endpoint - Webhook delivery logs show failed attempts - Signature verification is failing ### Diagnosis - The endpoint URL must use HTTPS, or http:// to localhost or 127.0.0.1 outside production - Verify it is publicly reachable: delivery refuses any hostname that resolves to a loopback, private or link-local address - Your endpoint must respond with a 2xx status within 10 seconds (delivery makes up to 7 attempts over about 10 hours) - Check [webhook delivery logs](https://portal.printedsimply.com/dashboard/webhooks) for specific error details - Ensure you are reading the raw request body for signature verification (not parsed JSON) — in Express, register the webhook route before `express.json()` ### Resolution 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=,v1="), 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); }); ``` ## Artwork validation errors ### Symptoms - Artwork validation endpoint returns failures - Orders rejected due to artwork not meeting specifications ### Diagnosis - Minimum DPI depends on the product — see the table below; `POST /v1/artwork/validate` checks a file against it - Dimensions must match the product specifications - Send RGB (sRGB) artwork: a CMYK file prints, but previews and mockups show it converted to RGB, so it may not match what you approved on screen. - Files over 100 MB fail the size check, for every product ### Requirements by product | 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 | ### Resolution 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 }' ``` ## Order status questions ### Standard order flow `created` → `in_production` → `shipped` → `delivered` ### Webhooks vs polling - Use webhooks for real-time notifications. Register your endpoint in the [dashboard](https://portal.printedsimply.com/dashboard/webhooks) and listen for order.* events. - Use polling only as a fallback. Call `GET /api/v1/order/{id}` at reasonable intervals (no more than once per minute). - Use the `GET /api/v1/order/{id}/events` endpoint to see the full event timeline for an order. ### Cancellation window 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. ## SDK / CLI issues ### SDKs and the CLI are not yet published 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. ### In the meantime 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](https://portal.printedsimply.com/contact). Still stuck? [Error reference](https://portal.printedsimply.com/docs/errors) · [Full API docs](https://portal.printedsimply.com/docs) · Email us at developers@printedsimply.com --- # API error reference Source: https://portal.printedsimply.com/docs/errors Every error code the PrintedSimply API can return, with its HTTP status and an actionable fix. Errors share a structured body — `code`, `message`, and a `request_id`. An error's `doc_url` links straight to its entry below (`not_available`'s, to the API reference). ## Authentication & authorization [`missing_api_key`](https://portal.printedsimply.com/docs/errors#missing_api_key) 401 API key is required. Pass it in the x-api-key header or as an Authorization: Bearer credential. Suggestion: Include your API key in the x-api-key header, or send it as `Authorization: Bearer `. Get one at printedsimply.com/dashboard/api-keys. [`invalid_api_key`](https://portal.printedsimply.com/docs/errors#invalid_api_key) 401 The API key provided is invalid or has been revoked. Suggestion: Check that you're using the correct key. Test keys start with ps_test_, live keys with ps_live_ (keys created before the PrintedSimply rename start with mf_test_ or mf_live_ and keep working). If you sent the docs' placeholder (ps_test_YOUR_KEY), replace it with your own key from printedsimply.com/dashboard/api-keys. [`key_expired`](https://portal.printedsimply.com/docs/errors#key_expired) 401 This API key has expired. Suggestion: Generate a new key at printedsimply.com/dashboard/api-keys and update your integration. [`insufficient_scope`](https://portal.printedsimply.com/docs/errors#insufficient_scope) 403 The API key does not have the scope required for this endpoint. Suggestion: Create a key with the required scope, or use a key with full access (no explicit scopes). [`ip_not_allowed`](https://portal.printedsimply.com/docs/errors#ip_not_allowed) 403 This API key is restricted to specific IP addresses. Suggestion: Call from an allowed IP, or update the key's IP allowlist in the dashboard. [`sandbox_only`](https://portal.printedsimply.com/docs/errors#sandbox_only) 403 This operation is only available with a sandbox test key. Suggestion: Use a ps_test_ key for this endpoint. [`test_mode_only`](https://portal.printedsimply.com/docs/errors#test_mode_only) 400 This operation only applies to test-mode resources. Suggestion: Sandbox-only helpers (like order simulation) work on orders created with a test key. [`live_key_required`](https://portal.printedsimply.com/docs/errors#live_key_required) 403 This change reaches live traffic, which a test key cannot. Suggestion: A test key subscribes only to test traffic and manages only test-mode subscriptions. Use mode "test", or make the change with a live key (ps_live_*). [`auth_unavailable`](https://portal.printedsimply.com/docs/errors#auth_unavailable) 503 Your credentials could not be verified right now. Suggestion: Nothing is wrong with your API key — the credential store could not be read. Retry after the number of seconds in the Retry-After header; if it persists, quote the request_id to support. ## Rate limits [`rate_limit_exceeded`](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) 429 Rate limit exceeded. Try again later. Suggestion: The limit is 60 requests per 60 seconds. Check the X-RateLimit-Reset header for when you can retry. [`daily_quota_exceeded`](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded) 429 Daily request quota exceeded for your plan. Suggestion: You've reached your plan's daily request limit. It resets at 00:00 UTC. Retrying before then will not clear it. ## Validation [`validation_error`](https://portal.printedsimply.com/docs/errors#validation_error) 400 Request body failed validation. Suggestion: Check the details field for specific validation failures. Refer to the endpoint reference for required fields. [`invalid_json`](https://portal.printedsimply.com/docs/errors#invalid_json) 400 The request body is not valid JSON. Suggestion: Send a JSON body with Content-Type: application/json and check for syntax errors. [`cursor_source_changed`](https://portal.printedsimply.com/docs/errors#cursor_source_changed) 409 The catalog this cursor walks is now served from another source. Suggestion: A GET /v1/products cursor names a place in the catalog as it was served when the cursor was returned: live, from the last-good snapshot, from the local synced copy, or from our price book (`details.issued_under`). This page is served from another (`details.source`, with `X-Catalog-Stale` and `X-Catalog-Degraded` saying why), which is another list, so continuing would skip or repeat rows. Leave `cursor` out and walk the catalog again from the first page. `details.issued_under` is null for a cursor returned before cursors named their source; it is refused the same way. Retrying the same cursor succeeds only once the catalog is served from the source it names again. [`payload_too_large`](https://portal.printedsimply.com/docs/errors#payload_too_large) 413 Request body exceeds the size limit for this endpoint. Suggestion: Most endpoints accept up to 256 KB. Asset uploads accept up to 70 MB of base64 in JSON; bulk orders have their own limit. Send artwork through POST /v1/assets and reference it by id rather than inlining it elsewhere. [`not_found`](https://portal.printedsimply.com/docs/errors#not_found) 404 The requested resource was not found. Suggestion: Check the ID in the path. A resource another account owns answers the same 404 as one that does not exist. For a product, GET /v1/products lists every SKU you can order. [`method_not_allowed`](https://portal.printedsimply.com/docs/errors#method_not_allowed) 405 This path does not take the request's method. Suggestion: The Allow header, and details.allow, list the methods this path takes; check the method against the API reference. Every path that takes GET also answers HEAD, and every path answers OPTIONS. [`method_not_implemented`](https://portal.printedsimply.com/docs/errors#method_not_implemented) 501 This API does not implement the request's method on any path. Suggestion: Send GET, HEAD, OPTIONS, POST, PUT, PATCH or DELETE: no other method is implemented anywhere. Where the path exists, the Allow header and details.allow list the methods it takes. ## Orders [`order_not_found`](https://portal.printedsimply.com/docs/errors#order_not_found) 404 Order not found. Suggestion: Check the order ID and ensure you're using the correct API key mode (test vs live). [`already_cancelled`](https://portal.printedsimply.com/docs/errors#already_cancelled) 400 Order has already been cancelled. Suggestion: This order was previously cancelled. Use GET /order/{id} to check current status. [`cancellation_failed`](https://portal.printedsimply.com/docs/errors#cancellation_failed) 502 Could not cancel order in production system. Suggestion: The order may have already entered production. Contact support if this persists. [`not_cancellable`](https://portal.printedsimply.com/docs/errors#not_cancellable) 409 The order can no longer be cancelled. Suggestion: An order can be cancelled only before it goes into production (pending_payment, paid or accepted). Once production has started it is made and shipped as placed, and nothing is refunded by this call; for anything else about the order, contact support at /contact. [`order_not_modifiable`](https://portal.printedsimply.com/docs/errors#order_not_modifiable) 409 The order can no longer be modified. Suggestion: Address, shipping and line changes are taken only before the order goes into production (pending_payment, paid or accepted). Past that the order is made and shipped as placed; `actions` on GET /v1/order/{id} says what an order still takes, and support (/contact) answers anything else. [`duplicate_reference`](https://portal.printedsimply.com/docs/errors#duplicate_reference) 409 An order with this reference was already placed. Nothing was charged. Suggestion: details.order_id names the order that already carries this reference (placed in the last 30 days and not cancelled, with a key of the same mode): read it with GET /v1/order/{id} instead of placing it again. details.in_flight means another request with this reference is being placed right now; wait a moment and look it up with GET /v1/orders?reference=. Cancelling an order frees its reference. To place a second order under the same reference on purpose, send allow_duplicate_reference: true. [`reference_check_unavailable`](https://portal.printedsimply.com/docs/errors#reference_check_unavailable) 503 Whether this order's reference is already in use could not be checked, so the order was not placed. Nothing was charged. Suggestion: Retry the same request after the number of seconds in the Retry-After header; the Idempotency-Key is free, because nothing was charged or placed. If it persists, quote the request_id to support. [`address_invalid`](https://portal.printedsimply.com/docs/errors#address_invalid) 422 The shipping address cannot be delivered as written. Nothing was charged. Suggestion: details.findings names each problem with its field: zip_format (send five digits, or ZIP+4 as 12345-6789), state_unknown (send a USPS state, territory or military code such as TX, PR or AE), zip_state_mismatch (the ZIP belongs to a state that does not border the one named, so one of the two is wrong), or delivery_point_not_found (USPS has no delivery point at the street address: check the house number and street; a live order only, where USPS verification is configured). details.address_check.normalized is the address in USPS style. Check an address before ordering with POST /v1/addresses/validate. Nothing was charged and the Idempotency-Key is free for the corrected order. [`shopper_paid_order`](https://portal.printedsimply.com/docs/errors#shopper_paid_order) 409 A shopper paid for this order, so it takes no charge on your card. Suggestion: A shopper paid for this order, so its refunds go back to the shopper. Place a separate order for anything you want to add. [`letter_report_refused`](https://portal.printedsimply.com/docs/errors#letter_report_refused) 409 This order cannot be reported as a missing letter. Suggestion: details.reason says why: not_a_letter (it shipped as a parcel; use its tracking), not_shipped (it has not left the plant), not_yet_due (wait until details.expected_by), already_reshipped (one free replacement per letter; details.replacement_order_id names it) or is_a_reship (a replacement is not reshipped again; contact support). [`order_error`](https://portal.printedsimply.com/docs/errors#order_error) 500 The order could not be processed. Suggestion: Retry the request; if the problem persists, contact support with the request id. [`settlement_recording_failed`](https://portal.printedsimply.com/docs/errors#settlement_recording_failed) 500 The payment was processed but the order record could not be updated. This request will not be retried automatically. Suggestion: The money movement already completed — do NOT retry with a new Idempotency-Key, which would charge or refund a second time. Fetch the order to see its current state and contact support with the request id if it looks wrong. [`invalid_state`](https://portal.printedsimply.com/docs/errors#invalid_state) 409 The resource is not in a state that allows this operation. Suggestion: Fetch the resource to confirm its current state before retrying. [`already_terminal`](https://portal.printedsimply.com/docs/errors#already_terminal) 400 The resource is already in a terminal state. Suggestion: No further transitions are possible for this resource. [`idempotency_conflict`](https://portal.printedsimply.com/docs/errors#idempotency_conflict) 409 Another request with this Idempotency-Key is still in flight. Suggestion: Wait for the original request to finish, then retry with the same key to get its result. [`idempotency_key_reuse`](https://portal.printedsimply.com/docs/errors#idempotency_key_reuse) 422 This Idempotency-Key was already used for a request with a different payload. Suggestion: An Idempotency-Key identifies one specific request. Reuse it only to retry that exact payload; generate a new key for a new request. [`idempotency_unavailable`](https://portal.printedsimply.com/docs/errors#idempotency_unavailable) 503 The Idempotency-Key could not be checked right now, so the request was not run. Nothing was charged. Suggestion: Retry the same request, with the same Idempotency-Key, after the number of seconds in the Retry-After header — nothing ran, so the retry cannot duplicate anything. If it persists, quote the request_id to support. [`concurrent_modification`](https://portal.printedsimply.com/docs/errors#concurrent_modification) 409 Another request is already modifying this resource. Suggestion: A competing write is in flight for the same resource. Retry shortly — this is a lock conflict, not a rejection, and the retry usually succeeds. [`variable_data_invalid`](https://portal.printedsimply.com/docs/errors#variable_data_invalid) 422 Variable data on one or more lines failed the preflight. Suggestion: details.problems names each problem by item, unit and slot, with a code: not_own_plant (the SKU is not flat goods made at our own plant, or the order names another provider), count_mismatch (units needs exactly one entry per unit of quantity), unknown_slot, slot_conflict, missing_value, overflow (a value longer than its slot's max_length, or a QR payload over its ceiling) or too_many_units. Nothing was priced or charged; fix the lines and send the request again. [`variable_data_unavailable`](https://portal.printedsimply.com/docs/errors#variable_data_unavailable) 422 Variable data is not yet available for production. Suggestion: Live orders and quotes carrying items[].variable_data are refused, before anything is charged, until production is switched on. A test key (ps_test_*) accepts them end to end (quote, order, variant proofs and the sandbox lifecycle), so integrate there now. [`artwork_not_printable`](https://portal.printedsimply.com/docs/errors#artwork_not_printable) 422 The artwork on one or more own-plant lines cannot be printed as it is. Suggestion: details.problems names each line by item_index with a code: unreachable (the artwork URL answered 404 or 410), not_an_image (it returned a page or other non-image bytes) or low_resolution (too few pixels for the print size; the message says how many it needs). Nothing was charged and the Idempotency-Key is free: fix the files and send the order again. A test key (ps_test_*) is never refused for artwork; its response carries the same verdict in artwork_check.would_refuse. [`artwork_required`](https://portal.printedsimply.com/docs/errors#artwork_required) 422 One or more lines our own plant makes carry no artwork. Suggestion: details.items names each line by item_index and sku (and order_index on POST /v1/orders/bulk). Send the line's file as artwork_url, or as print_file: a URL, or an asset id from POST /v1/assets. Nothing was charged and the Idempotency-Key is free. A test key (ps_test_*) is never refused for it; its response lists the lines in artwork_check.missing_artwork and says artwork_check.would_refuse. ## Items [`item_not_found`](https://portal.printedsimply.com/docs/errors#item_not_found) 404 Item not found. Suggestion: Check the item ID. Use GET /order/{id} to see all items in the order. ## Payment [`spend_cap_exceeded`](https://portal.printedsimply.com/docs/errors#spend_cap_exceeded) 402 This order exceeds a spend cap set on the API key. Suggestion: The API key has a per-order or daily spend cap. Raise the cap in the dashboard, or use a different key. A cap bounds what one key can spend, whoever holds it. [`no_payment_method`](https://portal.printedsimply.com/docs/errors#no_payment_method) 402 No payment method on file. Add one at printedsimply.com/dashboard/billing. Suggestion: Add a credit card at printedsimply.com/dashboard/billing before placing live orders. [`payment_failed`](https://portal.printedsimply.com/docs/errors#payment_failed) 402 Card was declined. Suggestion: Update your payment method at printedsimply.com/dashboard/billing. [`authentication_required`](https://portal.printedsimply.com/docs/errors#authentication_required) 402 This payment requires 3DS authentication. Suggestion: The card's bank wants the cardholder to approve this payment. On POST /order, details carries payment_intent and client_secret: approve it with Stripe.js handleNextAction, then send the same order again with payment_intent and a new Idempotency-Key. A caller with no browser (an MCP client, a server) hands the order to the person instead: they place the same order on the dashboard's order page, /dashboard/order, which runs the bank's check in their browser. Nothing is charged until that order is placed. [`refund_failed`](https://portal.printedsimply.com/docs/errors#refund_failed) 500 Could not process refund. Suggestion: The refund could not be processed automatically. Contact support with your order ID and request ID. [`fraud_review`](https://portal.printedsimply.com/docs/errors#fraud_review) 402 The order was held for fraud review. Suggestion: Our team reviews held orders quickly. Contact support to expedite. ## Pricing & quotes [`pricing_error`](https://portal.printedsimply.com/docs/errors#pricing_error) 400 Could not calculate price. Check SKUs are valid. Suggestion: Verify that the SKUs exist by calling GET /products. Common SKUs: STICKER-DIE-CUT-3X3, STICKER-KISS-CUT-3X3. [`mixed_facilities`](https://portal.printedsimply.com/docs/errors#mixed_facilities) 400 These items require different production facilities. Suggestion: Split the cart into one order per production method and place them separately. [`invalid_quote`](https://portal.printedsimply.com/docs/errors#invalid_quote) 400 The quote token is invalid or has expired. Suggestion: Request a fresh quote and place the order before it expires. [`quote_mismatch`](https://portal.printedsimply.com/docs/errors#quote_mismatch) 400 The order does not match the quoted items or destination. Suggestion: Place the order with exactly the items and address that were quoted, or get a new quote. [`unsupported_currency`](https://portal.printedsimply.com/docs/errors#unsupported_currency) 400 We do not hold an exchange rate for that presentment currency. Suggestion: Quote in one of the supported currencies listed in the error details, or omit `currency` to settle in USD. [`letter_mail_unavailable`](https://portal.printedsimply.com/docs/errors#letter_mail_unavailable) 400 This order cannot ship as an untracked letter. Suggestion: details.reason says why: not_offered (letter mail is not a service on this platform), a rule the items broke (only stickers and small vinyl decals from our own plant, a few units, under the value ceiling), or destination_not_us (our own plant ships to US addresses only). Choose a parcel method on offer. [`shipping_method_unavailable`](https://portal.printedsimply.com/docs/errors#shipping_method_unavailable) 400 This shipping method is not offered on this platform. Suggestion: details.offered lists the methods you can choose (standard always; priority and express once they are priced). POST /v1/shipping/estimate lists them for your items. [`destination_unavailable`](https://portal.printedsimply.com/docs/errors#destination_unavailable) 422 Our own facilities do not ship to this destination. Suggestion: details.reason is destination_not_us and details.country names the destination. Our own plant ships to US addresses only, so send a US address. An order already placed cannot be re-pointed abroad (PUT /v1/order/{id}/address). [`below_cost_floor`](https://portal.printedsimply.com/docs/errors#below_cost_floor) 422 This order would cost more to make and ship than it is charged. Suggestion: Nothing was charged. details.skus names the items and details.contribution_cents how far below zero the order falls at worst-case postage. Add units, combine it with another order, or choose another shipping method on offer. [`unknown_sku`](https://portal.printedsimply.com/docs/errors#unknown_sku) 400 One or more SKUs are not in the catalog. Suggestion: Check each SKU against GET /v1/products/{sku}: only a product the catalog can make can be ordered, quoted or sold. `details.skus` names the ones it does not have. ## System [`production_reconciliation_required`](https://portal.printedsimply.com/docs/errors#production_reconciliation_required) 409 Production submission needs reconciliation. Suggestion: Do not retry with a new key or refund the payment. Contact support with the request ID; production may already exist. [`fulfillment_error`](https://portal.printedsimply.com/docs/errors#fulfillment_error) 502 Could not complete operation in the production system. Suggestion: This is a temporary issue with the production system. Retry with an Idempotency-Key header. Contact support if it persists. [`provider_unavailable`](https://portal.printedsimply.com/docs/errors#provider_unavailable) 503 The chosen production facility is not accepting orders right now. Suggestion: Every order here is made at our own plant, so there is no other facility to send it to, and naming a `provider` will not change that. Nothing stands charged. Retry in a few minutes. A 503 sent before anything was charged frees the Idempotency-Key, so retry with the same one; a 503 sent after a charge was refunded says so in its message and needs a new key. If it persists, quote the request_id to support. [`upstream_error`](https://portal.printedsimply.com/docs/errors#upstream_error) 502 Could not fetch data from upstream service. Suggestion: This is usually a temporary issue: retry the request shortly. If the X-Catalog-Degraded header reads not_configured, the catalog is not set up on this deployment and a retry will not help; quote the request_id to support. [`stripe_unconfigured`](https://portal.printedsimply.com/docs/errors#stripe_unconfigured) 503 Payments are not configured on this deployment. Suggestion: The Stripe signing secret is set but the Stripe secret key is not, so this event's handler cannot reach Stripe. Set STRIPE_SECRET_KEY; the event redelivers on Stripe's own retry schedule and is processed then. [`internal_error`](https://portal.printedsimply.com/docs/errors#internal_error) 500 The request could not be completed because of an unexpected error on our side. Suggestion: This is our fault, not the request's — the same request may well succeed on retry. If it does not, quote the `request_id` from this response to support; it identifies the captured error. ## Network [`not_supported_by_provider`](https://portal.printedsimply.com/docs/errors#not_supported_by_provider) 501 The shop that produced this order does not support this operation. Suggestion: This operation is unavailable at the producing facility. Contact support to confirm the existing production order before retrying an amendment or replacing it. ## Availability [`not_available`](https://portal.printedsimply.com/docs/errors#not_available) 404 This operation is not available on this portal. Suggestion: The API reference lists every operation this portal serves: orders, quotes, products, pricing and shipping estimates, artwork validation, asset uploads, webhooks and events. [`sku_not_available`](https://portal.printedsimply.com/docs/errors#sku_not_available) 422 This product isn't available from PrintedSimply. Suggestion: details.skus names each one, and details.did_you_mean the products it most likely meant, when one is close. Only products our own plant makes can be ordered here — stickers, decals, magnets and posters; GET /v1/products lists them. Nothing was priced or charged; remove or replace those lines and send the request again. [`provider_not_available`](https://portal.printedsimply.com/docs/errors#provider_not_available) 422 Orders here are made by PrintedSimply. Suggestion: Omit `provider`, or send "printedsimply". Nothing was priced or charged. [`event_not_available`](https://portal.printedsimply.com/docs/errors#event_not_available) 400 This webhook event isn't raised on PrintedSimply. Suggestion: details.events names each one, and details.available lists every event a subscription can name here — an order's lifecycle, the catalog's and your endpoints' own — with "*" for all of them. Nothing was saved; drop those names and send the request again. Need help debugging? [Troubleshooting guide](https://portal.printedsimply.com/docs/troubleshooting) · [Full API docs](https://portal.printedsimply.com/docs) ## Problem details Every error on this page comes back in the envelope by default. Send `Accept: application/problem+json` and the same error comes back as an RFC 9457 problem document, with `Content-Type: application/problem+json`: the shape HTTP clients and API tooling read without knowing ours. Request: ```bash curl https://portal.printedsimply.com/api/v1/order/ord_missing \ -H "x-api-key: ps_test_YOUR_KEY" \ -H "Accept: application/problem+json" ``` Response: ```json HTTP/1.1 404 Not Found Content-Type: application/problem+json Vary: Accept X-Request-Id: 550e8400-e29b-41d4-a716-446655440000 { "type": "https://portal.printedsimply.com/docs/errors#order_not_found", "title": "Order not found", "status": 404, "detail": "Order not found.", "instance": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", "code": "order_not_found", "request_id": "550e8400-e29b-41d4-a716-446655440000", "doc_url": "https://portal.printedsimply.com/docs/errors#order_not_found", "suggestion": "Check the order ID and ensure you're using the correct API key mode (test vs live)." } ``` | Member | What it carries | | --- | --- | | `type` | Where the code is documented, as an absolute URI: its entry on this page, the same link as `doc_url`. | | `title` | The code's short title, the same on every occurrence: “Order not found” for `order_not_found`. | | `status` | The HTTP status, always the response's own. | | `detail` | What went wrong this time: the envelope's message. | | `instance` | This occurrence, as `urn:uuid:` and the request id: the id `X-Request-Id` and `request_id` carry. An idempotent replay is the original answer, so it repeats the original's `request_id` and `instance` under a new `X-Request-Id`. | | `code, request_id, doc_url, suggestion, details` | The envelope's own members, unchanged, as extension members. Keep branching on code. | ### Which one you get - Problem details when `Accept` names `application/problem+json` with a higher weight than `application/json`: `application/problem+json` alone, `application/problem+json, application/json;q=0.9`, or `application/problem+json, */*`. - The envelope, byte for byte, with no `Accept`, or with `*/*`, `application/*` or `application/json` — and when `application/json` and `application/problem+json` are listed at the same weight. - A success never changes. Every response answered in the envelope says `Vary: Accept`, its headers (`X-Request-Id`, the rate-limit fields, `Retry-After`) are the same either way, and an idempotent replay of a stored error is converted like the original. ### Unknown paths and methods A path no operation is at answers `404` [`not_found`](https://portal.printedsimply.com/docs/errors#not_found), and a method a path does not take answers `405` [`method_not_allowed`](https://portal.printedsimply.com/docs/errors#method_not_allowed) with an `Allow` header that names the methods it takes (and `details.allow`), both with a request id, in either shape. A method no path takes at all, such as `PROPFIND`, answers `501` [`method_not_implemented`](https://portal.printedsimply.com/docs/errors#method_not_implemented) on any path. Every path that takes `GET` answers `HEAD` too, and every path answers `OPTIONS`. --- # Rate limits Source: https://portal.printedsimply.com/docs/rate-limits The PrintedSimply API enforces per-plan rate limits to keep the platform fast and fair. Learn the tiers, the response headers, and how to back off gracefully. ## Overview Rate limits are applied per account on a sliding window basis. When you exceed your limit, the API responds with `429 Too Many Requests` until the window resets. Every response includes headers so you can monitor your usage in real time. ## Rate limit tiers | Plan | Requests / minute | Daily quota | Notes | | --- | --- | --- | --- | | Free | 60 | 10,000 | Free — default for all new API keys | | Premium | 300 | 200,000 | Not offered on PrintedSimply — existing accounts only | | Growth | 120 | 50,000 | Legacy — existing accounts only | | Scale | 300 | 200,000 | Legacy — existing accounts only | | Enterprise | 600 | 1,000,000 | Legacy — existing accounts only | Both the per-minute limit and the daily quota are enforced per account, shared across all of your API keys — creating more keys does not multiply your budget. See [pricing](https://portal.printedsimply.com/pricing) for full plan details. Every response states your own limit in `X-RateLimit-Limit`. ## What the Free plan includes The Free plan is the default for every new account, and these are its complete limits — there is no separate trial, no expiring credit, and nothing on this list changes after a first order. | Limit | Free plan | Notes | | --- | --- | --- | | Requests per minute | 60 | Per account, shared across every key | | Requests per day | 10,000 | Per account, resets daily | | Monthly request quota | None | The daily quota is the only ceiling | | Overage | $0 | Free never bills for API traffic | | Sandbox orders | Unlimited | $0 each, full lifecycle, real webhooks | | API keys | 20 | Concurrent keys per account | There is no monthly request quota and no overage billing on Free. Exceeding the per-minute or daily limit returns 429 with the reset timestamp in the response headers — never a charge. Sandbox orders are not capped or counted separately. A test key places as many $0 orders as your request quota allows, each running the full time-compressed lifecycle with real signed webhooks. ## Response headers Every API response the limiter counted includes the following rate-limit headers (a 401 for a missing or unknown key carries none: there is no account whose window they could describe): | Header | Description | | --- | --- | | `X-RateLimit-Limit` | The maximum number of requests allowed in the current window. | | `X-RateLimit-Remaining` | The number of requests remaining in the current window. | | `X-RateLimit-Reset` | Unix timestamp in **milliseconds** when the current window resets. Wait until this time before retrying — the delay is `reset - Date.now()`. | ``` HTTP/1.1 200 OK X-RateLimit-Limit: 60 X-RateLimit-Remaining: 42 X-RateLimit-Reset: 1710523200000 ``` ## RateLimit and RateLimit-Policy The same window is also sent in the IETF HTTP API working group's RateLimit fields, which a generic HTTP client parses without knowing our units. `RateLimit-Policy` names each window with its quota (`q`) and length in seconds (`w`); `RateLimit` says what is left (`r`) and how many seconds until it comes back (`t`) — a delta, so there is no clock to skew and no unit to guess. ``` HTTP/1.1 200 OK RateLimit-Policy: "minute";q=60;w=60 RateLimit: "minute";r=42;t=18 ``` A `daily_quota_exceeded` 429 adds the day, because the day is the window that refused: `RateLimit: "minute";r=12;t=30, "day";r=0;t=3600`. To back off, wait the largest `t` among the items whose `r` is 0, and never less than `Retry-After` when the response carries one (an `ai_daily_limit` 429 waits for the day's AI allowance, not the minute); both SDKs do exactly that. A `rate_limit_exceeded` or `daily_quota_exceeded` 429 is in your request log (Dashboard → Logs, filter by its error code), like every refusal answered before your request runs: none of those is billed or counted as a request in your usage. An `ai_daily_limit` 429 is the operation's own answer and counts like one. In a storm the log keeps a bounded sample — up to 120 refusals a minute for each account on each of our servers — and the rest are answered but not written. ## Handling 429 responses When you exceed the rate limit, the API responds with: ``` HTTP/1.1 429 Too Many Requests Retry-After: 45 X-RateLimit-Limit: 60 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1710523260000 RateLimit-Policy: "minute";q=60;w=60 RateLimit: "minute";r=0;t=45 Content-Type: application/json { "status": "error", "error": { "code": "rate_limit_exceeded", "message": "Rate limit exceeded. Try again later." }, "request_id": "b3f1c2de-9a47-4e0b-8f5d-2c6a1e7d9b40" } ``` - Read the `X-RateLimit-Reset` header to know exactly when to retry - Use exponential backoff with jitter as a fallback strategy - Never retry in a tight loop without a delay ## Exponential backoff Implement a retry strategy that respects the `X-RateLimit-Reset` header when available, and falls back to exponential backoff with jitter: JavaScript: ```javascript async function fetchWithRetry(url, options, maxRetries = 5) { for (let attempt = 0; attempt <= maxRetries; attempt++) { const response = await fetch(url, options); if (response.status !== 429) return response; const resetAt = response.headers.get("X-RateLimit-Reset"); // X-RateLimit-Reset is a Unix timestamp in milliseconds. const waitMs = resetAt ? Math.max(0, Number(resetAt) - Date.now()) : Math.pow(2, attempt) * 1000 + Math.random() * 1000; console.log(`Rate limited. Retrying in ${Math.round(waitMs / 1000)}s...`); await new Promise((r) => setTimeout(r, waitMs)); } throw new Error("Max retries exceeded"); } ``` Python: ```python import time import random import requests def fetch_with_retry(url: str, headers: dict, max_retries: int = 5): for attempt in range(max_retries + 1): response = requests.get(url, headers=headers) if response.status_code != 429: return response reset_at = response.headers.get("X-RateLimit-Reset") if reset_at: # X-RateLimit-Reset is a Unix timestamp in milliseconds. wait = max(0, float(reset_at) / 1000 - time.time()) else: wait = (2 ** attempt) + random.random() print(f"Rate limited. Retrying in {wait:.1f}s...") time.sleep(wait) raise Exception("Max retries exceeded") ``` ## Best practices - Monitor headers proactively — check `X-RateLimit-Remaining` on every response and throttle before hitting the limit. - Cache responses — avoid redundant API calls by caching product catalogs, shipping rates, and other slowly-changing data. - Use webhooks — subscribe to [webhook events](https://portal.printedsimply.com/docs/webhooks) instead of polling for order status updates. - Batch where possible — group related operations to minimize the number of requests. - Spread traffic — avoid sending many requests simultaneously; stagger them over time. [API reference](https://portal.printedsimply.com/docs/api-reference) · [Troubleshooting](https://portal.printedsimply.com/docs/troubleshooting) · Email us at developers@printedsimply.com --- # Reliability contract Source: https://portal.printedsimply.com/docs/reliability The behavior you can build on. Every guarantee below is enforced in code today — not a roadmap item — and each one names the exact status code, header, or error you will see when it fires. ## What this is Most API docs describe what an endpoint returns when everything works. Integrations break on everything else: the retry after a timeout, the page that shifts while you read it, the webhook that arrives twice, the secret you rotate at 3am, the 429 with a useless `Retry-After`. This page states those semantics as promises. If any of them is ever untrue, that is a bug we will fix — not a documentation nuance. The guarantees hold for every `/api/v1` endpoint. ## Idempotency is bound to the payload Send an `Idempotency-Key` header on any mutating call. The slot is claimed in Postgres **before** your handler runs, so a network timeout followed by a retry can never produce two orders. An exact retry replays the original response Same key, same payload → the stored response, with its **original status code**, plus `X-Idempotent-Replayed: true`. Stored responses replay for **24 hours**. Reusing a key for a different request is refused, not silently dropped Same key, different payload → `422 idempotency_key_reuse`. Answering with the first request's response would silently discard the second — an order the merchant believes was placed and that never existed. We would rather fail loudly. The payload fingerprint is canonicalized first: object key order does not matter (your SDK is free to re-serialize a retry), array order does. Two concurrent requests with one key: exactly one runs The loser gets `409 idempotency_conflict` with `Retry-After: 1`. Retry and you get the winner's response. A crashed request never wedges your key, unless it may have charged An in-flight claim expires after **15 minutes**, so a handler that died mid-flight cannot block a legitimate retry for the rest of the day. On a money operation the claim holds for the key's full **24 hours** instead. A request that ended without its answer being recorded may already have charged, and running it again under the same key could charge twice, so a same-key retry keeps getting `409 idempotency_conflict`. Check `GET /v1/orders` before placing it again with a new key. Errors that moved no money release the key Validation failures and declined cards free the slot: fix the request and retry with the _same_ key. An outcome that did touch money and then reversed it (charged, then refunded) is **sealed** — it replays as-is, and placing the order for real requires a **new** key. A money operation never runs unchecked On an operation that charges or refunds — an order, a batch, an item, a shipping or address change, a cancellation — a key the ledger cannot check is answered `503 idempotency_unavailable` with a `Retry-After`, and nothing runs or is charged. Retry with the _same_ key. ``` # One key per order, made once: every retry of that order sends it again. KEY="order-$(date +%s)" BODY='{ "items": [ { "sku": "sticker-die-cut-3x3", "quantity": 100, "artwork_url": "https://printedsimply.com/samples/sticker-3x3.png" } ], "address_to": { "name": "Jane Doe", "address1": "123 Main St", "city": "Austin", "state": "TX", "zip": "78701", "country": "US" }, "shipping": { "method": "standard" } }' # First call — creates the order. curl -i -X POST https://portal.printedsimply.com/api/v1/order \ -H "x-api-key: ps_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $KEY" \ -d "$BODY" # HTTP 200 # Exact retry: same key, same body — replays the stored response. No second order. curl -i -X POST https://portal.printedsimply.com/api/v1/order \ -H "x-api-key: ps_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $KEY" \ -d "$BODY" # HTTP 200 X-Idempotent-Replayed: true # Same key, different body — refused instead of silently dropped. # 422 {"error":{"code":"idempotency_key_reuse"}} # Same key, original still running — retry shortly. # 409 {"error":{"code":"idempotency_conflict"}} Retry-After: 1 ``` **The SDKs key every write for you.** The official TypeScript and Python clients generate an `Idempotency-Key` per call and reuse it across that call's own retries, so a 502 or a timeout after we committed replays the stored response instead of placing a second order. Send your own key when you want a write to stay replayable across process restarts too. **Where idempotency fails open.** On an operation that moves no money, if the idempotency ledger itself is unreachable we run your request rather than reject it — without the ledger we cannot coordinate, and refusing every write during a database blip is the worse outcome. During such an incident, deduplication there is best-effort. A money operation fails closed instead: `503 idempotency_unavailable`, and nothing runs or is charged (above). ## Pagination that does not skip rows These lists — orders, an order's events, products, assets, the event log, webhooks, and webhook deliveries — page one way: **keyset** pagination by an opaque cursor. You send `limit` and `cursor`; every answer's `meta` carries `has_more` and `next_cursor`. Ask without a cursor, then send each `next_cursor` back until `has_more` is false, sending the same query parameters each time (only `limit` may change). `limit` is 20 by default and 100 at most, except on the first page of the two lists below that keep their page walk's size. Consecutive pages never overlap and never skip The cursor holds the position of the **last row you were actually given** — its timestamp and row id — and the next page starts one row past it. Rows created while you page do not shift the ones you already read — the failure mode of `OFFSET` paging, where a single insert makes you miss a record entirely. This holds for rows written in the same instant, too. Every keyset list is ordered by its timestamp _and then by row id_, so a batch write — 50 orders from one `/orders/bulk` call — cannot leave a page boundary sitting inside a group of rows whose relative order is undefined. The product catalog is not a table: its cursor names the last row's place in the catalog's own order, its SKU and the SKU of the row after it, so a product added or removed elsewhere in the catalog moves nothing you have read, and if the last row you were given leaves, the walk resumes at the row that followed it. Only if both leave between two requests does it fall back to the old place. has_more is false on the final page — and only there We fetch one row beyond your page to decide it — never from a page that came back short — and `next_cursor` is null exactly when it is false. Loop on it; do not guess from the item count. The row that said there was more can be deleted before you ask for it (a webhook or an asset you remove mid-walk), so a page after `has_more: true` can come back empty; the walk still ends at the first `has_more: false`. A cursor works only where it was given A cursor is bound to the list that returned it, to your account, to the key's mode (test or live) and to the filters it was returned under — and an order's events cursor to that order. One that was altered, that came from another list, account or mode, or that is sent with other filters (a `status` dropped, a `search` changed), is refused with `400 validation_error` and a `details.reason`, never answered with someone else's page or a wrong one. Pass it back exactly as you received it. A bad limit is clamped, never fatal Above 100 it clamps to 100; `abc`, `Infinity`, `0` and an empty cursor fall back to the default, `-5` to 1, and `2.7` to 2. No 500s from a malformed query string. ``` curl "https://portal.printedsimply.com/api/v1/orders?limit=50" \ -H "x-api-key: ps_test_YOUR_KEY" { "data": [ ... 50 orders ... ], "meta": { "has_more": true, "next_cursor": "NEXT_CURSOR", "limit": 50, "total": 180 } } # Pass next_cursor straight back, unchanged. Repeat until has_more is false. curl "https://portal.printedsimply.com/api/v1/orders?limit=50&cursor=NEXT_CURSOR" \ -H "x-api-key: ps_test_YOUR_KEY" ``` `/orders`, `/assets`, `/order/{id}/events` and `/products` still accept the `page` they took before, deprecated: each is answered as it was, with its own page size, and a request to one of them without a cursor is that walk's page 1 at that size, so the `limit` and `pages` it reports are the ones `page` uses. For an order's events that is 500 events by default (1,000 at most) — nearly every order's whole history in one answer — and for the catalog every match unless `limit` caps it (200 at most); a page fetched with a cursor takes 20 and 100 on every list. Each carries `has_more` and `next_cursor` too — send the cursor back to switch that walk to keyset, one client at a time. A request that sends both walks by the cursor, and a cursor walk's answer omits `page`, because there is no page number that would be true. Reconciliation loops should use the cursor: order rows are written continuously, so a bulk run landing while you read page 2 shifts every offset behind it. An order's events stay a bare array, with the cursor in `meta`. Any change to how a list pages ships with an [API changelog](https://portal.printedsimply.com/docs/api-changelog) entry. ## Every webhook is signed twice ``` X-PS-Event-Id: evt_9pQ2m4Kd X-PS-Signature: sha256= X-PS-Signature-V2: t=1754460000,v1=,v1= ``` X-PS-Signature-V2 binds the timestamp into the MAC `t=,v1=` where the HMAC-SHA256 covers `.`. A body-only signature is valid forever — anyone who ever observes one delivery could replay it months later. Verify v2 and reject a timestamp drifting more than **300 seconds** from your clock. X-PS-Signature keeps working The original `sha256=` body-only header still ships on every request, so verifiers written against the older docs do not break. New integrations should verify v2. Signatures are computed per attempt A retry after a long backoff carries a fresh `t` describing when _that_ request was sent — it will not fail your clock-drift check for being old. ``` import crypto from "crypto"; function verify(rawBody, header, secrets, toleranceSeconds = 300) { // Parse "t=...,v1=...,v1=..." keeping EVERY v1 — a rotation sends one per // live secret, and yours may be any of them. const parts = header.split(",").map((p) => p.split("=")); const t = parts.find(([k]) => k === "t")?.[1]; const sent = parts.filter(([k]) => k === "v1").map(([, v]) => v); // Reject stale captures: the timestamp is inside the signed material. if (!t || Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false; return secrets.some((secret) => { const expected = crypto .createHmac("sha256", secret) .update(`${t}.${rawBody}`) .digest("hex"); return sent.some( (sig) => sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)) ); }); } ``` ## Secret rotation with zero downtime You can rotate with the credential you hold `POST /v1/webhooks/{id}/rotate-secret` needs an API key with `webhooks:manage` — not a browser session. When a signing secret leaks, the clock is running, and a remedy that requires a human to log in is a remedy your integration cannot reach. Close the overlap early with `DELETE` on the same path. Two secrets are live during the overlap window Rotate and we sign each delivery with **both** the new and the outgoing secret — one `v1=` per secret in the same `X-PS-Signature-V2` header. Deploy the new secret whenever you like inside the window; nothing 401s in between. Your verifier must accept ANY v1, not the first This is the one place a naive verifier breaks. Parsing the header into a map (`Object.fromEntries` / `dict(...)`) keeps only the last duplicate key. We emit the **active secret first and the outgoing one last** precisely so that a map-based verifier still holding the old secret keeps verifying for the whole window — but collect every `v1` and accept if any matches, as the snippet above does. The overlap ends exactly when it says it does Default grace is **24 hours**, and the maximum you can request is **168 hours**. Expiry is evaluated at signing time, not by a cleanup job — an expired secret stops being honored the moment it expires, never “whenever the sweeper next runs”. The legacy header carries the rotation too The single-valued `X-PS-Signature` can only hold one MAC, so during a rotation it is accompanied by `X-PS-Signature-Previous`. ## Delivery, dedupe, and replay X-PS-Event-Id is stable across every attempt One logical event keeps one id across all retries _and_ across a later manual redelivery. Dedupe on it and at-least-once delivery becomes effectively exactly-once for your side effects. Up to 7 attempts over about 10 hours Deliveries run from a durable queue, and the waits between attempts are 1 minute, 5 minutes, 15 minutes, 1 hour, 3 hours, then 6 hours, so an endpoint that is down for a deploy still receives everything it missed. We retry **5xx**, timeouts, **408**, and **429** — the responses that ask us to come back. Any other 4xx means the payload or the endpoint is wrong and a retry would fail identically, so we stop and notify you instead of hammering a broken endpoint. Every way a delivery ends for good reaches you There are two of them and neither is silent: the ladder runs out, or it stops early because the response can never succeed — a `404` after your endpoint moved, a `401` after you rotated your own auth, a redirect we do not follow, or a URL that has come to resolve somewhere we refuse to dial. Each ends in the same notice in your dashboard, debounced to one per endpoint per hour. Those are the failures you cannot discover from your side: nothing arrives, and nothing says why. Anything delivered can be replayed on demand `POST /v1/webhooks/deliveries/{id}/redeliver` re-sends the stored payload with a fresh signature and logs a new attempt, reusing the original `X-PS-Event-Id`. Your endpoint being down for an hour is a recoverable incident, not lost data. Deliveries are auditable `GET /v1/webhooks/deliveries` lists every attempt with its status code and outcome, filterable by webhook and by success, keyset-paginated like everything else. The log speaks the same id your handler dedupes on A row in that log is one _attempt_; its `event_id` is the logical event it was an attempt at — the same value we sent as `X-PS-Event-Id` and the same value your dedupe store is keyed on. So the retry ladder’s three rows and every later replay are visibly one event rather than five failures, and `?event_id=` answers the question you actually have about an id your handler never saw: did it ever leave, and what did our endpoint say. A delivery log you cannot join to your own records is a list, not a log. Webhook URLs are SSRF-guarded A subscription URL that resolves to a private, loopback, or link-local address is refused rather than delivered to. That is a property of the subscription, so it fails fast instead of burning the retry ladder. Full event catalog and payload shapes: [the webhooks guide](https://portal.printedsimply.com/docs/webhooks). ## Limits you can back off against Retry-After is honest Both 429s — `rate_limit_exceeded` and `daily_quota_exceeded` — carry a `Retry-After` in **seconds**, computed from the actual window reset. Sleep for it and your next call succeeds. You do not need a guessed backoff schedule on top. Every response carries your remaining budget `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` (a Unix timestamp in **milliseconds**) on success and on failure alike — including on an idempotent replay. Daily quota exhaustion adds `X-Daily-Limit` and `X-Daily-Remaining`. Limits are per account, not per key Both the per-minute limit (60 req/min) and the daily quota are enforced across your whole account. Creating more keys does not multiply your budget — so a fleet of keys, one per service, has a predictable ceiling rather than an accidental one. [See the tiers](https://portal.printedsimply.com/docs/rate-limits). ## Spend caps with a bounded blast radius Each API key can carry a per-order and a per-day cap — the control that makes handing a key to a script or a service you do not watch a bounded decision. Exceeding one returns `spend_cap_exceeded` **before** any charge is attempted. The daily cap is reserved atomically The day's tally is a durable Postgres row incremented in place, so concurrent orders can never each read the same pre-charge total and all slip under the cap. The tally survives a restart or cache flush. Money that was never spent is handed back An admitted order that never becomes a standing charge — card declined, fraud gate blocked it, fulfillment rejected and refunded — releases its reservation. A few declines cannot burn a key's entire daily budget and then report it as spent. Failure modes are chosen deliberately If the ledger is unreachable, the check **fails open**: a cap must never wrongly block a legitimate order. But once a running total is in hand, an over-cap decision is **final** — a later error cannot wash it out. And a failed release keeps the reservation, which is fail-safe: the cap can only end up more restrictive, never less. ## Regions, and where a read goes The API runs in one primary region, with its PostgreSQL primary beside it. There are no read replicas on the request path: every authenticated read is answered by the same database the write landed in. That is a smaller map than incumbents draw — and it is what makes the strongest guarantee on this page possible. Read-your-writes, on every endpoint A 200 on a write means the very next read sees it. The order you just created is in `GET /v1/orders` before the response reaches you, and a reconciliation lookup — `GET /v1/orders?reference=` after a create response you never received — is answered by the same primary that took the write. There is no replica lag because there is no replica in the path. A stale “not found” mid-reconciliation is the most expensive wrong answer this API can give, and this posture is what rules it out. No silent regional failover If the primary region is down, the API is down, the [status page](https://portal.printedsimply.com/status) says so. We deliberately do not fail over automatically: the idempotency ledger and the spend-cap reserve live beside the primary, and both fail open by design — so an automatic cutover that left them behind would disable exactly the controls that bound a key's blast radius, at the moment of maximum confusion. Cutting over is a deliberate operational decision, made once, not a race two regions can both win. What is served from more than one place Static marketing and documentation pages are cached at the CDN edge. Everything authenticated — every /v1 call, the dashboard, webhook dispatch — is single-region by design. ## What we don't promise A contract is only worth something if it also says where it ends. - **Webhook ordering.** Events are not guaranteed to arrive in the order they occurred. Reconcile against the order resource rather than assuming a sequence. - **Exactly-once delivery.** Delivery is at-least-once. Dedupe on `X-PS-Event-Id`; we keep it stable specifically so you can. - **Idempotency beyond 24 hours.** Stored responses are retained for a day. A retry a week later is a new request. - **Deduplication during a ledger outage, off the money path.** Spend caps fail open by design, and so does an idempotency key on an operation that moves no money. A money operation fails closed instead (503, nothing charged) — see the boxes above for what each means in practice. - **Automatic cross-region failover.** A regional outage is an outage — measured on the status page — not a silent cutover. The [regions section](https://portal.printedsimply.com/docs/reliability#regions) explains why that trade is deliberate. - **Cursor stability across endpoints.** A `next_cursor` is opaque and only valid for the endpoint that issued it. Do not parse it or reuse it elsewhere. - **A delivery date.** Nothing on this page promises when an order arrives — these are API semantics, not fulfillment ones. Live availability and latency numbers are published on [the status page](https://portal.printedsimply.com/status). [API reference](https://portal.printedsimply.com/docs/api-reference) · [Webhooks](https://portal.printedsimply.com/docs/webhooks) · [Rate limits](https://portal.printedsimply.com/docs/rate-limits) · [Error reference](https://portal.printedsimply.com/docs/errors)