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.
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.
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: 1The 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).
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 entry.
X-PS-Event-Id: evt_9pQ2m4Kd
X-PS-Signature: sha256=<hex>
X-PS-Signature-V2: t=1754460000,v1=<hex>,v1=<hex>X-PS-Signature-V2 binds the timestamp into the MAC
t=<unix-seconds>,v1=<hex> where the HMAC-SHA256 covers <t>.<raw body>. 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=<hex> 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))
);
});
}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.
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.
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.
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.
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 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.
A contract is only worth something if it also says where it ends.
X-PS-Event-Id; we keep it stable specifically so you can.next_cursor is opaque and only valid for the endpoint that issued it. Do not parse it or reuse it elsewhere.Live availability and latency numbers are published on the status page.