# 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=<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))
    );
  });
}
```

## 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)
