Get HMAC-signed, real-time notifications the moment an order changes — no polling, and every delivery is replayable.
X-PS-Signature-V2: t=<unix>,v1=<hex>, an HMAC-SHA256 of <t>.<raw body> under your endpoint's secret. Refuse a t more than 5 minutes from your clock and compare in constant time — the verifier.GET /v1/webhooks/deliveries.POST /v1/webhooks/deliveries/{id}/redeliver) or any event in your feed (POST /v1/events/{eventId}/replay), signed afresh, under the same event id.id, sent as X-PS-Event-Id, is the same on every retry and replay. Store it and skip one you have handled.Three steps with the test key from the quickstart: subscribe, place and ship a $0 test order, read what arrives. Nothing is charged or printed.
Point url at your handler — a deployed one, or the one on your laptop through an HTTPS tunnel (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.
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"}'secret is in this response and in no later one: store it now. Your handler verifies every delivery with it.
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:
# 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"}'order.shipped as your handler receives it — the body, and the request it arrives in:
{
"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
}
}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.
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=<unix>,v1=<hex>) is an HMAC-SHA256 over <t>.<raw body>. 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=<hex>) 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.
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 |
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, so either surface can manage what the other created.
# 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.
2xx status code within 10 secondsEvery 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.
# 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"}'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.
Verify every webhook against the X-PS-Signature-V2 header using the webhook secret from your dashboard. Its value is t=<unix-seconds>,v1=<hex>, where the hex digest is an HMAC-SHA256 of <t>.<raw body> — 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=<hex>, 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.
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=<new>,v1=<old>"), so collect them all and accept ANY match.
// A missing header (undefined or null) is refused like a malformed one.
function verifyWebhook(rawBody, 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))
);
}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.
# 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=<unix>,v1=<new>,v1=<outgoing>. 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.
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 — 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.
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: 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). 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.
# 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"}'printedsimply listenEvery 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). Otherwise the log it reads — and therefore your laptop — fills with your production order traffic alongside the sandbox events you are actually debugging.
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/printedsimplyForwarded 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.
Every attempt is recorded with its response status and the payload that was sent. The delivery log 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:
# 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.4xx — treated as a permanent rejection and not retried. Fix the handler, then replay.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.200 immediately and process the event asynchronously (e.g. via a job queue).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.API reference · Troubleshooting · Email us at developers@printedsimply.com