# API reference

Source: https://portal.printedsimply.com/docs/api-reference

Every operation in the PrintedSimply API — orders, products, pricing, shipping, artwork, and webhooks. Open one for its parameters, request body, response, scopes and errors, and the same request in curl, TypeScript, Python and PHP.

## Base URL

All API requests are made to the following base URL:

```
https://portal.printedsimply.com/api/v1
```

Every path below is relative to it: `/order` means `https://portal.printedsimply.com/api/v1/order`. Answers are JSON, in the envelope [described below](https://portal.printedsimply.com/docs/api-reference#response-format).

Every operation below is also in the [Postman collection](https://portal.printedsimply.com/api/postman-collection) (with these bodies and a fresh `Idempotency-Key` on each write) and the [OpenAPI document](https://portal.printedsimply.com/api/openapi.json) this page is read from.

**Import into Postman, Insomnia or Bruno**

Each takes the API from a URL, so there is no file to keep up to date: import again to pick up a change.

`Postman collection`

```
https://portal.printedsimply.com/api/postman-collection
```

`OpenAPI document`

```
https://portal.printedsimply.com/api/openapi.json
```

- **Postman:** choose Import and paste the collection's URL. Each request comes with its example body, a fresh `Idempotency-Key` on each write, and the ids one request hands the next. Set the collection's `apiKey` variable to your test key.
- **Insomnia:** choose Import, pick URL, paste either URL and choose Scan.
- **Bruno:** choose `+`, then Import Collection, and enter the OpenAPI document's URL (Bruno takes a Postman collection as a downloaded file).

Every request sends your key in `x-api-key`; a test key places $0 orders.

## Authentication

Send your API key in the `x-api-key` header:

```
curl https://portal.printedsimply.com/api/v1/products \
  -H "x-api-key: ps_test_YOUR_KEY"
```

- Test keys start with `ps_test_` and live keys with `ps_live_`. A test key places $0 orders that run the whole lifecycle. Keys created before the PrintedSimply rename start with `mf_test_` or `mf_live_` and keep working, in the same mode.
- Manage your keys in the [API Keys dashboard](https://portal.printedsimply.com/dashboard/api-keys). A key made with a scope list may call only the operations whose scopes it holds; each operation names its own.
- Requests without a valid key return `401 Unauthorized`.

**Key format, and the patterns a secret scanner matches**

Key format: the prefix, then 38 base62 characters (`0-9`, `A-Z`, `a-z`): 32 random, then a 6-character checksum. The checksum is the CRC-32 (IEEE) of the 32 random characters, written in base62, most significant digit first, padded with `0` to six. A key in that shape whose checksum fails, a typo for instance, is refused with `401 invalid_api_key` before any lookup. The checksum is the same under either prefix, so it holds for an `mf_` key too. A secret scanner matches a key, under either prefix, with:

Regular expression:

```
\b(?:ps|mf)_(?:live|test)_[0-9A-Za-z]{38}\b
```

Keys created before the checksum was added (32 characters after the prefix, which may include `_` and `-`) keep working, but that pattern does not match them. Rotating an older key gives one it matches; until then, this one matches the older shape:

Regular expression (older keys):

```
\bmf_(?:live|test)_[0-9A-Za-z_-]{32}(?![0-9A-Za-z_-])
```

## Create an order

One request places an order: [`POST /order`](https://portal.printedsimply.com/docs/api-reference/createOrder) with the lines, the address and how to ship it. With a test key it costs $0 and runs end to end.

`POST /api/v1/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" }
  }'
```

- Required: `address_to` (name, address1, city, state, zip, country), `items` (sku, quantity). The rest is optional, and every field is described under the operation.
- Send an `Idempotency-Key`: a retry with the same key and body returns the first answer instead of placing a second order.
- The answer's `order_id` is the order (a `test_ord_` id on a test key). Read it back with [`GET /order/{id}`](https://portal.printedsimply.com/docs/api-reference/getOrder), and on a test key move it along with [`POST /order/{id}/simulate`](https://portal.printedsimply.com/docs/api-reference/simulateOrder).

## Discovery

The base URL itself answers without a credential, so a client can find the machine-readable spec before it has a key.

**GET `/` API index (public discovery)**

Unauthenticated service descriptor: the API's name and version plus absolute links to the OpenAPI spec, the API reference, the API changelog and the MCP client config. Returns a plain JSON object, not the success envelope.

**Credential**

None: it answers without one

### Request

`GET /api/v1`

cURL:

```bash
curl "https://portal.printedsimply.com/api/v1"
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1", {
  signal: AbortSignal.timeout(30_000),
})
const body = await res.json()
```

Python:

```python
import json
import urllib.request

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1",
)
with urllib.request.urlopen(req, timeout=30) as res:
    body = json.load(res)
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$reply = json_decode($raw, true);
```

### Response `200`

The service descriptor. NOT wrapped in the success envelope — this route answers before any of that machinery, which is also why it carries no request_id.

- `name` string required

- `version` string required

  The TRANSPORT major, matching the /api/v1 prefix. Deliberately frozen at "1.0" — it is not the contract version and does not move.

- `api_version` string required

  The date-based CONTRACT version, which is the one that moves. Published separately rather than overloading `version`, so a discovery agent learns which version it will be served without fetching the whole spec.

- `minimum_api_version` string required

  The oldest version still served. A pin below this resolves upward.

- `description` string required

- `openapi_spec` string required

  Path to this document.

- `documentation` string required

- `api_changelog` string required

- `mcp_config` string required

  Absolute URL of the MCP client config: an mcpServers block naming this deployment's MCP server, for an agent that would rather hold tools than endpoints.

Example response:

```json
{
  "name": "PrintedSimply API",
  "version": "1.0",
  "api_version": "2026-07-26",
  "minimum_api_version": "2026-02-15",
  "description": "Route print orders to PrintedSimply's US plant by API.",
  "openapi_spec": "https://portal.printedsimply.com/api/openapi.json",
  "documentation": "https://portal.printedsimply.com/docs/api-reference",
  "api_changelog": "https://portal.printedsimply.com/docs/api-changelog",
  "mcp_config": "https://portal.printedsimply.com/mcp-config.json"
}
```

**GET `/me` Describe the calling credential**

What this API key or OAuth token is allowed to do, from the perspective of the credential itself: its prefix, name, test/live mode, scopes, and expiry; the account's plan; the per-minute and per-day request limits with what is left of each (both enforced per ACCOUNT, not per key); whether this request's RFC 9421 signature verified and the rate multiplier it bought; and the spend controls — per-order cap, daily cap, and how much of today's is already committed. Read-only: it reserves nothing and moves no tally. Call it before planning a run rather than discovering the same limits as a 403, a 429, or a spend_cap_exceeded on an order you had already decided to place. Requires no scope — a credential can always describe itself — but does consume one request from both rate windows, which is why the numbers it reports are the ones remaining after this call. Fields that cannot be read (a spend ledger blip) come back null rather than zero.

**Credential**

An API key in `x-api-key`

**Scopes**

None: every key may call it

### Request

`GET /api/v1/me`

cURL:

```bash
curl "https://portal.printedsimply.com/api/v1/me" \
  -H "x-api-key: ps_test_YOUR_KEY"
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/me", {
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
  },
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/me",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
    },
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/me');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

Credential scopes, limits, and spend controls

`data`

- `key` object required

- `key.prefix` string

  The key's public prefix. Never the key itself.

- `key.name` string | null

- `key.mode` string

  One of `test` `live`

- `key.scopes` string[]

- `key.expires_at` string (date-time) | null

- `account` object required

- `account.plan` string

- `account.plan_label` string

- `rate_limits` object required

  Both windows are enforced per ACCOUNT, not per key — minting more keys does not buy more throughput, and `scope` says so rather than leaving it to be inferred from a per-key-looking response.

- `rate_limits.scope` string

  Always `account`

- `rate_limits.requests_per_minute` object

- `rate_limits.requests_per_minute.limit` integer required

- `rate_limits.requests_per_minute.remaining` integer required

- `rate_limits.requests_per_minute.reset` integer required

  When the window resets, as a Unix timestamp in milliseconds (the same value as X-RateLimit-Reset).

- `rate_limits.requests_per_day` object

  The same fields as `rate_limits.requests_per_minute`

- `verified_agent` object required

  `signed` is what the request claimed; `verified` is what checked out. Only the second buys the rate multiplier, and an agent debugging its own signature needs to see the two disagree.

- `verified_agent.signed` boolean

- `verified_agent.verified` boolean

- `verified_agent.key_id` string | null

- `verified_agent.agent_name` string | null

- `verified_agent.rate_multiplier` number

- `verified_agent.reason` string | null

  Why the signature did not verify. Null when it verified, and when the request carried none.

- `spend_controls` object required

- `spend_controls.per_order_cap_cents` integer | null

- `spend_controls.daily_cap_cents` integer | null

- `spend_controls.day` string

- `spend_controls.spent_today_cents` integer | null

  Reserved against the daily cap so far today. Null when the key has no daily cap, or when the day's tally could not be read.

- `spend_controls.remaining_today_cents` integer | null

- `api_version` string required

  The version this response was served under — the same value as the `MF-Version` header.

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "key": {
      "prefix": "mf_test_xxxxxxxx",
      "name": "Examples",
      "mode": "test",
      "scopes": [
        "all"
      ],
      "expires_at": null
    },
    "account": {
      "plan": "starter",
      "plan_label": "Free"
    },
    "rate_limits": {
      "scope": "account",
      "requests_per_minute": {
        "limit": 60,
        "remaining": 60,
        "reset": 1792076462000
      },
      "requests_per_day": {
        "limit": 10000,
        "remaining": 10000,
        "reset": 1792108800000
      }
    },
    "verified_agent": {
      "signed": false,
      "verified": false,
      "key_id": null,
      "agent_name": null,
      "rate_multiplier": 1,
      "reason": null
    },
    "spend_controls": {
      "per_order_cap_cents": null,
      "daily_cap_cents": null,
      "day": "2026-10-15",
      "spent_today_cents": null,
      "remaining_today_cents": null
    },
    "api_version": "2026-07-26"
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

**GET `/setup` Describe what this account still needs**

What the account behind this credential has, what it still needs, and the one thing to do next — so an agent setting up a merchant learns it up front instead of as a refusal on the call it was making. `ready` says whether each way of selling would be accepted now: `live_orders` (a live key, a card on file, and an account not held to sandbox; otherwise a live order is refused with 402 no_payment_method or 403 sandbox_only); `hosted_sales` and `store_orders` are always false here, where orders arrive through this API and its dashboard only. `steps` lists each step with whether it is done, measured from the account's own rows rather than ticked by hand, and the dashboard page that completes it; `next_action` is the first required step not yet done, or null. Optional steps (the webhook, and a live key of your own) are ones the first live order does not need, and never the next action. Which steps are required depends on the credential asking: on a live one (a live key, or an OAuth sign-in, which is always live) the only required step is a card on file and `live_key` reads done; on a test key every step is optional and `next_action` is null, since its $0 orders need nothing. Requires no scope, like GET /me: the narrowly scoped key that was just refused is the one that most needs to learn why. Reports booleans and dashboard links only — no card, payout or customer detail — and changes nothing.

**Credential**

An API key in `x-api-key`

**Scopes**

None: every key may call it

### Request

`GET /api/v1/setup`

cURL:

```bash
curl "https://portal.printedsimply.com/api/v1/setup" \
  -H "x-api-key: ps_test_YOUR_KEY"
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/setup", {
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
  },
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/setup",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
    },
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/setup');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

Readiness by way of selling, every setup step, and the next action

`data`

- `ready` object required

  Whether each way of selling would be accepted right now — the same condition the refusing route checks.

- `ready.live_orders` boolean required

  A live key — the credential asking, when it is a live one (a live key or an OAuth sign-in), and otherwise one you made on the API keys page that is not revoked or expired (the key the `live_key` step counts) — a card on file, and an account not held to sandbox.

- `ready.hosted_sales` boolean required

  Always false here: orders reach this portal through this API and its dashboard only.

- `ready.store_orders` boolean required

  Always false here: orders reach this portal through this API and its dashboard only.

- `sandbox_only` boolean required

  Held to test mode by the platform; live orders are refused until support lifts it.

- `steps` object[] required

  Every setup step in order, each measured from the account's rows.

- `steps[].key` string required

  One of `test_key` `sandbox_order` `webhook` `payment_method` `live_key` `first_sale`

- `steps[].label` string required

- `steps[].hint` string required

  What completing the step does, and what is refused until it is done.

- `steps[].done` boolean required

- `steps[].optional` boolean required

  A step the first live order does not need: the webhook (GET /order/{id} reads the same state), and a live key of your own (a real order placed from the dashboard's order page uses the key that page makes). Asked by a live credential, every step but the card is optional; asked by a test key, every step is. Never holds back `complete`, and never the next action.

- `steps[].dashboard_url` string (uri) required

  The dashboard page that completes it.

- `next_action` object | any required

  The first required step not yet done, or null when every required step is done.

- `next_action.key` string required

  One of `test_key` `sandbox_order` `webhook` `payment_method` `live_key` `first_sale`

- `next_action.label` string required

- `next_action.hint` string required

- `next_action.dashboard_url` string (uri) required

- `complete` boolean required

  Every required step is done. Optional steps never hold this back.

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "ready": {
      "live_orders": false,
      "hosted_sales": false,
      "store_orders": false
    },
    "sandbox_only": false,
    "steps": [
      {
        "key": "test_key",
        "label": "Create your test key",
        "hint": "It starts with ps_test_ and places $0 orders that are never printed or charged. It is made on the API keys page and shown in full once, when it is made.",
        "done": true,
        "optional": true,
        "dashboard_url": "https://portal.printedsimply.com/dashboard/api-keys"
      },
      {
        "key": "sandbox_order",
        "label": "Send a $0 test order",
        "hint": "Send POST /v1/order with a test key. The order runs to delivered on a compressed clock, with a tracking link; nothing is printed or charged.",
        "done": false,
        "optional": true,
        "dashboard_url": "https://portal.printedsimply.com/dashboard"
      },
      {
        "key": "webhook",
        "label": "Add a webhook and watch the order move",
        "hint": "We send a signed event to your URL as each order moves, test orders included, and keep a delivery log you can replay. No server yet? A hosted test inbox receives your test events for you, with nothing to install and no tunnel.",
        "done": false,
        "optional": true,
        "dashboard_url": "https://portal.printedsimply.com/dashboard/webhooks"
      },
      {
        "key": "payment_method",
        "label": "Add a card",
        "hint": "A live order is charged to the card on file when it is placed; without one it is refused with no_payment_method. Test orders never need one.",
        "done": false,
        "optional": true,
        "dashboard_url": "https://portal.printedsimply.com/dashboard/billing#payment-method"
      },
      {
        "key": "live_key",
        "label": "Create a live key",
        "hint": "It starts with ps_live_ and places real orders. Your code does not change, only the key.",
        "done": true,
        "optional": true,
        "dashboard_url": "https://portal.printedsimply.com/dashboard/api-keys"
      },
      {
        "key": "first_sale",
        "label": "Send your first live order",
        "hint": "Send POST /v1/order with your live key, your own artwork (an https link or an uploaded asset id) and a real address, and it is printed in our US plant and shipped.",
        "done": false,
        "optional": true,
        "dashboard_url": "https://portal.printedsimply.com/dashboard/orders"
      }
    ],
    "next_action": null,
    "complete": true
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

- `500` [internal_error](https://portal.printedsimply.com/docs/errors#internal_error)

  internal_error — the account's setup could not be read. Nothing is reported rather than a list of wrong claims; retry shortly.

## Orders

**GET `/orders` List orders**

Retrieve a paginated list of your orders, newest first, with optional filters. Pages by cursor (see Pagination): send back `meta.next_cursor` as `cursor` until `meta.has_more` is false, a keyset walk that cannot repeat or skip a row while orders are being written. Reconciliation should use the cursor. The deprecated `page` walk is still answered as before.

**Credential**

An API key in `x-api-key`

**Scopes**

`orders:read`

### Parameters

- `status` query · string

  Filter by order status. An unknown value is refused with 400 validation_error, never ignored — an unrecognised status would otherwise match no row and answer 200 with an empty page, which is indistinguishable from having no such orders. Current statuses: pending_approval, pending_payment, paid, accepted, in_production, quality_check, shipped, delivered, cancelled, issue_flagged, rerouting. Historical values, accepted so orders older than this vocabulary stay reconcilable but no longer written: pending, processing, on-hold, completed, refunded, failed.

  One of `pending_approval` `pending_payment` `paid` `accepted` `in_production` `quality_check` `shipped` `delivered` `cancelled` `issue_flagged` `rerouting` `pending` `processing` `on-hold` `completed` `refunded` `failed`

- `reference` query · string

  Exact match on the `reference` you supplied at create. Use it to reconcile your own order ids against ours — including after a create response you never received.

- `from` query · string (date-time)

  Filter orders created on or after this date. A value that is not a date, or a `from` later than `to`, returns 400 validation_error — it is never dropped, because an unfiltered page is indistinguishable from a windowed one.

- `to` query · string (date-time)

  Filter orders created on or before this date. A value that is not a date returns 400 validation_error.

- `cursor` query · string

  Opaque cursor: send back `meta.next_cursor` from the previous page for the page after it; omit it for the first page. Good only on this list, with a key of the same account and mode (test or live), and with the same filters (only `limit` may change); an altered or foreign cursor, or one sent with other filters, is refused with 400 validation_error. Wins over `page` when both are sent.

- `limit` query · integer

  Orders per page: 20 by default, 100 at most. A value above 100 is clamped to 100 and one below 1 to 1, a fraction is truncated, and a missing, zero or non-numeric value falls back to 20; none is refused.

  Default 20

- `page` query · integer deprecated

  Deprecated: walk with `cursor`. The page number of the offset walk this operation always had, answered as before (`limit` 20 by default, 100 at most; `offset`, a row count, is accepted in its place). An order written while you page shifts every offset behind it; a cursor cannot be shifted. Ignored when `cursor` is sent, and a cursor walk's `meta` carries no `page` or `pages`.

  Default 1

### Request

`GET /api/v1/orders`

cURL:

```bash
curl "https://portal.printedsimply.com/api/v1/orders" \
  -H "x-api-key: ps_test_YOUR_KEY"
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/orders", {
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
  },
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/orders",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
    },
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/orders');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

Paginated list of orders. meta carries has_more, next_cursor (null on the last page), total and limit — plus page and pages except on a cursor walk. Each row carries the `reference` you supplied at create (null if you supplied none) and the `guarantee` in force on that order (null when it carries none).

`data` is a list; each entry:

- `order_id` string required

  The fulfilment network's order id — the one `GET /v1/order/{id}` and every other order operation take.

- `portal_order_id` string required

  This platform's own id for the order.

- `provider` string required

  The fulfilment lane that holds the order.

- `made_by` object | null required

  The independent network shop that produced an order, or null when an in-house lane did. `GET /v1/orders` and `GET /v1/order/{id}` build it from the same helper and both `$ref` this, so they cannot describe one fact two ways — which they did, one of them calling it a string. Every order placed on this portal is made at our own plant, so it is null on them; a shop is named only on an order placed before that.

- `made_by.name` string required

  The shop's display name.

- `made_by.slug` string required

  The shop's directory slug.

- `made_by.profile_url` string (uri) required

  Its public profile page on this platform.

- `guarantee` object | null required

  The delivery guarantee in force on this order, or null when the order carries none (guarantees are issued only on lanes whose measured on-time record clears the floor, and only while the feature is enabled). The keys mirror the `guarantee` block returned by POST /orders/quote, so this is the object you were quoted, read back, plus what has since happened to it.

- `guarantee.guarantee_id` string required

  Same id the quote issued.

- `guarantee.deliver_by` string (date-time) required

  The date this order is guaranteed to arrive by.

- `guarantee.quality_standard` string required

  The quality standard guaranteed ("standard" in v1).

- `guarantee.confidence` number required

  Lower bound on the lane's on-time probability at issue time (Wilson), between 0 and 1.

- `guarantee.remedy` object required

  The promise. cap_cents is the ceiling on what a breach can pay — never an amount owed or paid; read `remedies` for what was actually recorded.

- `guarantee.remedy.type` string required

  credit is the only remedy issued today, and the one that settles as a real refund. remake and expedite are reserved for the ops-driven remedies; one would settle as not_applicable rather than pay.

  One of `credit` `remake` `expedite`

- `guarantee.remedy.cap_cents` integer required

- `guarantee.status` string required

  issued while the promise is open, honored once the order was delivered on time, remedied once it was not. There is deliberately no `breached` value: a breach and its capped remedy are recorded in one transaction (so two overlapping sweeps cannot both pay out), so the terminal state of a broken promise is `remedied` and whether the money moved is the remedy's own status.

  One of `issued` `honored` `remedied`

- `guarantee.issued_at` string (date-time) required

- `guarantee.resolved_at` string (date-time) | null required

  When the guarantee was honored or remedied; null while still open.

- `guarantee.remedies` object[] required

  Remedies recorded against this guarantee. Empty unless it was breached.

- `guarantee.remedies[].type` string required

  One of `credit` `remake` `expedite`

- `guarantee.remedies[].amount_cents` integer required

  What was recorded, capped at the order value at issue.

- `guarantee.remedies[].status` string required

  applied is the only value that means money moved — a real refund on the order's charge. not_applicable is the ordinary sandbox outcome (a test order has no charge) and also covers a remake/expedite, which is an ops action rather than an automatic payment. failed is an owed remedy that has not been paid, kept distinct from not_applicable so it stays visible.

  One of `pending` `applied` `failed` `not_applicable`

- `guarantee.remedies[].applied_at` string (date-time) | null required

- `guarantee.remedies[].created_at` string (date-time) required

- `reference` string | null required

  The `reference` you supplied at creation, echoed back. Null on orders placed without one, and on every order that predates the column.

- `vat` string | null required

  The tax-registration number declared on this order; null when none was supplied.

- `landed_cost` object | null required

  What the border cost on this order, as recorded for the parcel that ships. Null on a domestic shipment — there is no border, so there is nothing to state, and a row of zeros would read like a customs assessment that came to nothing. When a cross-border order has no stored figures, `recorded` is false and only `destination_country` and `not_recorded_reason` are present: the duty, tax and FX rates that applied on the day are not recoverable, and backfilling them with today's rates would be a fabricated record rather than a missing one. That is what an order placed before these figures were stored looks like, and also a free replacement order, which carries no charged goods value to assess. The figures are a snapshot, never recomputed on read, so a stale figure cannot be mistaken for a current one — but they ARE re-recorded when the parcel itself changes, because the border is costed against the destination, the goods and the shipping charge. Change the address, add or cancel a line, change a quantity or change the shipping method and the order is costed again at that moment; `recorded_at` moves with it, and it is the order's creation time until something does. An order re-routed to a domestic address goes back to null. `estimated` stays true throughout: on DAP the courier bills the recipient directly, so we never learn what customs actually assessed.

- `landed_cost.recorded` boolean required

  False when no landed cost was stored for this order.

- `landed_cost.destination_country` string

  ISO 3166-1 alpha-2.

- `landed_cost.not_recorded_reason` string

  Present only when `recorded` is false.

- `landed_cost.recorded_at` string (date-time)

  When the figures were computed — never the moment of the read. The order's creation time, or the moment of the last change to its destination, lines or shipping charge.

- `landed_cost.duty_basis` string

  DAP today: goods and shipping are collected at checkout, and the courier bills the recipient for duties and import tax on delivery.

  One of `DDP` `DAP`

- `landed_cost.duty_rate` number

- `landed_cost.tax_rate` number

- `landed_cost.breakdown` object

- `landed_cost.breakdown.goods_cents` integer

- `landed_cost.breakdown.shipping_cents` integer

- `landed_cost.breakdown.duties_cents` integer

- `landed_cost.breakdown.tax_cents` integer

- `landed_cost.total_landed_cents` integer

  What the buyer ends up paying in total.

- `landed_cost.pre_collected_cents` integer

  Collected at checkout. Equals goods + shipping — never more than was charged.

- `landed_cost.due_on_delivery_cents` integer

  Billed by the courier on delivery. Sums with `pre_collected_cents` to the total.

- `landed_cost.presentment` object

- `landed_cost.presentment.currency` string

- `landed_cost.presentment.amount_cents` integer

- `landed_cost.presentment.minor_units` integer

- `landed_cost.presentment.fx_rate` number

- `landed_cost.estimated` boolean

- `landed_cost.note` string

- `status` string required

  The order's lifecycle status, from the same vocabulary the `status` filter above accepts.

  One of `pending_approval` `pending_payment` `paid` `accepted` `in_production` `quality_check` `shipped` `delivered` `cancelled` `issue_flagged` `rerouting` `pending` `processing` `on-hold` `completed` `refunded` `failed`

- `issue_reason` string | null required

  Why the order is `issue_flagged`, as `GET /v1/order/{id}` reports it; null when it is not flagged, or no reason fits.

  One of `artwork_unprintable` `address_undeliverable` `shipment_exception` `on_hold` `cancellation_pending`

- `total` string required

  What the order was charged, formatted for display — a dollar string such as `$42.00`, NOT an integer of cents. `total_cents` beside it carries the same money as an integer, under the name `GET /v1/order/{id}` uses; read that one. Kept because the shape is published and changing it would break every caller reading it.

- `shipping` string required

  The shipping charge, formatted the same way as `total`; `shipping_cents` is the same money in cents.

- `total_cents` integer required

  What the order was charged, in cents — the same money as `total`, and the same field `GET /v1/order/{id}` carries.

- `shipping_cents` integer required

  The shipping charge, in cents — the same money as `shipping`.

- `is_test` boolean required

  True on a sandbox order. The same value as `test_mode` beside it; kept because it is published.

- `test_mode` boolean required

  True on a sandbox order, false on a live one — `is_test` under the name the create response and `GET /v1/order/{id}` use. Present on every row here (the order read omits it on a live order).

- `channel` string required

  The surface that placed the order. On `hosted`, `acp` and `ucp` a shopper paid your store, and `total` is that sale; on every other channel you placed the order and `total` is what it cost you. `unknown` is an order older than the record.

  One of `acp` `ucp` `mcp` `verified_agent` `oauth_app` `api` `dashboard` `autopilot` `hosted` `storefront` `unknown`

- `drop_id` string | null required

  The drop a shopper bought this order on; null on an order no drop sold.

- `items` object[] required

  The order's lines. Each carries the id our item and return operations resolve — see OrderLine.

- `items[].id` string required

  This line's id. Use it as `{itemId}` on PUT /v1/{id}/item/{itemId} and DELETE /v1/{id}/item/{itemId}/cancel.

- `items[].sku` string required

- `items[].quantity` integer required

  at least 1

- `items[].unit_price_cents` integer required

- `items[].artwork` object

  The line's artwork declaration, as the order accepted it: how the artwork was made, and who approved it and when. Absent on a line that declared none.

- `items[].artwork.provenance` string

  How the artwork was made: `human` (made by a person), `ai_assisted` (a person made it with a model's help) or `ai_generated` (a model made it).

  One of `human` `ai_assisted` `ai_generated`

- `items[].artwork.approved_by` string

  Who approved the artwork for print: a name or an email address, at most 200 characters. Control and invisible formatting characters are removed and whitespace collapsed before it is stored. Send it with approved_at.

  non-empty · at most 200 characters

- `items[].artwork.approved_at` string (date-time)

  When the artwork was approved: an ISO 8601 date-time with a time zone, not in the future. Stored and echoed in UTC. Send it with approved_by.

- `line_items` object[] required

  The same lines as `items`, under the name `GET /v1/order/{id}` publishes them as, so one name reads the lines from either.

  The same fields as `items`

- `actions` object required

  Which changes this order still takes, read from the same checks the operations refuse on, so a client can show or offer an action without trying it first. `true` means the order's own state does not refuse it; the request is still checked as usual (the key's scope; the line an item action names, which must exist and, on a live order at our own plant, carry its artwork; and on a live order the facility, which can refuse a change with 502 fulfillment_error). `false` names the refusal the operation would give. Live PrintedSimply orders have add_item, update_item and upgrade_shipping false; only supported no-cost or downward shipping requests remain requestable. On other orders a shopper paid for (a drop, a checkout link or an agent checkout), `update_item` and `update_shipping` stay true because a decrease or a downgrade is allowed, while an increase or an upgrade is refused 409 shopper_paid_order, as `add_item` is.

- `actions.cancel` boolean required

  DELETE /order/{id}/cancel. True only before production (pending_payment, paid or accepted); false once the order is cancelled (400 already_cancelled) or past that (409 not_cancellable).

- `actions.update_address` boolean required

  PUT /order/{id}/address. False on an order an independent shop made (501 not_supported_by_provider) and once the order is past the point of change (409 order_not_modifiable).

- `actions.update_shipping` boolean required

  PUT /order/{id}/shipping. False for the reasons `update_address` is.

- `actions.upgrade_shipping` boolean required

  Positive-cost PUT /order/{id}/shipping upgrades. False for live PrintedSimply orders (501 not_supported_by_provider), shopper-paid orders (409 shopper_paid_order), and when update_shipping is false. A downgrade or unchanged method can still be allowed when this is false.

- `actions.add_item` boolean required

  POST /{id}/item. False for the reasons `update_address` is, on live PrintedSimply orders (501 not_supported_by_provider), and on an order a shopper paid for (409 shopper_paid_order).

- `actions.update_item` boolean required

  PUT /{id}/item/{itemId}. False for the reasons `update_address` is and on live PrintedSimply orders (501 not_supported_by_provider). An unchanged quantity is still an allowed no-op.

- `actions.cancel_item` boolean required

  DELETE /{id}/item/{itemId}/cancel. False for the reasons `update_address` is.

- `tracking_token` string | null required

  The opaque token for this order's public tracking page; null until one is issued.

- `tracking_number` string | null required

  The carrier's tracking number; null until the order ships.

- `tracking_carrier` string | null required

  The carrier that holds the shipment; null until the order ships.

- `tracking_url` string | null required

  The carrier's own tracking URL; null until the order ships.

- `tracking` object required

  Where the parcel is. The same block on `GET /v1/order/{id}` and on every `GET /v1/orders` row. `number`, `carrier` and `url` are null until the order ships, and equal the flat `tracking_number`, `tracking_carrier` and `tracking_url` beside it. On a live order whose facility reports its own `tracking` object, that object's keys are kept and these are added where it has none.

- `tracking.number` string | null required

  The carrier's tracking number; null until the order ships.

- `tracking.carrier` string | null required

  The carrier that holds the shipment; null until the order ships.

- `tracking.url` string | null required

  The carrier's tracking link; null until the order ships.

- `tracking.page_url` string (uri) | null required

  The order's hosted tracking page, the link to give your customer. It exists from the moment the order is placed and shows each step, including before the carrier has a number; the create response's `tracking_url` opens the same page. Null only on an order with no tracking token.

- `estimated_delivery` object | null required

  When the order should arrive, as an estimate: the producing facility's production estimate plus the shipping method's transit window (our plant's standard parcel from the plant's own transit estimate), in business days counted in UTC, Monday to Friday, never on a day GET /calendar lists (the federal holidays, each on the weekday it is observed, and the plant's own closure days), counted from when the order was placed (when a person approved it, for an order placed with approval). The same function POST /orders/quote dates an order with, so it is the quote's `estimated_delivery` for the order as placed. Not a promise, and it does not move when an order runs late. Null on a lane that is not one of our own facilities (it sets its own times), on an order with no known shipping method, and whenever the order is not on its way: `pending_approval`, `pending_payment`, `delivered`, `cancelled`, `issue_flagged`, `rerouting` and every legacy status. Present (non-null) only on `paid`, `accepted`, `in_production`, `quality_check` and `shipped`.

- `estimated_delivery.earliest` string (date) required

  YYYY-MM-DD, UTC: the soonest the order should arrive.

- `estimated_delivery.latest` string (date) required

  YYYY-MM-DD, UTC: the latest it should, before anything goes wrong.

- `estimated_delivery.ships_by` string (date) required

  YYYY-MM-DD, UTC: the day it should leave the facility, when its production estimate ends, counted the same way.

- `estimated_delivery.is_estimate` boolean required

  Always true: say it as an estimate wherever it is shown.

  Always `true`

- `reordered_from` string | null required

  On an order POST /order/{id}/reorder placed: the source order's `portal_order_id`, the value the reorder's own answer stated. Null on any other order.

- `created_at` string (date-time) required

- `updated_at` string (date-time) required

`meta`

- `has_more` boolean required

  Whether another page follows this one. Decided from one row past this page, never from a page that came back short — so a page after `has_more: true` can come back empty only if rows were deleted in between, and the walk still ends at the first `has_more: false`.

- `next_cursor` string | null required

  Send it back as `cursor` for the next page; null exactly when `has_more` is false. Opaque: pass it back unchanged. Good only on the list that returned it, with a key of the same account and mode (test or live), and with the same filters.

- `limit` integer

  The page size applied, which is not necessarily the one asked for: a larger `limit` is clamped. Absent only on a GET /products request without `cursor` that sent no `limit`, which answers every match.

- `total` integer

  Every row matching the request's filters, on the lists that count them (orders, an order's events, assets and products).

- `page` integer deprecated

  The page number of the deprecated offset walk, on the lists that published it (orders and an order's events, on every request without `cursor`; products, on a request that sent `page`). Absent on a cursor walk, where no page number would be true.

- `pages` integer deprecated

  `total` over `limit`, rounded up, beside `page` (orders and an order's events). Absent on a cursor walk.

- `since` string | null

  On GET /order/{id}/events only: where to ask from next — the position of the last event in this answer, or, when it has none, the position the request read from. Send it back as `since` (with `wait_seconds` to wait for the next event). Unlike `next_cursor` it is not null at the end of the history; it is null only when the order has no events and the request named no position. Opaque, and bound like a cursor to this order, account and key mode.

Example response:

```json
{
  "status": "success",
  "data": [
    {
      "order_id": "test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk",
      "portal_order_id": "ord_wVK9ZiG-VYiPrlSeBUBmrUVk",
      "provider": "printedsimply",
      "made_by": null,
      "guarantee": null,
      "reference": null,
      "vat": null,
      "landed_cost": null,
      "status": "paid",
      "issue_reason": null,
      "total": "$72.99",
      "shipping": "$8.99",
      "total_cents": 7299,
      "shipping_cents": 899,
      "is_test": true,
      "test_mode": true,
      "channel": "api",
      "drop_id": null,
      "items": [
        {
          "id": "itm_fowT5-1iZwSnQcGX0ByoA64l",
          "sku": "sticker-die-cut-3x3",
          "quantity": 100,
          "unit_price_cents": 64
        }
      ],
      "line_items": [
        {
          "id": "itm_fowT5-1iZwSnQcGX0ByoA64l",
          "sku": "sticker-die-cut-3x3",
          "quantity": 100,
          "unit_price_cents": 64
        }
      ],
      "actions": {
        "cancel": true,
        "update_address": true,
        "update_shipping": true,
        "upgrade_shipping": true,
        "add_item": true,
        "update_item": true,
        "cancel_item": true
      },
      "tracking_token": "trk_AquJFqDLDkisK6Jh-HABHz9L",
      "tracking_number": null,
      "tracking_carrier": null,
      "tracking_url": null,
      "tracking": {
        "number": null,
        "carrier": null,
        "url": null,
        "page_url": "https://portal.printedsimply.com/track/eyJ2IjoxLCJ0IjoidHJhY2tpbmciLCJ0b2siOiJ0cmtfQXF1SkZxRExEa2lzSzZKaC1IQUJIejlMIn0.199c0c58721512fcccd784902c422ca4e827c6d09b7e495bbb4db9b06ae62003"
      },
      "estimated_delivery": {
        "earliest": "2026-10-20",
        "latest": "2026-10-26",
        "ships_by": "2026-10-14",
        "is_estimate": true
      },
      "reordered_from": null,
      "created_at": "2026-10-10T08:09:14.588Z",
      "updated_at": "2026-10-10T08:09:14.588Z"
    }
  ],
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a",
  "meta": {
    "page": 1,
    "pages": 1,
    "limit": 20,
    "total": 1,
    "has_more": false,
    "next_cursor": null
  }
}
```

### Errors

- `400` [validation_error](https://portal.printedsimply.com/docs/errors#validation_error)

  Invalid request body or parameters. When the request failed the operation's schema, `details` carries `formErrors` (problems with the request as a whole), `fieldErrors` (messages keyed by top-level field) and `errors`: one `{ pointer, code, message }` per refused value, `pointer` an RFC 6901 JSON pointer into the body (or into the object of query parameters, for a query parameter) — `/items/0/sku`, with `~` written `~0` and `/` written `~1`, and `""` for the request as a whole. POST /v1/orders/bulk answers `errors` as one `{ index, details }` per refused order, each `details` with its own pointers from the request body (`/orders/3/items/0/sku`); POST /v1/orders/multi-ship keeps `errors` as the `{ formErrors, fieldErrors }` it always published; POST /v1/mockups/generate and POST /v1/products/recommend keep their fields at the top of `details`, with `errors` beside them. A refusal of a rule the schema cannot state (an asset that does not exist, an expiry too soon) names it in `message` and carries no `errors`.

- `401`

  Unauthorized — invalid or missing API key

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

**POST `/order` Create an order**

Submit a print-on-demand order. Charges the default payment method, then submits to the production system. Returns order ID and charge breakdown.

**Credential**

An API key in `x-api-key`

**Scopes**

`orders:write`

### Parameters

- `Idempotency-Key` header · string (uuid)

  Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422 `idempotency_key_reuse` rather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503 `idempotency_unavailable` with `Retry-After`, and nothing runs or is charged: retry with the same key.

### Request body application/json, required

- `address_to` object required

- `address_to.name` string required

  non-empty

- `address_to.company` string

- `address_to.address1` string required

  non-empty

- `address_to.address2` string

- `address_to.city` string required

  non-empty

- `address_to.state` string required

  non-empty

- `address_to.zip` string required

  non-empty

- `address_to.country` string required

  ISO 3166-1 alpha-2 country code

  exactly 2 characters

- `address_to.phone` string

- `address_to.email` string (email)

- `address_from` object

  The same fields as `address_to`

- `shipping` object

- `shipping.carrier` string

- `shipping.method` string

  The shipping tiers this platform prices. Per parcel, not per item: `standard` by the parcel's weight class (POST /v1/shipping/estimate reports it), and for items made at the partner facility (apparel, mugs) at the larger of that and the partner's own estimate for the parcel; `priority` and `express` at their price and never below standard for the same parcel. An unrecognised value is refused with 400 validation_error rather than priced as `standard`. `standard` is always offered; `priority` and `express` are offered when priced (each at or above the USPS cost of the service), and until then a request for either is refused 400 `shipping_method_unavailable`, with `details.offered` naming the methods that are, and nothing is charged. `letter` is untracked letter mail for stickers and small vinyl decals from our own plant, offered only where the platform prices it, for an order whose every item qualifies, within a unit and an order-value ceiling: otherwise a request for it is refused 400 `letter_mail_unavailable` with `details.reason`, and nothing is charged. On the API it is never chosen for you — no default, estimate or storefront mapping picks it — and it is placed through POST /v1/order only (bulk and bundle orders ship by parcel). A hosted store is the one exception, and only where its merchant turned on small orders by letter in the dashboard's store editor (off by default, launched stores included): a qualifying basket bought on its store page or a buy link ships as a letter, and the shopper is told before paying. A letter has no carrier tracking: its order events and tracking page state the expected window, and POST /v1/order/{id}/missing-letter reships it once if it does not arrive.

  One of `standard` `priority` `express` `letter`

- `items` object[] required

  1 to 100 entries

- `items[].sku` string required

  non-empty

- `items[].quantity` integer required

  1 to 10000

- `items[].artwork_url` string (uri)

  The artwork for this line, as a public http(s) URL. Printed when set; otherwise the line's `print_file` is. Send one of the two: a line with neither has nothing to print.

- `items[].print_file` string

  The artwork for this line: a public http(s) URL, or the id of a file you uploaded with POST /assets (`ast_…`), resolved to that file's URL. Read when `artwork_url` is not set.

- `items[].variable_data` object

  A different name, number or QR payload on every unit of this line. Accepted only on flat goods made at our own plant (stickers, decals, magnets, posters); an order carrying it is produced there. Declare the named slots of the line's design, then give every unit a value for every slot: from `units` (one entry per unit, so its length must equal `quantity`), from a `sequence` that fills one slot, or both. An optional `qr.template` builds each unit's QR payload from its values. Preflighted before anything is priced or charged: a value longer than its slot, a `units` array of the wrong length, an undeclared slot, a missing value or another plant's SKU is refused with 422 `variable_data_invalid`, naming each problem by item, unit and slot. Never changes the price. Live orders and quotes carrying it are refused with 422 `variable_data_unavailable` until production is switched on; sandbox (ps_test_*) orders accept it end to end.

- `items[].variable_data.slots` object[] required

  The named text slots of the line's design, each with the most characters it holds.

  1 to 10 entries

- `items[].variable_data.slots[].name` string required

  Lower-case letters, digits and underscores, starting with a letter, e.g. name or serial. Each name once.

  matches ^[a-z][a-z0-9_]{0,39}$

- `items[].variable_data.slots[].max_length` integer required

  The longest value this slot prints. A longer value is refused as `overflow`, not shrunk or cut.

  1 to 200

- `items[].variable_data.units` object[]

  One object per unit, in print order: { slot name: value }. Its length must equal the line's quantity. Values are strings or numbers, with no line breaks. Omit it when a `sequence` fills every slot.

  1 to 10000 entries

- `items[].variable_data.sequence` object

  Numbers one slot per unit: prefix + (start + (unit - 1) × step), zero-padded to `pad` digits, e.g. A-0001, A-0002. A unit may not also set that slot.

- `items[].variable_data.sequence.slot` string required

  A declared slot.

  non-empty · at most 40 characters

- `items[].variable_data.sequence.prefix` string

  Printed before the number. Default empty.

  at most 40 characters

- `items[].variable_data.sequence.start` integer

  The first unit's number. Default 1.

  0 to 1000000000

- `items[].variable_data.sequence.step` integer

  Added per unit. Default 1.

  1 to 1000000

- `items[].variable_data.sequence.pad` integer

  Zero-pad the number to this many digits. Default 0 (no padding).

  0 to 12

- `items[].variable_data.qr` object

  Each unit's QR payload, built from its values.

- `items[].variable_data.qr.template` string required

  Text with {slot} placeholders, e.g. https://example.com/t/{serial}. Every placeholder must name a declared slot.

  non-empty · at most 1000 characters

- `items[].variable_data.qr.max_length` integer

  The longest payload accepted, for a small sticker whose code must stay scannable. Default 1000.

  1 to 1000

- `items[].artwork` object

  Optional: how this line's artwork was made, and who approved it and when. A declaration you state, stored on the line as sent (approved_by sanitized, approved_at in UTC), echoed on the order's reads (`line_items[].artwork`) and on its `order.created` webhook. It never changes the price, the routing or the artwork check, and a line without it is placed exactly as before. Strict: an unknown key is a 400 validation_error.

- `items[].artwork.provenance` string

  How the artwork was made: `human` (made by a person), `ai_assisted` (a person made it with a model's help) or `ai_generated` (a model made it).

  One of `human` `ai_assisted` `ai_generated`

- `items[].artwork.approved_by` string

  Who approved the artwork for print: a name or an email address, at most 200 characters. Control and invisible formatting characters are removed and whitespace collapsed before it is stored. Send it with approved_at.

  non-empty · at most 200 characters

- `items[].artwork.approved_at` string (date-time)

  When the artwork was approved: an ISO 8601 date-time with a time zone, not in the future. Stored and echoed in UTC. Send it with approved_by.

- `provider` string

  Where the order is made: always our own plant here. Omit it, or send `printedsimply`; any other value is refused with 422 `provider_not_available` before anything is priced or charged.

  Always `printedsimply`

  Default printedsimply

- `reference` string

  Your own identifier for this order — a marketplace order number, a cart id, whatever you key on. At most 200 characters, and no NUL byte. Stored verbatim, echoed on the create response, on GET /orders and GET /order/{id}, and on the order webhooks, and filterable with GET /orders?reference=. One reference is one order: while an order of your account placed in the last 30 days with a key of the same mode (a test key against test orders) and not cancelled carries it, another order with the same reference is refused 409 `duplicate_reference` before anything is charged, whatever its Idempotency-Key, and `details.order_id` names the order that has it. Use Idempotency-Key for exactly-once retries of one request; this catches the duplicate sent under a new key. When whether the reference is free cannot be checked, the order is not placed: 503 `reference_check_unavailable`, nothing charged.

  at most 200 characters

- `allow_duplicate_reference` boolean

  Place the order even when another order already carries its `reference` (see `reference`). For a reference that is not unique on your side, such as order numbers from two stores that each count from 1. Omit it, or send false, to have a duplicate refused with 409 `duplicate_reference`.

  Default false

- `vat` string

  Tax-registration number (VAT / IOSS / GST) to declare for this shipment. Stored, echoed on the create response and on GET /orders and GET /order/{id}, and forwarded to the fulfilling facility for the customs declaration. Supply it per order — an international run can ship to several destinations under different registrations. Omit it on domestic shipments.

- `quote_token` string

  A signed quote from POST /orders/quote. When present, the order is charged exactly that quote's locked total instead of being re-priced and the quoted facility routing is honored. Refused (400) when expired, issued to a different key, or when the order's items or shipping method differ from what was quoted.

- `payment_intent` string

  Live keys only. When a live order's card needs 3D Secure, the order is answered 402 `authentication_required` with `details.payment_intent` and `details.client_secret`: the amount opened on the card as an authorization only, nothing charged. Have the cardholder approve it in their browser with Stripe.js, loaded with `details.publishable_key` (the intent is on our Stripe account), calling `handleNextAction({ clientSecret })`, then send the same order again with this field set to `details.payment_intent` and a new Idempotency-Key. The order captures that authorization instead of charging the card again. It is refused with 402 `payment_failed` unless it was opened for this account, authorizes exactly this order's total and has not been captured — `details.code` says which check failed — and one the cardholder has not finished approving answers `authentication_required` again. An approval no order captures is released by the card network on its own. Ignored on a test key, which charges nothing.

  matches ^pi_[A-Za-z0-9]+$

- `approval` string

  `required` holds the order for a person's approval before anything is charged: the order is checked and priced exactly as without it (every refusal it would get, it gets now), then recorded `pending_approval` with nothing charged, nothing counted against the key's spend caps and nothing sent to production, and answered 202 `OrderAwaitingApproval` with `approval_url`, a signed page the person opens with no account. It shows each line's artwork, product, size, quantity and price, the total, the town it ships to and the line's artwork declaration. Approving it places the order through this same operation, as the key it was held with, at the total the person was shown: the checks run again, the card is charged and the order goes to production (`order.approval_approved`, then `order.created` and `order.paid`). If the price has moved, the card is declined or a spend cap refuses it, nothing is charged, `approval.status` reads `failed` with the reason, and the person may approve it again. Declined, or not decided within 72 hours (`approval_expires_at`), the order is cancelled with nothing charged (`order.approval_declined`, `order.approval_expired`); DELETE /order/{id}/cancel cancels it too, with nothing to refund. A held order takes no address, shipping or line change. A `quote_token` is honoured at approval only while it is valid (24 hours): approved later, the order places nothing (`invalid_quote`). Taken on an API key's order only (a platform OAuth token's is 400 validation_error, `details.field: approval`) and not with `payment_intent`. On POST /orders/bulk an entry carrying it fails validation.

  Always `required`

### Request

`POST /api/v1/order`

cURL:

```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://portal.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"
    }
  }'
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/order", {
  method: "POST",
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    "items": [
      {
        "sku": "sticker-die-cut-3x3",
        "quantity": 100,
        "artwork_url": "https://portal.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"
    }
  }),
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request
import uuid

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/order",
    method="POST",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
        "Content-Type": "application/json",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    data=json.dumps({
        "items": [
            {
                "sku": "sticker-die-cut-3x3",
                "quantity": 100,
                "artwork_url": "https://portal.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",
        },
    }).encode(),
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/order');
$body = <<<'JSON'
{
  "items": [
    {
      "sku": "sticker-die-cut-3x3",
      "quantity": 100,
      "artwork_url": "https://portal.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"
  }
}
JSON;
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
        'Content-Type: application/json',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS => $body,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

Order created successfully

`data`

- `order_id` string required

  WordPress order ID

- `provider` string required

  The fulfilment network the order was placed with.

- `tracking_token` string required

  Opaque token for the public tracking page. Safe to share; it names no account.

- `tracking_url` string required

  The buyer-facing tracking page for this order.

- `test_mode` boolean

  True when the order was placed with a sandbox key. Present on sandbox orders only.

- `lifecycle` string

  `simulated` on a sandbox order — the order was priced and recorded but never sent to a facility.

- `pricing_degraded` string

  Present only when the price could not be computed: `provider_unavailable` or `pricing_unavailable`. The breakdown is then an absence of a price, not a price of zero.

  One of `provider_unavailable` `pricing_unavailable`

- `portal_order_id` string required

  This platform's own id for the order (`ord_…`).

- `reference` string

  The reference you supplied, echoed back. Absent when you supplied none.

- `vat` string

  The tax-registration number you supplied, echoed back. Absent when you supplied none.

- `charged` string required

  Amount charged (e.g. '$12.50').

- `quote_id` string

  The id of the honored quote, echoed when the order was placed with a quote_token. Absent otherwise.

- `reordered_from` string

  On an order POST /order/{id}/reorder placed: the source order's `portal_order_id` (GET /order/{id} takes it). Absent on any other order.

- `variable_data` object[]

  One entry per line carrying `variable_data`: the variant proofs (first, last and longest unit) of what the order prints. Absent when no line carries it.

- `variable_data[].item_index` integer required

  The line's position in the request's `items`.

  at least 0

- `variable_data[].sku` string required

- `variable_data[].units` integer required

  Units on the line, each printed with its own values.

  at least 1

- `variable_data[].slots` object[] required

  Each declared slot with its `max_length` and the `longest` value any unit gives it: the headroom.

- `variable_data[].slots[].name` string required

- `variable_data[].slots[].max_length` integer required

- `variable_data[].slots[].longest` integer required

  Characters in this slot's longest value on the line.

- `variable_data[].qr` object

  Present when the line has a QR template: the payload ceiling and the longest payload on the line.

- `variable_data[].qr.max_length` integer required

- `variable_data[].qr.longest` integer required

- `variable_data[].proofs` object[] required

  The variant proofs: the first unit, the last, and the longest (the most printed characters, earliest on a tie), with the exact values and QR payload each will print. A unit that is more than one of the three is listed once, with each variant it stands for. Described rather than drawn: a plain line's artwork carries no slot positions to draw a value into.

- `variable_data[].proofs[].unit` integer required

  The unit's position on the line, 1-based, in print order.

  at least 1

- `variable_data[].proofs[].variants` string[] required

- `variable_data[].proofs[].values` object required

  Slot name to the text this unit prints.

- `variable_data[].proofs[].qr_payload` string

  What this unit's QR code encodes. Present when the line has a QR template.

- `variable_data[].live_production` boolean required

  Whether a LIVE order carrying this line would be accepted now. False while variable-data production is switched off: sandbox orders run end to end, and a live order or quote is refused with 422 variable_data_unavailable.

- `artwork_check` object

  Each own-plant line's artwork, read and graded before anything was charged (resolution at its print size, a file that is not there or not an image, CMYK, a die-cut sticker's transparency). On a test key, `would_refuse` says whether a live order would have been refused, and `missing_artwork` lists the own-plant lines that carry none. Absent when no line is made at our own plant with artwork, and (on a test key) none is made there without it.

- `artwork_check.lines` object[] required

  One entry per line made at our own plant (stickers, decals, magnets, posters) that carries artwork, in item order. A line another lane makes is graded there, not here.

- `artwork_check.lines[].item_index` integer required

  The line's position in the request's `items`, 0-based.

  at least 0

- `artwork_check.lines[].sku` string required

- `artwork_check.lines[].status` string required

  passed: read, nothing to say. warned: read, at least one warning. refused: at least one error (a live order is refused). unverified: the file could not be read just now (see the `unverified` finding); the order is not refused for it.

  One of `passed` `warned` `refused` `unverified`

- `artwork_check.lines[].width_px` integer

  The file's width in pixels. Absent when it was not read, or is vector.

  at least 1

- `artwork_check.lines[].height_px` integer

  The file's height in pixels.

  at least 1

- `artwork_check.lines[].print_size_in` number[]

  The print size resolution was measured against, width and height in inches: the SKU's `WxH`, else the line's `size`. Absent when neither names one.

  2 to 2 entries

- `artwork_check.lines[].effective_dpi` integer

  Pixels per inch with the artwork printed as large as the print size allows (turned a quarter if that fits it larger).

  at least 1

- `artwork_check.lines[].min_dpi` integer

  The product's recommended minimum DPI. Below 75% of it is an error.

  at least 1

- `artwork_check.lines[].findings` object[] required

- `artwork_check.lines[].findings[].code` string required

  One of `unreachable` `not_an_image` `low_resolution` `below_recommended_resolution` `cmyk` `no_transparency` `touches_edge` `no_bleed` `content_in_bleed` `no_cutline` `size_unknown` `vector` `unverified` `format_not_accepted` `not_fetchable` `design_embeds_raster` `generated_design` `placement_clipped` `geometry_unverified`

- `artwork_check.lines[].findings[].severity` string required

  error refuses a live order; warning and info never do.

  One of `error` `warning` `info`

- `artwork_check.lines[].findings[].message` string required

  What was found and what to do about it, naming the line.

- `artwork_check.missing_artwork` object[]

  Test keys (ps_test_*) only, on POST /order, POST /orders/bulk and POST /{id}/item: the lines made at our own plant that carry no artwork at all, which a live request is refused for with 422 `artwork_required`. Absent when there are none.

- `artwork_check.missing_artwork[].order_index` integer

  POST /orders/bulk's 422 only: the order's position in `orders`.

  at least 0

- `artwork_check.missing_artwork[].item_index` integer required

  The line's position in the order's `items`, 0-based.

  at least 0

- `artwork_check.missing_artwork[].sku` string required

- `artwork_check.would_refuse` boolean

  Test keys (ps_test_*) only: whether a live order with these lines would have been refused, with 422 `artwork_not_printable` or (for a line in `missing_artwork`) `artwork_required`. A test key is never refused for artwork, so placeholder artwork still places a sandbox order. Absent on a live order, which either was refused or was not.

- `address_check` object

  What POST /addresses/validate finds in `address_to`, warnings included. A live order whose address has an `error` finding is refused 422 address_invalid before anything is charged; on a test key `valid: false` here says a live order would have been.

- `address_check.valid` boolean required

  False when any finding is an `error`.

- `address_check.checked` boolean required

  False for an address outside the US, which is passed through as written.

- `address_check.normalized` object required

  The address in USPS style: upper case, no punctuation, ZIP+4 hyphenated, the state as its code, a unit typed into address1 moved to address2. Name, company, phone and email are trimmed and otherwise as sent.

- `address_check.normalized.name` string

- `address_check.normalized.company` string

- `address_check.normalized.address1` string required

- `address_check.normalized.address2` string

- `address_check.normalized.city` string required

- `address_check.normalized.state` string required

- `address_check.normalized.zip` string required

- `address_check.normalized.country` string required

- `address_check.normalized.phone` string

- `address_check.normalized.email` string

- `address_check.findings` object[] required

- `address_check.findings[].code` string required

  The `delivery_point_*` codes come from delivery-point verification, which only a live POST /order, POST /orders/bulk entry or PUT /order/{id}/address runs, on a US address the other checks pass, and only where the deployment has USPS verification configured: `delivery_point_not_found` (`error`) — USPS answered that no delivery point exists at the street address; `delivery_point_incomplete` (`warning`) — the building exists but its unit is missing or not confirmed, or more than one address matched; `delivery_point_confirmed` (`info`). When USPS cannot be reached the order proceeds without them.

  One of `zip_format` `state_unknown` `zip_state_mismatch` `zip_state_border` `zip_unassigned` `po_box` `military_state` `military` `address2_split` `not_checked` `delivery_point_not_found` `delivery_point_incomplete` `delivery_point_confirmed`

- `address_check.findings[].severity` string required

  `error`: the address cannot be right as written, and a live order or quote is refused with 422 address_invalid. `warning`: worth a look, never refused. `info`: what normalization did, or that nothing was checked.

  One of `error` `warning` `info`

- `address_check.findings[].field` string required

  One of `address1` `address2` `city` `state` `zip` `country`

- `address_check.findings[].message` string required

- `breakdown` object required

- `breakdown.items` string required

- `breakdown.shipping` string required

- `breakdown.processing_fee` string required

  Compatibility field for platform and card-processing surcharges. Zero on orders priced under the current no-added-fees policy; retained on historical records.

- `breakdown.platform_fee` string

  The platform fee as a line of its own. Always 0: there is no platform fee on any order, on any plan (since 2026-10-17). Kept so a client that reads it still parses.

- `breakdown.card_fee` string

  Card processing on everything else the order is charged, passed through at cost.

- `breakdown.total` string required

- `shipping` object required

  The shipping the figure in `breakdown` is for: its method, and the surcharges that figure already holds, itemised. Nothing here is added to the total.

- `shipping.method` string required

  The shipping method the figure was priced for.

- `shipping.surcharges` object[] required

  Compatibility itemisation, never added to the total. Current quotes and new orders contain no seasonal peak-postage surcharge: PrintedSimply absorbs the carrier expense, so this array is empty. Historical stored orders may retain their original itemisation; a fee-bearing old quote must be refreshed before a new charge.

- `shipping.surcharges[].code` string required

  `peak_season`: the carrier's temporary holiday price change.

  Always `peak_season`

- `shipping.surcharges[].reason` string required

  What it is, in words.

- `shipping.surcharges[].amount_cents` integer required

  How much of the shipping figure this is, in cents, folded the way that figure is (all-in where the figure is all-in).

  at least 1

- `shipping.surcharges[].window` object required

  The days it applies on, both inclusive.

- `shipping.surcharges[].window.from` string (date) required

  YYYY-MM-DD, UTC.

- `shipping.surcharges[].window.through` string (date) required

  YYYY-MM-DD, UTC.

- `shipping.surcharges[].source` string (uri) required

  Where the amount and the dates come from: the carrier's own announcement.

- `shipping.surcharges[].included_in` string required

  Always `shipping`: the figure this is part of. Never an additional charge.

  Always `shipping`

- `estimated_delivery` object | null

  When the order should arrive, as an estimate: the quote's `estimated_delivery` for the order as placed, counted from now. Null on a lane that is not one of our own facilities. GET /order/{id}, GET /orders and the order's `order.in_production`, `order.quality_check` and `order.shipped` events carry the same date.

- `estimated_delivery.earliest` string (date) required

  YYYY-MM-DD, UTC: the soonest the order should arrive.

- `estimated_delivery.latest` string (date) required

  YYYY-MM-DD, UTC: the latest it should, before anything goes wrong.

- `estimated_delivery.ships_by` string (date) required

  YYYY-MM-DD, UTC: the day it should leave the facility, when its production estimate ends, counted the same way.

- `estimated_delivery.is_estimate` boolean required

  Always true: say it as an estimate wherever it is shown.

  Always `true`

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "order_id": "test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk",
    "portal_order_id": "ord_wVK9ZiG-VYiPrlSeBUBmrUVk",
    "provider": "merchfarm",
    "tracking_token": "trk_AquJFqDLDkisK6Jh-HABHz9L",
    "tracking_url": "https://portal.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
    },
    "breakdown": {
      "items": "$64.00",
      "shipping": "$8.99",
      "processing_fee": "$0.00",
      "platform_fee": "$0.00",
      "card_fee": "$0.00",
      "total": "$72.99"
    },
    "shipping": {
      "method": "standard",
      "surcharges": []
    },
    "estimated_delivery": {
      "earliest": "2026-10-20",
      "latest": "2026-10-26",
      "ships_by": "2026-10-14",
      "is_estimate": true
    }
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `400` [validation_error](https://portal.printedsimply.com/docs/errors#validation_error)

  Invalid request body or parameters. When the request failed the operation's schema, `details` carries `formErrors` (problems with the request as a whole), `fieldErrors` (messages keyed by top-level field) and `errors`: one `{ pointer, code, message }` per refused value, `pointer` an RFC 6901 JSON pointer into the body (or into the object of query parameters, for a query parameter) — `/items/0/sku`, with `~` written `~0` and `/` written `~1`, and `""` for the request as a whole. POST /v1/orders/bulk answers `errors` as one `{ index, details }` per refused order, each `details` with its own pointers from the request body (`/orders/3/items/0/sku`); POST /v1/orders/multi-ship keeps `errors` as the `{ formErrors, fieldErrors }` it always published; POST /v1/mockups/generate and POST /v1/products/recommend keep their fields at the top of `details`, with `errors` beside them. A refusal of a rule the schema cannot state (an asset that does not exist, an expiry too soon) names it in `message` and carries no `errors`.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `402` [no_payment_method](https://portal.printedsimply.com/docs/errors#no_payment_method) [payment_failed](https://portal.printedsimply.com/docs/errors#payment_failed) [authentication_required](https://portal.printedsimply.com/docs/errors#authentication_required) [fraud_review](https://portal.printedsimply.com/docs/errors#fraud_review) [spend_cap_exceeded](https://portal.printedsimply.com/docs/errors#spend_cap_exceeded)

  The order was refused before anything was charged or placed. `no_payment_method`, `payment_failed` or `authentication_required` on the card; `fraud_review` from the risk gate; `spend_cap_exceeded` when the key's per-order or daily cap would be passed, with the cap and the day's spend in the message. The cap counts what this API charges for the order: here every order is made, and charged, at our own plant. A test key meets the spend-cap refusal exactly as a live key would.

- `403` [sandbox_only](https://portal.printedsimply.com/docs/errors#sandbox_only)

  sandbox_only — the account is restricted to sandbox mode and the request used a live key. Use a test key (ps_test_*) or contact support to enable live orders.

- `409` [duplicate_reference](https://portal.printedsimply.com/docs/errors#duplicate_reference) [idempotency_conflict](https://portal.printedsimply.com/docs/errors#idempotency_conflict)

  duplicate_reference — an order of your account already carries this `reference` (placed in the last 30 days with a key of the same mode, not cancelled); nothing was charged. `details.order_id` and `details.portal_order_id` name it; read it instead of placing it again. `details.in_flight: true` instead means another request with this reference is being placed right now. Send `allow_duplicate_reference: true` to place a second order under the same reference on purpose. Also idempotency_conflict, while a request with the same Idempotency-Key is still in flight.

- `413` [payload_too_large](https://portal.printedsimply.com/docs/errors#payload_too_large)

  payload_too_large — the request body exceeded this route's byte cap (256 KB unless the route raises it with a written reason). It is refused before it is parsed, so nothing was created. Send fewer rows, or use the endpoint's own asynchronous door where it has one.

- `422` [variable_data_invalid](https://portal.printedsimply.com/docs/errors#variable_data_invalid) [variable_data_unavailable](https://portal.printedsimply.com/docs/errors#variable_data_unavailable) [artwork_not_printable](https://portal.printedsimply.com/docs/errors#artwork_not_printable) [artwork_required](https://portal.printedsimply.com/docs/errors#artwork_required) [destination_unavailable](https://portal.printedsimply.com/docs/errors#destination_unavailable) [below_cost_floor](https://portal.printedsimply.com/docs/errors#below_cost_floor) [address_invalid](https://portal.printedsimply.com/docs/errors#address_invalid) `max_provider_cost_required` [sku_not_available](https://portal.printedsimply.com/docs/errors#sku_not_available) [provider_not_available](https://portal.printedsimply.com/docs/errors#provider_not_available)

  variable_data_invalid or variable_data_unavailable — as POST /orders/quote: `items[].variable_data` failed its preflight, or a live order carries it while production is switched off. artwork_not_printable — a live order's artwork on a line made at our own plant cannot be printed as it is: `details.problems[]` names each line by `item_index` with a code (unreachable, not_an_image, low_resolution) and a message saying what it needs, and `details.artwork_check` carries every line's verdict. Nothing was charged and the Idempotency-Key is free for the corrected order. artwork_required — a live order has a line made at our own plant with no artwork at all (no `artwork_url`, no `print_file`), so there is nothing to print: `details.items[]` names every such line by `item_index` and `sku`, checked before any file is read, and nothing was charged. A test key is never refused for artwork; its 200 carries the same verdict with `artwork_check.would_refuse`, and a line with no artwork in `artwork_check.missing_artwork`. destination_unavailable — the address is outside the US and the order would be made at our own plant, which ships to US addresses only: `details.reason` is `destination_not_us` and `details.country` names the destination. A test key gets the same answer. below_cost_floor — the order would be made at our own plant and would cost more to make and ship than it is charged, at worst-case postage, and its loss is not one our cost floor accepts: only today's named losses are, no larger than they are today — the flat standard parcel under its postage, for a mailer up to a pound or a poster tube up to two, and list-priced goods short only of the per-order handling cost (or a partner SKU would be charged below what the partner bills for it). `details.skus` names the items and `details.contribution_cents` how far below zero the order falls. Nothing was charged; add units, combine it with another order, or choose another shipping method on offer. A test key gets the same answer. address_invalid — a live order's `address_to` cannot be right as written: `details.findings[]` names each `error` (`zip_format`, `state_unknown`, `zip_state_mismatch`, or `delivery_point_not_found`: USPS has no delivery point at the street address, where the deployment has USPS verification configured) with its field, and `details.address_check` is the whole check, as POST /addresses/validate answers it. Nothing was charged; a test key is never refused for it and its 200 carries `address_check`. sku_not_available — a line names a product our own plant does not make: only PrintedSimply's own products (stickers, decals, labels, magnets and posters, as GET /products lists them) are sold here. `details.skus` names each one. Asked before anything is priced, so nothing was priced or charged. provider_not_available — `provider` names a lane other than our own plant. Orders here are made by PrintedSimply: omit `provider`, or send `printedsimply`. `details.provider` echoes what was sent and `details.available` lists what is accepted. Nothing was priced or charged.

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

- `500` [order_error](https://portal.printedsimply.com/docs/errors#order_error)

  order_error — the order was placed and CHARGED but the confirmation could not be returned or the record could not be written. Do NOT retry: a retry places and charges a second one. Look the order up, or quote the request_id to support. The one exception carries its own message — "Payment refunded. Retry with a new Idempotency-Key."

- `502`

  Production system error

- `503` [provider_unavailable](https://portal.printedsimply.com/docs/errors#provider_unavailable) [pricing_error](https://portal.printedsimply.com/docs/errors#pricing_error) [unknown_sku](https://portal.printedsimply.com/docs/errors#unknown_sku) [reference_check_unavailable](https://portal.printedsimply.com/docs/errors#reference_check_unavailable) [idempotency_unavailable](https://portal.printedsimply.com/docs/errors#idempotency_unavailable)

  provider_unavailable — the facility that produces these items is not accepting orders right now (unconfigured, or its circuit breaker is open) or (`details.reason: plant_unreachable`, with `Retry-After`) our plant gave no answer about a SKU — the request did not reach it, timed out, or came back 5xx — which is a fact about the plant and not the SKU: a definite answer that it does not carry the SKU is still a 400 pricing_error. Nothing stands charged: the pre-charge refusal never took money, and the post-charge one refunded before answering. Retry later; a pre-charge 503 may be retried with the same Idempotency-Key, a post-charge 503 needs a new one. A test key is not refused for an unanswering plant: its order is recorded at $0 with `pricing_degraded: pricing_unavailable`. Only for a SKU the catalog has, though: one the price book does not price, our plant's products do not name and the synced catalog does not hold is refused 400 unknown_sku with `details.skus`, as it is on POST /order, POST /orders/quote and POST /pricing/estimate alike. reference_check_unavailable — the order carries a `reference` and whether another order already holds it could not be checked, so it was not placed and nothing was charged; retry the same request after `Retry-After`. idempotency_unavailable — the request carried an `Idempotency-Key` and the ledger that makes it mean at-most-once could not be reached, so nothing ran and nothing was charged; retry the same request with the same key after `Retry-After`.

**GET `/order/{id}` Get order details**

Retrieve one order by its `order_id` or its `portal_order_id` — either id resolves the same order, and the response names it by its `order_id`. The read carries where the parcel is: `tracking` (number, carrier, carrier link and the hosted tracking page) and the flat `tracking_number`, `tracking_carrier` and `tracking_url`, the same values `GET /v1/orders` and the `order.shipped` webhook report, with `created_at` and `updated_at`. Alongside the fulfilment record, the response carries the fields this API is authoritative for: the `reference` and `vat` you supplied, `made_by` (the network shop that produced it), the `guarantee` in force on the order — what you were promised at quote time, whether it held, and any remedy recorded — `artwork_access`, the record of which independent shops were given this order's print files, how many times each fetched them, and whether they can still be fetched, and `line_items`, the order's lines with the id of each. That id is the `{itemId}` in `PUT /v1/{id}/item/{itemId}` and `DELETE /v1/{id}/item/{itemId}/cancel`, and the `order_item_id` a `size_exchange` return requires. It is deliberately NOT the `items[].id` in the fulfilment record beside it: that one is the facility's own line id, in a different namespace, and none of those three operations accept it. `actions` says which changes the order still takes (cancel, address, shipping, and adding, changing or cancelling a line), from the same checks those operations refuse on, so a client can offer an action without trying it first.

**Credential**

An API key in `x-api-key`

**Scopes**

`orders:read`

### Parameters

- `id` path · string required

  The order's `order_id`, or its `portal_order_id` (`ord_…`): either resolves the same order. An id the order answered to before it was re-routed resolves too.

### Request

`GET /api/v1/order/{id}`

cURL:

```bash
curl "https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk" \
  -H "x-api-key: ps_test_YOUR_KEY"
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk", {
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
  },
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
    },
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

Order details

`data`

- `guarantee` object | null

  The delivery guarantee in force on this order, or null when the order carries none (guarantees are issued only on lanes whose measured on-time record clears the floor, and only while the feature is enabled). The keys mirror the `guarantee` block returned by POST /orders/quote, so this is the object you were quoted, read back, plus what has since happened to it.

- `guarantee.guarantee_id` string required

  Same id the quote issued.

- `guarantee.deliver_by` string (date-time) required

  The date this order is guaranteed to arrive by.

- `guarantee.quality_standard` string required

  The quality standard guaranteed ("standard" in v1).

- `guarantee.confidence` number required

  Lower bound on the lane's on-time probability at issue time (Wilson), between 0 and 1.

- `guarantee.remedy` object required

  The promise. cap_cents is the ceiling on what a breach can pay — never an amount owed or paid; read `remedies` for what was actually recorded.

- `guarantee.remedy.type` string required

  credit is the only remedy issued today, and the one that settles as a real refund. remake and expedite are reserved for the ops-driven remedies; one would settle as not_applicable rather than pay.

  One of `credit` `remake` `expedite`

- `guarantee.remedy.cap_cents` integer required

- `guarantee.status` string required

  issued while the promise is open, honored once the order was delivered on time, remedied once it was not. There is deliberately no `breached` value: a breach and its capped remedy are recorded in one transaction (so two overlapping sweeps cannot both pay out), so the terminal state of a broken promise is `remedied` and whether the money moved is the remedy's own status.

  One of `issued` `honored` `remedied`

- `guarantee.issued_at` string (date-time) required

- `guarantee.resolved_at` string (date-time) | null required

  When the guarantee was honored or remedied; null while still open.

- `guarantee.remedies` object[] required

  Remedies recorded against this guarantee. Empty unless it was breached.

- `guarantee.remedies[].type` string required

  One of `credit` `remake` `expedite`

- `guarantee.remedies[].amount_cents` integer required

  What was recorded, capped at the order value at issue.

- `guarantee.remedies[].status` string required

  applied is the only value that means money moved — a real refund on the order's charge. not_applicable is the ordinary sandbox outcome (a test order has no charge) and also covers a remake/expedite, which is an ops action rather than an automatic payment. failed is an owed remedy that has not been paid, kept distinct from not_applicable so it stays visible.

  One of `pending` `applied` `failed` `not_applicable`

- `guarantee.remedies[].applied_at` string (date-time) | null required

- `guarantee.remedies[].created_at` string (date-time) required

- `order_id` string

  The fulfilment network's order id: the one every order operation takes, whichever id this request was made with. Present on every order; on a live one the facility's own body supplies it when it carries one.

- `portal_order_id` string

  This platform's own id for the order (`ord_…`), the id in the dashboard's order page. `GET /v1/order/{id}` and every other order operation accept it in place of `order_id`. Present on every order; on a live one the facility's own body supplies it when it carries one.

- `reference` string | null

  The `reference` you supplied at creation, echoed back.

- `vat` string | null

  The `vat` you supplied at creation, echoed back.

- `status` string

  The order's lifecycle status. The published vocabulary is the one `GET /v1/orders` filters on. Always on a sandbox order; on a live one it comes from the facility's own body. `pending_approval` on an order held for a person's approval (see `approval`), which is served from our own record, as a sandbox order is, until it is placed.

- `approval` object

  On an order placed with `approval: "required"` only: whether a person has approved it, the link they decide at while it still decides, and their decision. A hold whose link has expired is closed by this read (`expired`, the order `cancelled`).

- `approval.status` string required

  `pending`: waiting for the person. `approving`: a decision is placing it now. `approved`: approved and placed. `declined`: the person declined; the order is cancelled. `expired`: nobody decided in time; cancelled. `cancelled`: you cancelled it before anyone decided. `failed`: an approval did not place it (see `failure`); nothing was charged and it may be approved again until it expires.

  One of `pending` `approving` `approved` `declined` `expired` `cancelled` `failed`

- `approval.url` string | null required

  The signed page the person approves or declines on, with no account. Null once it no longer decides (decided, expired, cancelled).

- `approval.expires_at` string (date-time) | null required

  When the link stops deciding: 72 hours after the hold.

- `approval.decided_at` string (date-time) | null required

  When the person approved or declined it. Null until then.

- `approval.decided_by` string | null required

  The name the person typed with their decision, if any. No address or other identifier is recorded.

- `approval.failure` object | null required

  Why the last approval placed nothing: the code and message POST /order answered. Null unless an approval failed.

- `approval.failure.code` string required

- `approval.failure.message` string required

- `approval.failure.at` string (date-time) required

- `issue_reason` string | null

  Why the order is `issue_flagged`, when the facility's status said: the same code `order.issue` carries. Null when the order is not flagged, or is flagged without a reason that fits one of these. Always on a sandbox order (a simulation names it); on a live one it is filled from our record wherever the facility's own body carries no key of that name.

  One of `artwork_unprintable` `address_undeliverable` `shipment_exception` `on_hold` `cancellation_pending`

- `provider` string

  The fulfilment network that holds the order. Always on a sandbox order; on a live one it comes from the facility's own body.

- `made_by` object | null

  The independent network shop that produced an order, or null when an in-house lane did. `GET /v1/orders` and `GET /v1/order/{id}` build it from the same helper and both `$ref` this, so they cannot describe one fact two ways — which they did, one of them calling it a string. Every order placed on this portal is made at our own plant, so it is null on them; a shop is named only on an order placed before that.

- `made_by.name` string required

  The shop's display name.

- `made_by.slug` string required

  The shop's directory slug.

- `made_by.profile_url` string (uri) required

  Its public profile page on this platform.

- `total_cents` integer

  What the order was charged, in cents. Sandbox orders only — a live response reports money the way the facility does.

- `shipping_cents` integer

  The shipping charge, in cents. Sandbox orders only, like `total_cents`.

- `tracking_token` string | null

  The opaque token for this order's hosted tracking page, as `GET /v1/orders` reports it; null on an order with none.

- `tracking_number` string | null

  The carrier's tracking number; null until the order ships. The same value as `tracking.number`, the list's `tracking_number` and the `order.shipped` webhook's.

- `tracking_carrier` string | null

  The carrier that holds the shipment; null until the order ships.

- `tracking_url` string | null

  The carrier's tracking link; null until the order ships. (The create response's `tracking_url` is the hosted tracking page, which this read publishes as `tracking.page_url`.)

- `tracking` object

  Where the parcel is. The same block on `GET /v1/order/{id}` and on every `GET /v1/orders` row. `number`, `carrier` and `url` are null until the order ships, and equal the flat `tracking_number`, `tracking_carrier` and `tracking_url` beside it. On a live order whose facility reports its own `tracking` object, that object's keys are kept and these are added where it has none.

- `tracking.number` string | null required

  The carrier's tracking number; null until the order ships.

- `tracking.carrier` string | null required

  The carrier that holds the shipment; null until the order ships.

- `tracking.url` string | null required

  The carrier's tracking link; null until the order ships.

- `tracking.page_url` string (uri) | null required

  The order's hosted tracking page, the link to give your customer. It exists from the moment the order is placed and shows each step, including before the carrier has a number; the create response's `tracking_url` opens the same page. Null only on an order with no tracking token.

- `estimated_delivery` object | null

  When the order should arrive, as an estimate counted from placement; null where no honest date exists (see OrderEstimatedDelivery). Ours on a live order too: a key of that name in the facility's body is replaced.

- `estimated_delivery.earliest` string (date) required

  YYYY-MM-DD, UTC: the soonest the order should arrive.

- `estimated_delivery.latest` string (date) required

  YYYY-MM-DD, UTC: the latest it should, before anything goes wrong.

- `estimated_delivery.ships_by` string (date) required

  YYYY-MM-DD, UTC: the day it should leave the facility, when its production estimate ends, counted the same way.

- `estimated_delivery.is_estimate` boolean required

  Always true: say it as an estimate wherever it is shown.

  Always `true`

- `reordered_from` string | null

  On an order POST /order/{id}/reorder placed: the source order's `portal_order_id` (this operation takes it), as the order's row records it and the reorder's own answer stated it. Null on any other order. Ours on a live order too: a key of that name in the facility's body is replaced.

- `created_at` string (date-time)

  When the order was placed. On a live order the facility's own body supplies it when it carries one.

- `updated_at` string (date-time)

  When the order last changed. On a live order the facility's own body supplies it when it carries one.

- `test_mode` boolean

  Present, and true, only on a sandbox order. Its absence is how a live order is told apart.

- `artwork_access` object[]

  Every print-file grant ever issued on this order, oldest first. Empty when no independent shop has been given this order's artwork — which is every order produced in-house, and every sandbox order.

- `artwork_access[].asset_id` string required

  Stable handle for the file this grant covers — a digest of the artwork URL, so two order lines printing the same design share one grant and one count. The artwork URL itself is deliberately not returned: it is held only so the shop never receives it.

- `artwork_access[].location` string | null required

  Print area ("front", "back", …) or "mockup"; null when the order line named none.

- `artwork_access[].shop` object | any required

  The shop the file was released to. Null only if that shop's record has since been deleted — a suspended or de-listed shop is still named here, because an audit entry that disappeared with the shop's directory listing would hide exactly the disclosure worth reading.

- `artwork_access[].shop.name` string

- `artwork_access[].shop.slug` string

- `artwork_access[].status` string required

  live while the signed window is open, expired once it closed without being re-issued (the ordinary end state — we simply stop re-issuing), revoked if we withdrew it first. live is necessary but not sufficient for a fetch to succeed: access also requires the shop to still be live on the network and the order not to be cancelled, both of which this response already reports via made_by and status.

  One of `live` `expired` `revoked`

- `artwork_access[].granted_at` string (date-time) required

  When the file was first released to this shop.

- `artwork_access[].expires_at` string (date-time) required

  End of the current signed window. In the past on an expired grant.

- `artwork_access[].access_count` integer required

  Fetches recorded against this grant. 0 means the file was granted and never downloaded.

- `artwork_access[].last_accessed_at` string (date-time) | null required

  Most recent fetch, or null if there has never been one.

- `artwork_access[].revoked_at` string (date-time) | null required

- `artwork_access[].revoked_reason` string | null required

  Why the grant was withdrawn; null unless status is revoked. A closed set: provider_declined (the shop refused the order), submission_failed (submission failed after the URLs had gone out), order_cancelled, order_rerouted (the order moved to another maker), reroute_submit_failed (the replacement refused it too).

  One of `provider_declined` `submission_failed` `order_cancelled` `order_rerouted` `reroute_submit_failed` `account_purged`

- `landed_cost` object | null

  What the border cost on this order, as recorded for the parcel that ships. Null on a domestic shipment — there is no border, so there is nothing to state, and a row of zeros would read like a customs assessment that came to nothing. When a cross-border order has no stored figures, `recorded` is false and only `destination_country` and `not_recorded_reason` are present: the duty, tax and FX rates that applied on the day are not recoverable, and backfilling them with today's rates would be a fabricated record rather than a missing one. That is what an order placed before these figures were stored looks like, and also a free replacement order, which carries no charged goods value to assess. The figures are a snapshot, never recomputed on read, so a stale figure cannot be mistaken for a current one — but they ARE re-recorded when the parcel itself changes, because the border is costed against the destination, the goods and the shipping charge. Change the address, add or cancel a line, change a quantity or change the shipping method and the order is costed again at that moment; `recorded_at` moves with it, and it is the order's creation time until something does. An order re-routed to a domestic address goes back to null. `estimated` stays true throughout: on DAP the courier bills the recipient directly, so we never learn what customs actually assessed.

- `landed_cost.recorded` boolean required

  False when no landed cost was stored for this order.

- `landed_cost.destination_country` string

  ISO 3166-1 alpha-2.

- `landed_cost.not_recorded_reason` string

  Present only when `recorded` is false.

- `landed_cost.recorded_at` string (date-time)

  When the figures were computed — never the moment of the read. The order's creation time, or the moment of the last change to its destination, lines or shipping charge.

- `landed_cost.duty_basis` string

  DAP today: goods and shipping are collected at checkout, and the courier bills the recipient for duties and import tax on delivery.

  One of `DDP` `DAP`

- `landed_cost.duty_rate` number

- `landed_cost.tax_rate` number

- `landed_cost.breakdown` object

- `landed_cost.breakdown.goods_cents` integer

- `landed_cost.breakdown.shipping_cents` integer

- `landed_cost.breakdown.duties_cents` integer

- `landed_cost.breakdown.tax_cents` integer

- `landed_cost.total_landed_cents` integer

  What the buyer ends up paying in total.

- `landed_cost.pre_collected_cents` integer

  Collected at checkout. Equals goods + shipping — never more than was charged.

- `landed_cost.due_on_delivery_cents` integer

  Billed by the courier on delivery. Sums with `pre_collected_cents` to the total.

- `landed_cost.presentment` object

- `landed_cost.presentment.currency` string

- `landed_cost.presentment.amount_cents` integer

- `landed_cost.presentment.minor_units` integer

- `landed_cost.presentment.fx_rate` number

- `landed_cost.estimated` boolean

- `landed_cost.note` string

- `line_items` object[]

  The order's lines, each with the id our own item and return operations resolve. Present on live and sandbox orders alike.

- `line_items[].id` string required

  This line's id. Use it as `{itemId}` on PUT /v1/{id}/item/{itemId} and DELETE /v1/{id}/item/{itemId}/cancel.

- `line_items[].sku` string required

- `line_items[].quantity` integer required

  at least 1

- `line_items[].unit_price_cents` integer required

- `line_items[].artwork` object

  The line's artwork declaration, as the order accepted it: how the artwork was made, and who approved it and when. Absent on a line that declared none.

- `line_items[].artwork.provenance` string

  How the artwork was made: `human` (made by a person), `ai_assisted` (a person made it with a model's help) or `ai_generated` (a model made it).

  One of `human` `ai_assisted` `ai_generated`

- `line_items[].artwork.approved_by` string

  Who approved the artwork for print: a name or an email address, at most 200 characters. Control and invisible formatting characters are removed and whitespace collapsed before it is stored. Send it with approved_at.

  non-empty · at most 200 characters

- `line_items[].artwork.approved_at` string (date-time)

  When the artwork was approved: an ISO 8601 date-time with a time zone, not in the future. Stored and echoed in UTC. Send it with approved_by.

- `actions` object

  Which changes this order still takes, read from the same checks the operations refuse on, so a client can show or offer an action without trying it first. `true` means the order's own state does not refuse it; the request is still checked as usual (the key's scope; the line an item action names, which must exist and, on a live order at our own plant, carry its artwork; and on a live order the facility, which can refuse a change with 502 fulfillment_error). `false` names the refusal the operation would give. Live PrintedSimply orders have add_item, update_item and upgrade_shipping false; only supported no-cost or downward shipping requests remain requestable. On other orders a shopper paid for (a drop, a checkout link or an agent checkout), `update_item` and `update_shipping` stay true because a decrease or a downgrade is allowed, while an increase or an upgrade is refused 409 shopper_paid_order, as `add_item` is.

- `actions.cancel` boolean required

  DELETE /order/{id}/cancel. True only before production (pending_payment, paid or accepted); false once the order is cancelled (400 already_cancelled) or past that (409 not_cancellable).

- `actions.update_address` boolean required

  PUT /order/{id}/address. False on an order an independent shop made (501 not_supported_by_provider) and once the order is past the point of change (409 order_not_modifiable).

- `actions.update_shipping` boolean required

  PUT /order/{id}/shipping. False for the reasons `update_address` is.

- `actions.upgrade_shipping` boolean required

  Positive-cost PUT /order/{id}/shipping upgrades. False for live PrintedSimply orders (501 not_supported_by_provider), shopper-paid orders (409 shopper_paid_order), and when update_shipping is false. A downgrade or unchanged method can still be allowed when this is false.

- `actions.add_item` boolean required

  POST /{id}/item. False for the reasons `update_address` is, on live PrintedSimply orders (501 not_supported_by_provider), and on an order a shopper paid for (409 shopper_paid_order).

- `actions.update_item` boolean required

  PUT /{id}/item/{itemId}. False for the reasons `update_address` is and on live PrintedSimply orders (501 not_supported_by_provider). An unchanged quantity is still an allowed no-op.

- `actions.cancel_item` boolean required

  DELETE /{id}/item/{itemId}/cancel. False for the reasons `update_address` is.

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "order_id": "test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk",
    "portal_order_id": "ord_wVK9ZiG-VYiPrlSeBUBmrUVk",
    "reference": null,
    "vat": null,
    "status": "paid",
    "issue_reason": null,
    "provider": "merchfarm",
    "made_by": null,
    "guarantee": null,
    "artwork_access": [],
    "line_items": [
      {
        "id": "itm_fowT5-1iZwSnQcGX0ByoA64l",
        "sku": "sticker-die-cut-3x3",
        "quantity": 100,
        "unit_price_cents": 64
      }
    ],
    "actions": {
      "cancel": true,
      "update_address": true,
      "update_shipping": true,
      "upgrade_shipping": true,
      "add_item": true,
      "update_item": true,
      "cancel_item": true
    },
    "total_cents": 7299,
    "shipping_cents": 899,
    "landed_cost": null,
    "tracking_token": "trk_AquJFqDLDkisK6Jh-HABHz9L",
    "tracking_number": null,
    "tracking_carrier": null,
    "tracking_url": null,
    "tracking": {
      "number": null,
      "carrier": null,
      "url": null,
      "page_url": "https://portal.printedsimply.com/track/eyJ2IjoxLCJ0IjoidHJhY2tpbmciLCJ0b2siOiJ0cmtfQXF1SkZxRExEa2lzSzZKaC1IQUJIejlMIn0.199c0c58721512fcccd784902c422ca4e827c6d09b7e495bbb4db9b06ae62003"
    },
    "estimated_delivery": {
      "earliest": "2026-10-20",
      "latest": "2026-10-26",
      "ships_by": "2026-10-14",
      "is_estimate": true
    },
    "reordered_from": null,
    "created_at": "2026-10-10T08:09:14.588Z",
    "updated_at": "2026-10-10T08:09:14.588Z",
    "test_mode": true
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `404`

  Resource not found

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

- `502`

  Production system error

**DELETE `/order/{id}/cancel` Cancel an order**

Cancel an order and refund all successful charges. The order is cancelled in the production system first, then all charges are refunded. `refund_status` says what happened to the money: `refunded`, `not_owed`, or `pending` when the refund failed at the card processor and is being retried; the order is cancelled either way. An order held for a person's approval (`pending_approval`) was never charged or sent: it is cancelled on our record alone, `refund_status: not_owed`, and its approval link stops deciding; while an approval is placing it, the cancel is 409 concurrent_modification.

**Credential**

An API key in `x-api-key`

**Scopes**

`orders:write`

### Parameters

- `id` path · string required

  The order's `order_id`, or its `portal_order_id` (`ord_…`): either resolves the same order. An id the order answered to before it was re-routed resolves too.

- `Idempotency-Key` header · string (uuid)

  Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422 `idempotency_key_reuse` rather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503 `idempotency_unavailable` with `Retry-After`, and nothing runs or is charged: retry with the same key.

### Request

`DELETE /api/v1/order/{id}/cancel`

cURL:

```bash
curl -X DELETE "https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/cancel" \
  -H "x-api-key: ps_test_YOUR_KEY" \
  -H "Idempotency-Key: cancel-order-$(date +%s)"
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/cancel", {
  method: "DELETE",
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
    "Idempotency-Key": crypto.randomUUID(),
  },
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request
import uuid

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/cancel",
    method="DELETE",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/cancel');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'DELETE',
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

Order cancelled and refunded

`data`

- `order_id` string required

- `test_mode` boolean

  True when the cancelled order was a sandbox order.

- `refunded` string required

  Amount refunded (e.g. '$12.50')

- `refunded_cents` integer required

- `refund_status` string

  What happened to the money. `refunded`: everything the order's charges still held went back (`refunded_cents` equals `owed_cents`). `not_owed`: nothing was charged or it was already refunded, as for every sandbox order. `pending`: the order is cancelled but some or all of the refund failed at the card processor; it is retried automatically, `refunded_cents` is what went back now and `owed_cents` what is due in total.

  One of `refunded` `not_owed` `pending`

- `owed_cents` integer

  What the order's charges held when it was cancelled: the amount the cancellation refunds.

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "order_id": "test_ord_JyZLq7sUxTZxc7qjzI76ZQYU",
    "refunded": "$0.00",
    "refunded_cents": 0,
    "refund_status": "not_owed",
    "owed_cents": 0,
    "test_mode": true
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `400` [already_cancelled](https://portal.printedsimply.com/docs/errors#already_cancelled)

  already_cancelled — the order was cancelled before; nothing more is refunded or sent. Read it with GET /order/{id}.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `404`

  Resource not found

- `409` [not_cancellable](https://portal.printedsimply.com/docs/errors#not_cancellable) [already_cancelled](https://portal.printedsimply.com/docs/errors#already_cancelled) [production_reconciliation_required](https://portal.printedsimply.com/docs/errors#production_reconciliation_required)

  not_cancellable — the order has gone into production (or shipped, been delivered, been flagged or is being re-routed): an order can be cancelled only before production, while it is pending_payment, paid or accepted. Not a retry: the window does not reopen. An order already cancelled answers 400 already_cancelled instead. production_reconciliation_required — this order has an unresolved production submission. Nothing is cancelled or refunded until support reconciles the actual plant order.

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

- `502`

  Production system error

- `503` [idempotency_unavailable](https://portal.printedsimply.com/docs/errors#idempotency_unavailable)

  idempotency_unavailable — the request carried an `Idempotency-Key` and the ledger that makes it mean at-most-once could not be reached, so nothing ran and nothing was charged; retry the same request with the same key after `Retry-After`.

**PUT `/order/{id}/address` Update shipping address**

Update the shipping address for an order that has not yet gone into production. On a live order the new address is checked as POST /order checks one (POST /addresses/validate's check), and an address that cannot be right as written is refused 422 address_invalid before the facility or the order is touched; a test order is never refused for it. Our own facilities ship to US addresses only, so an address outside the US is refused 422 destination_unavailable before anything is changed.

**Credential**

An API key in `x-api-key`

**Scopes**

`orders:write`

### Parameters

- `id` path · string required

  The order's `order_id`, or its `portal_order_id` (`ord_…`): either resolves the same order. An id the order answered to before it was re-routed resolves too.

- `Idempotency-Key` header · string (uuid)

  Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422 `idempotency_key_reuse` rather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503 `idempotency_unavailable` with `Retry-After`, and nothing runs or is charged: retry with the same key.

### Request body application/json, required

- `name` string required

  non-empty

- `company` string

- `address1` string required

  non-empty

- `address2` string

- `city` string required

  non-empty

- `state` string required

  non-empty

- `zip` string required

  non-empty

- `country` string required

  ISO 3166-1 alpha-2 country code

  exactly 2 characters

- `phone` string

- `email` string (email)

### Request

`PUT /api/v1/order/{id}/address`

cURL:

```bash
curl -X PUT "https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/address" \
  -H "x-api-key: ps_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: update-order-address-$(date +%s)" \
  -d '{
    "name": "Jane Doe",
    "address1": "456 Oak Ave",
    "city": "Austin",
    "state": "TX",
    "zip": "78702",
    "country": "US"
  }'
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/address", {
  method: "PUT",
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    "name": "Jane Doe",
    "address1": "456 Oak Ave",
    "city": "Austin",
    "state": "TX",
    "zip": "78702",
    "country": "US"
  }),
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request
import uuid

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/address",
    method="PUT",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
        "Content-Type": "application/json",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    data=json.dumps({
        "name": "Jane Doe",
        "address1": "456 Oak Ave",
        "city": "Austin",
        "state": "TX",
        "zip": "78702",
        "country": "US",
    }).encode(),
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/address');
$body = <<<'JSON'
{
  "name": "Jane Doe",
  "address1": "456 Oak Ave",
  "city": "Austin",
  "state": "TX",
  "zip": "78702",
  "country": "US"
}
JSON;
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'PUT',
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
        'Content-Type: application/json',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS => $body,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

Address updated

`data`

- `order_id` string required

  The order's id, the one every order operation takes. On a live order, the facility's reply supplies it when it carries one.

- `portal_order_id` string required

  This platform's own id for the order (`ord_…`). On a live order, the facility's reply supplies it when it carries one.

- `status` string

  The order's status; the amendment does not move it. Always present on a sandbox answer, one of pending_approval, pending_payment, paid, accepted, in_production, quality_check, shipped, delivered, cancelled, issue_flagged, rerouting, pending, processing, on-hold, completed, refunded, failed. On a live answer, the facility's own value when its reply carries one, and otherwise absent: it is never filled from our record (read GET /order/{id}).

- `provider` string

  Always `merchfarm` on a sandbox answer. On a live answer, the facility's own value when its reply carries one, and otherwise absent.

- `ship_to` object required

  The address the order now ships to, as accepted. On a live order, the facility's own `ship_to` when its reply carries one; otherwise the address on our record, which this call has just written.

- `test_mode` boolean

  Present and true on a sandbox order; absent on a live one, as on GET /order/{id}.

  Always `true`

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "order_id": "test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk",
    "portal_order_id": "ord_wVK9ZiG-VYiPrlSeBUBmrUVk",
    "status": "paid",
    "provider": "merchfarm",
    "ship_to": {
      "name": "Jane Doe",
      "address1": "456 Oak Ave",
      "city": "Austin",
      "state": "TX",
      "zip": "78702",
      "country": "US"
    },
    "test_mode": true
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `400` [validation_error](https://portal.printedsimply.com/docs/errors#validation_error)

  Invalid request body or parameters. When the request failed the operation's schema, `details` carries `formErrors` (problems with the request as a whole), `fieldErrors` (messages keyed by top-level field) and `errors`: one `{ pointer, code, message }` per refused value, `pointer` an RFC 6901 JSON pointer into the body (or into the object of query parameters, for a query parameter) — `/items/0/sku`, with `~` written `~0` and `/` written `~1`, and `""` for the request as a whole. POST /v1/orders/bulk answers `errors` as one `{ index, details }` per refused order, each `details` with its own pointers from the request body (`/orders/3/items/0/sku`); POST /v1/orders/multi-ship keeps `errors` as the `{ formErrors, fieldErrors }` it always published; POST /v1/mockups/generate and POST /v1/products/recommend keep their fields at the top of `details`, with `errors` beside them. A refusal of a rule the schema cannot state (an asset that does not exist, an expiry too soon) names it in `message` and carries no `errors`.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `404`

  Resource not found

- `409` [order_not_modifiable](https://portal.printedsimply.com/docs/errors#order_not_modifiable)

  order_not_modifiable — the order has gone into production (or later), past the point where its address can change: an order takes a change only while it is pending_payment, paid or accepted. Terminal for this order.

- `413` [payload_too_large](https://portal.printedsimply.com/docs/errors#payload_too_large)

  payload_too_large — the request body exceeded this route's byte cap (256 KB unless the route raises it with a written reason). It is refused before it is parsed, so nothing was created. Send fewer rows, or use the endpoint's own asynchronous door where it has one.

- `422` [destination_unavailable](https://portal.printedsimply.com/docs/errors#destination_unavailable) [address_invalid](https://portal.printedsimply.com/docs/errors#address_invalid)

  destination_unavailable — the new address is outside the US, and the order is already with one of our own facilities, which ship to US addresses only. `details.reason` is `destination_not_us` and `details.country` names the destination; cancel the order and place a new one. address_invalid — a live order's new address cannot be right as written: `details.findings[]` names each `error` (`zip_format`, `state_unknown`, `zip_state_mismatch`, or `delivery_point_not_found` where USPS verification is configured) with its field, and `details.address_check` is the whole check, as POST /addresses/validate answers it. Nothing was sent to the facility and the order still ships to its previous address. A test order is never refused for it.

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

- `501` [not_supported_by_provider](https://portal.printedsimply.com/docs/errors#not_supported_by_provider)

  not_supported_by_provider — the order was produced by an independent network shop, which does not accept amendments at all. Not a timing problem and never retryable, which is what separates it from the 409.

- `502` [fulfillment_error](https://portal.printedsimply.com/docs/errors#fulfillment_error)

  fulfillment_error — the facility refused the change, answered while still holding the previous address, or did not confirm it (unreachable, timed out, or a reply that could not be read). The order's ship_to and its webhooks are unchanged either way. When the facility refused, or is known to hold the previous address, the order still ships there. When the facility did not confirm, the order is read back from it: if it already holds the new address the request answers 200 as if it had confirmed, and if that cannot be told the message says the change may not have been applied. Retrying the same request is safe in every case: it sends the same address again.

- `503` [idempotency_unavailable](https://portal.printedsimply.com/docs/errors#idempotency_unavailable)

  idempotency_unavailable — the request carried an `Idempotency-Key` and the ledger that makes it mean at-most-once could not be reached, so nothing ran and nothing was charged; retry the same request with the same key after `Retry-After`.

**PUT `/order/{id}/shipping` Update shipping method**

Change the shipping method for an order that has not yet gone into production.

**This call moves money.** The difference between the order's current shipping rate and the new one is settled immediately: an upgrade charges the delta to your card on file, a downgrade refunds it across the order's outstanding charges. The new method is priced as the order path prices it: by the parcel's weight class, and for items made at the partner facility at no less than the partner's own estimate. Naming the method the order is recorded as shipping by is not a change: it moves no money, whatever the rates have done since the order was placed. An order placed before methods were recorded carries none, so a change on it is priced; asking such an order for `standard`, the cheapest parcel, never charges: it refunds only what the order paid above today's standard rate. That makes it subject to the same controls as any other charge — the API key's per-order and daily spend caps are checked *before* the fulfillment system is told anything, so an over-cap upgrade returns 402 `spend_cap_exceeded` with the cap and the day's spend so far, and neither charges the card nor changes the shipping method. An upgrade with no payment method on file returns 402 `no_payment_method`; a declined card returns 402 `payment_failed`, and neither consumes the key's daily budget. The account-level sandbox switch applies here as it does to every charging endpoint: a live key on an account restricted to sandbox returns 403 `sandbox_only`, and neither the card nor the fulfillment system is touched.

A sandbox order settles nothing: the change is reflected on the order record and Stripe is never called. An order produced by an independent shop cannot have its shipping changed after placement (501 `not_supported_by_provider`) — the published provider contract has no endpoint for it.

**Credential**

An API key in `x-api-key`

**Scopes**

`orders:write`

### Parameters

- `id` path · string required

  The order's `order_id`, or its `portal_order_id` (`ord_…`): either resolves the same order. An id the order answered to before it was re-routed resolves too.

- `Idempotency-Key` header · string (uuid)

  Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422 `idempotency_key_reuse` rather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503 `idempotency_unavailable` with `Retry-After`, and nothing runs or is charged: retry with the same key.

### Request body application/json, required

- `carrier` string

- `method` string

  The shipping tiers this platform prices. Per parcel, not per item: `standard` by the parcel's weight class (POST /v1/shipping/estimate reports it), and for items made at the partner facility (apparel, mugs) at the larger of that and the partner's own estimate for the parcel; `priority` and `express` at their price and never below standard for the same parcel. An unrecognised value is refused with 400 validation_error rather than priced as `standard`. `standard` is always offered; `priority` and `express` are offered when priced (each at or above the USPS cost of the service), and until then a request for either is refused 400 `shipping_method_unavailable`, with `details.offered` naming the methods that are, and nothing is charged. `letter` is untracked letter mail for stickers and small vinyl decals from our own plant, offered only where the platform prices it, for an order whose every item qualifies, within a unit and an order-value ceiling: otherwise a request for it is refused 400 `letter_mail_unavailable` with `details.reason`, and nothing is charged. On the API it is never chosen for you — no default, estimate or storefront mapping picks it — and it is placed through POST /v1/order only (bulk and bundle orders ship by parcel). A hosted store is the one exception, and only where its merchant turned on small orders by letter in the dashboard's store editor (off by default, launched stores included): a qualifying basket bought on its store page or a buy link ships as a letter, and the shopper is told before paying. A letter has no carrier tracking: its order events and tracking page state the expected window, and POST /v1/order/{id}/missing-letter reships it once if it does not arrive.

  One of `standard` `priority` `express` `letter`

### Request

`PUT /api/v1/order/{id}/shipping`

cURL:

```bash
curl -X PUT "https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/shipping" \
  -H "x-api-key: ps_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: update-order-shipping-$(date +%s)" \
  -d '{
    "method": "standard"
  }'
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/shipping", {
  method: "PUT",
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    "method": "standard"
  }),
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request
import uuid

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/shipping",
    method="PUT",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
        "Content-Type": "application/json",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    data=json.dumps({
        "method": "standard",
    }).encode(),
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/shipping');
$body = <<<'JSON'
{
  "method": "standard"
}
JSON;
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'PUT',
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
        'Content-Type: application/json',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS => $body,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

Shipping updated

`data`

- `order_id` string required

  The order's id, the one every order operation takes. On a live order, the facility's reply supplies it when it carries one.

- `portal_order_id` string required

  This platform's own id for the order (`ord_…`). On a live order, the facility's reply supplies it when it carries one.

- `status` string

  The order's status; the amendment does not move it. Always present on a sandbox answer, one of pending_approval, pending_payment, paid, accepted, in_production, quality_check, shipped, delivered, cancelled, issue_flagged, rerouting, pending, processing, on-hold, completed, refunded, failed. On a live answer, the facility's own value when its reply carries one, and otherwise absent.

- `provider` string

  Always `merchfarm` on a sandbox answer. On a live answer, the facility's own value when its reply carries one, and otherwise absent.

- `shipping_cents` integer

  The order's shipping line after the change, in cents. Always present on a sandbox answer; on a live one, the facility's own figure when its reply carries one, and otherwise absent.

- `total_cents` integer

  The order's total after the change, in cents. Always present on a sandbox answer; on a live one, the facility's own figure when its reply carries one, and otherwise absent.

- `test_mode` boolean

  Present and true on a sandbox order; absent on a live one, as on GET /order/{id}.

  Always `true`

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "order_id": "test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk",
    "portal_order_id": "ord_wVK9ZiG-VYiPrlSeBUBmrUVk",
    "status": "paid",
    "provider": "merchfarm",
    "shipping_cents": 1399,
    "total_cents": 7799,
    "test_mode": true
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `400` [validation_error](https://portal.printedsimply.com/docs/errors#validation_error)

  Invalid request body or parameters. When the request failed the operation's schema, `details` carries `formErrors` (problems with the request as a whole), `fieldErrors` (messages keyed by top-level field) and `errors`: one `{ pointer, code, message }` per refused value, `pointer` an RFC 6901 JSON pointer into the body (or into the object of query parameters, for a query parameter) — `/items/0/sku`, with `~` written `~0` and `/` written `~1`, and `""` for the request as a whole. POST /v1/orders/bulk answers `errors` as one `{ index, details }` per refused order, each `details` with its own pointers from the request body (`/orders/3/items/0/sku`); POST /v1/orders/multi-ship keeps `errors` as the `{ formErrors, fieldErrors }` it always published; POST /v1/mockups/generate and POST /v1/products/recommend keep their fields at the top of `details`, with `errors` beside them. A refusal of a rule the schema cannot state (an asset that does not exist, an expiry too soon) names it in `message` and carries no `errors`.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `402`

  Payment failed or no payment method configured

- `403` [sandbox_only](https://portal.printedsimply.com/docs/errors#sandbox_only)

  sandbox_only — the account is restricted to sandbox mode and the request used a live key.

- `404`

  Resource not found

- `409` [order_not_modifiable](https://portal.printedsimply.com/docs/errors#order_not_modifiable) [shopper_paid_order](https://portal.printedsimply.com/docs/errors#shopper_paid_order)

  order_not_modifiable — the order has gone into production (or later), past the point where its shipping can change. shopper_paid_order — a shipping upgrade on an order a shopper paid for (a drop, a checkout link or an agent checkout); it would be charged to your card and refunded to the shopper. A downgrade is allowed and refunds the shopper's own charge.

- `413` [payload_too_large](https://portal.printedsimply.com/docs/errors#payload_too_large)

  payload_too_large — the request body exceeded this route's byte cap (256 KB unless the route raises it with a written reason). It is refused before it is parsed, so nothing was created. Send fewer rows, or use the endpoint's own asynchronous door where it has one.

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

- `501` [not_supported_by_provider](https://portal.printedsimply.com/docs/errors#not_supported_by_provider)

  not_supported_by_provider — the order was produced by an independent network shop, which does not accept amendments at all. Never retryable, unlike the 409. Live PrintedSimply paid shipping upgrades are also refused before any charge because the plant handler is not confirmed.

- `502` [fulfillment_error](https://portal.printedsimply.com/docs/errors#fulfillment_error)

  fulfillment_error — the facility could not be reached or refused the change. Where an upgrade had already been charged the message says so and the payment was refunded; retry with a NEW Idempotency-Key.

- `503` [idempotency_unavailable](https://portal.printedsimply.com/docs/errors#idempotency_unavailable)

  idempotency_unavailable — the request carried an `Idempotency-Key` and the ledger that makes it mean at-most-once could not be reached, so nothing ran and nothing was charged; retry the same request with the same key after `Retry-After`.

**GET `/order/{id}/events` Get order events**

Retrieve the event history for an order including status changes and tracking updates, oldest first. Pages by cursor (see Pagination): `data` stays a bare array, and `meta` carries `has_more`, `next_cursor` and the full event count (`total`). A request without `cursor` answers up to 500 events (1,000 with `limit`), which is nearly every order's whole history in one answer. To follow an order without a webhook address: keep `meta.since` from each answer and send it back as `since` with `wait_seconds` (at most 25). The answer comes as soon as the order has an event after `since`; when it has none yet, the request waits for one, up to `wait_seconds`, and then answers with an empty `data` and the same `meta.since`, so the next request asks from the same place. One request, counted once by the rate limit.

**Credential**

An API key in `x-api-key`

**Scopes**

`orders:read`

### Parameters

- `id` path · string required

  The order's `order_id`, or its `portal_order_id` (`ord_…`): either resolves the same order. An id the order answered to before it was re-routed resolves too.

- `cursor` query · string

  Opaque cursor: send back `meta.next_cursor` from the previous page for the page after it; omit it for the first page. Good only on this list, with a key of the same account and mode (test or live), and only for this order, and with the same filters (only `limit` may change); an altered or foreign cursor, or one sent with other filters, is refused with 400 validation_error. Wins over `page` when both are sent.

- `limit` query · integer

  Events per page. Without `cursor`: 500 by default and 1000 at most, which is nearly every order's whole history in one answer — the size this list's page walk always had, so a first answer and `page` agree on it. With `cursor`: 20 by default and 100 at most, as on every list. Either way a value above the ceiling is clamped to it and one below 1 to 1, a fraction is truncated, and a missing, zero or non-numeric value takes the default; none is refused.

  Default 500

- `page` query · integer deprecated

  Deprecated: walk with `cursor`. The page number of the offset walk this operation always had, answered as before (`offset`, a row count, is accepted in its place), at the page size a request without `cursor` takes. Ignored when `cursor` or `since` is sent.

  Default 1

- `since` query · string

  The position to read on from: `meta.since` from an earlier answer for this order (a `meta.next_cursor` is accepted too), sent back unchanged. Only events after it are returned, oldest first, at a cursor page's size (`limit` 20 by default, 100 at most). Good only for this order, with a key of the same account and mode; an altered or foreign value is refused with 400 validation_error and `details.parameter: "since"`, and so is a request that sends `cursor` as well. Omit it to read from the first event.

- `wait_seconds` query · integer

  How long to wait for an event when there is none to return yet (a long poll). 0, the default, answers at once. Otherwise the request answers as soon as an event is found — the order's events are re-read every 2 seconds — or after this many seconds with an empty `data`. A value above 25 is clamped to 25, a fraction is truncated, and a missing, negative or non-numeric value is 0; none is refused.

  Default 0

### Request

`GET /api/v1/order/{id}/events`

cURL:

```bash
curl "https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/events" \
  -H "x-api-key: ps_test_YOUR_KEY"
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/events", {
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
  },
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/events",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
    },
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/events');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

The order's history, oldest first. `data` is a BARE ARRAY — the count, `has_more` and `next_cursor` live in `meta`, because there is nowhere in the body to hang them without breaking every existing client.

`data` is a list; each entry:

- `action` string required
- `details` string | null required
- `timestamp` string (date-time) required

`meta`

- `page` integer deprecated

  The page number of the deprecated offset walk, on the lists that published it (orders and an order's events, on every request without `cursor`; products, on a request that sent `page`). Absent on a cursor walk, where no page number would be true.

- `total` integer

  Every row matching the request's filters, on the lists that count them (orders, an order's events, assets and products).

- `limit` integer

  The page size applied, which is not necessarily the one asked for: a larger `limit` is clamped. Absent only on a GET /products request without `cursor` that sent no `limit`, which answers every match.

- `pages` integer deprecated

  `total` over `limit`, rounded up, beside `page` (orders and an order's events). Absent on a cursor walk.

- `next_cursor` string | null required

  Send it back as `cursor` for the next page; null exactly when `has_more` is false. Opaque: pass it back unchanged. Good only on the list that returned it, with a key of the same account and mode (test or live), and with the same filters.

- `has_more` boolean required

  Whether another page follows this one. Decided from one row past this page, never from a page that came back short — so a page after `has_more: true` can come back empty only if rows were deleted in between, and the walk still ends at the first `has_more: false`.

- `since` string | null

  On GET /order/{id}/events only: where to ask from next — the position of the last event in this answer, or, when it has none, the position the request read from. Send it back as `since` (with `wait_seconds` to wait for the next event). Unlike `next_cursor` it is not null at the end of the history; it is null only when the order has no events and the request named no position. Opaque, and bound like a cursor to this order, account and key mode.

Example response:

```json
{
  "status": "success",
  "data": [
    {
      "action": "order_submitted",
      "details": "A test order: nothing is charged, printed or sent. The total is what a live order would pay.",
      "timestamp": "2026-10-15T15:00:10.000Z"
    },
    {
      "action": "artwork_checked",
      "details": "The artwork prints well (1200×1200 px, 400 DPI at 3×3 in).",
      "timestamp": "2026-10-15T15:00:10.000Z"
    }
  ],
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a",
  "meta": {
    "page": 1,
    "pages": 1,
    "limit": 500,
    "total": 2,
    "has_more": false,
    "next_cursor": null,
    "since": "eyJyIjoib3JkZXJfZXZlbnRzIiwiayI6WyIyMDI2LTEwLTE1VDE1OjAwOjEwLjAwMFoiLCJjdnEwNHlsNjQ5bzd0M283YWJpa3Y5NzE0Il19.9452219bf0c9044af2bbb15342e6bd3d"
  }
}
```

### Errors

- `400` [validation_error](https://portal.printedsimply.com/docs/errors#validation_error)

  Invalid request body or parameters. When the request failed the operation's schema, `details` carries `formErrors` (problems with the request as a whole), `fieldErrors` (messages keyed by top-level field) and `errors`: one `{ pointer, code, message }` per refused value, `pointer` an RFC 6901 JSON pointer into the body (or into the object of query parameters, for a query parameter) — `/items/0/sku`, with `~` written `~0` and `/` written `~1`, and `""` for the request as a whole. POST /v1/orders/bulk answers `errors` as one `{ index, details }` per refused order, each `details` with its own pointers from the request body (`/orders/3/items/0/sku`); POST /v1/orders/multi-ship keeps `errors` as the `{ formErrors, fieldErrors }` it always published; POST /v1/mockups/generate and POST /v1/products/recommend keep their fields at the top of `details`, with `errors` beside them. A refusal of a rule the schema cannot state (an asset that does not exist, an expiry too soon) names it in `message` and carries no `errors`.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `404`

  Resource not found

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

**GET `/order/{id}/timeline` Get an order's timeline**

One order's API calls, events and webhook delivery attempts in one list, oldest first, as GET /order/{id}/events orders its events. Each entry has a `type` (`request`, `event` or `delivery`), its own `id`, the time it happened (`at`) and one object named after its type. A `request` is a call that placed this order (POST /order, a reorder, a batch) or changed it: one that named it in its path with any method but GET (a cancel, an address, shipping or item change, a simulated step). A read is not listed, this timeline included: it acts on no order. It carries the method, the operation's path template, the status, `request_id`, `trace_id`, the duration, the prefix of the key that made it (never more of the key) and the error code; no request or response body, which the request log does not keep. Its `at` is when it was answered, so at one instant an event comes first, then a request, then a delivery: the events a call wrote come before it. An `event` is the order's event exactly as GET /order/{id}/events returns it. A `delivery` is one attempt at sending a webhook about this order to one of your endpoints: the endpoint's id and the host the attempt was sent to (never its path or query), the event and its `event_id` (the `X-PS-Event-Id` that all attempts at one event carry), the rung of the retry ladder, whether it was delivered, the status your endpoint answered, the latency, and when the next attempt is due while one is (none while the endpoint is paused or disabled, which holds its attempts). Never a payload or a signing secret: GET /webhooks/deliveries/{id} returns an attempt's payload. What a credential reads: delivery attempts need `webhooks:manage` as well, the scope GET /webhooks/deliveries needs, and a connected app reads only the attempts to the endpoints it subscribed. A key created with a list of scopes, or a connected app, reads only the calls it made itself; a key created without one reads the calls of all the account's credentials. A test key lists only calls made with test keys and only sandbox deliveries. Calls and delivery attempts are kept for 90 days, as the request log and the delivery log keep them; events are kept with the order, so an older order's timeline lists its events alone. Calls answered before the request log recorded the order a call reached are listed when their path named this order; a call that placed an order before then is not. Pass `type` to list one kind only (only the calls, only the deliveries). Pages by cursor (see Pagination): `data` is a bare array and `meta` carries `has_more` and `next_cursor`. A walk lists what had happened when its first page was read, so it ends whatever is written meanwhile; the next walk lists what was. An order of another account, or a live order asked for with a test key, is 404 order_not_found.

**Credential**

An API key in `x-api-key`

**Scopes**

`orders:read`

### Parameters

- `id` path · string required

  The order's `order_id`, or its `portal_order_id` (`ord_…`): either resolves the same order. An id the order answered to before it was re-routed resolves too.

- `limit` query · integer

  Entries per page: 20 by default, 100 at most. A value above 100 is clamped to 100 and one below 1 to 1, a fraction is truncated, and a missing, zero or non-numeric value falls back to 20; none is refused.

  Default 20

- `cursor` query · string

  Opaque cursor: send back `meta.next_cursor` from the previous page for the page after it; omit it for the first page. Good only on this list, with a key of the same account and mode (test or live), and only for this order, and with the same filters (only `limit` may change); an altered or foreign cursor, or one sent with other filters, is refused with 400 validation_error. Wins over `page` when both are sent.

- `type` query · string

  Only entries of this `type`: `request` (the calls that placed or changed the order), `event` (its events) or `delivery` (the attempts at delivering webhooks about it, which still need `webhooks:manage`: without it a `delivery` timeline is empty). Leave it out for every kind, merged, as before. Any other value is 400 validation_error. The cursor is bound to it: send the same `type` (or none) with every page of a walk; a cursor sent with another is 400 `other_filters`.

  One of `request` `event` `delivery`

### Request

`GET /api/v1/order/{id}/timeline`

cURL:

```bash
curl "https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/timeline" \
  -H "x-api-key: ps_test_YOUR_KEY"
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/timeline", {
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
  },
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/timeline",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
    },
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/timeline');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

The order's requests, events and delivery attempts, oldest first. `data` is a bare array, as on GET /order/{id}/events; `has_more` and `next_cursor` live in `meta`.

`data` is a list; each entry:

- `type` string required

  Always `request`

- `id` string required

  The request log row's id.

- `at` string (date-time) required

  When the call was answered.

- `request` object

- `request.method` string required

  The HTTP method the call was made with.

- `request.path` string required

  The operation's path template, such as `/v1/order/{id}/events`: the ids the call named are not repeated.

- `request.status` integer required

  The HTTP status the call was answered with.

- `request.request_id` string | null required

  The `X-Request-Id` (and `request_id`) the call was answered with: what the dashboard's request log finds it by.

- `request.trace_id` string | null required

  The W3C trace id the response's `traceparent` carried; null on calls logged before traces were kept.

- `request.duration_ms` integer required

  How long the call took to answer, in milliseconds.

  at least 0

- `request.key_prefix` string | null required

  The prefix of the API key that made the call, as the keys page shows it. Never more of the key; null for a call a connected app made.

- `request.oauth_client_id` string | null required

  The connected app that made the call, for a call made with its access token; null for a key.

- `request.mode` string | null required

  Whether a test or a live credential made the call.

  One of `test` `live`

- `request.error_code` string | null required

  The error's `code`, for a call answered with a status of 400 or more.

- `event` object

- `event.action` string required

- `event.details` string | null required

- `event.timestamp` string (date-time) required

- `delivery` object

- `delivery.webhook_id` string required

  The endpoint the attempt was sent to.

- `delivery.endpoint_host` string | null required

  The host of the URL the attempt was sent to, as it was then (an endpoint's URL can change since); never its path or query. Null on attempts logged before the log kept it.

- `delivery.event` string required

  One of `order.created` `order.paid` `order.in_production` `order.shipped` `order.delivered` `order.cancelled` `order.failed` `order.updated` `order.rerouted` `order.quality_check` `order.issue` `order.approval_requested` `order.approval_approved` `order.approval_declined` `order.approval_expired` `campaign.opened` `campaign.order_added` `campaign.closed` `campaign.batch_in_production` `campaign.completed` `design.proof_sent` `design.changes_requested` `design.approved` `return.requested` `return.approved` `return.remake_created` `return.resolved` `return.rejected` `drop.opened` `drop.sale` `drop.closed` `drop.sold_out` `checkout_link.sale` `gift.claimed` `gift.expired` `product.updated` `catalog.synced` `webhook.disabled` `autopilot.run.created` `autopilot.run.awaiting_approval` `autopilot.run.completed` `autopilot.run.failed` `autopilot.run.cancelled` `design_run.item_ready` `design_run.completed` `guarantee.issued` `guarantee.honored` `guarantee.breached` `guarantee.remedied` `test.ping`

- `delivery.event_id` string | null required

  The `X-PS-Event-Id` the attempt carried. Every attempt at one event shares it; null only on attempts logged before the log carried it.

- `delivery.attempt` integer | null required

  The rung of the retry ladder (`X-PS-Delivery-Attempt`), 1 for the first send and at most 7; null for a redelivery you asked for, and on attempts logged before the log kept it.

  1 to 7

- `delivery.outcome` string required

  `delivered` when your endpoint answered 2xx.

  One of `delivered` `failed`

- `delivery.status_code` integer | null required

  The status your endpoint answered; null when nothing came back.

- `delivery.failure_cause` string | null required

  Why a failed attempt failed, from the delivery log's causes; null when delivered, and on a failure logged with neither a cause nor a status.

  One of `dns` `unsafe_url` `refused` `timeout` `tls` `reset` `network` `redirect` `unauthorized` `http_4xx` `http_5xx` `inbox_refused` `egress` `signing` `internal`

- `delivery.latency_ms` integer | null required

  Milliseconds from sending to the answer or the failure; null when nothing was sent, and on attempts logged before the log kept it.

  at least 0

- `delivery.next_retry_at` string (date-time) | null required

  When the next attempt at this event is due, on the latest failed attempt of a retry ladder still running to an active endpoint; null otherwise, and while the endpoint is paused or disabled (its attempts are held until it is resumed).

`meta`

- `page` integer deprecated

  The page number of the deprecated offset walk, on the lists that published it (orders and an order's events, on every request without `cursor`; products, on a request that sent `page`). Absent on a cursor walk, where no page number would be true.

- `total` integer

  Every row matching the request's filters, on the lists that count them (orders, an order's events, assets and products).

- `limit` integer

  The page size applied, which is not necessarily the one asked for: a larger `limit` is clamped. Absent only on a GET /products request without `cursor` that sent no `limit`, which answers every match.

- `pages` integer deprecated

  `total` over `limit`, rounded up, beside `page` (orders and an order's events). Absent on a cursor walk.

- `next_cursor` string | null required

  Send it back as `cursor` for the next page; null exactly when `has_more` is false. Opaque: pass it back unchanged. Good only on the list that returned it, with a key of the same account and mode (test or live), and with the same filters.

- `has_more` boolean required

  Whether another page follows this one. Decided from one row past this page, never from a page that came back short — so a page after `has_more: true` can come back empty only if rows were deleted in between, and the walk still ends at the first `has_more: false`.

- `since` string | null

  On GET /order/{id}/events only: where to ask from next — the position of the last event in this answer, or, when it has none, the position the request read from. Send it back as `since` (with `wait_seconds` to wait for the next event). Unlike `next_cursor` it is not null at the end of the history; it is null only when the order has no events and the request named no position. Opaque, and bound like a cursor to this order, account and key mode.

Example response:

```json
{
  "status": "success",
  "data": [
    {
      "type": "event",
      "id": "c57yvwpkmchhnkd7fa6p20ued",
      "at": "2026-10-15T15:00:47.000Z",
      "event": {
        "action": "artwork_checked",
        "details": "The artwork prints well (1200×1200 px, 400 DPI at 3×3 in).",
        "timestamp": "2026-10-15T15:00:47.000Z"
      }
    },
    {
      "type": "event",
      "id": "cfbgb3yt0b96cqzl7lijwdpe0",
      "at": "2026-10-15T15:00:47.000Z",
      "event": {
        "action": "order_submitted",
        "details": "A test order: nothing is charged, printed or sent. The total is what a live order would pay.",
        "timestamp": "2026-10-15T15:00:47.000Z"
      }
    },
    {
      "type": "request",
      "id": "c64ztkjlkujdlmlc4sviofcv6",
      "at": "2026-10-15T15:00:47.000Z",
      "request": {
        "method": "POST",
        "path": "/v1/order",
        "status": 200,
        "request_id": "a40f913f-9b81-49ea-8d9c-c03bd20ad9b2",
        "trace_id": "4e2da63b674690074ebd7d6a9629312d",
        "duration_ms": 0,
        "key_prefix": "mf_test_xxxxxxxx",
        "oauth_client_id": null,
        "mode": "test",
        "error_code": null
      }
    },
    {
      "type": "delivery",
      "id": "c2g0xl6j1z0c35hx7fd355s1c",
      "at": "2026-10-15T15:00:47.000Z",
      "delivery": {
        "webhook_id": "whk_4czoxnKiV1lGoLIP_yodB3xT",
        "endpoint_host": "example.com",
        "event": "order.created",
        "event_id": "evt_nyPRpD32bgnJc6D8T0TE6Y89",
        "attempt": 1,
        "outcome": "delivered",
        "status_code": 200,
        "failure_cause": null,
        "latency_ms": 0,
        "next_retry_at": null
      }
    }
  ],
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a",
  "meta": {
    "limit": 20,
    "has_more": false,
    "next_cursor": null
  }
}
```

### Errors

- `400` [validation_error](https://portal.printedsimply.com/docs/errors#validation_error)

  Invalid request body or parameters. When the request failed the operation's schema, `details` carries `formErrors` (problems with the request as a whole), `fieldErrors` (messages keyed by top-level field) and `errors`: one `{ pointer, code, message }` per refused value, `pointer` an RFC 6901 JSON pointer into the body (or into the object of query parameters, for a query parameter) — `/items/0/sku`, with `~` written `~0` and `/` written `~1`, and `""` for the request as a whole. POST /v1/orders/bulk answers `errors` as one `{ index, details }` per refused order, each `details` with its own pointers from the request body (`/orders/3/items/0/sku`); POST /v1/orders/multi-ship keeps `errors` as the `{ formErrors, fieldErrors }` it always published; POST /v1/mockups/generate and POST /v1/products/recommend keep their fields at the top of `details`, with `errors` beside them. A refusal of a rule the schema cannot state (an asset that does not exist, an expiry too soon) names it in `message` and carries no `errors`.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `404`

  Resource not found

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

**POST `/order/{id}/missing-letter` Report a missing letter and reship it once**

For an order that shipped by `letter` — untracked letter mail, which carries no carrier scans. Once its expected window has passed (the plant's production estimate plus the slowest letter transit, stated on the order's `letter_mail` event and its tracking page), reporting it missing creates ONE free replacement: the same lines at $0, to the same address, by letter, produced where the original was. Nothing is charged. The original fires `order.updated` with `changes.action: "letter_reshipped"` and the replacement's ids.

Once per letter: a second report — on the original or on the replacement — is refused 409 `letter_report_refused`, and two racing reports produce one replacement. `details.reason` is `not_a_letter` (it shipped as a parcel; read its tracking), `not_shipped` (it has not left the plant), `not_yet_due` (with `details.expected_by`), `already_reshipped` (with `details.replacement_order_id`) or `is_a_reship`. A sandbox letter may be reported as soon as it has shipped — the sandbox compresses time — and its replacement runs the sandbox lifecycle. Letter mail exists only where the platform prices it; on a deployment that does not, no order is a letter and every report is `not_a_letter`.

**Credential**

An API key in `x-api-key`

**Scopes**

`orders:write`

### Parameters

- `id` path · string required

  The order's `order_id`, or its `portal_order_id` (`ord_…`): either resolves the same order. An id the order answered to before it was re-routed resolves too.

- `Idempotency-Key` header · string (uuid)

  Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422 `idempotency_key_reuse` rather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503 `idempotency_unavailable` with `Retry-After`, and nothing runs or is charged: retry with the same key.

### Request

`POST /api/v1/order/{id}/missing-letter`

cURL:

```bash
curl -X POST "https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/missing-letter" \
  -H "x-api-key: ps_test_YOUR_KEY" \
  -H "Idempotency-Key: report-missing-letter-$(date +%s)"
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/missing-letter", {
  method: "POST",
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
    "Idempotency-Key": crypto.randomUUID(),
  },
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request
import uuid

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/missing-letter",
    method="POST",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/missing-letter');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

The letter was reported missing and its one free replacement created

`data`

- `order_id` string required

  The letter that was reported missing.

- `portal_order_id` string required

- `replacement` object required

  The one free replacement. An order in its own right: GET /v1/order/{id} reads it.

- `replacement.order_id` string required

  Its public id. A placeholder until the plant accepts it, then the plant's own id; the portal id never changes.

- `replacement.portal_order_id` string required

- `replacement.status` string required

  `paid` when it is written; it moves through the lifecycle like any order.

  One of `pending_approval` `pending_payment` `paid` `accepted` `in_production` `quality_check` `shipped` `delivered` `cancelled` `issue_flagged` `rerouting`

- `replacement.shipping_method` string required

  A replacement ships the way the original did.

  Always `letter`

- `replacement.total_cents` integer required

  Nothing is charged for a replacement.

  Always `0`

- `replacement.tracking_url` string (uri) required

  Its tracking page, which states the letter's expected window — there are no carrier scans.

- `test_mode` boolean required

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "order_id": "test_ord_rzbohZBiqt6XnFMC5RazH_R5",
    "portal_order_id": "ord_rzbohZBiqt6XnFMC5RazH_R5",
    "replacement": {
      "order_id": "rmk_KEFW7K8h6mUCGRWlDC",
      "portal_order_id": "ord_FvjgWGKEFW7K8h6mUCGRWlDC",
      "status": "paid",
      "shipping_method": "letter",
      "total_cents": 0,
      "tracking_url": "https://portal.printedsimply.com/track/eyJ2IjoxLCJ0IjoidHJhY2tpbmciLCJ0b2siOiJ0cmtfMkJMeUh2TVd1RS1MbDc5QlFEU050dHdFIiwiZXhwIjoiMjAyNy0wMS0xM1QxNTowMDo0MS4wMDBaIn0.d6738ba42dc0cc76aa05ceb30c4b0f02fae1f278f1dbd4b69434d65549551044"
    },
    "test_mode": true
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `403` [sandbox_only](https://portal.printedsimply.com/docs/errors#sandbox_only)

  sandbox_only — the account is restricted to sandbox mode and the request used a live key.

- `404`

  Resource not found

- `409` [letter_report_refused](https://portal.printedsimply.com/docs/errors#letter_report_refused)

  letter_report_refused — `details.reason` says why: not_a_letter, not_shipped, not_yet_due (with expected_by), already_reshipped (with replacement_order_id) or is_a_reship.

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

**POST `/order/{id}/reorder` Place an order again**

Place the same order again in one call. The new order is built from what the source order stored: each line's SKU, quantity, artwork (the `print_file` it was sent) and variable data, its `address_to`, its shipping method and its `vat`; a line's quantity, the address, the method and the VAT id can be changed in the body, and a return address (`address_from`) sent. It is then placed exactly as POST /order places that body, at today's price: the same validation, artwork check, address check, price floor, spend caps, charge on a live key and $0 sandbox order on a test key, and the same webhooks. The answer is POST /order's, with `reordered_from` naming the source. Not copied: the `reference` (send a new one), a quote and the return address. POST /order does not store a line's `size`, `color`, `product_id`, `design_name`, `notes` or `mock_image_url`, or any file but the one it prints, so a line that carried them goes again without them: place such an order with POST /order. A line that kept no artwork at all is refused 422 artwork_required with `details.items[]`, on either key, and nothing is charged. The source must be an order of your account in the key's mode: a test key reorders a test order and a live key a live one, and any other id is 404 order_not_found. A SKU no longer sold and artwork that can no longer be fetched or no longer passes the check are refused with the codes POST /order uses.

**Credential**

An API key in `x-api-key`

**Scopes**

`orders:write`

### Parameters

- `id` path · string required

  The order's `order_id`, or its `portal_order_id` (`ord_…`): either resolves the same order. An id the order answered to before it was re-routed resolves too.

- `Idempotency-Key` header · string (uuid)

  Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422 `idempotency_key_reuse` rather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503 `idempotency_unavailable` with `Retry-After`, and nothing runs or is charged: retry with the same key.

### Request body application/json

- `items` object[]

  Quantities to change, one entry per line: name the line by its `line_item_id` (from `line_items` on GET /order/{id}) or by `sku` where exactly one line carries it (any case). A line not named keeps its quantity. A name that matches no line, or a sku on more than one, is a 400 validation_error whose `details.lines` lists the source's lines.

  1 to 100 entries

- `items[].line_item_id` string

  The line's id. Send this or `sku`, not both.

  non-empty

- `items[].sku` string

  The line's SKU, when exactly one line carries it. Send this or `line_item_id`, not both.

  non-empty

- `items[].quantity` integer required

  1 to 10000

- `address_to` object

  Ship to this address instead of the source's.

- `address_to.name` string required

  non-empty

- `address_to.company` string

- `address_to.address1` string required

  non-empty

- `address_to.address2` string

- `address_to.city` string required

  non-empty

- `address_to.state` string required

  non-empty

- `address_to.zip` string required

  non-empty

- `address_to.country` string required

  ISO 3166-1 alpha-2 country code

  exactly 2 characters

- `address_to.phone` string

- `address_to.email` string (email)

- `address_from` object

  As on POST /order: the return address for this order. An order does not store one, so it is not copied; without it the account's brand return address applies.

  The same fields as `address_to`

- `shipping` object

  Ship by this method instead of the source's. Without `method`, the source's.

- `shipping.method` string

  The shipping tiers this platform prices. Per parcel, not per item: `standard` by the parcel's weight class (POST /v1/shipping/estimate reports it), and for items made at the partner facility (apparel, mugs) at the larger of that and the partner's own estimate for the parcel; `priority` and `express` at their price and never below standard for the same parcel. An unrecognised value is refused with 400 validation_error rather than priced as `standard`. `standard` is always offered; `priority` and `express` are offered when priced (each at or above the USPS cost of the service), and until then a request for either is refused 400 `shipping_method_unavailable`, with `details.offered` naming the methods that are, and nothing is charged. `letter` is untracked letter mail for stickers and small vinyl decals from our own plant, offered only where the platform prices it, for an order whose every item qualifies, within a unit and an order-value ceiling: otherwise a request for it is refused 400 `letter_mail_unavailable` with `details.reason`, and nothing is charged. On the API it is never chosen for you — no default, estimate or storefront mapping picks it — and it is placed through POST /v1/order only (bulk and bundle orders ship by parcel). A hosted store is the one exception, and only where its merchant turned on small orders by letter in the dashboard's store editor (off by default, launched stores included): a qualifying basket bought on its store page or a buy link ships as a letter, and the shopper is told before paying. A letter has no carrier tracking: its order events and tracking page state the expected window, and POST /v1/order/{id}/missing-letter reships it once if it does not arrive.

  One of `standard` `priority` `express` `letter`

- `vat` string

  As on POST /order: the tax registration number (VAT, IOSS or GST) to declare. Without it, the source's goes again.

- `reference` string

  Your own identifier for the new order, as on POST /order: one reference is one order, so the source's is never copied, and one another order of the account carries (last 30 days, same key mode, not cancelled) is refused 409 duplicate_reference before anything is charged.

  at most 200 characters

- `allow_duplicate_reference` boolean

  As on POST /order.

  Default false

- `payment_intent` string

  As on POST /order: after a 402 `authentication_required`, the approved `details.payment_intent`, sent with the same reorder and a new Idempotency-Key.

  matches ^pi_[A-Za-z0-9]+$

### Request

`POST /api/v1/order/{id}/reorder`

cURL:

```bash
curl -X POST "https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/reorder" \
  -H "x-api-key: ps_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: reorder-order-$(date +%s)" \
  -d '{
    "items": [
      {
        "sku": "sticker-die-cut-3x3",
        "quantity": 200
      }
    ]
  }'
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/reorder", {
  method: "POST",
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    "items": [
      {
        "sku": "sticker-die-cut-3x3",
        "quantity": 200
      }
    ]
  }),
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request
import uuid

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/reorder",
    method="POST",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
        "Content-Type": "application/json",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    data=json.dumps({
        "items": [
            {
                "sku": "sticker-die-cut-3x3",
                "quantity": 200,
            },
        ],
    }).encode(),
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/reorder');
$body = <<<'JSON'
{
  "items": [
    {
      "sku": "sticker-die-cut-3x3",
      "quantity": 200
    }
  ]
}
JSON;
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
        'Content-Type: application/json',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS => $body,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

The new order, as POST /order answers it, with `reordered_from`

`data`

- `order_id` string required

  WordPress order ID

- `provider` string required

  The fulfilment network the order was placed with.

- `tracking_token` string required

  Opaque token for the public tracking page. Safe to share; it names no account.

- `tracking_url` string required

  The buyer-facing tracking page for this order.

- `test_mode` boolean

  True when the order was placed with a sandbox key. Present on sandbox orders only.

- `lifecycle` string

  `simulated` on a sandbox order — the order was priced and recorded but never sent to a facility.

- `pricing_degraded` string

  Present only when the price could not be computed: `provider_unavailable` or `pricing_unavailable`. The breakdown is then an absence of a price, not a price of zero.

  One of `provider_unavailable` `pricing_unavailable`

- `portal_order_id` string required

  This platform's own id for the order (`ord_…`).

- `reference` string

  The reference you supplied, echoed back. Absent when you supplied none.

- `vat` string

  The tax-registration number you supplied, echoed back. Absent when you supplied none.

- `charged` string required

  Amount charged (e.g. '$12.50').

- `quote_id` string

  The id of the honored quote, echoed when the order was placed with a quote_token. Absent otherwise.

- `reordered_from` string required

  On an order POST /order/{id}/reorder placed: the source order's `portal_order_id` (GET /order/{id} takes it). Absent on any other order.

- `variable_data` object[]

  One entry per line carrying `variable_data`: the variant proofs (first, last and longest unit) of what the order prints. Absent when no line carries it.

- `variable_data[].item_index` integer required

  The line's position in the request's `items`.

  at least 0

- `variable_data[].sku` string required

- `variable_data[].units` integer required

  Units on the line, each printed with its own values.

  at least 1

- `variable_data[].slots` object[] required

  Each declared slot with its `max_length` and the `longest` value any unit gives it: the headroom.

- `variable_data[].slots[].name` string required

- `variable_data[].slots[].max_length` integer required

- `variable_data[].slots[].longest` integer required

  Characters in this slot's longest value on the line.

- `variable_data[].qr` object

  Present when the line has a QR template: the payload ceiling and the longest payload on the line.

- `variable_data[].qr.max_length` integer required

- `variable_data[].qr.longest` integer required

- `variable_data[].proofs` object[] required

  The variant proofs: the first unit, the last, and the longest (the most printed characters, earliest on a tie), with the exact values and QR payload each will print. A unit that is more than one of the three is listed once, with each variant it stands for. Described rather than drawn: a plain line's artwork carries no slot positions to draw a value into.

- `variable_data[].proofs[].unit` integer required

  The unit's position on the line, 1-based, in print order.

  at least 1

- `variable_data[].proofs[].variants` string[] required

- `variable_data[].proofs[].values` object required

  Slot name to the text this unit prints.

- `variable_data[].proofs[].qr_payload` string

  What this unit's QR code encodes. Present when the line has a QR template.

- `variable_data[].live_production` boolean required

  Whether a LIVE order carrying this line would be accepted now. False while variable-data production is switched off: sandbox orders run end to end, and a live order or quote is refused with 422 variable_data_unavailable.

- `artwork_check` object

  Each own-plant line's artwork, read and graded before anything was charged (resolution at its print size, a file that is not there or not an image, CMYK, a die-cut sticker's transparency). On a test key, `would_refuse` says whether a live order would have been refused, and `missing_artwork` lists the own-plant lines that carry none. Absent when no line is made at our own plant with artwork, and (on a test key) none is made there without it.

- `artwork_check.lines` object[] required

  One entry per line made at our own plant (stickers, decals, magnets, posters) that carries artwork, in item order. A line another lane makes is graded there, not here.

- `artwork_check.lines[].item_index` integer required

  The line's position in the request's `items`, 0-based.

  at least 0

- `artwork_check.lines[].sku` string required

- `artwork_check.lines[].status` string required

  passed: read, nothing to say. warned: read, at least one warning. refused: at least one error (a live order is refused). unverified: the file could not be read just now (see the `unverified` finding); the order is not refused for it.

  One of `passed` `warned` `refused` `unverified`

- `artwork_check.lines[].width_px` integer

  The file's width in pixels. Absent when it was not read, or is vector.

  at least 1

- `artwork_check.lines[].height_px` integer

  The file's height in pixels.

  at least 1

- `artwork_check.lines[].print_size_in` number[]

  The print size resolution was measured against, width and height in inches: the SKU's `WxH`, else the line's `size`. Absent when neither names one.

  2 to 2 entries

- `artwork_check.lines[].effective_dpi` integer

  Pixels per inch with the artwork printed as large as the print size allows (turned a quarter if that fits it larger).

  at least 1

- `artwork_check.lines[].min_dpi` integer

  The product's recommended minimum DPI. Below 75% of it is an error.

  at least 1

- `artwork_check.lines[].findings` object[] required

- `artwork_check.lines[].findings[].code` string required

  One of `unreachable` `not_an_image` `low_resolution` `below_recommended_resolution` `cmyk` `no_transparency` `touches_edge` `no_bleed` `content_in_bleed` `no_cutline` `size_unknown` `vector` `unverified` `format_not_accepted` `not_fetchable` `design_embeds_raster` `generated_design` `placement_clipped` `geometry_unverified`

- `artwork_check.lines[].findings[].severity` string required

  error refuses a live order; warning and info never do.

  One of `error` `warning` `info`

- `artwork_check.lines[].findings[].message` string required

  What was found and what to do about it, naming the line.

- `artwork_check.missing_artwork` object[]

  Test keys (ps_test_*) only, on POST /order, POST /orders/bulk and POST /{id}/item: the lines made at our own plant that carry no artwork at all, which a live request is refused for with 422 `artwork_required`. Absent when there are none.

- `artwork_check.missing_artwork[].order_index` integer

  POST /orders/bulk's 422 only: the order's position in `orders`.

  at least 0

- `artwork_check.missing_artwork[].item_index` integer required

  The line's position in the order's `items`, 0-based.

  at least 0

- `artwork_check.missing_artwork[].sku` string required

- `artwork_check.would_refuse` boolean

  Test keys (ps_test_*) only: whether a live order with these lines would have been refused, with 422 `artwork_not_printable` or (for a line in `missing_artwork`) `artwork_required`. A test key is never refused for artwork, so placeholder artwork still places a sandbox order. Absent on a live order, which either was refused or was not.

- `address_check` object

  What POST /addresses/validate finds in `address_to`, warnings included. A live order whose address has an `error` finding is refused 422 address_invalid before anything is charged; on a test key `valid: false` here says a live order would have been.

- `address_check.valid` boolean required

  False when any finding is an `error`.

- `address_check.checked` boolean required

  False for an address outside the US, which is passed through as written.

- `address_check.normalized` object required

  The address in USPS style: upper case, no punctuation, ZIP+4 hyphenated, the state as its code, a unit typed into address1 moved to address2. Name, company, phone and email are trimmed and otherwise as sent.

- `address_check.normalized.name` string

- `address_check.normalized.company` string

- `address_check.normalized.address1` string required

- `address_check.normalized.address2` string

- `address_check.normalized.city` string required

- `address_check.normalized.state` string required

- `address_check.normalized.zip` string required

- `address_check.normalized.country` string required

- `address_check.normalized.phone` string

- `address_check.normalized.email` string

- `address_check.findings` object[] required

- `address_check.findings[].code` string required

  The `delivery_point_*` codes come from delivery-point verification, which only a live POST /order, POST /orders/bulk entry or PUT /order/{id}/address runs, on a US address the other checks pass, and only where the deployment has USPS verification configured: `delivery_point_not_found` (`error`) — USPS answered that no delivery point exists at the street address; `delivery_point_incomplete` (`warning`) — the building exists but its unit is missing or not confirmed, or more than one address matched; `delivery_point_confirmed` (`info`). When USPS cannot be reached the order proceeds without them.

  One of `zip_format` `state_unknown` `zip_state_mismatch` `zip_state_border` `zip_unassigned` `po_box` `military_state` `military` `address2_split` `not_checked` `delivery_point_not_found` `delivery_point_incomplete` `delivery_point_confirmed`

- `address_check.findings[].severity` string required

  `error`: the address cannot be right as written, and a live order or quote is refused with 422 address_invalid. `warning`: worth a look, never refused. `info`: what normalization did, or that nothing was checked.

  One of `error` `warning` `info`

- `address_check.findings[].field` string required

  One of `address1` `address2` `city` `state` `zip` `country`

- `address_check.findings[].message` string required

- `breakdown` object required

- `breakdown.items` string required

- `breakdown.shipping` string required

- `breakdown.processing_fee` string required

  Compatibility field for platform and card-processing surcharges. Zero on orders priced under the current no-added-fees policy; retained on historical records.

- `breakdown.platform_fee` string

  The platform fee as a line of its own. Always 0: there is no platform fee on any order, on any plan (since 2026-10-17). Kept so a client that reads it still parses.

- `breakdown.card_fee` string

  Card processing on everything else the order is charged, passed through at cost.

- `breakdown.total` string required

- `shipping` object required

  The shipping the figure in `breakdown` is for: its method, and the surcharges that figure already holds, itemised. Nothing here is added to the total.

- `shipping.method` string required

  The shipping method the figure was priced for.

- `shipping.surcharges` object[] required

  Compatibility itemisation, never added to the total. Current quotes and new orders contain no seasonal peak-postage surcharge: PrintedSimply absorbs the carrier expense, so this array is empty. Historical stored orders may retain their original itemisation; a fee-bearing old quote must be refreshed before a new charge.

- `shipping.surcharges[].code` string required

  `peak_season`: the carrier's temporary holiday price change.

  Always `peak_season`

- `shipping.surcharges[].reason` string required

  What it is, in words.

- `shipping.surcharges[].amount_cents` integer required

  How much of the shipping figure this is, in cents, folded the way that figure is (all-in where the figure is all-in).

  at least 1

- `shipping.surcharges[].window` object required

  The days it applies on, both inclusive.

- `shipping.surcharges[].window.from` string (date) required

  YYYY-MM-DD, UTC.

- `shipping.surcharges[].window.through` string (date) required

  YYYY-MM-DD, UTC.

- `shipping.surcharges[].source` string (uri) required

  Where the amount and the dates come from: the carrier's own announcement.

- `shipping.surcharges[].included_in` string required

  Always `shipping`: the figure this is part of. Never an additional charge.

  Always `shipping`

- `estimated_delivery` object | null

  When the order should arrive, as an estimate: the quote's `estimated_delivery` for the order as placed, counted from now. Null on a lane that is not one of our own facilities. GET /order/{id}, GET /orders and the order's `order.in_production`, `order.quality_check` and `order.shipped` events carry the same date.

- `estimated_delivery.earliest` string (date) required

  YYYY-MM-DD, UTC: the soonest the order should arrive.

- `estimated_delivery.latest` string (date) required

  YYYY-MM-DD, UTC: the latest it should, before anything goes wrong.

- `estimated_delivery.ships_by` string (date) required

  YYYY-MM-DD, UTC: the day it should leave the facility, when its production estimate ends, counted the same way.

- `estimated_delivery.is_estimate` boolean required

  Always true: say it as an estimate wherever it is shown.

  Always `true`

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "order_id": "test_ord_3WDuvjlojmq4wMObnNbeIMyk",
    "portal_order_id": "ord_3WDuvjlojmq4wMObnNbeIMyk",
    "provider": "merchfarm",
    "tracking_token": "trk__bBBdLzQVxbyNDvtBMfw9N32",
    "tracking_url": "https://portal.printedsimply.com/track/eyJ2IjoxLCJ0IjoidHJhY2tpbmciLCJ0b2siOiJ0cmtfX2JCQmRMelFWeGJ5TkR2dEJNZnc5TjMyIiwiZXhwIjoiMjAyNy0wMS0xM1QxNTowMDo0NS4wMDBaIn0.9b18151b2dd9ce9ff005f798c145ddf495f7f532bfa31354836e1b3cfd3c4d9a",
    "test_mode": true,
    "lifecycle": "simulated",
    "charged": "$0.00",
    "reordered_from": "ord_wVK9ZiG-VYiPrlSeBUBmrUVk",
    "artwork_check": {
      "lines": [
        {
          "item_index": 0,
          "sku": "sticker-die-cut-3x3",
          "status": "passed",
          "width_px": 1200,
          "height_px": 1200,
          "findings": [],
          "print_size_in": [
            3,
            3
          ],
          "effective_dpi": 400,
          "min_dpi": 300
        }
      ],
      "would_refuse": false
    },
    "address_check": {
      "valid": true,
      "checked": true,
      "normalized": {
        "name": "Jane Doe",
        "address1": "456 OAK AVE",
        "city": "AUSTIN",
        "state": "TX",
        "zip": "78702",
        "country": "US"
      },
      "findings": []
    },
    "estimated_delivery": {
      "earliest": "2026-10-23",
      "latest": "2026-10-29",
      "ships_by": "2026-10-19",
      "is_estimate": true
    },
    "breakdown": {
      "items": "$128.00",
      "shipping": "$13.99",
      "processing_fee": "$0.00",
      "platform_fee": "$0.00",
      "card_fee": "$0.00",
      "total": "$141.99"
    },
    "shipping": {
      "method": "standard",
      "surcharges": []
    }
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `400` [validation_error](https://portal.printedsimply.com/docs/errors#validation_error) [pricing_error](https://portal.printedsimply.com/docs/errors#pricing_error) [unknown_sku](https://portal.printedsimply.com/docs/errors#unknown_sku)

  validation_error — the body failed validation (a field POST /order takes and a reorder does not, such as `provider` or `quote_token`, is one), an `items` entry names no line of the source or a sku on more than one (`details.lines` lists the lines), or the source holds no address or shipping method an order takes (send `address_to` or `shipping.method`). Otherwise any 400 POST /order answers for the order built, such as pricing_error or unknown_sku.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `402` [no_payment_method](https://portal.printedsimply.com/docs/errors#no_payment_method) [payment_failed](https://portal.printedsimply.com/docs/errors#payment_failed) [authentication_required](https://portal.printedsimply.com/docs/errors#authentication_required) [fraud_review](https://portal.printedsimply.com/docs/errors#fraud_review) [spend_cap_exceeded](https://portal.printedsimply.com/docs/errors#spend_cap_exceeded) `spend_cap_unenforceable`

  As on POST /order: refused before anything was charged (`no_payment_method`, `payment_failed`, `authentication_required`, `fraud_review`, `spend_cap_exceeded`, `spend_cap_unenforceable`).

- `403` [sandbox_only](https://portal.printedsimply.com/docs/errors#sandbox_only) [insufficient_scope](https://portal.printedsimply.com/docs/errors#insufficient_scope)

  sandbox_only, as on POST /order; or insufficient_scope.

- `404` [order_not_found](https://portal.printedsimply.com/docs/errors#order_not_found)

  order_not_found — no order of this account has this id in the key's mode.

- `409` [invalid_state](https://portal.printedsimply.com/docs/errors#invalid_state) [duplicate_reference](https://portal.printedsimply.com/docs/errors#duplicate_reference) [idempotency_conflict](https://portal.printedsimply.com/docs/errors#idempotency_conflict)

  invalid_state — every line of the source was cancelled, so there is nothing to place again; or the source is a team-store member order, or has a line whose stored personalization POST /order has no field for (`details.items[].fields`). duplicate_reference — as on POST /order, for the `reference` you sent. Also idempotency_conflict, while a request with the same Idempotency-Key is still in flight.

- `413` [payload_too_large](https://portal.printedsimply.com/docs/errors#payload_too_large)

  payload_too_large — the request body exceeded this route's byte cap (256 KB unless the route raises it with a written reason). It is refused before it is parsed, so nothing was created. Send fewer rows, or use the endpoint's own asynchronous door where it has one.

- `422` [variable_data_invalid](https://portal.printedsimply.com/docs/errors#variable_data_invalid) [variable_data_unavailable](https://portal.printedsimply.com/docs/errors#variable_data_unavailable) [artwork_not_printable](https://portal.printedsimply.com/docs/errors#artwork_not_printable) [artwork_required](https://portal.printedsimply.com/docs/errors#artwork_required) [destination_unavailable](https://portal.printedsimply.com/docs/errors#destination_unavailable) [below_cost_floor](https://portal.printedsimply.com/docs/errors#below_cost_floor) [address_invalid](https://portal.printedsimply.com/docs/errors#address_invalid) `max_provider_cost_required` [sku_not_available](https://portal.printedsimply.com/docs/errors#sku_not_available) [provider_not_available](https://portal.printedsimply.com/docs/errors#provider_not_available)

  variable_data_invalid or variable_data_unavailable — as POST /orders/quote: `items[].variable_data` failed its preflight, or a live order carries it while production is switched off. artwork_not_printable — a live order's artwork on a line made at our own plant cannot be printed as it is: `details.problems[]` names each line by `item_index` with a code (unreachable, not_an_image, low_resolution) and a message saying what it needs, and `details.artwork_check` carries every line's verdict. Nothing was charged and the Idempotency-Key is free for the corrected order. artwork_required — a live order has a line made at our own plant with no artwork at all (no `artwork_url`, no `print_file`), so there is nothing to print: `details.items[]` names every such line by `item_index` and `sku`, checked before any file is read, and nothing was charged. A test key is never refused for artwork; its 200 carries the same verdict with `artwork_check.would_refuse`, and a line with no artwork in `artwork_check.missing_artwork`. destination_unavailable — the address is outside the US and the order would be made at our own plant, which ships to US addresses only: `details.reason` is `destination_not_us` and `details.country` names the destination. A test key gets the same answer. below_cost_floor — the order would be made at our own plant and would cost more to make and ship than it is charged, at worst-case postage, and its loss is not one our cost floor accepts: only today's named losses are, no larger than they are today — the flat standard parcel under its postage, for a mailer up to a pound or a poster tube up to two, and list-priced goods short only of the per-order handling cost (or a partner SKU would be charged below what the partner bills for it). `details.skus` names the items and `details.contribution_cents` how far below zero the order falls. Nothing was charged; add units, combine it with another order, or choose another shipping method on offer. A test key gets the same answer. address_invalid — a live order's `address_to` cannot be right as written: `details.findings[]` names each `error` (`zip_format`, `state_unknown`, `zip_state_mismatch`, or `delivery_point_not_found`: USPS has no delivery point at the street address, where the deployment has USPS verification configured) with its field, and `details.address_check` is the whole check, as POST /addresses/validate answers it. Nothing was charged; a test key is never refused for it and its 200 carries `address_check`. sku_not_available — a line names a product our own plant does not make: only PrintedSimply's own products (stickers, decals, labels, magnets and posters, as GET /products lists them) are sold here. `details.skus` names each one. Asked before anything is priced, so nothing was priced or charged. provider_not_available — `provider` names a lane other than our own plant. Orders here are made by PrintedSimply: omit `provider`, or send `printedsimply`. `details.provider` echoes what was sent and `details.available` lists what is accepted. Nothing was priced or charged.

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

- `500` [order_error](https://portal.printedsimply.com/docs/errors#order_error)

  order_error — as on POST /order: the order was placed and charged but the confirmation could not be returned or the record written. Do not retry; look the order up first.

- `502`

  Production system error

- `503` [provider_unavailable](https://portal.printedsimply.com/docs/errors#provider_unavailable) [reference_check_unavailable](https://portal.printedsimply.com/docs/errors#reference_check_unavailable) [idempotency_unavailable](https://portal.printedsimply.com/docs/errors#idempotency_unavailable)

  provider_unavailable or reference_check_unavailable, as on POST /order. idempotency_unavailable — the request carried an `Idempotency-Key` and the ledger that makes it mean at-most-once could not be reached, so nothing ran and nothing was charged; retry the same request with the same key after `Retry-After`.

**POST `/order/{id}/simulate` Force a sandbox order to a lifecycle state**

Test keys only. Deterministically advances a sandbox order to in_production, shipped, delivered, or cancelled, firing the same signed webhooks the timed sandbox sweep would — so integration tests can drive the full lifecycle without waiting on the clock. `issue_flagged` rehearses the unhappy path: the order takes the status and the `issue_reason` you name, as a live order does when the facility flags it, and `order.issue` is enqueued carrying the same `issue_reason`. Omit `issue_reason` to rehearse a flag the facility gave no reason for. A flag stops the sandbox clock; simulate a later state to move the order on, which clears it.

**Credential**

An API key in `x-api-key`

**Scopes**

`orders:write`

### Parameters

- `id` path · string required

  The order's `order_id`, or its `portal_order_id` (`ord_…`): either resolves the same order. An id the order answered to before it was re-routed resolves too.

- `Idempotency-Key` header · string (uuid)

  Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422 `idempotency_key_reuse` rather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503 `idempotency_unavailable` with `Retry-After`, and nothing runs or is charged: retry with the same key.

### Request body application/json, required

- `target_state` string required

  One of `in_production` `shipped` `delivered` `cancelled` `issue_flagged`

- `issue_reason` string

  Only with `target_state: issue_flagged`: why the rehearsed flag was raised — the same codes a live order's `issue_reason` carries on its reads and on `order.issue`. Sent with any other target_state, it is a 400 validation_error.

  One of `artwork_unprintable` `address_undeliverable` `shipment_exception` `on_hold` `cancellation_pending`

### Request

`POST /api/v1/order/{id}/simulate`

cURL:

```bash
curl -X POST "https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/simulate" \
  -H "x-api-key: ps_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: simulate-order-$(date +%s)" \
  -d '{
    "target_state": "shipped"
  }'
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/simulate", {
  method: "POST",
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    "target_state": "shipped"
  }),
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request
import uuid

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/simulate",
    method="POST",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
        "Content-Type": "application/json",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    data=json.dumps({
        "target_state": "shipped",
    }).encode(),
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/order/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/simulate');
$body = <<<'JSON'
{
  "target_state": "shipped"
}
JSON;
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
        'Content-Type: application/json',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS => $body,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

Order advanced; webhooks fired

`data`

- `order_id` string

- `portal_order_id` string

- `status` string

  The status the order was moved to. A simulation only ever moves an order forward through the statuses the platform writes, never into a legacy one.

  One of `pending_approval` `pending_payment` `paid` `accepted` `in_production` `quality_check` `shipped` `delivered` `cancelled` `issue_flagged` `rerouting`

- `issue_reason` string | null

  Present only when `status` is `issue_flagged`: the `issue_reason` the flag was raised with, or null when none was named.

  One of `artwork_unprintable` `address_undeliverable` `shipment_exception` `on_hold` `cancellation_pending`

- `simulated` boolean

  Always `true`

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example 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"
}
```

### Errors

- `400` [test_mode_only](https://portal.printedsimply.com/docs/errors#test_mode_only) [validation_error](https://portal.printedsimply.com/docs/errors#validation_error) [already_terminal](https://portal.printedsimply.com/docs/errors#already_terminal)

  Live key (test_mode_only), invalid target_state or issue_reason (validation_error), or order already terminal (already_terminal)

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `404`

  Resource not found

- `413` [payload_too_large](https://portal.printedsimply.com/docs/errors#payload_too_large)

  payload_too_large — the request body exceeded this route's byte cap (256 KB unless the route raises it with a written reason). It is refused before it is parsed, so nothing was created. Send fewer rows, or use the endpoint's own asynchronous door where it has one.

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

**GET `/orders/export` Export orders as CSV**

The account's orders as a text/csv artifact, streamed newest first — the bookkeeping export. Every order in the window is a row, including cancelled and never-paid ones (visible with their status, never silently absent); money columns are CENTS (gross_cents, refunded_cents, net_cents — refunds attributed to the order they refund), and counts_as_revenue carries the platform's own revenue basis so the sheet filters to exactly the number the dashboard reports. The ops columns ride beside the money ones: an items summary and tracking. The key's test/live side selects the rows, as on GET /v1/orders; an unknown status filter is refused rather than answered with an empty file. This endpoint serves the file itself rather than a JSON envelope — agents wanting rows use GET /v1/orders.

**Credential**

An API key in `x-api-key`

**Scopes**

`orders:read`

### Parameters

- `from` query · string (date-time)

  Inclusive lower bound on created_at.

- `to` query · string (date-time)

  Exclusive upper bound.

- `status` query · string

  One order status; unknown values are refused.

  One of `pending_approval` `pending_payment` `paid` `accepted` `in_production` `quality_check` `shipped` `delivered` `cancelled` `issue_flagged` `rerouting` `pending` `processing` `on-hold` `completed` `refunded` `failed`

### Request

`GET /api/v1/orders/export`

cURL:

```bash
curl "https://portal.printedsimply.com/api/v1/orders/export" \
  -H "x-api-key: ps_test_YOUR_KEY"
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/orders/export", {
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
  },
  signal: AbortSignal.timeout(30_000),
})
const csv = await res.text()
```

Python:

```python
import urllib.request

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/orders/export",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
    },
)
with urllib.request.urlopen(req, timeout=30) as res:
    csv = res.read().decode()
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/orders/export');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$csv = $raw;
```

### Response `200`

The CSV, streamed. Content-Disposition names the file.

### Errors

- `400` [validation_error](https://portal.printedsimply.com/docs/errors#validation_error)

  Invalid request body or parameters. When the request failed the operation's schema, `details` carries `formErrors` (problems with the request as a whole), `fieldErrors` (messages keyed by top-level field) and `errors`: one `{ pointer, code, message }` per refused value, `pointer` an RFC 6901 JSON pointer into the body (or into the object of query parameters, for a query parameter) — `/items/0/sku`, with `~` written `~0` and `/` written `~1`, and `""` for the request as a whole. POST /v1/orders/bulk answers `errors` as one `{ index, details }` per refused order, each `details` with its own pointers from the request body (`/orders/3/items/0/sku`); POST /v1/orders/multi-ship keeps `errors` as the `{ formErrors, fieldErrors }` it always published; POST /v1/mockups/generate and POST /v1/products/recommend keep their fields at the top of `details`, with `errors` beside them. A refusal of a rule the schema cannot state (an asset that does not exist, an expiry too soon) names it in `message` and carries no `errors`.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

**POST `/orders/bulk` Create bulk orders**

Submit up to 50 orders in a single API call. Each order is processed independently — partial success is possible. Returns per-order results. An order may carry a quote_token from POST /orders/quote and is then charged that quote's locked total instead of being re-priced; the honored quote_id comes back on the result. A token that is expired, issued to another key, or does not match the order's line items and shipping method fails that one order, not the batch. With a test key the whole batch is validated exactly as a live batch and every order is persisted as a real sandbox order (no charge, not sent to production), so the returned ids work with GET /order/{id} and run the time-compressed lifecycle. An order whose `reference` another order already carries — placed earlier, or another entry of this batch — fails that one order, uncharged, with an error starting `duplicate_reference:` that names the order holding it, as POST /order refuses it with 409; `allow_duplicate_reference: true` on the entry places it anyway. An entry whose reference could not be checked fails alone, uncharged, with an error starting `reference_check_unavailable:`.

**Credential**

An API key in `x-api-key`

**Scopes**

`orders:write`

### Parameters

- `Idempotency-Key` header · string (uuid)

  Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422 `idempotency_key_reuse` rather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503 `idempotency_unavailable` with `Retry-After`, and nothing runs or is charged: retry with the same key.

### Request body application/json, required

- `orders` object[] required

  1 to 50 entries

- `orders[].address_to` object required

- `orders[].address_to.name` string required

  non-empty

- `orders[].address_to.company` string

- `orders[].address_to.address1` string required

  non-empty

- `orders[].address_to.address2` string

- `orders[].address_to.city` string required

  non-empty

- `orders[].address_to.state` string required

  non-empty

- `orders[].address_to.zip` string required

  non-empty

- `orders[].address_to.country` string required

  ISO 3166-1 alpha-2 country code

  exactly 2 characters

- `orders[].address_to.phone` string

- `orders[].address_to.email` string (email)

- `orders[].address_from` object

  The same fields as `orders[].address_to`

- `orders[].shipping` object

- `orders[].shipping.carrier` string

- `orders[].shipping.method` string

  The shipping tiers this platform prices. Per parcel, not per item: `standard` by the parcel's weight class (POST /v1/shipping/estimate reports it), and for items made at the partner facility (apparel, mugs) at the larger of that and the partner's own estimate for the parcel; `priority` and `express` at their price and never below standard for the same parcel. An unrecognised value is refused with 400 validation_error rather than priced as `standard`. `standard` is always offered; `priority` and `express` are offered when priced (each at or above the USPS cost of the service), and until then a request for either is refused 400 `shipping_method_unavailable`, with `details.offered` naming the methods that are, and nothing is charged. `letter` is untracked letter mail for stickers and small vinyl decals from our own plant, offered only where the platform prices it, for an order whose every item qualifies, within a unit and an order-value ceiling: otherwise a request for it is refused 400 `letter_mail_unavailable` with `details.reason`, and nothing is charged. On the API it is never chosen for you — no default, estimate or storefront mapping picks it — and it is placed through POST /v1/order only (bulk and bundle orders ship by parcel). A hosted store is the one exception, and only where its merchant turned on small orders by letter in the dashboard's store editor (off by default, launched stores included): a qualifying basket bought on its store page or a buy link ships as a letter, and the shopper is told before paying. A letter has no carrier tracking: its order events and tracking page state the expected window, and POST /v1/order/{id}/missing-letter reships it once if it does not arrive.

  One of `standard` `priority` `express` `letter`

- `orders[].items` object[] required

  1 to 100 entries

- `orders[].items[].sku` string required

  non-empty

- `orders[].items[].quantity` integer required

  1 to 10000

- `orders[].items[].artwork_url` string (uri)

  The artwork for this line, as a public http(s) URL. Printed when set; otherwise the line's `print_file` is. Send one of the two: a line with neither has nothing to print.

- `orders[].items[].print_file` string

  The artwork for this line: a public http(s) URL, or the id of a file you uploaded with POST /assets (`ast_…`), resolved to that file's URL. Read when `artwork_url` is not set.

- `orders[].items[].variable_data` object

  A different name, number or QR payload on every unit of this line. Accepted only on flat goods made at our own plant (stickers, decals, magnets, posters); an order carrying it is produced there. Declare the named slots of the line's design, then give every unit a value for every slot: from `units` (one entry per unit, so its length must equal `quantity`), from a `sequence` that fills one slot, or both. An optional `qr.template` builds each unit's QR payload from its values. Preflighted before anything is priced or charged: a value longer than its slot, a `units` array of the wrong length, an undeclared slot, a missing value or another plant's SKU is refused with 422 `variable_data_invalid`, naming each problem by item, unit and slot. Never changes the price. Live orders and quotes carrying it are refused with 422 `variable_data_unavailable` until production is switched on; sandbox (ps_test_*) orders accept it end to end.

- `orders[].items[].variable_data.slots` object[] required

  The named text slots of the line's design, each with the most characters it holds.

  1 to 10 entries

- `orders[].items[].variable_data.units` object[]

  One object per unit, in print order: { slot name: value }. Its length must equal the line's quantity. Values are strings or numbers, with no line breaks. Omit it when a `sequence` fills every slot.

  1 to 10000 entries

- `orders[].items[].variable_data.sequence` object

  Numbers one slot per unit: prefix + (start + (unit - 1) × step), zero-padded to `pad` digits, e.g. A-0001, A-0002. A unit may not also set that slot.

- `orders[].items[].variable_data.qr` object

  Each unit's QR payload, built from its values.

- `orders[].items[].artwork` object

  Optional: how this line's artwork was made, and who approved it and when. A declaration you state, stored on the line as sent (approved_by sanitized, approved_at in UTC), echoed on the order's reads (`line_items[].artwork`) and on its `order.created` webhook. It never changes the price, the routing or the artwork check, and a line without it is placed exactly as before. Strict: an unknown key is a 400 validation_error.

- `orders[].items[].artwork.provenance` string

  How the artwork was made: `human` (made by a person), `ai_assisted` (a person made it with a model's help) or `ai_generated` (a model made it).

  One of `human` `ai_assisted` `ai_generated`

- `orders[].items[].artwork.approved_by` string

  Who approved the artwork for print: a name or an email address, at most 200 characters. Control and invisible formatting characters are removed and whitespace collapsed before it is stored. Send it with approved_at.

  non-empty · at most 200 characters

- `orders[].items[].artwork.approved_at` string (date-time)

  When the artwork was approved: an ISO 8601 date-time with a time zone, not in the future. Stored and echoed in UTC. Send it with approved_by.

- `orders[].provider` string

  Where the order is made: always our own plant here. Omit it, or send `printedsimply`; any other value is refused with 422 `provider_not_available` before anything is priced or charged.

  Always `printedsimply`

  Default printedsimply

- `orders[].reference` string

  Your own identifier for this order — a marketplace order number, a cart id, whatever you key on. At most 200 characters, and no NUL byte. Stored verbatim, echoed on the create response, on GET /orders and GET /order/{id}, and on the order webhooks, and filterable with GET /orders?reference=. One reference is one order: while an order of your account placed in the last 30 days with a key of the same mode (a test key against test orders) and not cancelled carries it, another order with the same reference is refused 409 `duplicate_reference` before anything is charged, whatever its Idempotency-Key, and `details.order_id` names the order that has it. Use Idempotency-Key for exactly-once retries of one request; this catches the duplicate sent under a new key. When whether the reference is free cannot be checked, the order is not placed: 503 `reference_check_unavailable`, nothing charged.

  at most 200 characters

- `orders[].allow_duplicate_reference` boolean

  Place the order even when another order already carries its `reference` (see `reference`). For a reference that is not unique on your side, such as order numbers from two stores that each count from 1. Omit it, or send false, to have a duplicate refused with 409 `duplicate_reference`.

  Default false

- `orders[].vat` string

  Tax-registration number (VAT / IOSS / GST) to declare for this shipment. Stored, echoed on the create response and on GET /orders and GET /order/{id}, and forwarded to the fulfilling facility for the customs declaration. Supply it per order — an international run can ship to several destinations under different registrations. Omit it on domestic shipments.

- `orders[].quote_token` string

  A signed quote from POST /orders/quote. When present, the order is charged exactly that quote's locked total instead of being re-priced and the quoted facility routing is honored. Refused (400) when expired, issued to a different key, or when the order's items or shipping method differ from what was quoted.

- `orders[].payment_intent` string

  Live keys only. When a live order's card needs 3D Secure, the order is answered 402 `authentication_required` with `details.payment_intent` and `details.client_secret`: the amount opened on the card as an authorization only, nothing charged. Have the cardholder approve it in their browser with Stripe.js, loaded with `details.publishable_key` (the intent is on our Stripe account), calling `handleNextAction({ clientSecret })`, then send the same order again with this field set to `details.payment_intent` and a new Idempotency-Key. The order captures that authorization instead of charging the card again. It is refused with 402 `payment_failed` unless it was opened for this account, authorizes exactly this order's total and has not been captured — `details.code` says which check failed — and one the cardholder has not finished approving answers `authentication_required` again. An approval no order captures is released by the card network on its own. Ignored on a test key, which charges nothing.

  matches ^pi_[A-Za-z0-9]+$

- `orders[].approval` string

  `required` holds the order for a person's approval before anything is charged: the order is checked and priced exactly as without it (every refusal it would get, it gets now), then recorded `pending_approval` with nothing charged, nothing counted against the key's spend caps and nothing sent to production, and answered 202 `OrderAwaitingApproval` with `approval_url`, a signed page the person opens with no account. It shows each line's artwork, product, size, quantity and price, the total, the town it ships to and the line's artwork declaration. Approving it places the order through this same operation, as the key it was held with, at the total the person was shown: the checks run again, the card is charged and the order goes to production (`order.approval_approved`, then `order.created` and `order.paid`). If the price has moved, the card is declined or a spend cap refuses it, nothing is charged, `approval.status` reads `failed` with the reason, and the person may approve it again. Declined, or not decided within 72 hours (`approval_expires_at`), the order is cancelled with nothing charged (`order.approval_declined`, `order.approval_expired`); DELETE /order/{id}/cancel cancels it too, with nothing to refund. A held order takes no address, shipping or line change. A `quote_token` is honoured at approval only while it is valid (24 hours): approved later, the order places nothing (`invalid_quote`). Taken on an API key's order only (a platform OAuth token's is 400 validation_error, `details.field: approval`) and not with `payment_intent`. On POST /orders/bulk an entry carrying it fails validation.

  Always `required`

### Request

`POST /api/v1/orders/bulk`

cURL:

```bash
curl -X POST "https://portal.printedsimply.com/api/v1/orders/bulk" \
  -H "x-api-key: ps_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: create-bulk-orders-$(date +%s)" \
  -d '{
    "orders": [
      {
        "items": [
          {
            "sku": "sticker-die-cut-3x3",
            "quantity": 100,
            "artwork_url": "https://portal.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"
        }
      },
      {
        "items": [
          {
            "sku": "sticker-die-cut-3x3",
            "quantity": 100,
            "artwork_url": "https://portal.printedsimply.com/samples/sticker-3x3.png"
          }
        ],
        "address_to": {
          "name": "Sam Lee",
          "address1": "420 Pine St",
          "city": "Seattle",
          "state": "WA",
          "zip": "98101",
          "country": "US"
        },
        "shipping": {
          "method": "standard"
        }
      }
    ]
  }'
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/orders/bulk", {
  method: "POST",
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    "orders": [
      {
        "items": [
          {
            "sku": "sticker-die-cut-3x3",
            "quantity": 100,
            "artwork_url": "https://portal.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"
        }
      },
      {
        "items": [
          {
            "sku": "sticker-die-cut-3x3",
            "quantity": 100,
            "artwork_url": "https://portal.printedsimply.com/samples/sticker-3x3.png"
          }
        ],
        "address_to": {
          "name": "Sam Lee",
          "address1": "420 Pine St",
          "city": "Seattle",
          "state": "WA",
          "zip": "98101",
          "country": "US"
        },
        "shipping": {
          "method": "standard"
        }
      }
    ]
  }),
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request
import uuid

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/orders/bulk",
    method="POST",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
        "Content-Type": "application/json",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    data=json.dumps({
        "orders": [
            {
                "items": [
                    {
                        "sku": "sticker-die-cut-3x3",
                        "quantity": 100,
                        "artwork_url": "https://portal.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",
                },
            },
            {
                "items": [
                    {
                        "sku": "sticker-die-cut-3x3",
                        "quantity": 100,
                        "artwork_url": "https://portal.printedsimply.com/samples/sticker-3x3.png",
                    },
                ],
                "address_to": {
                    "name": "Sam Lee",
                    "address1": "420 Pine St",
                    "city": "Seattle",
                    "state": "WA",
                    "zip": "98101",
                    "country": "US",
                },
                "shipping": {
                    "method": "standard",
                },
            },
        ],
    }).encode(),
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/orders/bulk');
$body = <<<'JSON'
{
  "orders": [
    {
      "items": [
        {
          "sku": "sticker-die-cut-3x3",
          "quantity": 100,
          "artwork_url": "https://portal.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"
      }
    },
    {
      "items": [
        {
          "sku": "sticker-die-cut-3x3",
          "quantity": 100,
          "artwork_url": "https://portal.printedsimply.com/samples/sticker-3x3.png"
        }
      ],
      "address_to": {
        "name": "Sam Lee",
        "address1": "420 Pine St",
        "city": "Seattle",
        "state": "WA",
        "zip": "98101",
        "country": "US"
      },
      "shipping": {
        "method": "standard"
      }
    }
  ]
}
JSON;
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
        'Content-Type: application/json',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS => $body,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

Bulk order results — one row per entry, partial success inside a 200. An entry our plant gave no answer about (the request did not reach it, timed out, or came back 5xx — POST /order's 503 provider_unavailable with `details.reason: plant_unreachable`) fails alone, uncharged, with an error starting `plant_unreachable:`, and the 200 carries `Retry-After`; a test key's entry is recorded at $0 instead, as on POST /order. Place the failed entries again in a new request: a replay of this one under the same Idempotency-Key returns these same rows.

`data`

- `total` integer required

- `succeeded` integer required

- `failed` integer required

- `results` object[] required

- `results[].index` integer required

  Position of this entry in the request's orders array

- `results[].status` string required

  One of `success` `error`

- `results[].order_id` string

  The public order id, on success

- `results[].portal_order_id` string

- `results[].provider` string

  The facility that took the order

- `results[].charged` string

  What was charged for this entry, e.g. '$23.79'

- `results[].tracking_url` string (uri)

- `results[].test_mode` boolean

- `results[].quote_id` string

  Set when this entry was charged a signed quote's locked total

- `results[].reference` string

  Your own identifier for this entry, echoed back

- `results[].artwork_check` object

  On success: the verdict on each of this entry's lines made at our own plant, numbered by the entry's own item_index, as POST /order's artwork_check. Absent when no line has one and, on a test key, no own-plant line lacks one.

- `results[].artwork_check.lines` object[] required

  One entry per line made at our own plant (stickers, decals, magnets, posters) that carries artwork, in item order. A line another lane makes is graded there, not here.

- `results[].artwork_check.lines[].item_index` integer required

  The line's position in the request's `items`, 0-based.

  at least 0

- `results[].artwork_check.lines[].sku` string required

- `results[].artwork_check.lines[].status` string required

  passed: read, nothing to say. warned: read, at least one warning. refused: at least one error (a live order is refused). unverified: the file could not be read just now (see the `unverified` finding); the order is not refused for it.

  One of `passed` `warned` `refused` `unverified`

- `results[].artwork_check.lines[].width_px` integer

  The file's width in pixels. Absent when it was not read, or is vector.

  at least 1

- `results[].artwork_check.lines[].height_px` integer

  The file's height in pixels.

  at least 1

- `results[].artwork_check.lines[].print_size_in` number[]

  The print size resolution was measured against, width and height in inches: the SKU's `WxH`, else the line's `size`. Absent when neither names one.

  2 to 2 entries

- `results[].artwork_check.lines[].effective_dpi` integer

  Pixels per inch with the artwork printed as large as the print size allows (turned a quarter if that fits it larger).

  at least 1

- `results[].artwork_check.lines[].min_dpi` integer

  The product's recommended minimum DPI. Below 75% of it is an error.

  at least 1

- `results[].artwork_check.lines[].findings` object[] required

- `results[].artwork_check.missing_artwork` object[]

  Test keys (ps_test_*) only, on POST /order, POST /orders/bulk and POST /{id}/item: the lines made at our own plant that carry no artwork at all, which a live request is refused for with 422 `artwork_required`. Absent when there are none.

- `results[].artwork_check.missing_artwork[].order_index` integer

  POST /orders/bulk's 422 only: the order's position in `orders`.

  at least 0

- `results[].artwork_check.missing_artwork[].item_index` integer required

  The line's position in the order's `items`, 0-based.

  at least 0

- `results[].artwork_check.missing_artwork[].sku` string required

- `results[].artwork_check.would_refuse` boolean

  Test keys (ps_test_*) only: whether a live order with these lines would have been refused, with 422 `artwork_not_printable` or (for a line in `missing_artwork`) `artwork_required`. A test key is never refused for artwork, so placeholder artwork still places a sandbox order. Absent on a live order, which either was refused or was not.

- `results[].address_check` object | any

  What POST /addresses/validate finds in this entry's `address_to`, warnings included: POST /order's `address_check`. A live entry with an `error` finding is refused (`error_code: address_invalid`) before anything is charged; on a test key `valid: false` here says a live entry would have been. Null only where the check did not run.

- `results[].address_check.valid` boolean required

  False when any finding is an `error`.

- `results[].address_check.checked` boolean required

  False for an address outside the US, which is passed through as written.

- `results[].address_check.normalized` object required

  The address in USPS style: upper case, no punctuation, ZIP+4 hyphenated, the state as its code, a unit typed into address1 moved to address2. Name, company, phone and email are trimmed and otherwise as sent.

- `results[].address_check.normalized.name` string

- `results[].address_check.normalized.company` string

- `results[].address_check.normalized.address1` string required

- `results[].address_check.normalized.address2` string

- `results[].address_check.normalized.city` string required

- `results[].address_check.normalized.state` string required

- `results[].address_check.normalized.zip` string required

- `results[].address_check.normalized.country` string required

- `results[].address_check.normalized.phone` string

- `results[].address_check.normalized.email` string

- `results[].address_check.findings` object[] required

- `results[].address_check.findings[].code` string required

  The `delivery_point_*` codes come from delivery-point verification, which only a live POST /order, POST /orders/bulk entry or PUT /order/{id}/address runs, on a US address the other checks pass, and only where the deployment has USPS verification configured: `delivery_point_not_found` (`error`) — USPS answered that no delivery point exists at the street address; `delivery_point_incomplete` (`warning`) — the building exists but its unit is missing or not confirmed, or more than one address matched; `delivery_point_confirmed` (`info`). When USPS cannot be reached the order proceeds without them.

  One of `zip_format` `state_unknown` `zip_state_mismatch` `zip_state_border` `zip_unassigned` `po_box` `military_state` `military` `address2_split` `not_checked` `delivery_point_not_found` `delivery_point_incomplete` `delivery_point_confirmed`

- `results[].address_check.findings[].severity` string required

  `error`: the address cannot be right as written, and a live order or quote is refused with 422 address_invalid. `warning`: worth a look, never refused. `info`: what normalization did, or that nothing was checked.

  One of `error` `warning` `info`

- `results[].address_check.findings[].field` string required

  One of `address1` `address2` `city` `state` `zip` `country`

- `results[].address_check.findings[].message` string required

- `results[].estimated_delivery` object | null

  On success: when this entry's order should arrive, as POST /order's `estimated_delivery` dates it. Null on a refused entry and on a lane that is not one of our own facilities.

- `results[].estimated_delivery.earliest` string (date) required

  YYYY-MM-DD, UTC: the soonest the order should arrive.

- `results[].estimated_delivery.latest` string (date) required

  YYYY-MM-DD, UTC: the latest it should, before anything goes wrong.

- `results[].estimated_delivery.ships_by` string (date) required

  YYYY-MM-DD, UTC: the day it should leave the facility, when its production estimate ends, counted the same way.

- `results[].estimated_delivery.is_estimate` boolean required

  Always true: say it as an estimate wherever it is shown.

  Always `true`

- `results[].error` string

  Why this entry was refused, on error. Every refusal the route words itself starts with its code (`destination_unavailable:`, `duplicate_reference:`, `invalid_quote:`, `spend_cap_exceeded:`, `payment_failed:`, …), which `error_code` carries as a field of its own; `plant_unreachable:` means our plant gave no answer about a SKU — nothing was charged, and the entry may be placed again after the response's `Retry-After`. `production_reconciliation_required:` means production may exist and support must reconcile it; do not refund or resubmit this entry under another key. `fulfillment_error:` and `order_error:` entries were charged: the sentence says whether the payment was refunded or the order stands.

- `results[].error_code` string | null

  The code `error` starts with, to branch on without splitting the string: the code POST /order answers the same refusal with (`plant_unreachable` is POST /order's `details.reason` on its 503 `provider_unavailable`). Null on success, and on an error the route did not word (an unexpected failure, whose `error` starts with no code).

  One of `address_invalid` `authentication_required` `destination_unavailable` `duplicate_reference` `fraud_review` `fulfillment_error` `invalid_quote` `order_error` `payment_failed` `plant_unreachable` `pricing_error` `production_reconciliation_required` `provider_cannot_produce` `provider_unavailable` `quote_mismatch` `reference_check_unavailable` `spend_cap_exceeded` `unknown_sku`

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "total": 2,
    "succeeded": 2,
    "failed": 0,
    "results": [
      {
        "index": 0,
        "status": "success",
        "order_id": "test_ord_qbpi4ALIbckcSICFfIPmmOKl",
        "portal_order_id": "ord_qbpi4ALIbckcSICFfIPmmOKl",
        "provider": "merchfarm",
        "charged": "$0.00",
        "tracking_url": "https://portal.printedsimply.com/track/eyJ2IjoxLCJ0IjoidHJhY2tpbmciLCJ0b2siOiJ0cmtfckVOcGFBcXFhY3ZWWUZubVdZcGhYUGdiIiwiZXhwIjoiMjAyNy0wMS0xM1QxNTowMDoxOC4wMDBaIn0.1ff490e6f8eebee240ec6a31f301d2bb46d857291a20c15e906488dc38e80ed8",
        "test_mode": true,
        "artwork_check": {
          "lines": [
            {
              "item_index": 0,
              "sku": "sticker-die-cut-3x3",
              "status": "passed",
              "width_px": 1200,
              "height_px": 1200,
              "findings": [],
              "print_size_in": [
                3,
                3
              ],
              "effective_dpi": 400,
              "min_dpi": 300
            }
          ],
          "would_refuse": false
        },
        "estimated_delivery": {
          "earliest": "2026-10-23",
          "latest": "2026-10-29",
          "ships_by": "2026-10-19",
          "is_estimate": true
        },
        "address_check": {
          "valid": true,
          "checked": true,
          "normalized": {
            "name": "Jane Doe",
            "address1": "123 MAIN ST",
            "city": "AUSTIN",
            "state": "TX",
            "zip": "78701",
            "country": "US"
          },
          "findings": []
        },
        "error_code": null
      },
      {
        "index": 1,
        "status": "success",
        "order_id": "test_ord_JyZLq7sUxTZxc7qjzI76ZQYU",
        "portal_order_id": "ord_JyZLq7sUxTZxc7qjzI76ZQYU",
        "provider": "merchfarm",
        "charged": "$0.00",
        "tracking_url": "https://portal.printedsimply.com/track/eyJ2IjoxLCJ0IjoidHJhY2tpbmciLCJ0b2siOiJ0cmtfaklVQXZFTTl6UXZMank4d3lWUmZwOUpvIiwiZXhwIjoiMjAyNy0wMS0xM1QxNTowMDoxOC4wMDBaIn0.c342585c817ecadf771a6b3c3a8c0b0af13404c28dd1bca7129bd38c102ec43f",
        "test_mode": true,
        "artwork_check": {
          "lines": [
            {
              "item_index": 0,
              "sku": "sticker-die-cut-3x3",
              "status": "passed",
              "width_px": 1200,
              "height_px": 1200,
              "findings": [],
              "print_size_in": [
                3,
                3
              ],
              "effective_dpi": 400,
              "min_dpi": 300
            }
          ],
          "would_refuse": false
        },
        "estimated_delivery": {
          "earliest": "2026-10-23",
          "latest": "2026-10-29",
          "ships_by": "2026-10-19",
          "is_estimate": true
        },
        "address_check": {
          "valid": true,
          "checked": true,
          "normalized": {
            "name": "Sam Lee",
            "address1": "420 PINE ST",
            "city": "SEATTLE",
            "state": "WA",
            "zip": "98101",
            "country": "US"
          },
          "findings": []
        },
        "error_code": null
      }
    ]
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `400` [validation_error](https://portal.printedsimply.com/docs/errors#validation_error)

  Invalid request body or parameters. When the request failed the operation's schema, `details` carries `formErrors` (problems with the request as a whole), `fieldErrors` (messages keyed by top-level field) and `errors`: one `{ pointer, code, message }` per refused value, `pointer` an RFC 6901 JSON pointer into the body (or into the object of query parameters, for a query parameter) — `/items/0/sku`, with `~` written `~0` and `/` written `~1`, and `""` for the request as a whole. POST /v1/orders/bulk answers `errors` as one `{ index, details }` per refused order, each `details` with its own pointers from the request body (`/orders/3/items/0/sku`); POST /v1/orders/multi-ship keeps `errors` as the `{ formErrors, fieldErrors }` it always published; POST /v1/mockups/generate and POST /v1/products/recommend keep their fields at the top of `details`, with `errors` beside them. A refusal of a rule the schema cannot state (an asset that does not exist, an expiry too soon) names it in `message` and carries no `errors`.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `402`

  Payment failed or no payment method configured

- `403` [sandbox_only](https://portal.printedsimply.com/docs/errors#sandbox_only)

  sandbox_only — the account is restricted to sandbox mode and the request used a live key. The whole batch is refused before any row is placed.

- `413` [payload_too_large](https://portal.printedsimply.com/docs/errors#payload_too_large)

  payload_too_large — the request body exceeded this route's byte cap (256 KB unless the route raises it with a written reason). It is refused before it is parsed, so nothing was created. Send fewer rows, or use the endpoint's own asynchronous door where it has one.

- `422` [variable_data_invalid](https://portal.printedsimply.com/docs/errors#variable_data_invalid) [variable_data_unavailable](https://portal.printedsimply.com/docs/errors#variable_data_unavailable) [artwork_not_printable](https://portal.printedsimply.com/docs/errors#artwork_not_printable) [artwork_required](https://portal.printedsimply.com/docs/errors#artwork_required) [sku_not_available](https://portal.printedsimply.com/docs/errors#sku_not_available) [provider_not_available](https://portal.printedsimply.com/docs/errors#provider_not_available)

  variable_data_invalid — an entry's `items[].variable_data` failed the preflight; the whole batch is refused before any row is placed or charged, and each of `details.problems[]` carries the entry's `order_index` beside its item, unit and slot. variable_data_unavailable — a live batch carries variable data while production is switched off; refused whole, nothing charged. artwork_not_printable — a live batch has a line made at our own plant whose artwork cannot be printed as it is (unreachable, not_an_image, low_resolution); the whole batch is refused before any row is placed or charged, each of `details.problems[]` names the line by `order_index` and `item_index`, and `details.artwork_checks[]` carries every checked order's verdicts. artwork_required — a live batch has a line made at our own plant with no artwork at all (no artwork_url, no print_file); the whole batch is refused before any file is read or any row is placed or charged, and `details.items[]` names each line by `order_index`, `item_index` and `sku`. A test key is never refused for artwork; each row's `artwork_check.would_refuse` says what a live batch would do, and `artwork_check.missing_artwork` lists a row's lines with none. See POST /order's 422. sku_not_available or provider_not_available — an entry names a product our own plant does not make, or a lane other than our plant: the whole batch is refused before anything is priced or charged, and `details.errors[]` names each refused entry by `index` with its `code` and its `skus`, or its `provider` and `available`.

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

- `503` [idempotency_unavailable](https://portal.printedsimply.com/docs/errors#idempotency_unavailable)

  idempotency_unavailable — the request carried an `Idempotency-Key` and the ledger that makes it mean at-most-once could not be reached, so nothing ran and nothing was charged; retry the same request with the same key after `Retry-After`.

**POST `/orders/multi-ship` Create multi-address order**

Place an order whose items ship to several addresses. Give the full item list once, then a shipment per destination naming the item indices that go there — every item in exactly one shipment. Each shipment is placed as its own order through the same path POST /orders/bulk uses (priced, charged, routed, and tracked independently, with quote honoring, spend caps, fraud gating, and webhooks identical to a batch), and the response groups them under group_id with order_ids and one row per shipment: shipment_id, index, status, order_id, portal_order_id, tracking_url, charged, error, address, items. Partial success is a 200: a shipment the platform refuses fails its own row and charges nothing while the others proceed; on a live key that includes a shipment whose address cannot be right as written (POST /addresses/validate's check), whose error starts `address_invalid:`. A test key places real sandbox orders that run the time-compressed lifecycle; a live key charges and routes to production — the same code path in both modes. At most 50 shipments per call. Counts as one request, against your rate limit and in your usage.

**Credential**

An API key in `x-api-key`

**Scopes**

`orders:write`

### Parameters

- `Idempotency-Key` header · string (uuid)

  Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422 `idempotency_key_reuse` rather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503 `idempotency_unavailable` with `Retry-After`, and nothing runs or is charged: retry with the same key.

### Request body application/json, required

- `items` object[] required

  1 to 100 entries

- `items[].sku` string required

  non-empty

- `items[].product_id` integer

  Catalog product ID

  at least 1

- `items[].quantity` integer required

  1 to 10000

- `items[].size` string

  Product size (e.g. S, M, L, XL)

- `items[].color` string

  Product color

- `items[].design_name` string

  Design/artwork name

- `items[].artwork_url` string (uri)

  URL to artwork file

- `items[].print_file` string

  URL or asset ID (ast_*)

- `items[].notes` string

  Item-level notes

- `items[].artwork` object

  Optional: how this line's artwork was made, and who approved it and when. A declaration you state, stored on the line as sent (approved_by sanitized, approved_at in UTC), echoed on the order's reads (`line_items[].artwork`) and on its `order.created` webhook. It never changes the price, the routing or the artwork check, and a line without it is placed exactly as before. Strict: an unknown key is a 400 validation_error.

- `items[].artwork.provenance` string

  How the artwork was made: `human` (made by a person), `ai_assisted` (a person made it with a model's help) or `ai_generated` (a model made it).

  One of `human` `ai_assisted` `ai_generated`

- `items[].artwork.approved_by` string

  Who approved the artwork for print: a name or an email address, at most 200 characters. Control and invisible formatting characters are removed and whitespace collapsed before it is stored. Send it with approved_at.

  non-empty · at most 200 characters

- `items[].artwork.approved_at` string (date-time)

  When the artwork was approved: an ISO 8601 date-time with a time zone, not in the future. Stored and echoed in UTC. Send it with approved_by.

- `shipments` object[] required

  at least 1 entry

- `shipments[].address` object required

- `shipments[].address.name` string required

  non-empty

- `shipments[].address.company` string

- `shipments[].address.address1` string required

  non-empty

- `shipments[].address.address2` string

- `shipments[].address.city` string required

  non-empty

- `shipments[].address.state` string required

  non-empty

- `shipments[].address.zip` string required

  non-empty

- `shipments[].address.country` string required

  ISO 3166-1 alpha-2 country code

  exactly 2 characters

- `shipments[].address.phone` string

- `shipments[].address.email` string (email)

- `shipments[].item_indices` integer[] required

  Indices into the items array for this shipment

  at least 1 entry

### Request

`POST /api/v1/orders/multi-ship`

cURL:

```bash
curl -X POST "https://portal.printedsimply.com/api/v1/orders/multi-ship" \
  -H "x-api-key: ps_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: create-multi-ship-order-$(date +%s)" \
  -d '{
    "items": [
      {
        "sku": "sticker-die-cut-3x3",
        "quantity": 50,
        "artwork_url": "https://example.com/logo.png"
      },
      {
        "sku": "sticker-die-cut-3x3",
        "quantity": 50,
        "artwork_url": "https://example.com/logo.png"
      }
    ],
    "shipments": [
      {
        "address": {
          "name": "Jane Doe",
          "address1": "123 Main St",
          "city": "Austin",
          "state": "TX",
          "zip": "78701",
          "country": "US"
        },
        "item_indices": [
          0
        ]
      },
      {
        "address": {
          "name": "Sam Lee",
          "address1": "420 Pine St",
          "city": "Seattle",
          "state": "WA",
          "zip": "98101",
          "country": "US"
        },
        "item_indices": [
          1
        ]
      }
    ]
  }'
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/orders/multi-ship", {
  method: "POST",
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    "items": [
      {
        "sku": "sticker-die-cut-3x3",
        "quantity": 50,
        "artwork_url": "https://example.com/logo.png"
      },
      {
        "sku": "sticker-die-cut-3x3",
        "quantity": 50,
        "artwork_url": "https://example.com/logo.png"
      }
    ],
    "shipments": [
      {
        "address": {
          "name": "Jane Doe",
          "address1": "123 Main St",
          "city": "Austin",
          "state": "TX",
          "zip": "78701",
          "country": "US"
        },
        "item_indices": [
          0
        ]
      },
      {
        "address": {
          "name": "Sam Lee",
          "address1": "420 Pine St",
          "city": "Seattle",
          "state": "WA",
          "zip": "98101",
          "country": "US"
        },
        "item_indices": [
          1
        ]
      }
    ]
  }),
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request
import uuid

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/orders/multi-ship",
    method="POST",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
        "Content-Type": "application/json",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    data=json.dumps({
        "items": [
            {
                "sku": "sticker-die-cut-3x3",
                "quantity": 50,
                "artwork_url": "https://example.com/logo.png",
            },
            {
                "sku": "sticker-die-cut-3x3",
                "quantity": 50,
                "artwork_url": "https://example.com/logo.png",
            },
        ],
        "shipments": [
            {
                "address": {
                    "name": "Jane Doe",
                    "address1": "123 Main St",
                    "city": "Austin",
                    "state": "TX",
                    "zip": "78701",
                    "country": "US",
                },
                "item_indices": [
                    0,
                ],
            },
            {
                "address": {
                    "name": "Sam Lee",
                    "address1": "420 Pine St",
                    "city": "Seattle",
                    "state": "WA",
                    "zip": "98101",
                    "country": "US",
                },
                "item_indices": [
                    1,
                ],
            },
        ],
    }).encode(),
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/orders/multi-ship');
$body = <<<'JSON'
{
  "items": [
    {
      "sku": "sticker-die-cut-3x3",
      "quantity": 50,
      "artwork_url": "https://example.com/logo.png"
    },
    {
      "sku": "sticker-die-cut-3x3",
      "quantity": 50,
      "artwork_url": "https://example.com/logo.png"
    }
  ],
  "shipments": [
    {
      "address": {
        "name": "Jane Doe",
        "address1": "123 Main St",
        "city": "Austin",
        "state": "TX",
        "zip": "78701",
        "country": "US"
      },
      "item_indices": [
        0
      ]
    },
    {
      "address": {
        "name": "Sam Lee",
        "address1": "420 Pine St",
        "city": "Seattle",
        "state": "WA",
        "zip": "98101",
        "country": "US"
      },
      "item_indices": [
        1
      ]
    }
  ]
}
JSON;
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
        'Content-Type: application/json',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS => $body,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

Multi-ship group placed — one row per shipment, partial success inside a 200. A shipment our plant gave no answer about fails alone, uncharged, with POST /v1/orders/bulk's error starting `plant_unreachable:`, and the 200 carries `Retry-After`.

`data`

- `group_id` string required

  The group the per-shipment orders were placed under (msh_…)

- `order_ids` string[] required

  The order ids of the shipments that succeeded

- `shipments` object[] required

- `shipments[].shipment_id` string required

- `shipments[].index` integer required

  Position of this shipment in the request's shipments array

- `shipments[].status` string required

  One of `success` `error`

- `shipments[].order_id` string | null

  The order placed for this shipment, on success

- `shipments[].portal_order_id` string | null

- `shipments[].tracking_url` string (uri) | null

- `shipments[].charged` string | null

- `shipments[].error` string | null

  Why this shipment was refused, on error: POST /v1/orders/bulk's row error, so `plant_unreachable:` means our plant gave no answer about a SKU and nothing was charged for it.

- `shipments[].error_code` string | null

  POST /v1/orders/bulk's row `error_code`: the code `error` starts with, the code POST /order answers the same refusal with. Null on success, and on an error whose `error` starts with no code.

  One of `address_invalid` `authentication_required` `destination_unavailable` `duplicate_reference` `fraud_review` `fulfillment_error` `invalid_quote` `order_error` `payment_failed` `plant_unreachable` `pricing_error` `production_reconciliation_required` `provider_cannot_produce` `provider_unavailable` `quote_mismatch` `reference_check_unavailable` `spend_cap_exceeded` `unknown_sku`

- `shipments[].address_check` object | any

  POST /order's `address_check` for this shipment's address, warnings included. A live shipment with an `error` finding is refused (`error_code: address_invalid`) before anything is charged. Null only where the check did not run.

- `shipments[].address_check.valid` boolean required

  False when any finding is an `error`.

- `shipments[].address_check.checked` boolean required

  False for an address outside the US, which is passed through as written.

- `shipments[].address_check.normalized` object required

  The address in USPS style: upper case, no punctuation, ZIP+4 hyphenated, the state as its code, a unit typed into address1 moved to address2. Name, company, phone and email are trimmed and otherwise as sent.

- `shipments[].address_check.normalized.name` string

- `shipments[].address_check.normalized.company` string

- `shipments[].address_check.normalized.address1` string required

- `shipments[].address_check.normalized.address2` string

- `shipments[].address_check.normalized.city` string required

- `shipments[].address_check.normalized.state` string required

- `shipments[].address_check.normalized.zip` string required

- `shipments[].address_check.normalized.country` string required

- `shipments[].address_check.normalized.phone` string

- `shipments[].address_check.normalized.email` string

- `shipments[].address_check.findings` object[] required

- `shipments[].address_check.findings[].code` string required

  The `delivery_point_*` codes come from delivery-point verification, which only a live POST /order, POST /orders/bulk entry or PUT /order/{id}/address runs, on a US address the other checks pass, and only where the deployment has USPS verification configured: `delivery_point_not_found` (`error`) — USPS answered that no delivery point exists at the street address; `delivery_point_incomplete` (`warning`) — the building exists but its unit is missing or not confirmed, or more than one address matched; `delivery_point_confirmed` (`info`). When USPS cannot be reached the order proceeds without them.

  One of `zip_format` `state_unknown` `zip_state_mismatch` `zip_state_border` `zip_unassigned` `po_box` `military_state` `military` `address2_split` `not_checked` `delivery_point_not_found` `delivery_point_incomplete` `delivery_point_confirmed`

- `shipments[].address_check.findings[].severity` string required

  `error`: the address cannot be right as written, and a live order or quote is refused with 422 address_invalid. `warning`: worth a look, never refused. `info`: what normalization did, or that nothing was checked.

  One of `error` `warning` `info`

- `shipments[].address_check.findings[].field` string required

  One of `address1` `address2` `city` `state` `zip` `country`

- `shipments[].address_check.findings[].message` string required

- `shipments[].estimated_delivery` object | null

  On success: when this shipment's order should arrive, as POST /order's `estimated_delivery` dates it. Null on a refused shipment and on a lane that is not one of our own facilities.

- `shipments[].estimated_delivery.earliest` string (date) required

  YYYY-MM-DD, UTC: the soonest the order should arrive.

- `shipments[].estimated_delivery.latest` string (date) required

  YYYY-MM-DD, UTC: the latest it should, before anything goes wrong.

- `shipments[].estimated_delivery.ships_by` string (date) required

  YYYY-MM-DD, UTC: the day it should leave the facility, when its production estimate ends, counted the same way.

- `shipments[].estimated_delivery.is_estimate` boolean required

  Always true: say it as an estimate wherever it is shown.

  Always `true`

- `shipments[].address` object required

- `shipments[].address.name` string required

  non-empty

- `shipments[].address.company` string

- `shipments[].address.address1` string required

  non-empty

- `shipments[].address.address2` string

- `shipments[].address.city` string required

  non-empty

- `shipments[].address.state` string required

  non-empty

- `shipments[].address.zip` string required

  non-empty

- `shipments[].address.country` string required

  ISO 3166-1 alpha-2 country code

  exactly 2 characters

- `shipments[].address.phone` string

- `shipments[].address.email` string (email)

- `shipments[].items` object[] required

  The items that ship to this address

- `total_items` integer required

- `total_shipments` integer required

- `succeeded` integer required

- `failed` integer required

- `test_mode` boolean required

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "group_id": "msh_4uWll5So4Wdjsd0Duh7TQtBN",
    "order_ids": [
      "test_ord_8zJ_6hfCTgPoJN4qw3R7zXKn",
      "test_ord__w1OSQwtOy5IVUF4Z7Zx0gyn"
    ],
    "shipments": [
      {
        "shipment_id": "shp_IBpsk1RQj3kzZYkcBgSLl2Zo",
        "index": 0,
        "status": "success",
        "order_id": "test_ord_8zJ_6hfCTgPoJN4qw3R7zXKn",
        "portal_order_id": "ord_8zJ_6hfCTgPoJN4qw3R7zXKn",
        "tracking_url": "https://portal.printedsimply.com/track/eyJ2IjoxLCJ0IjoidHJhY2tpbmciLCJ0b2siOiJ0cmtfbTVNUUN4eXlyWWVXMDdXN1Q2RWh2WXc4IiwiZXhwIjoiMjAyNy0wMS0xM1QxNTowMDoxOS4wMDBaIn0.68435d27ec0cb6ce5420fe82993e831b58dadf2b86100d464195bf6c8489e93e",
        "charged": "$0.00",
        "error": null,
        "error_code": null,
        "address_check": {
          "valid": true,
          "checked": true,
          "normalized": {
            "name": "Jane Doe",
            "address1": "123 MAIN ST",
            "city": "AUSTIN",
            "state": "TX",
            "zip": "78701",
            "country": "US"
          },
          "findings": []
        },
        "estimated_delivery": {
          "earliest": "2026-10-23",
          "latest": "2026-10-29",
          "ships_by": "2026-10-19",
          "is_estimate": true
        },
        "address": {
          "name": "Jane Doe",
          "address1": "123 Main St",
          "city": "Austin",
          "state": "TX",
          "zip": "78701",
          "country": "US"
        },
        "items": [
          {
            "sku": "sticker-die-cut-3x3",
            "artwork_url": "https://example.com/logo.png",
            "quantity": 50
          }
        ]
      },
      {
        "shipment_id": "shp_K44HhNfmUG5JUKJz2R9iXbbk",
        "index": 1,
        "status": "success",
        "order_id": "test_ord__w1OSQwtOy5IVUF4Z7Zx0gyn",
        "portal_order_id": "ord__w1OSQwtOy5IVUF4Z7Zx0gyn",
        "tracking_url": "https://portal.printedsimply.com/track/eyJ2IjoxLCJ0IjoidHJhY2tpbmciLCJ0b2siOiJ0cmtfVzVWdDk4NTlUc3JvZWt1QkptRm5tYnAyIiwiZXhwIjoiMjAyNy0wMS0xM1QxNTowMDoxOS4wMDBaIn0.01b69cca187803b0988f73da9cfc9548469566673537466b5a1360a35fed516c",
        "charged": "$0.00",
        "error": null,
        "error_code": null,
        "address_check": {
          "valid": true,
          "checked": true,
          "normalized": {
            "name": "Sam Lee",
            "address1": "420 PINE ST",
            "city": "SEATTLE",
            "state": "WA",
            "zip": "98101",
            "country": "US"
          },
          "findings": []
        },
        "estimated_delivery": {
          "earliest": "2026-10-23",
          "latest": "2026-10-29",
          "ships_by": "2026-10-19",
          "is_estimate": true
        },
        "address": {
          "name": "Sam Lee",
          "address1": "420 Pine St",
          "city": "Seattle",
          "state": "WA",
          "zip": "98101",
          "country": "US"
        },
        "items": [
          {
            "sku": "sticker-die-cut-3x3",
            "artwork_url": "https://example.com/logo.png",
            "quantity": 50
          }
        ]
      }
    ],
    "total_items": 2,
    "total_shipments": 2,
    "succeeded": 2,
    "failed": 0,
    "test_mode": true
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `400` [validation_error](https://portal.printedsimply.com/docs/errors#validation_error)

  Invalid request body or parameters. When the request failed the operation's schema, `details` carries `formErrors` (problems with the request as a whole), `fieldErrors` (messages keyed by top-level field) and `errors`: one `{ pointer, code, message }` per refused value, `pointer` an RFC 6901 JSON pointer into the body (or into the object of query parameters, for a query parameter) — `/items/0/sku`, with `~` written `~0` and `/` written `~1`, and `""` for the request as a whole. POST /v1/orders/bulk answers `errors` as one `{ index, details }` per refused order, each `details` with its own pointers from the request body (`/orders/3/items/0/sku`); POST /v1/orders/multi-ship keeps `errors` as the `{ formErrors, fieldErrors }` it always published; POST /v1/mockups/generate and POST /v1/products/recommend keep their fields at the top of `details`, with `errors` beside them. A refusal of a rule the schema cannot state (an asset that does not exist, an expiry too soon) names it in `message` and carries no `errors`.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `402`

  Payment failed or no payment method configured

- `403` [sandbox_only](https://portal.printedsimply.com/docs/errors#sandbox_only)

  sandbox_only — the account is restricted to sandbox mode and the request used a live key. Every shipment is placed through POST /v1/orders/bulk, which refuses the whole batch before any shipment is placed, and that refusal is answered here unchanged.

- `413` [payload_too_large](https://portal.printedsimply.com/docs/errors#payload_too_large)

  payload_too_large — the request body exceeded this route's byte cap (256 KB unless the route raises it with a written reason). It is refused before it is parsed, so nothing was created. Send fewer rows, or use the endpoint's own asynchronous door where it has one.

- `422` [variable_data_invalid](https://portal.printedsimply.com/docs/errors#variable_data_invalid) [variable_data_unavailable](https://portal.printedsimply.com/docs/errors#variable_data_unavailable) [sku_not_available](https://portal.printedsimply.com/docs/errors#sku_not_available)

  variable_data_invalid or variable_data_unavailable — an item's `variable_data` was refused by POST /v1/orders/bulk, which answers before any shipment is placed or charged; the refusal is answered here unchanged, and each problem's `order_index` is the shipment's position in `shipments`. sku_not_available — an item is a product our own plant does not make; refused before any shipment is placed or charged, and `details.skus` names each one.

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

- `502` [internal_error](https://portal.printedsimply.com/docs/errors#internal_error)

  internal_error — the placement answered without per-shipment results, so this response cannot say which shipments were placed. Check GET /v1/orders before retrying.

- `503` [idempotency_unavailable](https://portal.printedsimply.com/docs/errors#idempotency_unavailable)

  idempotency_unavailable — the request carried an `Idempotency-Key` and the ledger that makes it mean at-most-once could not be reached, so nothing ran and nothing was charged; retry the same request with the same key after `Retry-After`.

Artwork is read before the charge: on a sticker, decal, magnet or poster line, the file behind `artwork_url` or `print_file` is graded against the line's print size. A live order whose file is gone, is not an image, or is too small to print at that size is refused with 422 `artwork_not_printable`, naming each line and the pixels it needs, and nothing is charged. A live line with no file at all is refused first, with 422 `artwork_required` naming the line. A file we could not read just now never refuses an order. The answer carries those lines' verdicts in `artwork_check`; a test key is never refused for artwork, and `would_refuse` says whether a live order would be.

A different name on every sticker: an item of a flat-goods line made at our own plant may carry `variable_data`, the named slots of its design and a value for each slot on every unit, from a `units` array (one entry per unit), a number `sequence`, or both, plus an optional QR payload template. The quote and the order preflight it before anything is charged and refuse an overflow, a count mismatch or an unknown slot with 422 `variable_data_invalid`; both answer with the first, last and longest unit as proofs. It never changes the price. Test keys run it end to end; live orders carrying it are refused with `variable_data_unavailable` until production is switched on.

## Order items

Item paths are relative to an order id, e.g. `/{id}/item`.

**POST `/{id}/item` Add item to order**

Add a new item to an existing order. Charges the price delta to the default payment method: the line's goods, and what the line adds to the order's shipping. The line rides in the parcel the order already ships, so the parcel is re-priced for its new lines by the method the order is recorded as shipping by (standard when it recorded none), on its own lane, folded as POST /order folds it: the lines before and after are priced on today's terms and the difference is charged, so a line that moves the parcel into a heavier class pays for that and nothing else. `shipping` says what moved; `total_delta_cents` is the whole charge, the figure the key's spend caps see. On an order shipping by letter, a line the letter cannot take (a parcel-only item, too many units, goods over the ceiling) is refused 400 `letter_mail_unavailable` with `details.reason`, as POST /order refuses the same lines, and nothing is charged: change the order's method with PUT /order/{id}/shipping first. A parcel method no longer offered is refused 400 `shipping_method_unavailable`. The order's size limits hold here as on POST /order: a line past an order's 100th, or a quantity over 10,000, is refused 400 `validation_error` and nothing is charged. Add-items on one order are taken one at a time, so two sent together cannot pass the line limit between them. On a test order the line is priced $0, marked `pricing_degraded`, when its price cannot be had just now (the facility unconfigured, or not answering — `pricing_unavailable` for our plant not answering, which a live key is answered 503 provider_unavailable for); a SKU the catalog does not have is refused 400 unknown_sku with `details.skus`, and our plant's answer that it does not carry the SKU is the 400 pricing_error a live key gets, as on POST /order.

**Credential**

An API key in `x-api-key`

**Scopes**

`orders:write`

### Parameters

- `id` path · string required

  The order's `order_id`, or its `portal_order_id` (`ord_…`): either resolves the same order. An id the order answered to before it was re-routed resolves too.

- `Idempotency-Key` header · string (uuid)

  Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422 `idempotency_key_reuse` rather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503 `idempotency_unavailable` with `Retry-After`, and nothing runs or is charged: retry with the same key.

### Request body application/json, required

- `sku` string required

  non-empty

- `quantity` integer required

  1 to 10000

- `print_file` string (uri)

### Request

`POST /api/v1/{id}/item`

cURL:

```bash
curl -X POST "https://portal.printedsimply.com/api/v1/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/item" \
  -H "x-api-key: ps_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: add-item-$(date +%s)" \
  -d '{
    "sku": "MAGNET-4X4",
    "quantity": 25,
    "print_file": "https://example.com/magnet-design.png"
  }'
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/item", {
  method: "POST",
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    "sku": "MAGNET-4X4",
    "quantity": 25,
    "print_file": "https://example.com/magnet-design.png"
  }),
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request
import uuid

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/item",
    method="POST",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
        "Content-Type": "application/json",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    data=json.dumps({
        "sku": "MAGNET-4X4",
        "quantity": 25,
        "print_file": "https://example.com/magnet-design.png",
    }).encode(),
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/item');
$body = <<<'JSON'
{
  "sku": "MAGNET-4X4",
  "quantity": 25,
  "print_file": "https://example.com/magnet-design.png"
}
JSON;
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
        'Content-Type: application/json',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS => $body,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

Item added

`data`

- `item` object required

- `item.id` string

- `item.sku` string

- `item.quantity` integer

- `item.unit_price` string

- `item.charged` string

  The line's goods. What the card was charged is `total_delta_cents`.

- `shipping` object required

  What an item change did to the order's shipping line. The order ships one parcel, so adding a line or changing a quantity re-prices it: by the order's recorded method on its own lane, the lines before and after priced on today's terms, the difference charged with the goods on an increase and refunded with them on a decrease.

- `shipping.method` string required

  The method the parcel was priced by: the order's recorded one, or `standard` when it recorded none.

  One of `standard` `priority` `express` `letter`

- `shipping.shipping_class` object | null required

  The parcel the order's lines now pack into, as POST /shipping/estimate reports it: a weight band (`parcel_1lb`, `parcel_2lb`, `parcel_5lb`, `parcel_over_5lb`), with `_tube` when it ships rolled. Null on a letter.

- `shipping.shipping_class.name` string required

- `shipping.shipping_class.weight_oz` number required

  Estimated shipped weight in ounces, packing tube included.

- `shipping.delta_cents` integer required

  What this change moved on the shipping line, in cents: positive when it was charged, negative when it was refunded, 0 when the parcel's price did not change (or a decrease's shipping could not be priced and stays as charged).

- `shipping.shipping_cents` integer required

  The order's shipping line after this change, in cents.

- `total_delta_cents` integer required

  Everything this call charged, in cents: the line's goods and `shipping.delta_cents`. The figure the key's spend caps see and order.updated's `total_delta_cents`. On a sandbox order nothing is charged and this is what a live order would be.

- `test_mode` boolean

  Present and true on a sandbox order; nothing was charged.

- `pricing_degraded` string

  Sandbox only. The SKU's facility catalog was unreachable, so the line was added at $0.00 — an absence of a price, not a price — exactly as POST /order, /orders/quote and /pricing/estimate degrade for a test key. A live order gets 400 pricing_error instead.

  Always `provider_unavailable`

- `artwork_check` object

  The verdict on the added line's artwork when the order is made at our own plant, as POST /order's artwork_check (its one line is item_index 0); on a test key, `missing_artwork` when the line has no print_file. Absent otherwise.

- `artwork_check.lines` object[] required

  One entry per line made at our own plant (stickers, decals, magnets, posters) that carries artwork, in item order. A line another lane makes is graded there, not here.

- `artwork_check.lines[].item_index` integer required

  The line's position in the request's `items`, 0-based.

  at least 0

- `artwork_check.lines[].sku` string required

- `artwork_check.lines[].status` string required

  passed: read, nothing to say. warned: read, at least one warning. refused: at least one error (a live order is refused). unverified: the file could not be read just now (see the `unverified` finding); the order is not refused for it.

  One of `passed` `warned` `refused` `unverified`

- `artwork_check.lines[].width_px` integer

  The file's width in pixels. Absent when it was not read, or is vector.

  at least 1

- `artwork_check.lines[].height_px` integer

  The file's height in pixels.

  at least 1

- `artwork_check.lines[].print_size_in` number[]

  The print size resolution was measured against, width and height in inches: the SKU's `WxH`, else the line's `size`. Absent when neither names one.

  2 to 2 entries

- `artwork_check.lines[].effective_dpi` integer

  Pixels per inch with the artwork printed as large as the print size allows (turned a quarter if that fits it larger).

  at least 1

- `artwork_check.lines[].min_dpi` integer

  The product's recommended minimum DPI. Below 75% of it is an error.

  at least 1

- `artwork_check.lines[].findings` object[] required

- `artwork_check.lines[].findings[].code` string required

  One of `unreachable` `not_an_image` `low_resolution` `below_recommended_resolution` `cmyk` `no_transparency` `touches_edge` `no_bleed` `content_in_bleed` `no_cutline` `size_unknown` `vector` `unverified` `format_not_accepted` `not_fetchable` `design_embeds_raster` `generated_design` `placement_clipped` `geometry_unverified`

- `artwork_check.lines[].findings[].severity` string required

  error refuses a live order; warning and info never do.

  One of `error` `warning` `info`

- `artwork_check.lines[].findings[].message` string required

  What was found and what to do about it, naming the line.

- `artwork_check.missing_artwork` object[]

  Test keys (ps_test_*) only, on POST /order, POST /orders/bulk and POST /{id}/item: the lines made at our own plant that carry no artwork at all, which a live request is refused for with 422 `artwork_required`. Absent when there are none.

- `artwork_check.missing_artwork[].order_index` integer

  POST /orders/bulk's 422 only: the order's position in `orders`.

  at least 0

- `artwork_check.missing_artwork[].item_index` integer required

  The line's position in the order's `items`, 0-based.

  at least 0

- `artwork_check.missing_artwork[].sku` string required

- `artwork_check.would_refuse` boolean

  Test keys (ps_test_*) only: whether a live order with these lines would have been refused, with 422 `artwork_not_printable` or (for a line in `missing_artwork`) `artwork_required`. A test key is never refused for artwork, so placeholder artwork still places a sandbox order. Absent on a live order, which either was refused or was not.

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "item": {
      "id": "itm_vNx36IWd8kx2dCQqkFbsTVg3",
      "sku": "magnet-4x4",
      "quantity": 25,
      "unit_price": "$2.47",
      "charged": "$61.75"
    },
    "shipping": {
      "method": "standard",
      "shipping_class": {
        "name": "parcel_5lb",
        "weight_oz": 35
      },
      "delta_cents": 1200,
      "shipping_cents": 2099
    },
    "total_delta_cents": 7375,
    "test_mode": true,
    "artwork_check": {
      "lines": [
        {
          "item_index": 0,
          "sku": "magnet-4x4",
          "status": "passed",
          "width_px": 1200,
          "height_px": 1200,
          "findings": [],
          "print_size_in": [
            4,
            4
          ],
          "effective_dpi": 300,
          "min_dpi": 300
        }
      ],
      "would_refuse": false
    }
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `400` [validation_error](https://portal.printedsimply.com/docs/errors#validation_error)

  Invalid request body or parameters. When the request failed the operation's schema, `details` carries `formErrors` (problems with the request as a whole), `fieldErrors` (messages keyed by top-level field) and `errors`: one `{ pointer, code, message }` per refused value, `pointer` an RFC 6901 JSON pointer into the body (or into the object of query parameters, for a query parameter) — `/items/0/sku`, with `~` written `~0` and `/` written `~1`, and `""` for the request as a whole. POST /v1/orders/bulk answers `errors` as one `{ index, details }` per refused order, each `details` with its own pointers from the request body (`/orders/3/items/0/sku`); POST /v1/orders/multi-ship keeps `errors` as the `{ formErrors, fieldErrors }` it always published; POST /v1/mockups/generate and POST /v1/products/recommend keep their fields at the top of `details`, with `errors` beside them. A refusal of a rule the schema cannot state (an asset that does not exist, an expiry too soon) names it in `message` and carries no `errors`.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `402`

  Payment failed or no payment method configured

- `403` [sandbox_only](https://portal.printedsimply.com/docs/errors#sandbox_only)

  sandbox_only — the account is restricted to sandbox mode and the request used a live key.

- `404`

  Resource not found

- `409` [order_not_modifiable](https://portal.printedsimply.com/docs/errors#order_not_modifiable) [shopper_paid_order](https://portal.printedsimply.com/docs/errors#shopper_paid_order) [concurrent_modification](https://portal.printedsimply.com/docs/errors#concurrent_modification)

  order_not_modifiable — the order has gone into production (or later), past the point where items can be added. shopper_paid_order — a shopper paid for this order (a drop, a checkout link or an agent checkout), so it takes no charge on your card: its refunds go back to the shopper. Place a separate order instead. concurrent_modification — another line was being added to this order and did not finish within a few seconds: add-items on one order are taken one at a time, so the 100-line limit holds, and nothing was charged. Retry in a moment.

- `413` [payload_too_large](https://portal.printedsimply.com/docs/errors#payload_too_large)

  payload_too_large — the request body exceeded this route's byte cap (256 KB unless the route raises it with a written reason). It is refused before it is parsed, so nothing was created. Send fewer rows, or use the endpoint's own asynchronous door where it has one.

- `422` [artwork_not_printable](https://portal.printedsimply.com/docs/errors#artwork_not_printable) [artwork_required](https://portal.printedsimply.com/docs/errors#artwork_required) [sku_not_available](https://portal.printedsimply.com/docs/errors#sku_not_available)

  artwork_not_printable — the order is made at our own plant and the added line's artwork (print_file) cannot be printed as it is (unreachable, not_an_image, low_resolution). Checked before the line is priced or the card is charged: nothing was charged and the Idempotency-Key is free for the corrected line. `details.problems[]` and `details.artwork_check` are shaped as on POST /order's 422. artwork_required — the order is made at our own plant and the added line has no print_file, so there is nothing to print; refused before anything is read or charged, with `details.items[]` as on POST /order's 422. A test key is never refused for artwork; its 200 carries `artwork_check.would_refuse` (and `artwork_check.missing_artwork` for a line with none). sku_not_available — the added line is a product our own plant does not make; refused before it is priced or charged, and `details.skus` names it.

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

- `501` [not_supported_by_provider](https://portal.printedsimply.com/docs/errors#not_supported_by_provider)

  not_supported_by_provider — the order was produced by an independent network shop, which does not accept amendments at all. Never retryable, unlike the 409. Live PrintedSimply add-item calls are also refused before any charge because the plant handler is not confirmed.

- `502`

  Production system error

- `503` [provider_unavailable](https://portal.printedsimply.com/docs/errors#provider_unavailable) [pricing_error](https://portal.printedsimply.com/docs/errors#pricing_error) [idempotency_unavailable](https://portal.printedsimply.com/docs/errors#idempotency_unavailable)

  provider_unavailable (`details.reason: plant_unreachable`, with `Retry-After`) — the order is made at our own plant and the plant gave no answer about the added SKU (the request did not reach it, timed out, or came back 5xx), which is about the plant and not the SKU: a definite answer that it does not carry the SKU is still the 400 pricing_error. Asked before the card is charged, so nothing was charged and the same Idempotency-Key may be retried. A test order is not refused: its line is priced $0 with `pricing_degraded: pricing_unavailable`, as on POST /order. idempotency_unavailable — the request carried an `Idempotency-Key` and the ledger that makes it mean at-most-once could not be reached, so nothing ran and nothing was charged; retry the same request with the same key after `Retry-After`.

**PUT `/{id}/item/{itemId}` Update item quantity**

Change the quantity of an item. Charges the delta if increasing, refunds if decreasing: the line's goods, and what the new quantity does to the order's shipping. The order ships one parcel, so it is re-priced for the new quantity by the method the order is recorded as shipping by (standard when it recorded none), on its own lane, folded as POST /order folds it: the lines before and after are priced on today's terms and the difference moves with the goods, in the same direction. An increase never refunds shipping and a decrease never charges it; a decrease never refunds the shipping line below what the smaller parcel costs today. `shipping` says what moved; `total_delta_cents` is the whole charge or (negative) refund, the figure the key's spend caps see on an increase. On an order shipping by letter, an increase the letter cannot take is refused 400 `letter_mail_unavailable` with `details.reason`, as POST /order refuses the same lines, and nothing moves; a parcel method no longer offered refuses an increase with 400 `shipping_method_unavailable`. A decrease is never refused for either: its goods are refunded and its shipping stays as charged.

**Credential**

An API key in `x-api-key`

**Scopes**

`orders:write`

### Parameters

- `id` path · string required

  The order's `order_id`, or its `portal_order_id` (`ord_…`): either resolves the same order. An id the order answered to before it was re-routed resolves too.

- `itemId` path · string required

  Item ID

- `Idempotency-Key` header · string (uuid)

  Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422 `idempotency_key_reuse` rather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503 `idempotency_unavailable` with `Retry-After`, and nothing runs or is charged: retry with the same key.

### Request body application/json, required

- `quantity` integer required

  1 to 10000

### Request

`PUT /api/v1/{id}/item/{itemId}`

cURL:

```bash
curl -X PUT "https://portal.printedsimply.com/api/v1/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/item/itm_fowT5-1iZwSnQcGX0ByoA64l" \
  -H "x-api-key: ps_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: update-item-$(date +%s)" \
  -d '{
    "quantity": 5
  }'
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/item/itm_fowT5-1iZwSnQcGX0ByoA64l", {
  method: "PUT",
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    "quantity": 5
  }),
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request
import uuid

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/item/itm_fowT5-1iZwSnQcGX0ByoA64l",
    method="PUT",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
        "Content-Type": "application/json",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    data=json.dumps({
        "quantity": 5,
    }).encode(),
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/item/itm_fowT5-1iZwSnQcGX0ByoA64l');
$body = <<<'JSON'
{
  "quantity": 5
}
JSON;
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'PUT',
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
        'Content-Type: application/json',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS => $body,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

Item updated

`data`

- `test_mode` boolean

  True when the order is a sandbox order.

- `item` object required

- `item.id` string

- `item.sku` string

- `item.previous_quantity` integer

- `item.new_quantity` integer

- `item.unit_price` string

- `item.delta` string

  The line's goods delta. What the card moved is `total_delta_cents`.

- `shipping` object required

  What an item change did to the order's shipping line. The order ships one parcel, so adding a line or changing a quantity re-prices it: by the order's recorded method on its own lane, the lines before and after priced on today's terms, the difference charged with the goods on an increase and refunded with them on a decrease.

- `shipping.method` string required

  The method the parcel was priced by: the order's recorded one, or `standard` when it recorded none.

  One of `standard` `priority` `express` `letter`

- `shipping.shipping_class` object | null required

  The parcel the order's lines now pack into, as POST /shipping/estimate reports it: a weight band (`parcel_1lb`, `parcel_2lb`, `parcel_5lb`, `parcel_over_5lb`), with `_tube` when it ships rolled. Null on a letter.

- `shipping.shipping_class.name` string required

- `shipping.shipping_class.weight_oz` number required

  Estimated shipped weight in ounces, packing tube included.

- `shipping.delta_cents` integer required

  What this change moved on the shipping line, in cents: positive when it was charged, negative when it was refunded, 0 when the parcel's price did not change (or a decrease's shipping could not be priced and stays as charged).

- `shipping.shipping_cents` integer required

  The order's shipping line after this change, in cents.

- `total_delta_cents` integer required

  Everything this call moved, in cents: the line's goods delta and `shipping.delta_cents`, charged when positive and refunded across the order's charges when negative. The figure the key's spend caps see on an increase and order.updated's `total_delta_cents`. On a sandbox order nothing moves and this is what a live order would move.

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "item": {
      "id": "itm_vNx36IWd8kx2dCQqkFbsTVg3",
      "sku": "magnet-4x4",
      "previous_quantity": 25,
      "new_quantity": 5,
      "unit_price": "$2.47",
      "delta": "$-49.40"
    },
    "shipping": {
      "method": "standard",
      "shipping_class": {
        "name": "parcel_2lb",
        "weight_oz": 17.1
      },
      "delta_cents": -700,
      "shipping_cents": 1399
    },
    "total_delta_cents": -5640,
    "test_mode": true
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `400` [validation_error](https://portal.printedsimply.com/docs/errors#validation_error)

  Invalid request body or parameters. When the request failed the operation's schema, `details` carries `formErrors` (problems with the request as a whole), `fieldErrors` (messages keyed by top-level field) and `errors`: one `{ pointer, code, message }` per refused value, `pointer` an RFC 6901 JSON pointer into the body (or into the object of query parameters, for a query parameter) — `/items/0/sku`, with `~` written `~0` and `/` written `~1`, and `""` for the request as a whole. POST /v1/orders/bulk answers `errors` as one `{ index, details }` per refused order, each `details` with its own pointers from the request body (`/orders/3/items/0/sku`); POST /v1/orders/multi-ship keeps `errors` as the `{ formErrors, fieldErrors }` it always published; POST /v1/mockups/generate and POST /v1/products/recommend keep their fields at the top of `details`, with `errors` beside them. A refusal of a rule the schema cannot state (an asset that does not exist, an expiry too soon) names it in `message` and carries no `errors`.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `402`

  Payment failed or no payment method configured

- `403` [sandbox_only](https://portal.printedsimply.com/docs/errors#sandbox_only)

  sandbox_only — the account is restricted to sandbox mode and the request used a live key.

- `404`

  Resource not found

- `409` [order_not_modifiable](https://portal.printedsimply.com/docs/errors#order_not_modifiable) [shopper_paid_order](https://portal.printedsimply.com/docs/errors#shopper_paid_order)

  order_not_modifiable — the order has gone into production (or later), past the point where its items can change. shopper_paid_order — a quantity increase on an order a shopper paid for (a drop, a checkout link or an agent checkout); it would be charged to your card and refunded to the shopper. A decrease is allowed and refunds the shopper's own charge.

- `413` [payload_too_large](https://portal.printedsimply.com/docs/errors#payload_too_large)

  payload_too_large — the request body exceeded this route's byte cap (256 KB unless the route raises it with a written reason). It is refused before it is parsed, so nothing was created. Send fewer rows, or use the endpoint's own asynchronous door where it has one.

- `422` [variable_data_invalid](https://portal.printedsimply.com/docs/errors#variable_data_invalid) [artwork_required](https://portal.printedsimply.com/docs/errors#artwork_required) [sku_not_available](https://portal.printedsimply.com/docs/errors#sku_not_available)

  variable_data_invalid — the line was placed with per-unit variable data, which fixes its quantity to the units it carries (`details.problems[0].code` is `count_mismatch`). Nothing was charged or refunded; cancel the line and order it again with the units you want. artwork_required — a new quantity for a line with no artwork on record, on a live order at our own plant (`details.item_id`, `details.sku`): the change is sent to the plant with the line's SKU and its file, and the plant prints every line from a file, so it is refused before anything is charged or refunded. Cancel the line and add it again with a print_file. A test key is not refused, and a PUT that restates the current quantity sends the plant nothing. sku_not_available — a quantity increase on a line our own plant does not make (one placed before this portal sold only its own products); refused before anything is charged, and `details.skus` names it. A decrease is allowed.

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

- `500` [refund_failed](https://portal.printedsimply.com/docs/errors#refund_failed)

  refund_failed — the quantity decrease was applied but the refund for it could not be processed. The money is owed and support is alerted; do not retry, which would decrease the quantity again.

- `501` [not_supported_by_provider](https://portal.printedsimply.com/docs/errors#not_supported_by_provider)

  not_supported_by_provider — the order was produced by an independent network shop, which does not accept amendments at all. Never retryable, unlike the 409. Live PrintedSimply quantity changes are also refused before any charge or refund because the plant handler is not confirmed.

- `502`

  Production system error

- `503` [idempotency_unavailable](https://portal.printedsimply.com/docs/errors#idempotency_unavailable)

  idempotency_unavailable — the request carried an `Idempotency-Key` and the ledger that makes it mean at-most-once could not be reached, so nothing ran and nothing was charged; retry the same request with the same key after `Retry-After`.

**DELETE `/{id}/item/{itemId}/cancel` Remove item from order**

Remove an item from an order and refund its full cost.

**Credential**

An API key in `x-api-key`

**Scopes**

`orders:write`

### Parameters

- `id` path · string required

  The order's `order_id`, or its `portal_order_id` (`ord_…`): either resolves the same order. An id the order answered to before it was re-routed resolves too.

- `itemId` path · string required

  Item ID

- `Idempotency-Key` header · string (uuid)

  Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422 `idempotency_key_reuse` rather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503 `idempotency_unavailable` with `Retry-After`, and nothing runs or is charged: retry with the same key.

### Request

`DELETE /api/v1/{id}/item/{itemId}/cancel`

cURL:

```bash
curl -X DELETE "https://portal.printedsimply.com/api/v1/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/item/itm_fowT5-1iZwSnQcGX0ByoA64l/cancel" \
  -H "x-api-key: ps_test_YOUR_KEY" \
  -H "Idempotency-Key: cancel-item-$(date +%s)"
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/item/itm_fowT5-1iZwSnQcGX0ByoA64l/cancel", {
  method: "DELETE",
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
    "Idempotency-Key": crypto.randomUUID(),
  },
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request
import uuid

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/item/itm_fowT5-1iZwSnQcGX0ByoA64l/cancel",
    method="DELETE",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk/item/itm_fowT5-1iZwSnQcGX0ByoA64l/cancel');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'DELETE',
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

Item removed and refunded

`data`

- `item_id` string required

- `test_mode` boolean

  True when the order is a sandbox order.

- `refunded` string required

- `refunded_cents` integer required

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "item_id": "itm_vNx36IWd8kx2dCQqkFbsTVg3",
    "refunded": "$0.00",
    "refunded_cents": 0,
    "test_mode": true
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `404`

  Resource not found

- `409` [order_not_modifiable](https://portal.printedsimply.com/docs/errors#order_not_modifiable)

  order_not_modifiable — the order has gone into production (or later), past the point where items can be removed.

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

- `500`

  Refund processing failed

- `501` [not_supported_by_provider](https://portal.printedsimply.com/docs/errors#not_supported_by_provider)

  not_supported_by_provider — the order was produced by an independent network shop, which does not accept amendments at all. Never retryable, unlike the 409.

- `502`

  Production system error

- `503` [idempotency_unavailable](https://portal.printedsimply.com/docs/errors#idempotency_unavailable)

  idempotency_unavailable — the request carried an `Idempotency-Key` and the ledger that makes it mean at-most-once could not be reached, so nothing ran and nothing was charged; retry the same request with the same key after `Retry-After`.

## Products

**GET `/products` List products**

Browse the product catalog. Supports filtering by category, size, finish, and free-text search. Pages by cursor (see Pagination): the catalog is not a table, so it is walked in the order the catalog reports it, and a cursor names the last row's place and SKU, and the SKU after it — a product added or removed elsewhere in the catalog while you page moves nothing you have read, and if the last row you were given leaves, the walk resumes at the row that followed it. A request without `cursor` answers every match unless `limit` caps it, as it always has. Every price field on a row (`price`, `regular_price`, `sale_price`, `base_price`) is the price an order charges, the same on every plan: there is no platform fee (`platform_fee_pct` on GET /v1/pricing/tiers reads 0). For a product our own plant makes that our price book prices (a sticker, a vinyl decal up to 5×5 in, a poster, a magnet or a wall decal, at a size where one unit pays its own handling) the list price is the book's, a base plus a rate per square inch of the size the SKU names, and every price field on its row shows it — the plant's own catalog figure is a cross-check, never charged; for anything else (sticker sheets and rolls, larger vinyl decals, smaller sizes, and every other facility's goods) it is the facility's list price. POST /v1/order charges that list price, with only shipping and card processing added. On a row the price book does not price, a `sale_price` is the catalog's own and is never what an order charges; on a row it prices, `sale_price` shows the book's price like every other field. Every row our own plant makes also says what it sells and whether it can be ordered now: `size_in`, `finish`, `family`, `minimum_quantity` (1: a run of one is an order), `quantity_breaks` (the all-in unit price an order charges at each quantity where it changes, from the function the quote and the order charge with), and `orderable` with `not_orderable_code` and `not_orderable_reason` (the quote's own refusals, asked of the SKU first, as a code and as a sentence).

**Credential**

An API key in `x-api-key`

**Scopes**

`products:read`

### Parameters

- `cursor` query · string

  Opaque cursor: send back `meta.next_cursor` from the previous page for the page after it; omit it for the first page. Good only on this list, with a key of the same account and mode (test or live), and only on the catalog it was returned for, served from the source it was returned under (live, the last-good snapshot, the local synced copy or our price book): a cursor read against a page served from another source is refused with 409 cursor_source_changed, and the walk starts again without `cursor`, and with the same filters (only `limit` may change); an altered or foreign cursor, or one sent with other filters, is refused with 400 validation_error. Wins over `page` when both are sent.

- `limit` query · integer

  Products per page, applied after the filters (`total` still counts every match). Without `cursor`: no default (every match comes back) and 200 at most — the size this list's page walk always had, so a first answer and `page` agree on it. With `cursor`: 20 by default and 100 at most, as on every list. Without `cursor` a value is read by its leading whole number, as it always was: above 200 it is clamped to 200, and one below 1, or with no leading number, is read as 1. With `cursor` a value above 100 is clamped to 100 and one below 1 to 1, a fraction is truncated, and a missing, zero or non-numeric value takes the default. None is refused.

- `page` query · integer deprecated

  Deprecated: walk with `cursor`. The facility's own page of the catalog, passed through and answered as before, with `limit` as a request without `cursor` takes it; `meta.has_more` and `meta.next_cursor` say where the cursor walk continues after this page's last row. Ignored when `cursor` is sent.

  Default 1

- `category` query · string

  Filter by product category (e.g. 'stickers', 'magnets', 'decals'). Matched generously: a row is kept if its DERIVED category equals this exactly, or if the value appears anywhere in its raw category, SKU or name. So the result can hold rows whose `category` field is not this string.

- `search` query · string

  Free-text search across product name, SKU, and description

- `size` query · string

  Filter by size (e.g. '3x3', '4x4', '6x6')

- `finish` query · string

  Filter by finish (e.g. 'glossy', 'matte', 'holographic')

- `provider` query · string

  Which catalog to browse: `printedsimply`, our own plant's — the stickers, decals, labels, magnets and posters it makes and prices. It is the default and the only value; anything else is a 400.

  Always `printedsimply`

  Default printedsimply

- `dedupe` query · boolean

  Collapse the OMS's two-SKU-per-product rows to one canonical row per product. Opt-in: `true` enables it, anything else leaves the catalog as the facility reports it. `total` still counts the rows actually returned.

  Default false

### Request

`GET /api/v1/products`

cURL:

```bash
curl "https://portal.printedsimply.com/api/v1/products" \
  -H "x-api-key: ps_test_YOUR_KEY"
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/products", {
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
  },
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/products",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
    },
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/products');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

The matching catalog rows. May have been served out of the last-good snapshot — read `X-Catalog-Stale` on every facility answer, which is sent both ways. When our own plant's catalog (`printedsimply`) has nothing of its own to serve — no live answer, no snapshot, no local copy — its rows are our price book's: the sizes the book prices, at its list prices (the price an order is charged), each row carrying `source: "price_book"`, with `X-Catalog-Stale: true` and `X-Catalog-Degraded` naming why. A size or product the book does not list is missing from that answer, so do not republish it as the plant's whole catalog. Each of those rows says `orderable: false` and why (`facility_not_configured` or `plant_unreachable`). A stale answer is sent `Cache-Control: private, no-store`, so no shared cache keeps serving an outage's availability after the plant answers again.

`data`

- `products` object[] required

  Our own plant's catalog rows.

- `products[].sku` string | number

  The identifier every other operation takes: an order item's `sku`, the `{sku}` path parameter, a recommendation's `sku`. Numeric on some facility catalogs — compare it as a string.

- `products[].name` string

- `products[].description` string

- `products[].category` string required

  The facility's category if it published one, otherwise derived from the product name: one of swimwear, apparel, outerwear, headwear, bags, drinkware, stickers, decals, magnets, posters, accessories, other — `other` when no rule matched.

- `products[].source` string

  Present only on a row this API built from our own price book because our plant's catalog had nothing of its own to serve (the answer is then `X-Catalog-Stale: true`, with `X-Catalog-Degraded` naming why). Its price is the book's, which is what an order of the SKU is charged; the row carries `sku`, `name`, `base_price` and `category`. Absent on every row a facility sent.

  Always `price_book`

- `products[].size_in` number[] | null

  Our plant's rows only: [width, height] in inches, read from the size the SKU names (the size the price book and an order price). Null when the SKU names no size, as a sticker sheet or roll does.

  2 to 2 entries

- `products[].finish` string | null

  Our plant's rows only: the finish the plant's row names, else the one the SKU names (glossy, matte, holographic, clear, satin); null when neither names one. No finish changes the price.

- `products[].family` string

  Our plant's rows only: which of its product families the SKU is, the family its quantity ladder and its cost are read under.

  One of `stickers` `decals` `magnets` `posters`

- `products[].minimum_quantity` integer

  Our plant's rows only: the fewest units of this SKU one order line takes, which is 1. A run of one is an order, priced at the first `quantity_breaks` entry; POST /v1/order refuses a quantity below it with 400 validation_error.

  Always `1`

- `products[].quantity_breaks` object[]

  Our plant's rows only: the unit price an order of this SKU charges, from one unit up, one entry per quantity where it changes — computed by the function POST /v1/orders/quote and POST /v1/order charge each unit with (no platform fee is added on any plan). The first entry (`min_qty` 1) is the row's own price. A single entry when the SKU climbs no ladder (a SKU our price book does not price). Counted on an order's whole quantity of the SKU, across its lines. A negotiated price for your account takes no break and replaces these. Empty when the row carries no usable price (`not_orderable_code: no_price`).

- `products[].quantity_breaks[].min_qty` integer required

  From this many units of the SKU on one order (counted across its lines).

  at least 1

- `products[].quantity_breaks[].unit_cents` integer required

  The unit price, in cents, an order of `min_qty` or more charges, until the next entry's `min_qty`.

- `products[].orderable` boolean

  Our plant's rows only: true when nothing POST /v1/orders/quote checks before pricing would refuse this SKU right now. False says why in `not_orderable_reason` (a sentence) and `not_orderable_code` (a code). A hint for a cart, read when the catalog was: the quote and the order decide.

- `products[].not_orderable_reason` string | null

  Why not, in the order path's own words — the same field, meaning the same thing, as on every other row of this endpoint that says `orderable`. Null when `orderable`. On our plant's rows `not_orderable_code` is the same answer as a code.

- `products[].not_orderable_code` string | null

  Our plant's rows only: null when `orderable`; otherwise why not, as a code, in the order the quote meets them. `facility_not_configured` — the plant has no credentials in this environment (a live quote or order answers 503 provider_unavailable; a test key's degrades to $0 marked `pricing_degraded`). `plant_unreachable` — the plant's catalog did not answer this read (the row is a snapshot or the price book's); a quote, estimate or order asks the plant about the SKU, and answers 503 provider_unavailable with `Retry-After` (`details.reason: plant_unreachable`) until it does. `plant_price_held` — the plant's prices are held after one of its launch goods read far above its book price, and this SKU has no book price to show it is not wrong the same way; the quote refuses it until the hold lifts. `no_price` — the row carries no usable list price and our price book does not price the SKU, so the quote refuses it.

  One of `facility_not_configured` `plant_unreachable` `plant_price_held` `no_price`

- `total` integer required

  How many products matched the whole query. This is the match count, NOT `products.length`: `limit` truncates the array and leaves this untouched, so `total > products.length` means there is more behind the same filters.

- `provider` string required

  Which catalog answered: always `printedsimply` here.

  Always `printedsimply`

- `facets` object required

- `facets.category` object required

  Derived category to number of matching products — always the value derived from the product name, even for a row that carries the facility's own `category`. Categories with no matches are omitted rather than sent as 0, so a missing key means none matched.

- `filters` object required

  The filters this answer was computed under, echoed back. Null where you sent nothing.

- `filters.category` string | null required

- `filters.search` string | null required

- `filters.size` string | null required

- `filters.finish` string | null required

- `filters.dedupe` boolean required

`meta`

- `page` integer deprecated

  The page number of the deprecated offset walk, on the lists that published it (orders and an order's events, on every request without `cursor`; products, on a request that sent `page`). Absent on a cursor walk, where no page number would be true.

- `total` integer

  Every row matching the request's filters, on the lists that count them (orders, an order's events, assets and products).

- `limit` integer

  The page size applied, which is not necessarily the one asked for: a larger `limit` is clamped. Absent only on a GET /products request without `cursor` that sent no `limit`, which answers every match.

- `pages` integer deprecated

  `total` over `limit`, rounded up, beside `page` (orders and an order's events). Absent on a cursor walk.

- `next_cursor` string | null required

  Send it back as `cursor` for the next page; null exactly when `has_more` is false. Opaque: pass it back unchanged. Good only on the list that returned it, with a key of the same account and mode (test or live), and with the same filters.

- `has_more` boolean required

  Whether another page follows this one. Decided from one row past this page, never from a page that came back short — so a page after `has_more: true` can come back empty only if rows were deleted in between, and the walk still ends at the first `has_more: false`.

- `since` string | null

  On GET /order/{id}/events only: where to ask from next — the position of the last event in this answer, or, when it has none, the position the request read from. Send it back as `since` (with `wait_seconds` to wait for the next event). Unlike `next_cursor` it is not null at the end of the history; it is null only when the order has no events and the request named no position. Opaque, and bound like a cursor to this order, account and key mode.

Example response:

```json
{
  "status": "success",
  "data": {
    "products": [
      {
        "sku": "sticker-die-cut-3x3",
        "name": "Die-Cut Sticker 3\" x 3\"",
        "price": "2.67",
        "regular_price": "2.67",
        "category": "stickers"
      }
    ],
    "total": 1,
    "provider": "printedsimply",
    "facets": {
      "category": {
        "stickers": 1
      }
    },
    "filters": {
      "category": "stickers",
      "search": null,
      "size": "3x3",
      "finish": null,
      "dedupe": false
    }
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a",
  "meta": {
    "total": 1,
    "has_more": false,
    "next_cursor": null
  }
}
```

### Errors

- `400` [validation_error](https://portal.printedsimply.com/docs/errors#validation_error)

  Invalid request body or parameters. When the request failed the operation's schema, `details` carries `formErrors` (problems with the request as a whole), `fieldErrors` (messages keyed by top-level field) and `errors`: one `{ pointer, code, message }` per refused value, `pointer` an RFC 6901 JSON pointer into the body (or into the object of query parameters, for a query parameter) — `/items/0/sku`, with `~` written `~0` and `/` written `~1`, and `""` for the request as a whole. POST /v1/orders/bulk answers `errors` as one `{ index, details }` per refused order, each `details` with its own pointers from the request body (`/orders/3/items/0/sku`); POST /v1/orders/multi-ship keeps `errors` as the `{ formErrors, fieldErrors }` it always published; POST /v1/mockups/generate and POST /v1/products/recommend keep their fields at the top of `details`, with `errors` beside them. A refusal of a rule the schema cannot state (an asset that does not exist, an expiry too soon) names it in `message` and carries no `errors`.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `409` [cursor_source_changed](https://portal.printedsimply.com/docs/errors#cursor_source_changed)

  cursor_source_changed — `cursor` was returned while the catalog was served from one source (live, the last-good snapshot, the local synced copy or our price book; `details.issued_under`) and this page is served from another (`details.source`, with `X-Catalog-Stale` and `X-Catalog-Degraded` saying why). Each is another list, so the walk is refused rather than continued with rows skipped or repeated: leave `cursor` out and start again from the first page. A cursor returned before cursors named their source (`details.issued_under: null`) is refused the same way.

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

- `502`

  Nothing was servable — no live catalog, no snapshot, no local copy (never on our own plant's catalog, `printedsimply`, whose last resort is our price book: see 200). Deliberately loud: this endpoint never answers a catalog outage with an empty 200. `X-Catalog-Degraded` carries the reason, so a failure is diagnosable rather than indistinguishable from an empty catalog.

**GET `/products/{sku}` Get product by SKU**

Retrieve details and pricing for a specific product — its price fields as on GET /v1/products: the list price (our price book's, for a product of our own plant's that the book prices), the same on every plan since there is no platform fee — from the catalog of the facility whose capabilities cover it (a sticker SKU is answered by the flat-goods facility; apparel by the default facility). Pass `provider` to ask one facility's catalog explicitly. Returns 404 when that catalog has no such SKU — 'does this SKU exist?' is answerable here before ordering. When the facility gives no answer about the SKU (5xx, a refused credential, a timeout, a rate limit, or an open circuit), the row is served out of the last-good snapshot with `X-Catalog-Stale: true`, as `GET /v1/products` does — so the list and this read agree during an outage. A SKU the snapshot does not hold is a 502, never a 404: a stale list missing a row says nothing about a product added since. On our own plant's catalog, a SKU our price book lists is served from the book instead, as the list serves it (`source: "price_book"`, `X-Catalog-Stale: true`), and so is another size of a product it lists, spelled as the list spells it (STICKER-DIE-CUT-2X2 beside the listed 3×3), which a test key's quote and order take at $0 while the plant does not answer. A stale answer is sent `Cache-Control: private, no-store`, as on the list.

**Credential**

An API key in `x-api-key`

**Scopes**

`products:read`

### Parameters

- `sku` path · string required

  Product SKU identifier

- `provider` query · string

  Which catalog to ask: `printedsimply`, our own plant's. It is the default and the only value; anything else is a 400.

  Always `printedsimply`

  Default printedsimply

### Request

`GET /api/v1/products/{sku}`

cURL:

```bash
curl "https://portal.printedsimply.com/api/v1/products/sticker-die-cut-3x3" \
  -H "x-api-key: ps_test_YOUR_KEY"
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/products/sticker-die-cut-3x3", {
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
  },
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/products/sticker-die-cut-3x3",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
    },
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/products/sticker-die-cut-3x3');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

The catalog row for this SKU. `data` is the facility's own row, unwrapped — the same shape as one element of `GET /v1/products`, and open for the same reason. Note that `category` appears here only if the facility publishes one: the derivation that fills it runs on the list, not on this route. May have been served out of the last-good snapshot — read `X-Catalog-Stale`, which is sent both ways; a stale row from the local synced catalog carries only `sku`, `name` and `priceCents`, and one from our price book `sku`, `name`, `base_price`, `category` and `source: "price_book"` (with `alt_skus` where the book knows the product by another SKU too). Every row our own plant makes, whatever its source, also carries `size_in`, `finish`, `family`, `minimum_quantity`, `quantity_breaks`, `orderable`, `not_orderable_code` and `not_orderable_reason`, as on the list.

`data`

- `sku` string | number

  The identifier every other operation takes: an order item's `sku`, the `{sku}` path parameter, a recommendation's `sku`. Numeric on some facility catalogs — compare it as a string.

- `name` string

- `description` string

- `category` string

  The facility's own category, where it publishes one — an open vocabulary, not this API's. `GET /v1/products` always fills this field (see `ProductListItem`); by SKU it is present only if the facility sent it.

- `source` string

  Present only on a row this API built from our own price book because our plant's catalog had nothing of its own to serve (the answer is then `X-Catalog-Stale: true`, with `X-Catalog-Degraded` naming why). Its price is the book's, which is what an order of the SKU is charged; the row carries `sku`, `name`, `base_price` and `category`. Absent on every row a facility sent.

  Always `price_book`

- `size_in` number[] | null

  Our plant's rows only: [width, height] in inches, read from the size the SKU names (the size the price book and an order price). Null when the SKU names no size, as a sticker sheet or roll does.

  2 to 2 entries

- `finish` string | null

  Our plant's rows only: the finish the plant's row names, else the one the SKU names (glossy, matte, holographic, clear, satin); null when neither names one. No finish changes the price.

- `family` string

  Our plant's rows only: which of its product families the SKU is, the family its quantity ladder and its cost are read under.

  One of `stickers` `decals` `magnets` `posters`

- `minimum_quantity` integer

  Our plant's rows only: the fewest units of this SKU one order line takes, which is 1. A run of one is an order, priced at the first `quantity_breaks` entry; POST /v1/order refuses a quantity below it with 400 validation_error.

  Always `1`

- `quantity_breaks` object[]

  Our plant's rows only: the unit price an order of this SKU charges, from one unit up, one entry per quantity where it changes — computed by the function POST /v1/orders/quote and POST /v1/order charge each unit with (no platform fee is added on any plan). The first entry (`min_qty` 1) is the row's own price. A single entry when the SKU climbs no ladder (a SKU our price book does not price). Counted on an order's whole quantity of the SKU, across its lines. A negotiated price for your account takes no break and replaces these. Empty when the row carries no usable price (`not_orderable_code: no_price`).

- `quantity_breaks[].min_qty` integer required

  From this many units of the SKU on one order (counted across its lines).

  at least 1

- `quantity_breaks[].unit_cents` integer required

  The unit price, in cents, an order of `min_qty` or more charges, until the next entry's `min_qty`.

- `orderable` boolean

  Our plant's rows only: true when nothing POST /v1/orders/quote checks before pricing would refuse this SKU right now. False says why in `not_orderable_reason` (a sentence) and `not_orderable_code` (a code). A hint for a cart, read when the catalog was: the quote and the order decide.

- `not_orderable_reason` string | null

  Why not, in the order path's own words — the same field, meaning the same thing, as on every other row of this endpoint that says `orderable`. Null when `orderable`. On our plant's rows `not_orderable_code` is the same answer as a code.

- `not_orderable_code` string | null

  Our plant's rows only: null when `orderable`; otherwise why not, as a code, in the order the quote meets them. `facility_not_configured` — the plant has no credentials in this environment (a live quote or order answers 503 provider_unavailable; a test key's degrades to $0 marked `pricing_degraded`). `plant_unreachable` — the plant's catalog did not answer this read (the row is a snapshot or the price book's); a quote, estimate or order asks the plant about the SKU, and answers 503 provider_unavailable with `Retry-After` (`details.reason: plant_unreachable`) until it does. `plant_price_held` — the plant's prices are held after one of its launch goods read far above its book price, and this SKU has no book price to show it is not wrong the same way; the quote refuses it until the hold lifts. `no_price` — the row carries no usable list price and our price book does not price the SKU, so the quote refuses it.

  One of `facility_not_configured` `plant_unreachable` `plant_price_held` `no_price`

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "sku": "sticker-die-cut-3x3",
    "name": "Die-Cut Sticker, 3×3 in",
    "price": "2.67",
    "regular_price": "2.67",
    "category": "stickers",
    "purchasable": true,
    "stock_status": "instock",
    "size_in": [
      3,
      3
    ],
    "finish": null,
    "family": "stickers",
    "minimum_quantity": 1,
    "quantity_breaks": [
      {
        "min_qty": 1,
        "unit_cents": 267
      },
      {
        "min_qty": 10,
        "unit_cents": 160
      },
      {
        "min_qty": 25,
        "unit_cents": 104
      },
      {
        "min_qty": 50,
        "unit_cents": 72
      },
      {
        "min_qty": 100,
        "unit_cents": 64
      }
    ],
    "orderable": true,
    "not_orderable_code": null,
    "not_orderable_reason": null
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `400` [validation_error](https://portal.printedsimply.com/docs/errors#validation_error)

  Invalid request body or parameters. When the request failed the operation's schema, `details` carries `formErrors` (problems with the request as a whole), `fieldErrors` (messages keyed by top-level field) and `errors`: one `{ pointer, code, message }` per refused value, `pointer` an RFC 6901 JSON pointer into the body (or into the object of query parameters, for a query parameter) — `/items/0/sku`, with `~` written `~0` and `/` written `~1`, and `""` for the request as a whole. POST /v1/orders/bulk answers `errors` as one `{ index, details }` per refused order, each `details` with its own pointers from the request body (`/orders/3/items/0/sku`); POST /v1/orders/multi-ship keeps `errors` as the `{ formErrors, fieldErrors }` it always published; POST /v1/mockups/generate and POST /v1/products/recommend keep their fields at the top of `details`, with `errors` beside them. A refusal of a rule the schema cannot state (an asset that does not exist, an expiry too soon) names it in `message` and carries no `errors`.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `404`

  Resource not found

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

- `502`

  The facility catalog gave no answer and no snapshot holds the SKU (nor, on our own plant's catalog, our price book) — or, with not_configured, the facility has no credentials in this environment, which is an operator fact rather than an outage. `X-Catalog-Degraded` carries the reason on every 502, so a failure is diagnosable rather than indistinguishable from an empty catalog. This is not a verdict on the SKU: retry, or read `GET /v1/products`.

**GET `/products/options` List what a custom size may be**

For each product our own plant makes to any size (die-cut and kiss-cut stickers, vinyl and wall decals, magnets, posters), what an order of a custom size needs, from the rules the order itself applies: the SKU grammar (`sku_template`, a stem and `{width}x{height}` in decimal inches, case aside), the largest size (`max_width_in` × `max_height_in`, either way round), the smallest (`min_area_sq_in`, the least width × height the price book prices: below it one unit does not pay what it costs the plant to make and ship alone; null when every size is priced), the step (none, so a side is any decimal number of inches), the quantity a line may carry, the DPI the order grades artwork at (`min_dpi` recommended, refused below `refuse_below_dpi` on a live order) and the bleed, the finishes on offer (none yet) and the price book's list price for one unit (`base_cents` + width × height × `cents_per_sq_in`, rounded, before quantity breaks, any plan discount and the unit floor: POST /orders/quote is the price an order is charged). A size inside these bounds is one the book prices, and a test key's quote and order take it. A live quote or order also asks our plant for that exact SKU (`plant_confirms_sku`), and a size its catalog does not carry is refused 400 `pricing_error`; GET /products lists the sizes it carries now. Sticker sheets and rolls are not here: their SKUs name no printed area, so they are ordered at the sizes GET /products lists. One bounded document, not a page; it asks nothing of the plant, so it is never stale. Read-only.

**Credential**

An API key in `x-api-key`

**Scopes**

`products:read`

### Request

`GET /api/v1/products/options`

cURL:

```bash
curl "https://portal.printedsimply.com/api/v1/products/options" \
  -H "x-api-key: ps_test_YOUR_KEY"
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/products/options", {
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
  },
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/products/options",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
    },
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/products/options');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

Every configurable product, stickers first, then decals, magnets and posters.

`data`

- `products` object[] required

- `products[].product` string required

  The product, as `product_type` on POST /artwork/validate takes it.

  One of `sticker-die-cut` `sticker-kiss-cut` `decal-vinyl` `decal-wall` `magnet` `poster`

- `products[].name` string required

  Its name: "Die-Cut Sticker".

- `products[].family` string required

  Its family, as a catalog row's `family` names it.

  One of `stickers` `decals` `magnets` `posters`

- `products[].sku_template` string required

  How to spell a SKU of it at a size: replace `{width}` and `{height}` with the size in decimal inches (`sticker-die-cut-{width}x{height}` → `sticker-die-cut-4x6`). Case does not matter; a word after the size (a finish) changes neither the product nor its price.

- `products[].example_sku` string required

  A SKU of it, spelled by `sku_template`.

- `products[].size` object required

  The sizes the price book prices. Outside them the book prices nothing, and an order of the SKU is refused unless our plant's own catalog lists it.

- `products[].size.unit` string required

  Every size here, and in a SKU, is in inches.

  Always `in`

- `products[].size.max_width_in` number required

  The widest side the book prices.

- `products[].size.max_height_in` number required

  The tallest side the book prices.

- `products[].size.either_orientation` boolean required

  True: `{height}x{width}` is priced wherever `{width}x{height}` is.

- `products[].size.min_area_sq_in` number | null required

  The least width × height, in square inches, the book prices, to a hundredth: a smaller print does not pay what one unit costs the plant alone. Null when every size is priced.

- `products[].size.step_in` number | null required

  Null: there is no step, and a side is any decimal number of inches.

- `products[].quantity` object required

  How many units one order line may carry: the bounds `items[].quantity` on POST /order is held to.

- `products[].quantity.minimum` integer required

- `products[].quantity.maximum` integer required

- `products[].artwork` object required

  What the order grades an artwork file of this product against, at the size its SKU names.

- `products[].artwork.min_dpi` integer required

  The recommended resolution. Below it a line is warned (`below_recommended_resolution`) and still prints.

- `products[].artwork.refuse_below_dpi` number required

  Below this effective DPI a live order refuses the line (`low_resolution`).

- `products[].artwork.bleed_in` number required

  The margin the file should run past the trim, or keep clear around a die-cut outline, in inches.

- `products[].finishes` string[] required

  The finishes offered as an option. Empty: none is offered yet, and a finish named in a SKU is not priced or made differently.

- `products[].price_book` object required

  The price book's list price for one unit: `base_cents` + width × height × `cents_per_sq_in`, rounded to the cent. Quantity breaks (each catalog row's `quantity_breaks`), any plan discount and the unit floor apply after it; POST /orders/quote answers the price an order is charged.

- `products[].price_book.base_cents` integer required

- `products[].price_book.cents_per_sq_in` number required

- `products[].plant_confirms_sku` boolean required

  Always true: a live quote or order also asks our plant for the exact SKU, and a size its catalog does not carry is refused 400 `pricing_error`. A test key's takes any size within these bounds.

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "products": [
      {
        "product": "sticker-die-cut",
        "name": "Die-Cut Sticker",
        "family": "stickers",
        "sku_template": "sticker-die-cut-{width}x{height}",
        "example_sku": "sticker-die-cut-3x3",
        "size": {
          "unit": "in",
          "max_width_in": 17,
          "max_height_in": 17,
          "either_orientation": true,
          "min_area_sq_in": 1.55,
          "step_in": null
        },
        "quantity": {
          "minimum": 1,
          "maximum": 10000
        },
        "artwork": {
          "min_dpi": 300,
          "refuse_below_dpi": 225,
          "bleed_in": 0.125
        },
        "finishes": [],
        "price_book": {
          "base_cents": 177,
          "cents_per_sq_in": 10
        },
        "plant_confirms_sku": true
      },
      {
        "product": "sticker-kiss-cut",
        "name": "Kiss-Cut Sticker",
        "family": "stickers",
        "sku_template": "sticker-kiss-cut-{width}x{height}",
        "example_sku": "sticker-kiss-cut-3x3",
        "size": {
          "unit": "in",
          "max_width_in": 17,
          "max_height_in": 17,
          "either_orientation": true,
          "min_area_sq_in": 1.55,
          "step_in": null
        },
        "quantity": {
          "minimum": 1,
          "maximum": 10000
        },
        "artwork": {
          "min_dpi": 300,
          "refuse_below_dpi": 225,
          "bleed_in": 0.125
        },
        "finishes": [],
        "price_book": {
          "base_cents": 177,
          "cents_per_sq_in": 10
        },
        "plant_confirms_sku": true
      },
      {
        "product": "decal-vinyl",
        "name": "Vinyl Decal",
        "family": "decals",
        "sku_template": "decal-vinyl-{width}x{height}",
        "example_sku": "decal-vinyl-3x3",
        "size": {
          "unit": "in",
          "max_width_in": 5,
          "max_height_in": 5,
          "either_orientation": true,
          "min_area_sq_in": 1.55,
          "step_in": null
        },
        "quantity": {
          "minimum": 1,
          "maximum": 10000
        },
        "artwork": {
          "min_dpi": 150,
          "refuse_below_dpi": 112.5,
          "bleed_in": 0.25
        },
        "finishes": [],
        "price_book": {
          "base_cents": 177,
          "cents_per_sq_in": 10
        },
        "plant_confirms_sku": true
      },
      {
        "product": "decal-wall",
        "name": "Wall Decal",
        "family": "decals",
        "sku_template": "wall-decal-{width}x{height}",
        "example_sku": "wall-decal-24x36",
        "size": {
          "unit": "in",
          "max_width_in": 96,
          "max_height_in": 96,
          "either_orientation": true,
          "min_area_sq_in": 80.17,
          "step_in": null
        },
        "quantity": {
          "minimum": 1,
          "maximum": 10000
        },
        "artwork": {
          "min_dpi": 150,
          "refuse_below_dpi": 112.5,
          "bleed_in": 0.25
        },
        "finishes": [],
        "price_book": {
          "base_cents": 0,
          "cents_per_sq_in": 3
        },
        "plant_confirms_sku": true
      },
      {
        "product": "magnet",
        "name": "Magnet",
        "family": "magnets",
        "sku_template": "magnet-{width}x{height}",
        "example_sku": "magnet-3x3",
        "size": {
          "unit": "in",
          "max_width_in": 12,
          "max_height_in": 12,
          "either_orientation": true,
          "min_area_sq_in": 6.55,
          "step_in": null
        },
        "quantity": {
          "minimum": 1,
          "maximum": 10000
        },
        "artwork": {
          "min_dpi": 300,
          "refuse_below_dpi": 225,
          "bleed_in": 0.125
        },
        "finishes": [],
        "price_book": {
          "base_cents": 130,
          "cents_per_sq_in": 10
        },
        "plant_confirms_sku": true
      },
      {
        "product": "poster",
        "name": "Poster",
        "family": "posters",
        "sku_template": "poster-{width}x{height}",
        "example_sku": "poster-18x24",
        "size": {
          "unit": "in",
          "max_width_in": 36,
          "max_height_in": 48,
          "either_orientation": true,
          "min_area_sq_in": null,
          "step_in": null
        },
        "quantity": {
          "minimum": 1,
          "maximum": 10000
        },
        "artwork": {
          "min_dpi": 150,
          "refuse_below_dpi": 112.5,
          "bleed_in": 0.125
        },
        "finishes": [],
        "price_book": {
          "base_cents": 300,
          "cents_per_sq_in": 2
        },
        "plant_confirms_sku": true
      }
    ]
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

**POST `/products/recommend` Get product recommendations**

Rank the live catalog against what you are trying to make. Deterministic — a keyword and use-case scorer over the same catalog GET /v1/products serves, with no model anywhere in it, so the same query over the same catalog always returns the same ranking. Every recommendation is priced through the same path POST /v1/order charges from (your negotiated overrides, then your plan's catalog discount), so price_cents is what you would actually pay for one unit; a SKU the catalog cannot price comes back price_cents: null with priced_from: "unavailable" rather than a guess. Provide at least one of query or use_case.

**Credential**

An API key in `x-api-key`

**Scopes**

`products:read`

### Parameters

- `Idempotency-Key` header · string (uuid)

  Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422 `idempotency_key_reuse` rather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503 `idempotency_unavailable` with `Retry-After`, and nothing runs or is charged: retry with the same key.

### Request body application/json, required

- `query` string

  Free text describing what you need. Each word scores a point against a product's name, SKU, description and derived category — the same fields GET /v1/products?search= matches.

- `use_case` string

  Intended use case; scores products in the catalog categories and keywords that serve it.

  One of `branding` `packaging` `events` `marketing` `decor`

- `size` string

  Desired size, matched as a substring of the SKU or name — the same way GET /v1/products?size= matches it.

  One of `small` `medium` `large`

- `indoor_outdoor` string

  Indoor, outdoor, or both. The catalog has no such field, so this is matched against product copy: a product that says nothing scores nothing here and is never excluded for it.

  One of `indoor` `outdoor` `both`

- `quantity` integer

  Units you expect to order. Prices each recommendation at that quantity and adds estimated_items_total_cents (unit price x quantity). Our own plant's products priced by our price book carry quantity breaks (stickers 40% off from 10, 61% off from 25, 73% off from 50, 76% off from 100; magnets 15% off from 10, 30% off from 50, 40% off from 100; posters 8% off from 5, 15% off from 10, 18% off from 25, except the 18×24, which is $10.71 each from 5 and $9.89 each from 10 and $9.66 each from 25, and the 24×36, which is $19.31 each from 5; wall decals 8% off from 5, 15% off from 10), on the order's whole quantity of a SKU and after any plan discount; a negotiated price takes none. It excludes shipping and the processing fee; POST /v1/pricing/estimate quotes an order total.

  1 to 100000

- `budget` string

  Price preference, relative to the products that matched rather than to a fixed dollar band: low favours the cheapest third of the matches, premium the dearest third. Scores one point, the same as every other signal, and breaks remaining ties in that direction. Products the catalog could not price take no budget point and rank last.

  One of `low` `medium` `premium`

### Request

`POST /api/v1/products/recommend`

cURL:

```bash
curl -X POST "https://portal.printedsimply.com/api/v1/products/recommend" \
  -H "x-api-key: ps_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: recommend-products-$(date +%s)" \
  -d '{
    "query": "logo stickers for a trade show",
    "use_case": "events",
    "quantity": 100
  }'
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/products/recommend", {
  method: "POST",
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    "query": "logo stickers for a trade show",
    "use_case": "events",
    "quantity": 100
  }),
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request
import uuid

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/products/recommend",
    method="POST",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
        "Content-Type": "application/json",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    data=json.dumps({
        "query": "logo stickers for a trade show",
        "use_case": "events",
        "quantity": 100,
    }).encode(),
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/products/recommend');
$body = <<<'JSON'
{
  "query": "logo stickers for a trade show",
  "use_case": "events",
  "quantity": 100
}
JSON;
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
        'Content-Type: application/json',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS => $body,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

Up to 5 ranked recommendations, plus a `catalog` block saying what they were ranked over and whether it was stale.

`data`

- `recommendations` object[] required

  Best first, at most 5. Empty means nothing in the catalog matched — check `catalog.stale` before concluding that.

  at most 5 entries

- `recommendations[].sku` string required

  Pass straight to `POST /v1/pricing/estimate` or an order item.

- `recommendations[].name` string required

- `recommendations[].category` string required

  The derived catalog category — the same vocabulary as `CatalogProduct.category`.

- `recommendations[].price` string | null required

  The unit price formatted for display, e.g. "$4.50". **Null, not "$0.00"**, when the catalog could not price the SKU — a zero here would be a number nothing backs.

- `recommendations[].price_cents` integer | null required

  Your unit price in cents, already through your negotiated overrides and your plan's catalog discount, with no platform fee (there is none on any plan) — the same path `POST /v1/order` charges from, so this is what you would actually pay for one. Null when unpriced.

- `recommendations[].priced_from` string required

  `catalog` — the number came through the order pricing path. `unavailable` — the lookup failed, so no price is reported rather than a guessed one. There is deliberately no estimate case: the SKU still matched your signals, so it is returned unpriced instead of dropped.

  One of `catalog` `unavailable`

- `recommendations[].estimated_items_total_cents` integer

  Present only when you sent `quantity` AND the SKU is priced. Unit price x quantity — items only. Shipping and the processing fee are NOT in it; `POST /v1/pricing/estimate` quotes an order total.

- `recommendations[].estimated_items_total` string

  The same number formatted, e.g. "$450.00". Present under the same condition.

- `recommendations[].score` integer required

  Points this SKU scored against your signals. Comparable within one response only — it is a count of matched signals, not a rating of the product, and rows scoring zero are never returned.

- `recommendations[].match_reasons` string[] required

  Why it scored, one string per point, e.g. "keyword: waterproof", "use_case: branding (stickers)". This is the whole explanation — the ranking is deterministic and has no model in it.

- `quantity` integer | null required

  Echoed back; null when you sent none.

- `budget` string | null required

  Echoed back; null when you sent none.

  One of `low` `medium` `premium`

- `catalog` object required

  What this ranking was computed over. An empty `recommendations` off a stale snapshot is a different fact from an empty one off a healthy catalog, and these four fields are how you tell.

- `catalog.stale` boolean required

  Same signal as the `X-Catalog-Stale` header, in the body.

- `catalog.reason` string | null required

  Same values as `X-Catalog-Degraded`; null when not stale.

- `catalog.source` string

  Where the ranked rows came from: `catalog` (the plant's catalog, live or its last-good copy), or `price_book` when our own plant's catalog had nothing of its own to serve and the ranking is over the sizes our price book prices, as GET /v1/products then lists them. Stale either way when `stale` is true.

  One of `catalog` `price_book`

- `catalog.considered` integer required

  Sellable rows in the catalog this ranked over.

- `catalog.matched` integer required

  Of those, how many scored above zero. Only the top few are priced and returned, so this is normally larger than `recommendations.length`.

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "recommendations": [
      {
        "sku": "magnet-3x3",
        "name": "Magnet, 3×3 in",
        "category": "magnets",
        "price": "$1.32",
        "price_cents": 132,
        "priced_from": "catalog",
        "estimated_items_total_cents": 13200,
        "estimated_items_total": "$132.00",
        "score": 1,
        "match_reasons": [
          "keyword: a"
        ]
      },
      {
        "sku": "magnet-4x4",
        "name": "Magnet, 4×4 in",
        "category": "magnets",
        "price": "$1.74",
        "price_cents": 174,
        "priced_from": "catalog",
        "estimated_items_total_cents": 17400,
        "estimated_items_total": "$174.00",
        "score": 1,
        "match_reasons": [
          "keyword: a"
        ]
      },
      {
        "sku": "sticker-die-cut-3x3",
        "name": "Die-Cut Sticker, 3×3 in",
        "category": "stickers",
        "price": "$0.64",
        "price_cents": 64,
        "priced_from": "catalog",
        "estimated_items_total_cents": 6400,
        "estimated_items_total": "$64.00",
        "score": 1,
        "match_reasons": [
          "keyword: stickers"
        ]
      },
      {
        "sku": "wall-decal-24x36",
        "name": "Wall Decal, 24×36 in",
        "category": "decals",
        "price": "$22.03",
        "price_cents": 2203,
        "priced_from": "catalog",
        "estimated_items_total_cents": 220300,
        "estimated_items_total": "$2203.00",
        "score": 1,
        "match_reasons": [
          "keyword: a"
        ]
      }
    ],
    "quantity": 100,
    "budget": null,
    "catalog": {
      "stale": false,
      "reason": null,
      "source": "catalog",
      "considered": 6,
      "matched": 4
    }
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `400` [validation_error](https://portal.printedsimply.com/docs/errors#validation_error)

  Invalid request body or parameters. When the request failed the operation's schema, `details` carries `formErrors` (problems with the request as a whole), `fieldErrors` (messages keyed by top-level field) and `errors`: one `{ pointer, code, message }` per refused value, `pointer` an RFC 6901 JSON pointer into the body (or into the object of query parameters, for a query parameter) — `/items/0/sku`, with `~` written `~0` and `/` written `~1`, and `""` for the request as a whole. POST /v1/orders/bulk answers `errors` as one `{ index, details }` per refused order, each `details` with its own pointers from the request body (`/orders/3/items/0/sku`); POST /v1/orders/multi-ship keeps `errors` as the `{ formErrors, fieldErrors }` it always published; POST /v1/mockups/generate and POST /v1/products/recommend keep their fields at the top of `details`, with `errors` beside them. A refusal of a rule the schema cannot state (an asset that does not exist, an expiry too soon) names it in `message` and carries no `errors`.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `413` [payload_too_large](https://portal.printedsimply.com/docs/errors#payload_too_large)

  payload_too_large — the request body exceeded this route's byte cap (256 KB unless the route raises it with a written reason). It is refused before it is parsed, so nothing was created. Send fewer rows, or use the endpoint's own asynchronous door where it has one.

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

- `502`

  The catalog could not be read at all, so there was nothing to rank. Deliberately an error rather than an empty `recommendations` list, which would read as "nothing suits you". `X-Catalog-Degraded` carries the reason. Never for our own plant's catalog, which falls back to its price book (`catalog.source: "price_book"`), as GET /v1/products does.

## Pricing

**POST `/orders/quote` Get a signed, expiring price quote**

Prices an order and returns a signed quote_token (24h TTL). Pass the token to POST /order to charge exactly the quoted total — no re-pricing surprise. May include a non-binding delivery_risk advisory and cross-border presentment/duties blocks.

**Credential**

An API key in `x-api-key`

**Scopes**

`pricing:read`

### Parameters

- `Idempotency-Key` header · string (uuid)

  Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422 `idempotency_key_reuse` rather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503 `idempotency_unavailable` with `Retry-After`, and nothing runs or is charged: retry with the same key.

### Request body application/json, required

- `items` object[] required

  at least 1 entry

- `items[].sku` string required

  non-empty

- `items[].quantity` integer required

  at least 1

- `items[].variable_data` object

  The same per-unit field POST /order takes, preflighted the same way, so an overflow or a count mismatch is refused here before you order. Never priced: the quote is what it is without it. The response's `variable_data` carries the variant proofs.

- `items[].variable_data.slots` object[] required

  The named text slots of the line's design, each with the most characters it holds.

  1 to 10 entries

- `items[].variable_data.slots[].name` string required

  Lower-case letters, digits and underscores, starting with a letter, e.g. name or serial. Each name once.

  matches ^[a-z][a-z0-9_]{0,39}$

- `items[].variable_data.slots[].max_length` integer required

  The longest value this slot prints. A longer value is refused as `overflow`, not shrunk or cut.

  1 to 200

- `items[].variable_data.units` object[]

  One object per unit, in print order: { slot name: value }. Its length must equal the line's quantity. Values are strings or numbers, with no line breaks. Omit it when a `sequence` fills every slot.

  1 to 10000 entries

- `items[].variable_data.sequence` object

  Numbers one slot per unit: prefix + (start + (unit - 1) × step), zero-padded to `pad` digits, e.g. A-0001, A-0002. A unit may not also set that slot.

- `items[].variable_data.sequence.slot` string required

  A declared slot.

  non-empty · at most 40 characters

- `items[].variable_data.sequence.prefix` string

  Printed before the number. Default empty.

  at most 40 characters

- `items[].variable_data.sequence.start` integer

  The first unit's number. Default 1.

  0 to 1000000000

- `items[].variable_data.sequence.step` integer

  Added per unit. Default 1.

  1 to 1000000

- `items[].variable_data.sequence.pad` integer

  Zero-pad the number to this many digits. Default 0 (no padding).

  0 to 12

- `items[].variable_data.qr` object

  Each unit's QR payload, built from its values.

- `items[].variable_data.qr.template` string required

  Text with {slot} placeholders, e.g. https://example.com/t/{serial}. Every placeholder must name a declared slot.

  non-empty · at most 1000 characters

- `items[].variable_data.qr.max_length` integer

  The longest payload accepted, for a small sticker whose code must stay scannable. Default 1000.

  1 to 1000

- `shipping` object

- `shipping.method` string

  standard (default); priority or express once they are priced — until then a request for either is 400 shipping_method_unavailable.

- `currency` string

  Optional presentment currency (ISO 4217); adds a presentment block when non-USD. The block's amount_cents is in that currency's own minor units and minor_units carries the exponent (0 for JPY, 2 for EUR). An unsupported currency is rejected with unsupported_currency rather than quoted at a fallback rate.

  exactly 3 characters

- `destination_country` string

  ISO 3166-1 alpha-2. Our own plant ships to US addresses only, so any other country is refused 422 destination_unavailable. `address_to.country`, when sent, is the one checked.

  exactly 2 characters

- `deadline` string (date-time)

  Arrival deadline — delivery_risk warns when it's tight.

- `deliver_by` string (date)

  A day the order should arrive by, YYYY-MM-DD (UTC). Answered in `deliver_by_check`, judged against the estimate's latest day rather than its earliest, from the facility's production estimate and the method's transit window. Optional; a day before today, or anything but a real date, is 400 validation_error.

- `provider` string

  The lane to price: always our own plant here, which is where POST /order makes the order. Omit it, or send `printedsimply`; any other value is refused with 422 `provider_not_available` before anything is priced.

  Always `printedsimply`

  Default printedsimply

- `address_to` object

  The destination. Optional: our own plant prices shipping from its own table, so only its country is read, and it ships to US addresses only (any other country is refused 422 destination_unavailable).

- `address_to.name` string required

  non-empty

- `address_to.company` string

- `address_to.address1` string required

  non-empty

- `address_to.address2` string

- `address_to.city` string required

  non-empty

- `address_to.state` string required

  non-empty

- `address_to.zip` string required

  non-empty

- `address_to.country` string required

  ISO 3166-1 alpha-2 country code

  exactly 2 characters

- `address_to.phone` string

- `address_to.email` string (email)

### Request

`POST /api/v1/orders/quote`

cURL:

```bash
curl -X POST "https://portal.printedsimply.com/api/v1/orders/quote" \
  -H "x-api-key: ps_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: quote-order-$(date +%s)" \
  -d '{
    "items": [
      {
        "sku": "sticker-die-cut-3x3",
        "quantity": 100
      }
    ],
    "shipping": {
      "method": "standard"
    }
  }'
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/orders/quote", {
  method: "POST",
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    "items": [
      {
        "sku": "sticker-die-cut-3x3",
        "quantity": 100
      }
    ],
    "shipping": {
      "method": "standard"
    }
  }),
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request
import uuid

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/orders/quote",
    method="POST",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
        "Content-Type": "application/json",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    data=json.dumps({
        "items": [
            {
                "sku": "sticker-die-cut-3x3",
                "quantity": 100,
            },
        ],
        "shipping": {
            "method": "standard",
        },
    }).encode(),
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/orders/quote');
$body = <<<'JSON'
{
  "items": [
    {
      "sku": "sticker-die-cut-3x3",
      "quantity": 100
    }
  ],
  "shipping": {
    "method": "standard"
  }
}
JSON;
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
        'Content-Type: application/json',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS => $body,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

Signed quote, priced at our own plant: `quote_id`, `quote_token` (pass it to POST /order to be charged exactly this total), `expires_at`, `lines`, `breakdown`, `margin` and `shipping` (its method, and the peak-season surcharge the shipping figure already holds, itemised); plus `estimated_delivery`, `deliver_by_check` when the request named a deliver_by day, `delivery_risk` when the order carries a known risk, `dispatch_promise` (switched off today), `presentment` when a currency other than USD was asked for, `variable_data` when a line carries it, and, on a test key, `pricing_degraded`.

`data`

- `quote_id` string required

- `address_check` object

  What POST /addresses/validate finds in `address_to`, when the quote was sent one. A live key is refused an address with an `error` finding (422 address_invalid); on a test key `valid: false` here says a live quote would have been.

- `address_check.valid` boolean required

  False when any finding is an `error`.

- `address_check.checked` boolean required

  False for an address outside the US, which is passed through as written.

- `address_check.normalized` object required

  The address in USPS style: upper case, no punctuation, ZIP+4 hyphenated, the state as its code, a unit typed into address1 moved to address2. Name, company, phone and email are trimmed and otherwise as sent.

- `address_check.normalized.name` string

- `address_check.normalized.company` string

- `address_check.normalized.address1` string required

- `address_check.normalized.address2` string

- `address_check.normalized.city` string required

- `address_check.normalized.state` string required

- `address_check.normalized.zip` string required

- `address_check.normalized.country` string required

- `address_check.normalized.phone` string

- `address_check.normalized.email` string

- `address_check.findings` object[] required

- `address_check.findings[].code` string required

  The `delivery_point_*` codes come from delivery-point verification, which only a live POST /order, POST /orders/bulk entry or PUT /order/{id}/address runs, on a US address the other checks pass, and only where the deployment has USPS verification configured: `delivery_point_not_found` (`error`) — USPS answered that no delivery point exists at the street address; `delivery_point_incomplete` (`warning`) — the building exists but its unit is missing or not confirmed, or more than one address matched; `delivery_point_confirmed` (`info`). When USPS cannot be reached the order proceeds without them.

  One of `zip_format` `state_unknown` `zip_state_mismatch` `zip_state_border` `zip_unassigned` `po_box` `military_state` `military` `address2_split` `not_checked` `delivery_point_not_found` `delivery_point_incomplete` `delivery_point_confirmed`

- `address_check.findings[].severity` string required

  `error`: the address cannot be right as written, and a live order or quote is refused with 422 address_invalid. `warning`: worth a look, never refused. `info`: what normalization did, or that nothing was checked.

  One of `error` `warning` `info`

- `address_check.findings[].field` string required

  One of `address1` `address2` `city` `state` `zip` `country`

- `address_check.findings[].message` string required

- `quote_token` string required

  Signed, and what `POST /v1/order` honours. It carries the quote's own numbers, so an order placed against it cannot be re-pointed at a cheaper one.

- `expires_at` string (date-time) required

  After this the token is refused and the quote must be taken again.

- `lines` object[] required

  The priced lines, each { sku, quantity, unit_cents }.

- `lines[].sku` string required

- `lines[].quantity` integer required

- `lines[].unit_cents` integer required

  What this API charges per unit, in USD cents.

- `breakdown` object required

- `breakdown.items_cents` integer required

- `breakdown.shipping_cents` integer required

- `breakdown.processing_fee_cents` integer required

  Compatibility field for platform and card-processing surcharges in cents. Zero on orders priced under the current no-added-fees policy; retained on historical records.

- `breakdown.platform_fee_cents` integer

  The platform fee as a line of its own. Always 0: there is no platform fee on any order, on any plan (since 2026-10-17). Kept so a client that reads it still parses.

- `breakdown.card_fee_cents` integer

  Card processing on everything else the order is charged, passed through at cost.

- `breakdown.total_cents` integer required

- `margin` object required

  What to sell it for and what that leaves. A suggestion, not a commitment.

- `margin.suggested_retail_cents` integer required

- `margin.margin_pct` number required

- `presentment` object

  The total in the buyer's currency. Present only on a cross-border quote.

- `duties` object

  Present only when duties apply.

- `landed_cost` object

- `landed_cost.destination_country` string required

- `landed_cost.duty_basis` string required

  Who pays at the border. The VALUE is the incoterm; what customs assesses on is goods plus shipping plus duty, which is a different fact and was the whole of this description before.

  One of `DDP` `DAP`

- `landed_cost.duty_rate` number required

- `landed_cost.tax_rate` number required

- `landed_cost.breakdown` object required

- `landed_cost.breakdown.goods_cents` integer required

- `landed_cost.breakdown.shipping_cents` integer required

- `landed_cost.breakdown.duties_cents` integer required

- `landed_cost.breakdown.tax_cents` integer required

- `landed_cost.total_landed_cents` integer required

- `landed_cost.pre_collected_cents` integer required

  Charged with the order.

- `landed_cost.due_on_delivery_cents` integer required

  What the courier bills the recipient. The number a buyer is surprised by if nobody told them.

- `landed_cost.presentment` object required

  The same total in the destination's currency.

- `landed_cost.presentment.currency` string required

- `landed_cost.presentment.amount_cents` integer required

- `landed_cost.presentment.minor_units` integer required

- `landed_cost.presentment.fx_rate` number required

- `landed_cost.estimated` boolean required

  Always true off this path, and sent anyway: a consumer must be able to tell an estimate from a settled charge without knowing which endpoint produced it.

- `landed_cost.note` string required

- `guarantee` object

  The delivery guarantee this quote would carry. Present only when one is offered on the lane.

- `dispatch_promise` object

  A dispatch-by date for an order made at our own plant, and what is paid if it leaves the plant later. Present only when the platform has switched the dispatch promise on AND every line's measured dispatch p95 is inside its window — so today, with the promise switched off, no quote carries it. Inside the quote signature; an order placed against this quote is held to exactly this date and remedy.

- `dispatch_promise.dispatch_by` string (date-time) required

  The order is promised to leave the plant by this instant.

- `dispatch_promise.business_days` integer required

  The window the date was computed with, in business days (Saturday and Sunday do not count).

- `dispatch_promise.remedy` object required

- `dispatch_promise.remedy.type` string required

  `shipping`: the order's shipping charge is refunded. `credit`: a fixed amount is refunded.

  One of `shipping` `credit`

- `dispatch_promise.remedy.cents` integer required

  What a miss refunds, capped by what the order's charges have left.

- `delivery_risk` object

  Present only when the lane carries a known risk worth stating before the order. Non-binding. When a carrier advisory touches the destination between today and the estimate's latest day, `at_risk` is true and `advisories` lists them; a quote may also carry its lane's lead-time read beside them.

- `delivery_risk.at_risk` boolean

  Present, and true, when `advisories` is.

  Always `true`

- `delivery_risk.advisories` object[]

  The carrier advisories that touch the destination while the parcel would be on its way.

- `delivery_risk.advisories[].id` string required

  `adv_…`.

- `delivery_risk.advisories[].carrier` string required

  The carrier that posted it, as it names itself, e.g. "USPS".

- `delivery_risk.advisories[].states` string[] required

  USPS state codes it touches. With `zip_prefixes` both empty, it touches every US destination.

- `delivery_risk.advisories[].zip_prefixes` string[] required

  ZIP prefixes (one to five digits) it touches, beside `states`.

- `delivery_risk.advisories[].from` string (date) required

  The first day it applies, YYYY-MM-DD in UTC, inclusive.

- `delivery_risk.advisories[].through` string (date) required

  The last day it applies, YYYY-MM-DD in UTC, inclusive.

- `delivery_risk.advisories[].active` boolean required

  Whether today (UTC) is inside `from`–`through`.

- `delivery_risk.advisories[].extra_days` integer required

  The carrier's estimate of the business days it adds, as posted. An estimate.

- `delivery_risk.advisories[].message` string required

  One sentence about it.

- `delivery_risk.advisories[].source` string required

  The carrier's own notice, as a URL.

- `estimated_delivery` object

  When an order placed now should arrive, as an estimate: the facility's production estimate plus the shipping method's transit window (our plant's standard parcel from the plant's own transit estimate), in business days counted in UTC, Monday to Friday, never on a day GET /calendar lists (the federal holidays, each on the weekday it is observed, and the plant's own closure days). Not a promise and not inside `quote_token`: the binding date, where the account has one, is `dispatch_promise`. Present on a quote priced at one of our own facilities; absent on a degraded quote and on any other lane, which sets its own times.

- `estimated_delivery.earliest` string (date) required

  YYYY-MM-DD, UTC.

- `estimated_delivery.latest` string (date) required

  YYYY-MM-DD, UTC.

- `estimated_delivery.ships_by` string (date) required

  YYYY-MM-DD, UTC: the day an order placed now should leave the facility, when its production estimate ends, counted the same way.

- `estimated_delivery.is_estimate` boolean required

  Always true: say it as an estimate wherever it is shown.

  Always `true`

- `deliver_by_check` object

  The answer to `deliver_by`, read as an estimate: production and transit are the facility's and the carrier's estimates, so a day that `meets` can still be missed. Present only when `deliver_by` was sent and there is an estimate to judge it against.

- `deliver_by_check.requested` string (date) required

  The day asked about, as sent.

- `deliver_by_check.meets` boolean required

  On a quote: whether its own method's estimate ends on or before `requested` (`estimated_delivery.latest`, never the earliest day). On a shipping estimate: whether any parcel method listed does. False when a carrier advisory touches the destination (`status` is `at_risk`): true exactly when `status` is `meets`.

- `deliver_by_check.earliest_method` string | any required

  The parcel method on offer whose estimate ends soonest, when it ends on or before `requested`; null when no method on offer is estimated to arrive by then. Never `letter`.

- `deliver_by_check.latest_order_day` string (date) | null required

  The last UTC day an order can be placed and still be estimated to arrive by `requested`, computed by the same estimate as `estimated_delivery` from the same facility: on a quote, for its own method; on a shipping estimate, for the parcel method listed that allows the latest day. Any time that day counts, and it may be a weekend or a holiday (an order placed then starts counting on the next operating day). Null exactly when no order placed today or later is estimated to arrive by `requested`, so it is null when `status` is `misses`. It reads the estimate alone, never a carrier advisory. An estimate, like the dates it reads.

- `deliver_by_check.status` string required

  The verdict. `misses`: the estimate ends after `requested`. `at_risk`: the estimate ends by then, but a carrier advisory (`advisories`) touches the destination while the parcel would be on its way. `meets`: the estimate ends by then and no advisory touches it.

  One of `meets` `at_risk` `misses`

- `deliver_by_check.advisories` object[] required

  The carrier advisories that touch the destination between today and the estimate's latest day; empty when none does.

  The same fields as `delivery_risk.advisories`

- `shipping` object required

  The shipping the figure in `breakdown` is for: its method, and the surcharges that figure already holds, itemised. Nothing here is added to the total.

- `shipping.method` string required

  The shipping method the figure was priced for.

- `shipping.surcharges` object[] required

  Compatibility itemisation, never added to the total. Current quotes and new orders contain no seasonal peak-postage surcharge: PrintedSimply absorbs the carrier expense, so this array is empty. Historical stored orders may retain their original itemisation; a fee-bearing old quote must be refreshed before a new charge.

- `shipping.surcharges[].code` string required

  `peak_season`: the carrier's temporary holiday price change.

  Always `peak_season`

- `shipping.surcharges[].reason` string required

  What it is, in words.

- `shipping.surcharges[].amount_cents` integer required

  How much of the shipping figure this is, in cents, folded the way that figure is (all-in where the figure is all-in).

  at least 1

- `shipping.surcharges[].window` object required

  The days it applies on, both inclusive.

- `shipping.surcharges[].window.from` string (date) required

  YYYY-MM-DD, UTC.

- `shipping.surcharges[].window.through` string (date) required

  YYYY-MM-DD, UTC.

- `shipping.surcharges[].source` string (uri) required

  Where the amount and the dates come from: the carrier's own announcement.

- `shipping.surcharges[].included_in` string required

  Always `shipping`: the figure this is part of. Never an additional charge.

  Always `shipping`

- `pricing_degraded` string

  Sandbox-only. The $0 breakdown beside it is an ABSENCE of a price, not a price — the routing failed and a test key was given a shaped answer anyway.

  One of `provider_unavailable` `pricing_unavailable`

- `variable_data` object[]

  One entry per line carrying `variable_data`: the variant proofs (first, last and longest unit) and each slot's headroom, before the order is placed. Absent when no line carries it. The breakdown beside it is the same with or without it.

- `variable_data[].item_index` integer required

  The line's position in the request's `items`.

  at least 0

- `variable_data[].sku` string required

- `variable_data[].units` integer required

  Units on the line, each printed with its own values.

  at least 1

- `variable_data[].slots` object[] required

  Each declared slot with its `max_length` and the `longest` value any unit gives it: the headroom.

- `variable_data[].slots[].name` string required

- `variable_data[].slots[].max_length` integer required

- `variable_data[].slots[].longest` integer required

  Characters in this slot's longest value on the line.

- `variable_data[].qr` object

  Present when the line has a QR template: the payload ceiling and the longest payload on the line.

- `variable_data[].qr.max_length` integer required

- `variable_data[].qr.longest` integer required

- `variable_data[].proofs` object[] required

  The variant proofs: the first unit, the last, and the longest (the most printed characters, earliest on a tie), with the exact values and QR payload each will print. A unit that is more than one of the three is listed once, with each variant it stands for. Described rather than drawn: a plain line's artwork carries no slot positions to draw a value into.

- `variable_data[].proofs[].unit` integer required

  The unit's position on the line, 1-based, in print order.

  at least 1

- `variable_data[].proofs[].variants` string[] required

- `variable_data[].proofs[].values` object required

  Slot name to the text this unit prints.

- `variable_data[].proofs[].qr_payload` string

  What this unit's QR code encodes. Present when the line has a QR template.

- `variable_data[].live_production` boolean required

  Whether a LIVE order carrying this line would be accepted now. False while variable-data production is switched off: sandbox orders run end to end, and a live order or quote is refused with 422 variable_data_unavailable.

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "quote_id": "qt_EMyYo3DyOSrd7siDgh2niPvP",
    "quote_token": "eyJxdW90ZV9pZCI6InF0X0VNeVlvM0R5T1NyZDdzaURnaDJuaVB2UCJ9.3b71988dd79915637f2e65aeddd8893705bb2140ef57a64dcf71aa67148bef1e",
    "expires_at": "2026-10-11T08:09:14.578Z",
    "lines": [
      {
        "sku": "sticker-die-cut-3x3",
        "quantity": 100,
        "unit_cents": 64
      }
    ],
    "breakdown": {
      "items_cents": 6400,
      "shipping_cents": 899,
      "processing_fee_cents": 0,
      "platform_fee_cents": 0,
      "card_fee_cents": 0,
      "total_cents": 7299
    },
    "margin": {
      "suggested_retail_cents": 15666,
      "margin_pct": 50
    },
    "estimated_delivery": {
      "earliest": "2026-10-20",
      "latest": "2026-10-26",
      "ships_by": "2026-10-14",
      "is_estimate": true
    },
    "shipping": {
      "method": "standard",
      "surcharges": []
    }
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `400` [validation_error](https://portal.printedsimply.com/docs/errors#validation_error)

  Invalid request body or parameters. When the request failed the operation's schema, `details` carries `formErrors` (problems with the request as a whole), `fieldErrors` (messages keyed by top-level field) and `errors`: one `{ pointer, code, message }` per refused value, `pointer` an RFC 6901 JSON pointer into the body (or into the object of query parameters, for a query parameter) — `/items/0/sku`, with `~` written `~0` and `/` written `~1`, and `""` for the request as a whole. POST /v1/orders/bulk answers `errors` as one `{ index, details }` per refused order, each `details` with its own pointers from the request body (`/orders/3/items/0/sku`); POST /v1/orders/multi-ship keeps `errors` as the `{ formErrors, fieldErrors }` it always published; POST /v1/mockups/generate and POST /v1/products/recommend keep their fields at the top of `details`, with `errors` beside them. A refusal of a rule the schema cannot state (an asset that does not exist, an expiry too soon) names it in `message` and carries no `errors`.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `413` [payload_too_large](https://portal.printedsimply.com/docs/errors#payload_too_large)

  payload_too_large — the request body exceeded this route's byte cap (256 KB unless the route raises it with a written reason). It is refused before it is parsed, so nothing was created. Send fewer rows, or use the endpoint's own asynchronous door where it has one.

- `422` [variable_data_invalid](https://portal.printedsimply.com/docs/errors#variable_data_invalid) [variable_data_unavailable](https://portal.printedsimply.com/docs/errors#variable_data_unavailable) [destination_unavailable](https://portal.printedsimply.com/docs/errors#destination_unavailable) [below_cost_floor](https://portal.printedsimply.com/docs/errors#below_cost_floor) [address_invalid](https://portal.printedsimply.com/docs/errors#address_invalid) [sku_not_available](https://portal.printedsimply.com/docs/errors#sku_not_available) [provider_not_available](https://portal.printedsimply.com/docs/errors#provider_not_available)

  variable_data_invalid — `items[].variable_data` failed the preflight, before anything was priced or charged. `details.problems[]` names each problem (up to 50; `details.problem_count` is the total) by `item_index`, `unit` and `slot`, with a `code`. variable_data_unavailable — a LIVE order or quote carries variable data while production is switched off; nothing was charged, and a test key (ps_test_*) accepts the same request end to end. destination_unavailable — `address_to.country` (else `destination_country`) is outside the US: our own lanes ship to US addresses only. `details.reason` is `destination_not_us` and `details.country` names the destination. below_cost_floor — the order would be made at our own plant and would cost more to make and ship than it is charged, at worst-case postage, and its loss is not one our cost floor accepts: only today's named losses are, no larger than they are today — the flat standard parcel under its postage, for a mailer up to a pound or a poster tube up to two, and list-priced goods short only of the per-order handling cost (or a partner SKU would be charged below what the partner bills for it). `details.skus` names the items and `details.contribution_cents` how far below zero the order falls. Nothing was charged; add units, combine it with another order, or choose another shipping method on offer. address_invalid — a live quote's `address_to` cannot be right as written: `details.findings[]` names each `error` (`zip_format`, `state_unknown`, `zip_state_mismatch`) with its field and `details.address_check` is the whole check, as POST /addresses/validate answers it. A test key is never refused for it; its 200 carries `address_check`. sku_not_available — a line names a product our own plant does not make: only PrintedSimply's own products (stickers, decals, labels, magnets and posters, as GET /products lists them) are sold here. `details.skus` names each one. Asked before anything is priced, so nothing was priced or charged. provider_not_available — `provider` names a lane other than our own plant. Orders here are made by PrintedSimply: omit `provider`, or send `printedsimply`. `details.provider` echoes what was sent and `details.available` lists what is accepted. Nothing was priced or charged.

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

- `503` [provider_unavailable](https://portal.printedsimply.com/docs/errors#provider_unavailable) [unknown_sku](https://portal.printedsimply.com/docs/errors#unknown_sku)

  provider_unavailable — the facility that produces these items is not accepting orders right now, so there is no price to quote. Nothing was charged; a quote never charges. With `details.reason: plant_unreachable` and `Retry-After`, our plant gave no answer about a SKU (the request did not reach it, timed out, or came back 5xx) — about the plant, not the SKU; the catalog says the same on the row (`not_orderable_code: plant_unreachable`). A test key gets a $0 quote marked `pricing_degraded: pricing_unavailable` instead. Only for a SKU the catalog has, though: one the price book does not price, our plant's products do not name and the synced catalog does not hold is refused 400 unknown_sku with `details.skus`, as it is on POST /order, POST /orders/quote and POST /pricing/estimate alike.

**GET `/pricing/tiers` Get plan tiers**

The plan catalog and the plan your account is on. No plan is gated on monthly order volume — there are no volume discounts on monthly order counts, and min_orders/max_orders are null for that reason. Our own plant's products priced by our price book carry quantity breaks (stickers 40% off from 10, 61% off from 25, 73% off from 50, 76% off from 100; magnets 15% off from 10, 30% off from 50, 40% off from 100; posters 8% off from 5, 15% off from 10, 18% off from 25, except the 18×24, which is $10.71 each from 5 and $9.89 each from 10 and $9.66 each from 25, and the 24×36, which is $19.31 each from 5; wall decals 8% off from 5, 15% off from 10), on the order's whole quantity of a SKU and after any plan discount; a negotiated price takes none. No plan carries a platform fee: platform_fee_pct is 0 on every plan since 2026-10-17, as is pricing_model.platform_fee_pct, the rate your account pays today. catalog_discount_pct is the percentage the plan takes off the products our own plant makes (stickers, labels, decals, magnets and posters); no plan sold on this portal takes one. current.entitled_catalog_discount_pct is what your account may take today. Tiers with offered: false are not sold on this portal; accounts already on them keep them. The response is account-specific and served Cache-Control: private.

**Credential**

An API key in `x-api-key`

**Scopes**

`pricing:read`

### Request

`GET /api/v1/pricing/tiers`

cURL:

```bash
curl "https://portal.printedsimply.com/api/v1/pricing/tiers" \
  -H "x-api-key: ps_test_YOUR_KEY"
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/pricing/tiers", {
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
  },
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/pricing/tiers",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
    },
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/pricing/tiers');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

Every plan, plus the one this key's account is on. Account-specific, so `Cache-Control: private` — a shared cache must not hold it. The example lists the plans sold here; an answer also lists, with `offered: false`, each plan an account may already be on.

`data`

- `note` string required

  The pricing rules in prose, generated from the same constants that enforce them — including the sentence about plan volume discounts on monthly order counts, which changes on the day a plan has one, and the quantity breaks on one order's units of a SKU.

- `pricing_model` object required

- `pricing_model.volume_discounts` boolean required

  Whether ANY published plan prices on order volume. False today.

- `pricing_model.processing_fee_pct` number required deprecated

  Deprecated 2026-09-26: the single fee it described is now two lines. Holds `platform_fee_pct`; read that and the card fields instead.

- `pricing_model.platform_fee_pct` number

  The platform fee this account's next order pays, as a percentage of items plus shipping. 0: there is no platform fee on any order, on any plan, since 2026-10-17.

- `pricing_model.card_fee_pct` number

  Card processing's percentage, passed through at cost on everything else the order is charged.

- `pricing_model.card_fee_fixed_cents` integer

  Card processing's fixed part per charge, in cents.

- `pricing_model.catalog_discount_pct` number required

  The discount that will actually apply to this account's next order.

- `tiers` object[] required

  Every plan, offered ones first, then retired ones.

- `tiers[].tier` integer required

  1-based position in this list, offered plans first. An ordering, not a ranking.

- `tiers[].plan` string required

  The plan id an upgrade would name (`starter`, …).

- `tiers[].name` string required

  Display label (`Free`, …).

- `tiers[].offered` boolean required

  Whether this plan can be bought on this portal today. False means it is not sold here — accounts already on it keep it, nobody new can join it.

- `tiers[].monthly_price_cents` integer required

- `tiers[].catalog_discount_pct` number required

  % off the products our own plant makes (stickers, labels, decals, magnets, posters) this plan GRANTS, never below a unit's cost floor. No plan sold on this portal grants one. What the account may take today is `current.entitled_catalog_discount_pct`.

- `tiers[].platform_fee_pct` number

  The platform fee this plan pays, as a percentage of items plus shipping. 0 on every plan: there is no platform fee on any order since 2026-10-17. Card processing is separate and passed through at cost.

- `tiers[].discount_percent` number required

  Deprecated alias of `catalog_discount_pct`, kept because it is the field this endpoint has always published. It used to mean a volume discount and was hardcoded to 0; it now carries the plan discount, the only discount a plan grants. The quantity breaks on our own plant's goods are per order, not per plan, and the note states them.

- `tiers[].requests_per_minute` integer required

- `tiers[].requests_per_day` integer required

- `tiers[].recommended_monthly_orders` string required

  The order volume this plan is SUGGESTED for. Nothing is enforced on it — exceeding it costs nothing and changes nothing.

- `tiers[].min_orders` integer | null required

  The volume this plan's discount ladder starts at. **Null on every plan today**, because no plan carries a ladder — not zero, which would read as one starting at the first order.

- `tiers[].max_orders` integer | null required

  The top of the ladder. Null when there is none, and also when its top rung is open-ended (which it always is).

- `tiers[].bands` object[] required

  The plan's volume ladder, lowest rung first. Empty means one flat discount at every volume — which is every plan today.

- `tiers[].bands[].min_orders` integer required

  Inclusive lower bound, in billed orders over the trailing window.

- `tiers[].bands[].max_orders` integer | null required

  Inclusive upper bound; null on the open-ended top rung.

- `tiers[].bands[].catalog_discount_pct` number required

  % off the products our own plant makes at that volume.

- `tiers[].label` string required

  Price and the single most distinguishing benefit, prebuilt for display.

- `tiers[].features` string[] required

- `current` object required

  The plan this key's account is on.

- `current.tier` integer required

- `current.plan` string required

- `current.name` string required

- `current.offered` boolean required

- `current.catalog_discount_pct` number required

  What the plan grants.

- `current.discount_percent` number required

  Deprecated alias of `catalog_discount_pct`.

- `current.entitled_catalog_discount_pct` number required

  What the account may take TODAY, on our own plant's goods. Lower than `catalog_discount_pct` while a subscription is past_due, canceled or unpaid — budget against this one, not the plan's headline.

- `current.monthly_orders` any required

  **Always null.** This endpoint does not count your orders, and nothing prices on volume. Zero would read as "you have shipped nothing" to a merchant who has shipped thousands.

- `next_tier` object | null required

  The next plan up, or null when there is none to move to.

- `next_tier.tier` integer required

- `next_tier.plan` string required

- `next_tier.name` string required

- `next_tier.monthly_price_cents` integer required

- `next_tier.catalog_discount_pct` number required

- `next_tier.discount_percent` number required

  Deprecated alias of `catalog_discount_pct`.

- `next_tier.orders_needed` any required

  **Always null.** No plan is reached by placing orders, so there is no count that would get you there. Zero would read as "you already qualify".

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "note": "Order pricing is the published price for products our facilities make and their shipping, with no platform fee. No card-processing or seasonal peak fee is added. No plan is gated on monthly order volume — there are no volume discounts on monthly order counts, and min_orders/max_orders are null for that reason. Our own plant's products priced by our price book carry quantity breaks (stickers 40% off from 10, 61% off from 25, 73% off from 50, 76% off from 100; magnets 15% off from 10, 30% off from 50, 40% off from 100; posters 8% off from 5, 15% off from 10, 18% off from 25, except the 18×24, which is $10.71 each from 5 and $9.89 each from 10 and $9.66 each from 25, and the 24×36, which is $19.31 each from 5; wall decals 8% off from 5, 15% off from 10), on the order's whole quantity of a SKU and after any plan discount; a negotiated price takes none. Plans marked offered: false are not sold on PrintedSimply; accounts already on them keep them.",
    "pricing_model": {
      "volume_discounts": false,
      "platform_fee_pct": 0,
      "card_fee_pct": 2.9,
      "card_fee_fixed_cents": 30,
      "processing_fee_pct": 0,
      "catalog_discount_pct": 0
    },
    "tiers": [
      {
        "tier": 1,
        "plan": "starter",
        "name": "Free",
        "offered": true,
        "monthly_price_cents": 0,
        "catalog_discount_pct": 0,
        "discount_percent": 0,
        "platform_fee_pct": 0,
        "requests_per_minute": 60,
        "requests_per_day": 10000,
        "recommended_monthly_orders": "0–99",
        "min_orders": null,
        "max_orders": null,
        "bands": [],
        "label": "Free · 60 requests/min",
        "features": [
          "No platform fee — the price shown is the price",
          "No added card-processing or seasonal peak fees",
          "60 API requests/min",
          "REST API",
          "$0 time-compressed sandbox",
          "Test + live API keys, HMAC webhooks",
          "Community support"
        ]
      }
    ],
    "current": {
      "tier": 1,
      "plan": "starter",
      "name": "Free",
      "offered": true,
      "catalog_discount_pct": 0,
      "discount_percent": 0,
      "entitled_catalog_discount_pct": 0,
      "monthly_orders": null
    },
    "next_tier": null
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

**POST `/pricing/estimate` Get pricing estimate**

Calculate the product and shipping total for a set of items, with no added platform, card-processing or seasonal peak-postage fee. Does not create an order or charge anything. Priced through the same facility routing POST /order uses, so the estimate consults the catalog of the facility that would actually produce and charge the order (a sticker estimates at the flat-goods facility, a tee at the apparel facility). When no configured facility can produce the items, a live key gets 503 provider_unavailable — the same answer the order would give — and a test key gets a $0 breakdown flagged pricing_degraded: "provider_unavailable", matching the sandbox order path. This prices our own facilities' lanes, which ship to US addresses only: a destination_country other than US is refused 422 destination_unavailable, as the order would refuse it.

**Credential**

An API key in `x-api-key`

**Scopes**

`pricing:read`

### Parameters

- `Idempotency-Key` header · string (uuid)

  Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422 `idempotency_key_reuse` rather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503 `idempotency_unavailable` with `Retry-After`, and nothing runs or is charged: retry with the same key.

### Request body application/json, required

- `items` object[] required

  at least 1 entry

- `items[].sku` string required

  non-empty

- `items[].quantity` integer required

  at least 1

- `shipping_method` string

  One of `standard` `priority` `express` `letter`

  Default standard

- `destination_country` string

  ISO-3166-1 alpha-2. Optional. This estimate prices our own facilities' lanes, which ship to US addresses only, so any other country is refused 422 destination_unavailable.

  exactly 2 characters

### Request

`POST /api/v1/pricing/estimate`

cURL:

```bash
curl -X POST "https://portal.printedsimply.com/api/v1/pricing/estimate" \
  -H "x-api-key: ps_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: estimate-pricing-$(date +%s)" \
  -d '{
    "items": [
      {
        "sku": "sticker-die-cut-3x3",
        "quantity": 100
      }
    ],
    "shipping_method": "standard"
  }'
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/pricing/estimate", {
  method: "POST",
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    "items": [
      {
        "sku": "sticker-die-cut-3x3",
        "quantity": 100
      }
    ],
    "shipping_method": "standard"
  }),
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request
import uuid

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/pricing/estimate",
    method="POST",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
        "Content-Type": "application/json",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    data=json.dumps({
        "items": [
            {
                "sku": "sticker-die-cut-3x3",
                "quantity": 100,
            },
        ],
        "shipping_method": "standard",
    }).encode(),
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/pricing/estimate');
$body = <<<'JSON'
{
  "items": [
    {
      "sku": "sticker-die-cut-3x3",
      "quantity": 100
    }
  ],
  "shipping_method": "standard"
}
JSON;
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
        'Content-Type: application/json',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS => $body,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

Pricing breakdown

`data`

- `items` string required

  Item subtotal (e.g. '$25.00')

- `pricing_degraded` string

  Present only when the estimate could not be priced. The figures above are then an absence of a price, not a price of zero. Never present on a live estimate — those answer 503.

  One of `provider_unavailable` `pricing_unavailable`

- `landed_cost` object

  Duty and tax for a cross-border destination. Not returned today: this estimate prices our own lanes, which ship to US addresses only, so a non-US `destination_country` is refused 422 destination_unavailable and a US one has nothing at the border. Kept in the shape for when our own lanes ship abroad.

- `landed_cost.destination_country` string

- `landed_cost.duty_basis` string

  One of `DDP` `DAP`

- `landed_cost.duty_rate` number

- `landed_cost.tax_rate` number

- `landed_cost.breakdown` object

- `landed_cost.breakdown.goods_cents` integer

- `landed_cost.breakdown.shipping_cents` integer

- `landed_cost.breakdown.duties_cents` integer

- `landed_cost.breakdown.tax_cents` integer

- `landed_cost.total_landed_cents` integer

- `landed_cost.pre_collected_cents` integer

- `landed_cost.due_on_delivery_cents` integer

- `landed_cost.presentment` object

  The same total in the buyer's own currency.

- `landed_cost.presentment.currency` string

- `landed_cost.presentment.amount_cents` integer

- `landed_cost.presentment.minor_units` integer

- `landed_cost.presentment.fx_rate` number

- `shipping` string required

- `processing_fee` string required

  Compatibility field for platform and card-processing surcharges. Zero on orders priced under the current no-added-fees policy; retained on historical records.

- `platform_fee` string

  The platform fee as a line of its own. Always 0: there is no platform fee on any order, on any plan (since 2026-10-17). Kept so a client that reads it still parses.

- `card_fee` string

  Card processing on everything else the order is charged, passed through at cost.

- `total` string required

- `total_cents` integer required

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "items": "$64.00",
    "shipping": "$8.99",
    "processing_fee": "$0.00",
    "platform_fee": "$0.00",
    "card_fee": "$0.00",
    "total": "$72.99",
    "total_cents": 7299
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `400` [validation_error](https://portal.printedsimply.com/docs/errors#validation_error)

  Invalid request body or parameters. When the request failed the operation's schema, `details` carries `formErrors` (problems with the request as a whole), `fieldErrors` (messages keyed by top-level field) and `errors`: one `{ pointer, code, message }` per refused value, `pointer` an RFC 6901 JSON pointer into the body (or into the object of query parameters, for a query parameter) — `/items/0/sku`, with `~` written `~0` and `/` written `~1`, and `""` for the request as a whole. POST /v1/orders/bulk answers `errors` as one `{ index, details }` per refused order, each `details` with its own pointers from the request body (`/orders/3/items/0/sku`); POST /v1/orders/multi-ship keeps `errors` as the `{ formErrors, fieldErrors }` it always published; POST /v1/mockups/generate and POST /v1/products/recommend keep their fields at the top of `details`, with `errors` beside them. A refusal of a rule the schema cannot state (an asset that does not exist, an expiry too soon) names it in `message` and carries no `errors`.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `413` [payload_too_large](https://portal.printedsimply.com/docs/errors#payload_too_large)

  payload_too_large — the request body exceeded this route's byte cap (256 KB unless the route raises it with a written reason). It is refused before it is parsed, so nothing was created. Send fewer rows, or use the endpoint's own asynchronous door where it has one.

- `422` [destination_unavailable](https://portal.printedsimply.com/docs/errors#destination_unavailable) [below_cost_floor](https://portal.printedsimply.com/docs/errors#below_cost_floor) [sku_not_available](https://portal.printedsimply.com/docs/errors#sku_not_available)

  destination_unavailable — the destination is outside the US, and this estimate prices our own lanes only: our own facilities ship to US addresses only. `details.reason` is `destination_not_us` and `details.country` names the destination. below_cost_floor — the order would be made at our own plant and would cost more to make and ship than it is charged, at worst-case postage, and its loss is not one our cost floor accepts: only today's named losses are, no larger than they are today — the flat standard parcel under its postage, for a mailer up to a pound or a poster tube up to two, and list-priced goods short only of the per-order handling cost (or a partner SKU would be charged below what the partner bills for it). `details.skus` names the items and `details.contribution_cents` how far below zero the order falls. Nothing was charged; add units, combine it with another order, or choose another shipping method on offer. sku_not_available — a line names a product our own plant does not make: only PrintedSimply's own products (stickers, decals, labels, magnets and posters, as GET /products lists them) are sold here. `details.skus` names each one. Asked before anything is priced, so nothing was priced or charged.

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

- `503` [provider_unavailable](https://portal.printedsimply.com/docs/errors#provider_unavailable) [unknown_sku](https://portal.printedsimply.com/docs/errors#unknown_sku)

  provider_unavailable — no configured facility can produce these items, or (`details.reason: plant_unreachable`, with `Retry-After`) our plant gave no answer about a SKU — the request did not reach it, timed out, or came back 5xx, which is about the plant and not the SKU. Live keys; a test key gets a $0 estimate flagged `pricing_degraded` instead (`provider_unavailable` for an unconfigured facility, `pricing_unavailable` for an unanswering plant). Only for a SKU the catalog has, though: one the price book does not price, our plant's products do not name and the synced catalog does not hold is refused 400 unknown_sku with `details.skus`, as it is on POST /order, POST /orders/quote and POST /pricing/estimate alike.

## Shipping

**POST `/shipping/estimate` Estimate shipping costs**

Calculate shipping options with costs and estimated delivery dates for a set of items to a destination. Does not create an order. Each option's `cost_cents` is the figure the order path will charge for that method: shipping is charged per parcel, never per item, at the class the items' estimated weight puts the parcel in (`shipping_class`): $8.99 up to 1 lb, $13.99 to 2 lb, $20.99 to 5 lb, $4.69 a pound above, $5.20 more for a tube (posters and wall decals), and 55¢ more on parcels from 2026-10-04 to 2027-01-16, each bare. Items made at the partner facility (apparel, mugs) ship at the larger of that class rate and the partner's own estimate for the parcel, and the option quotes that same figure: the partner's answer for a basket is reused for five minutes, so an order placed in that window is charged what this quoted. A hundred 3×3 stickers and one ship for the same price. Like a product price, it carries no platform fee and is the same on every plan, as the order path charges it. `shipping_address` decides which destination the estimate is echoed for; `address_to`, the name every order operation gives the destination, is accepted in its place, so the address you will order to can be sent as it is. One of the two is required, and both is a 400 validation_error when they name different destinations. Our own facilities ship to US addresses only, so a country other than US is refused 422 destination_unavailable. `options` lists only the methods on offer: standard always, priority and express once they are priced.

**Credential**

An API key in `x-api-key`

**Scopes**

`pricing:read`

### Parameters

- `Idempotency-Key` header · string (uuid)

  Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422 `idempotency_key_reuse` rather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503 `idempotency_unavailable` with `Retry-After`, and nothing runs or is charged: retry with the same key.

### Request body application/json, required

- `items` object[] required

  at least 1 entry

- `items[].sku` string required

  non-empty

- `items[].product_id` integer

  Catalog product ID

- `items[].quantity` integer required

  at least 1

- `items[].size` string

  Product size (e.g. S, M, L, XL)

- `items[].color` string

  Product color

- `shipping_address` object

  Where the parcel goes. Only `country` decides the price (the rate depends on the method and the parcel's weight class, not the zone), so a country-only address is a complete request; `zip` is echoed back in `destination` when given. `state` and `zip` also decide which carrier advisories touch the destination (`delivery_risk`, `deliver_by_check.status`): a destination given by country alone is touched by every one. Required unless `address_to` is sent.

- `shipping_address.state` string

- `shipping_address.zip` string

  Optional. Echoed back as `destination.zip`; null in the response when omitted.

  non-empty

- `shipping_address.country` string

  Default US

- `address_to` object

  The same destination under the name POST /order gives it, so the order's own `address_to` can be sent as it is: only `state`, `zip` and `country` are read, and the rest is ignored. Send this or `shipping_address`; both are accepted only when every field they both give agrees (the country defaults to US), and otherwise refused 400 validation_error.

- `address_to.state` string

- `address_to.zip` string

  Optional. Echoed back as `destination.zip`; null in the response when omitted.

  non-empty

- `address_to.country` string

  Default US

- `deliver_by` string (date)

  A day the order should arrive by, YYYY-MM-DD (UTC). Answered in `deliver_by_check`, judged against the estimate's latest day rather than its earliest, from the facility's production estimate and the method's transit window. Optional; a day before today, or anything but a real date, is 400 validation_error.

### Request

`POST /api/v1/shipping/estimate`

cURL:

```bash
curl -X POST "https://portal.printedsimply.com/api/v1/shipping/estimate" \
  -H "x-api-key: ps_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: estimate-shipping-$(date +%s)" \
  -d '{
    "items": [
      {
        "sku": "sticker-die-cut-3x3",
        "quantity": 100
      }
    ],
    "shipping_address": {
      "zip": "78701",
      "country": "US"
    }
  }'
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/shipping/estimate", {
  method: "POST",
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    "items": [
      {
        "sku": "sticker-die-cut-3x3",
        "quantity": 100
      }
    ],
    "shipping_address": {
      "zip": "78701",
      "country": "US"
    }
  }),
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request
import uuid

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/shipping/estimate",
    method="POST",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
        "Content-Type": "application/json",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    data=json.dumps({
        "items": [
            {
                "sku": "sticker-die-cut-3x3",
                "quantity": 100,
            },
        ],
        "shipping_address": {
            "zip": "78701",
            "country": "US",
        },
    }).encode(),
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/shipping/estimate');
$body = <<<'JSON'
{
  "items": [
    {
      "sku": "sticker-die-cut-3x3",
      "quantity": 100
    }
  ],
  "shipping_address": {
    "zip": "78701",
    "country": "US"
  }
}
JSON;
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
        'Content-Type: application/json',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS => $body,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

Every method, priced per PARCEL rather than per item, with business-day windows.

`data`

- `destination` object required

  Echoed, not priced from.

- `destination.zip` string | null required

  Null when the caller estimated by country alone.

- `destination.country` string required

- `total_items` integer required

  Units across the request. The cost is per parcel regardless.

- `shipping_class` object

  The parcel these items pack into, which prices every parcel option: a weight band (`parcel_1lb`, `parcel_2lb`, `parcel_5lb`, `parcel_over_5lb`), with `_tube` when a poster or wall decal ships rolled. Estimated from the items' print area, not weighed.

- `shipping_class.name` string required

- `shipping_class.weight_oz` number required

  Estimated shipped weight in ounces, packing tube included.

- `options` object[] required

- `options[].method` string required

  The shipping tiers this platform prices. Per parcel, not per item: `standard` by the parcel's weight class (POST /v1/shipping/estimate reports it), and for items made at the partner facility (apparel, mugs) at the larger of that and the partner's own estimate for the parcel; `priority` and `express` at their price and never below standard for the same parcel. An unrecognised value is refused with 400 validation_error rather than priced as `standard`. `standard` is always offered; `priority` and `express` are offered when priced (each at or above the USPS cost of the service), and until then a request for either is refused 400 `shipping_method_unavailable`, with `details.offered` naming the methods that are, and nothing is charged. `letter` is untracked letter mail for stickers and small vinyl decals from our own plant, offered only where the platform prices it, for an order whose every item qualifies, within a unit and an order-value ceiling: otherwise a request for it is refused 400 `letter_mail_unavailable` with `details.reason`, and nothing is charged. On the API it is never chosen for you — no default, estimate or storefront mapping picks it — and it is placed through POST /v1/order only (bulk and bundle orders ship by parcel). A hosted store is the one exception, and only where its merchant turned on small orders by letter in the dashboard's store editor (off by default, launched stores included): a qualifying basket bought on its store page or a buy link ships as a letter, and the shopper is told before paying. A letter has no carrier tracking: its order events and tracking page state the expected window, and POST /v1/order/{id}/missing-letter reships it once if it does not arrive.

  One of `standard` `priority` `express` `letter`

- `options[].label` string required

- `options[].cost` string required

  Formatted, e.g. "$7.50". `cost_cents` is the number to compute with.

- `options[].cost_cents` integer required

  Flat per PARCEL, not per item: these items ship once, and this is what the order path will charge for the same method.

- `options[].estimated_delivery` object required

  An estimate: the producing facility's own production estimate plus this method's transit window (our plant's standard parcel from the plant's own transit estimate), in business days counted in UTC, Monday to Friday, never on a day GET /calendar lists (the federal holidays, each on the weekday it is observed, and the plant's own closure days).

- `options[].estimated_delivery.earliest` string (date) required

  A date, not a timestamp: YYYY-MM-DD in UTC, business days after today.

- `options[].estimated_delivery.latest` string (date) required

- `options[].estimated_delivery.ships_by` string (date) required

  YYYY-MM-DD, UTC: the day an order placed now should leave the facility, when its production estimate ends.

- `options[].estimated_delivery.transit_days` string required

  Prose, e.g. "3-5 business days": the transit window after production.

- `options[].estimated_delivery.is_estimate` boolean

  Always true: these are estimates, never a promise.

  Always `true`

- `options[].surcharges` object[] required

  Compatibility itemisation of charges already inside `cost_cents`, never added to it. Current quotes contain no seasonal peak-postage surcharge: PrintedSimply absorbs the carrier expense, so this array is empty.

- `options[].surcharges[].code` string required

  `peak_season`: the carrier's temporary holiday price change.

  Always `peak_season`

- `options[].surcharges[].reason` string required

  What it is, in words.

- `options[].surcharges[].amount_cents` integer required

  How much of the shipping figure this is, in cents, folded the way that figure is (all-in where the figure is all-in).

  at least 1

- `options[].surcharges[].window` object required

  The days it applies on, both inclusive.

- `options[].surcharges[].window.from` string (date) required

  YYYY-MM-DD, UTC.

- `options[].surcharges[].window.through` string (date) required

  YYYY-MM-DD, UTC.

- `options[].surcharges[].source` string (uri) required

  Where the amount and the dates come from: the carrier's own announcement.

- `options[].surcharges[].included_in` string required

  Always `shipping`: the figure this is part of. Never an additional charge.

  Always `shipping`

- `deliver_by_check` object

  The answer to `deliver_by`, read as an estimate: production and transit are the facility's and the carrier's estimates, so a day that `meets` can still be missed. Present only when `deliver_by` was sent and there is an estimate to judge it against.

- `deliver_by_check.requested` string (date) required

  The day asked about, as sent.

- `deliver_by_check.meets` boolean required

  On a quote: whether its own method's estimate ends on or before `requested` (`estimated_delivery.latest`, never the earliest day). On a shipping estimate: whether any parcel method listed does. False when a carrier advisory touches the destination (`status` is `at_risk`): true exactly when `status` is `meets`.

- `deliver_by_check.earliest_method` string | any required

  The parcel method on offer whose estimate ends soonest, when it ends on or before `requested`; null when no method on offer is estimated to arrive by then. Never `letter`.

- `deliver_by_check.latest_order_day` string (date) | null required

  The last UTC day an order can be placed and still be estimated to arrive by `requested`, computed by the same estimate as `estimated_delivery` from the same facility: on a quote, for its own method; on a shipping estimate, for the parcel method listed that allows the latest day. Any time that day counts, and it may be a weekend or a holiday (an order placed then starts counting on the next operating day). Null exactly when no order placed today or later is estimated to arrive by `requested`, so it is null when `status` is `misses`. It reads the estimate alone, never a carrier advisory. An estimate, like the dates it reads.

- `deliver_by_check.status` string required

  The verdict. `misses`: the estimate ends after `requested`. `at_risk`: the estimate ends by then, but a carrier advisory (`advisories`) touches the destination while the parcel would be on its way. `meets`: the estimate ends by then and no advisory touches it.

  One of `meets` `at_risk` `misses`

- `deliver_by_check.advisories` object[] required

  The carrier advisories that touch the destination between today and the estimate's latest day; empty when none does.

- `deliver_by_check.advisories[].id` string required

  `adv_…`.

- `deliver_by_check.advisories[].carrier` string required

  The carrier that posted it, as it names itself, e.g. "USPS".

- `deliver_by_check.advisories[].states` string[] required

  USPS state codes it touches. With `zip_prefixes` both empty, it touches every US destination.

- `deliver_by_check.advisories[].zip_prefixes` string[] required

  ZIP prefixes (one to five digits) it touches, beside `states`.

- `deliver_by_check.advisories[].from` string (date) required

  The first day it applies, YYYY-MM-DD in UTC, inclusive.

- `deliver_by_check.advisories[].through` string (date) required

  The last day it applies, YYYY-MM-DD in UTC, inclusive.

- `deliver_by_check.advisories[].active` boolean required

  Whether today (UTC) is inside `from`–`through`.

- `deliver_by_check.advisories[].extra_days` integer required

  The carrier's estimate of the business days it adds, as posted. An estimate.

- `deliver_by_check.advisories[].message` string required

  One sentence about it.

- `deliver_by_check.advisories[].source` string required

  The carrier's own notice, as a URL.

- `delivery_risk` object

  Present only when the lane carries a known risk worth stating before the order. Non-binding. When a carrier advisory touches the destination between today and the estimate's latest day, `at_risk` is true and `advisories` lists them; a quote may also carry its lane's lead-time read beside them.

- `delivery_risk.at_risk` boolean

  Present, and true, when `advisories` is.

  Always `true`

- `delivery_risk.advisories` object[]

  The carrier advisories that touch the destination while the parcel would be on its way.

  The same fields as `deliver_by_check.advisories`

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "destination": {
      "zip": "78701",
      "country": "US"
    },
    "total_items": 100,
    "shipping_class": {
      "name": "parcel_1lb",
      "weight_oz": 12.6
    },
    "options": [
      {
        "method": "standard",
        "label": "Standard Shipping",
        "cost": "$8.99",
        "cost_cents": 899,
        "estimated_delivery": {
          "earliest": "2026-10-20",
          "latest": "2026-10-26",
          "ships_by": "2026-10-14",
          "is_estimate": true,
          "transit_days": "4-8 business days"
        },
        "surcharges": []
      }
    ]
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `400` [validation_error](https://portal.printedsimply.com/docs/errors#validation_error)

  Invalid request body or parameters. When the request failed the operation's schema, `details` carries `formErrors` (problems with the request as a whole), `fieldErrors` (messages keyed by top-level field) and `errors`: one `{ pointer, code, message }` per refused value, `pointer` an RFC 6901 JSON pointer into the body (or into the object of query parameters, for a query parameter) — `/items/0/sku`, with `~` written `~0` and `/` written `~1`, and `""` for the request as a whole. POST /v1/orders/bulk answers `errors` as one `{ index, details }` per refused order, each `details` with its own pointers from the request body (`/orders/3/items/0/sku`); POST /v1/orders/multi-ship keeps `errors` as the `{ formErrors, fieldErrors }` it always published; POST /v1/mockups/generate and POST /v1/products/recommend keep their fields at the top of `details`, with `errors` beside them. A refusal of a rule the schema cannot state (an asset that does not exist, an expiry too soon) names it in `message` and carries no `errors`.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `413` [payload_too_large](https://portal.printedsimply.com/docs/errors#payload_too_large)

  payload_too_large — the request body exceeded this route's byte cap (256 KB unless the route raises it with a written reason). It is refused before it is parsed, so nothing was created. Send fewer rows, or use the endpoint's own asynchronous door where it has one.

- `422` [destination_unavailable](https://portal.printedsimply.com/docs/errors#destination_unavailable) [sku_not_available](https://portal.printedsimply.com/docs/errors#sku_not_available)

  destination_unavailable — the destination is outside the US, and this estimate prices our own plant's shipping, which goes to US addresses only. `details.reason` is `destination_not_us` and `details.country` names the destination. sku_not_available — a line names a product our own plant does not make: only PrintedSimply's own products (stickers, decals, labels, magnets and posters, as GET /products lists them) are sold here. `details.skus` names each one. Asked before anything is priced, so nothing was priced or charged.

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

**GET `/calendar` List the days no delivery estimate counts**

The weekdays every `estimated_delivery` skips, for the twelve months from today: the federal holidays of 5 U.S.C. 6103 (each on the weekday it is observed: a Saturday holiday on the Friday before, a Sunday one on the Monday after) and the plant's own closure days, each with its date, kind, reason and source. Saturdays and Sundays are never counted either (`weekly`). One bounded document, not a page: `from` is today (UTC) and `through` the day before the same date next year, or the last day the table covers when that is sooner. These are the days POST /orders/quote and POST /shipping/estimate skip when they date an order, so `estimated_delivery` and `deliver_by_check` already account for them; read this to show a buyer why a date moved, or to plan around a closure. Buying for a date, read this first. `order_by` answers the last day to order: for each parcel method on offer and each of Christmas Eve, Christmas Day, New Year's Eve (each in the next year it falls on or after today) and the next federal holiday, the last UTC day an order of our own plant's goods is estimated to arrive by that day, computed by the same estimate POST /orders/quote dates an order with, so a quote that day answers `meets: true`. For any other day send `deliver_by` on POST /orders/quote and read `deliver_by_check.latest_order_day`. `peak` is the carrier's peak postage window as the cost book holds it: its dates, the most it adds to one parcel's rate in cents (bare), the methods it can apply to, the carrier's zones and weight, and the carrier's announcement; null once the window has ended. PrintedSimply absorbs this carrier cost; it is not added as a customer surcharge. `advisories` lists every carrier advisory not yet over: a delay a carrier posted, as the plant entered it from the carrier's notice, with the carrier, the states and ZIP prefixes it touches (neither means every US destination), its first and last day, the carrier's estimate of the business days it adds, one sentence and the notice itself. A quote or shipping estimate into a destination one touches reports it in `delivery_risk`, and its `deliver_by_check` answers `at_risk` rather than `meets`. Requires an API key and no scope: it describes the platform, the same for every caller. Read-only.

**Credential**

An API key in `x-api-key`

**Scopes**

None: every key may call it

### Request

`GET /api/v1/calendar`

cURL:

```bash
curl "https://portal.printedsimply.com/api/v1/calendar" \
  -H "x-api-key: ps_test_YOUR_KEY"
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/calendar", {
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
  },
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/calendar",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
    },
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/calendar');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

The non-operating days from today for twelve months

`data`

- `from` string (date) required

  Today, YYYY-MM-DD in UTC.

- `through` string (date) required

  The last day this answer covers: the day before the same date next year, or the table's last day when that is sooner.

- `timezone` string required

  Every date here, and in every estimate, is a UTC day.

  Always `UTC`

- `weekly` string[] required

  The days of every week no estimate counts.

- `days` object[] required

  Every other day from `from` through `through` that no estimate counts, in date order.

- `days[].date` string (date) required

  YYYY-MM-DD, UTC.

- `days[].kind` string required

  `federal_holiday`: a legal public holiday of 5 U.S.C. 6103, on the weekday it is observed. `plant_closure`: a day the plant is closed.

  One of `federal_holiday` `plant_closure`

- `days[].reason` string required

  Why the day is not counted, in its source's words, e.g. "Thanksgiving Day", or "Independence Day (observed)" for the weekday that stands in for a weekend holiday.

- `days[].source` string required

  Where the day comes from: the statute's URL for a federal holiday.

- `order_by` object[] required

  The last day to order, by each parcel method on offer, for each day of a short table: Christmas Eve, Christmas Day, New Year's Eve (each in the next year it falls on or after today) and the next federal holiday after today, in date order. For our own plant's goods; another day, or another facility's goods, is answered by `deliver_by_check.latest_order_day` on POST /orders/quote. Estimates.

- `order_by[].deliver_by` string (date) required

  The day the order should arrive by, YYYY-MM-DD in UTC.

- `order_by[].occasion` string required

  What the day is: "Christmas Eve", "Christmas Day", "New Year's Eve", or the next federal holiday's name as `days` lists it.

- `order_by[].method` string required

  The shipping tiers this platform prices. Per parcel, not per item: `standard` by the parcel's weight class (POST /v1/shipping/estimate reports it), and for items made at the partner facility (apparel, mugs) at the larger of that and the partner's own estimate for the parcel; `priority` and `express` at their price and never below standard for the same parcel. An unrecognised value is refused with 400 validation_error rather than priced as `standard`. `standard` is always offered; `priority` and `express` are offered when priced (each at or above the USPS cost of the service), and until then a request for either is refused 400 `shipping_method_unavailable`, with `details.offered` naming the methods that are, and nothing is charged. `letter` is untracked letter mail for stickers and small vinyl decals from our own plant, offered only where the platform prices it, for an order whose every item qualifies, within a unit and an order-value ceiling: otherwise a request for it is refused 400 `letter_mail_unavailable` with `details.reason`, and nothing is charged. On the API it is never chosen for you — no default, estimate or storefront mapping picks it — and it is placed through POST /v1/order only (bulk and bundle orders ship by parcel). A hosted store is the one exception, and only where its merchant turned on small orders by letter in the dashboard's store editor (off by default, launched stores included): a qualifying basket bought on its store page or a buy link ships as a letter, and the shopper is told before paying. A letter has no carrier tracking: its order events and tracking page state the expected window, and POST /v1/order/{id}/missing-letter reships it once if it does not arrive.

  One of `standard` `priority` `express` `letter`

- `order_by[].latest_order_day` string (date) | null required

  The last UTC day an order placed by `method` is estimated to arrive by `deliver_by` (its estimated window's latest day on or before it); any time that day counts, and it may be a weekend or a holiday. Null when no order placed today or later is, which is the answer once the day has passed.

- `order_by[].is_estimate` boolean required

  Always true.

  Always `true`

- `peak` object | any required

  The carrier's peak postage window, or null once it has ended (a new one is published when the carrier announces it).

- `peak.from` string (date) required

  The first day of the window, YYYY-MM-DD in UTC, inclusive.

- `peak.through` string (date) required

  The last day of the window, YYYY-MM-DD in UTC, inclusive.

- `peak.in_window` boolean required

  Whether today (UTC) is inside the window.

- `peak.cents` integer required

  The surcharge per parcel, in cents, bare: the most the window adds to one parcel's rate before that rate is folded into an all-in figure. The amount on an order is the quote's `shipping.surcharges[].amount_cents`, folded the way its shipping is; a priority or express price set above the parcel's rate can hold some or all of it.

- `peak.methods` string[] required

  The parcel methods whose carrier expense can include it. PrintedSimply absorbs this expense; it is not a customer surcharge. Never `letter`: a letter carries no peak postage.

- `peak.carrier_zones` object required

  The USPS zones the carrier's surcharge applies in, both inclusive. Our own parcel rate is one figure for every zone, so inside the window it holds the surcharge whatever the zone.

- `peak.carrier_zones.from` integer required

- `peak.carrier_zones.through` integer required

- `peak.carrier_max_weight_lb` integer required

  The heaviest parcel, in pounds, the carrier's surcharge names. A heavier parcel carries the same amount here.

- `peak.source` string required

  The carrier's own announcement of the window.

- `advisories` object[] required

  Every carrier advisory not withdrawn and not yet over, soonest first: in effect today (`active`) or starting later. Empty when the carriers have posted none. A quote or shipping estimate into a destination one touches answers `deliver_by_check.status` `at_risk`.

- `advisories[].id` string required

  `adv_…`.

- `advisories[].carrier` string required

  The carrier that posted it, as it names itself, e.g. "USPS".

- `advisories[].states` string[] required

  USPS state codes it touches. With `zip_prefixes` both empty, it touches every US destination.

- `advisories[].zip_prefixes` string[] required

  ZIP prefixes (one to five digits) it touches, beside `states`.

- `advisories[].from` string (date) required

  The first day it applies, YYYY-MM-DD in UTC, inclusive.

- `advisories[].through` string (date) required

  The last day it applies, YYYY-MM-DD in UTC, inclusive.

- `advisories[].active` boolean required

  Whether today (UTC) is inside `from`–`through`.

- `advisories[].extra_days` integer required

  The carrier's estimate of the business days it adds, as posted. An estimate.

- `advisories[].message` string required

  One sentence about it.

- `advisories[].source` string required

  The carrier's own notice, as a URL.

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "from": "2026-10-15",
    "through": "2027-10-14",
    "timezone": "UTC",
    "weekly": [
      "saturday",
      "sunday"
    ],
    "days": [
      {
        "date": "2026-11-11",
        "kind": "federal_holiday",
        "reason": "Veterans Day",
        "source": "https://www.law.cornell.edu/uscode/text/5/6103"
      },
      {
        "date": "2026-11-26",
        "kind": "federal_holiday",
        "reason": "Thanksgiving Day",
        "source": "https://www.law.cornell.edu/uscode/text/5/6103"
      },
      {
        "date": "2026-12-25",
        "kind": "federal_holiday",
        "reason": "Christmas Day",
        "source": "https://www.law.cornell.edu/uscode/text/5/6103"
      },
      {
        "date": "2027-01-01",
        "kind": "federal_holiday",
        "reason": "New Year's Day",
        "source": "https://www.law.cornell.edu/uscode/text/5/6103"
      },
      {
        "date": "2027-01-18",
        "kind": "federal_holiday",
        "reason": "Birthday of Martin Luther King, Jr.",
        "source": "https://www.law.cornell.edu/uscode/text/5/6103"
      },
      {
        "date": "2027-02-15",
        "kind": "federal_holiday",
        "reason": "Washington's Birthday",
        "source": "https://www.law.cornell.edu/uscode/text/5/6103"
      },
      {
        "date": "2027-05-31",
        "kind": "federal_holiday",
        "reason": "Memorial Day",
        "source": "https://www.law.cornell.edu/uscode/text/5/6103"
      },
      {
        "date": "2027-06-18",
        "kind": "federal_holiday",
        "reason": "Juneteenth National Independence Day (observed)",
        "source": "https://www.law.cornell.edu/uscode/text/5/6103"
      },
      {
        "date": "2027-07-05",
        "kind": "federal_holiday",
        "reason": "Independence Day (observed)",
        "source": "https://www.law.cornell.edu/uscode/text/5/6103"
      },
      {
        "date": "2027-09-06",
        "kind": "federal_holiday",
        "reason": "Labor Day",
        "source": "https://www.law.cornell.edu/uscode/text/5/6103"
      },
      {
        "date": "2027-10-11",
        "kind": "federal_holiday",
        "reason": "Columbus Day",
        "source": "https://www.law.cornell.edu/uscode/text/5/6103"
      }
    ],
    "order_by": [
      {
        "deliver_by": "2026-11-11",
        "occasion": "Veterans Day",
        "method": "standard",
        "latest_order_day": "2026-10-27",
        "is_estimate": true
      },
      {
        "deliver_by": "2026-12-24",
        "occasion": "Christmas Eve",
        "method": "standard",
        "latest_order_day": "2026-12-10",
        "is_estimate": true
      },
      {
        "deliver_by": "2026-12-25",
        "occasion": "Christmas Day",
        "method": "standard",
        "latest_order_day": "2026-12-10",
        "is_estimate": true
      },
      {
        "deliver_by": "2026-12-31",
        "occasion": "New Year's Eve",
        "method": "standard",
        "latest_order_day": "2026-12-16",
        "is_estimate": true
      }
    ],
    "peak": {
      "from": "2026-10-04",
      "through": "2027-01-16",
      "in_window": true,
      "cents": 55,
      "methods": [
        "standard"
      ],
      "carrier_zones": {
        "from": 5,
        "through": 9
      },
      "carrier_max_weight_lb": 3,
      "source": "https://about.usps.com/newsroom/national-releases/2026/0825-usps-announces-temporary-price-change-for-2026-holiday-shipping-season.pdf"
    },
    "advisories": []
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

**POST `/addresses/validate` Check a shipping address**

Check a US shipping address before quoting or ordering against it. Does not create or store anything and asks no outside service: it checks the ZIP's shape (five digits or ZIP+4), the state against the USPS state, territory and military codes (a state's full name is accepted), and whether the ZIP's first three digits belong to that state, and flags a PO Box or military (APO/FPO/DPO) address. `valid` is false when any finding is an `error` — `zip_format`, `state_unknown`, or `zip_state_mismatch` (the ZIP belongs to a state that does not border the one named) — and POST /order and POST /orders/quote refuse such an address with a live key (422 `address_invalid`) and return the same findings as `address_check`. A ZIP from a neighbouring state is a `warning` (`zip_state_border`): a few ZIPs straddle a state line. A state written in a way the check does not know (it takes the codes, the full names, the usual abbreviations such as Calif. and W.Va., and names such as Washington, D.C. or USVI) is `state_unknown`, an `error` only when the ZIP does not settle it: when the ZIP's first three digits belong to one state it is a `warning` and `normalized.state` carries that state's code. `normalized` is the address in USPS style: upper case, no punctuation, ZIP+4 hyphenated, a unit typed into address1 moved to address2. Any country other than US is passed through with `checked: false`. It does not verify that the street or the house exists; a live POST /order does that with USPS where the deployment has it configured (the `delivery_point_*` codes on `AddressFinding`).

**Credential**

An API key in `x-api-key`

**Scopes**

`pricing:read`

### Parameters

- `Idempotency-Key` header · string (uuid)

  Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422 `idempotency_key_reuse` rather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503 `idempotency_unavailable` with `Retry-After`, and nothing runs or is charged: retry with the same key.

### Request body application/json, required

- `name` string

- `company` string

- `address1` string required

  non-empty

- `address2` string

- `city` string required

  non-empty

- `state` string required

  A USPS code (TX, PR, AE) or a state's full name.

  non-empty

- `zip` string required

  non-empty

- `country` string

  ISO 3166-1 alpha-2. Only US addresses are checked.

  Default US · exactly 2 characters

- `phone` string

- `email` string (email)

### Request

`POST /api/v1/addresses/validate`

cURL:

```bash
curl -X POST "https://portal.printedsimply.com/api/v1/addresses/validate" \
  -H "x-api-key: ps_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: validate-address-$(date +%s)" \
  -d '{
    "address1": "123 Main St. Apt 4",
    "city": "Austin",
    "state": "Texas",
    "zip": "787011234",
    "country": "US"
  }'
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/addresses/validate", {
  method: "POST",
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    "address1": "123 Main St. Apt 4",
    "city": "Austin",
    "state": "Texas",
    "zip": "787011234",
    "country": "US"
  }),
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request
import uuid

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/addresses/validate",
    method="POST",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
        "Content-Type": "application/json",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    data=json.dumps({
        "address1": "123 Main St. Apt 4",
        "city": "Austin",
        "state": "Texas",
        "zip": "787011234",
        "country": "US",
    }).encode(),
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/addresses/validate');
$body = <<<'JSON'
{
  "address1": "123 Main St. Apt 4",
  "city": "Austin",
  "state": "Texas",
  "zip": "787011234",
  "country": "US"
}
JSON;
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
        'Content-Type: application/json',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS => $body,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

The verdict, whatever it is: `valid: false` is an answer, not an error.

`data`

- `valid` boolean required

  False when any finding is an `error`.

- `checked` boolean required

  False for an address outside the US, which is passed through as written.

- `normalized` object required

  The address in USPS style: upper case, no punctuation, ZIP+4 hyphenated, the state as its code, a unit typed into address1 moved to address2. Name, company, phone and email are trimmed and otherwise as sent.

- `normalized.name` string

- `normalized.company` string

- `normalized.address1` string required

- `normalized.address2` string

- `normalized.city` string required

- `normalized.state` string required

- `normalized.zip` string required

- `normalized.country` string required

- `normalized.phone` string

- `normalized.email` string

- `findings` object[] required

- `findings[].code` string required

  The `delivery_point_*` codes come from delivery-point verification, which only a live POST /order, POST /orders/bulk entry or PUT /order/{id}/address runs, on a US address the other checks pass, and only where the deployment has USPS verification configured: `delivery_point_not_found` (`error`) — USPS answered that no delivery point exists at the street address; `delivery_point_incomplete` (`warning`) — the building exists but its unit is missing or not confirmed, or more than one address matched; `delivery_point_confirmed` (`info`). When USPS cannot be reached the order proceeds without them.

  One of `zip_format` `state_unknown` `zip_state_mismatch` `zip_state_border` `zip_unassigned` `po_box` `military_state` `military` `address2_split` `not_checked` `delivery_point_not_found` `delivery_point_incomplete` `delivery_point_confirmed`

- `findings[].severity` string required

  `error`: the address cannot be right as written, and a live order or quote is refused with 422 address_invalid. `warning`: worth a look, never refused. `info`: what normalization did, or that nothing was checked.

  One of `error` `warning` `info`

- `findings[].field` string required

  One of `address1` `address2` `city` `state` `zip` `country`

- `findings[].message` string required

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "valid": true,
    "checked": true,
    "normalized": {
      "address1": "123 MAIN ST",
      "city": "AUSTIN",
      "state": "TX",
      "zip": "78701-1234",
      "country": "US",
      "address2": "APT 4"
    },
    "findings": [
      {
        "code": "address2_split",
        "severity": "info",
        "field": "address2",
        "message": "Moved \"APT 4\" from address1 to address2."
      }
    ]
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `400` [validation_error](https://portal.printedsimply.com/docs/errors#validation_error)

  Invalid request body or parameters. When the request failed the operation's schema, `details` carries `formErrors` (problems with the request as a whole), `fieldErrors` (messages keyed by top-level field) and `errors`: one `{ pointer, code, message }` per refused value, `pointer` an RFC 6901 JSON pointer into the body (or into the object of query parameters, for a query parameter) — `/items/0/sku`, with `~` written `~0` and `/` written `~1`, and `""` for the request as a whole. POST /v1/orders/bulk answers `errors` as one `{ index, details }` per refused order, each `details` with its own pointers from the request body (`/orders/3/items/0/sku`); POST /v1/orders/multi-ship keeps `errors` as the `{ formErrors, fieldErrors }` it always published; POST /v1/mockups/generate and POST /v1/products/recommend keep their fields at the top of `details`, with `errors` beside them. A refusal of a rule the schema cannot state (an asset that does not exist, an expiry too soon) names it in `message` and carries no `errors`.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `413` [payload_too_large](https://portal.printedsimply.com/docs/errors#payload_too_large)

  payload_too_large — the request body exceeded this route's byte cap (256 KB unless the route raises it with a written reason). It is refused before it is parsed, so nothing was created. Send fewer rows, or use the endpoint's own asynchronous door where it has one.

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

## Artwork & mockups

**POST `/artwork/validate` Validate artwork file**

The order's own artwork check, before the order. Reads the file (fetched server-side through the same SSRF-guarded, size-capped reader POST /v1/order uses; stores nothing unless `auto_fix` is sent) and grades it with the order's grader against `sku`: `findings` carries the order's codes (low_resolution, below_recommended_resolution, cmyk, no_transparency, touches_edge, no_bleed, content_in_bleed, no_cutline, unreachable, not_an_image, …), the bleed ones measured from the file's own pixels at the SKU's print size, `effective_dpi` and `print_size_in` are what the order measures, and `would_refuse` says whether a live POST /v1/order of this SKU with this file would be refused 422 artwork_not_printable. A test key gets the same verdict. Only when the file cannot be read just now (a timeout, a 5xx, a host the guard will not fetch) is the answer built from what you declared (`width_px`, `height_px`, `dpi`, the URL's extension), with `verified: false` and `status: unverified` — the order is not refused for an unread file either. Name the file as exactly one of `file_url` or `asset_id`, as an order line names it. Send neither `sku` nor `product_type` and the one read of the file is graded at a fixed set of sizes in each family (stickers, decals, magnets, posters), from a small size up to the largest our own plant prints (30 today, the bound on the list): `fits` holds, for each, the `status`, `effective_dpi` and `would_refuse` the call naming that `sku` answers, and `largest_passing` names, per family, the largest of those sizes whose `status` is `passed`. Without a `sku`, the top-level `valid`, `status` and `would_refuse` are measured at no size: read `fits`.

**Credential**

An API key in `x-api-key`

**Scopes**

`artwork:write`

### Parameters

- `Idempotency-Key` header · string (uuid)

  Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422 `idempotency_key_reuse` rather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503 `idempotency_unavailable` with `Retry-After`, and nothing runs or is charged: retry with the same key.

### Request body application/json, required

- `file_url` string (uri)

  URL of the artwork file to validate: an order line's `artwork_url`. Send this or `asset_id`.

- `asset_id` string

  An asset you uploaded (POST /v1/assets), as an order line's `print_file` names it. Send this or `file_url`; an id that is not yours is a 400, as on the order.

  matches ^ast_[A-Za-z0-9_-]+$

- `sku` string

  The SKU the file is for (e.g. STICKER-DIE-CUT-3X3): its print size and format are what the file is graded against, as the order grades its line. Without it, `product_type` is read as one.

  non-empty · at most 200 characters

- `size` string

  The line's free-text size (e.g. 4 x 2 in), read for a print size when the SKU names none, as on an order line.

  non-empty · at most 100 characters

- `product_type` string

  Product type for spec matching (e.g. 'sticker-die-cut', 'decal-vinyl', 'poster'), or a SKU. Read as the SKU when `sku` is not sent.

  non-empty

- `width_px` integer

  Image width in pixels. Read only when the file cannot be: a file that is read is measured.

  at least 1

- `height_px` integer

  Image height in pixels. Read only when the file cannot be.

  at least 1

- `dpi` number

  Image resolution in dots per inch. Any positive number. A read file's resolution is measured at the SKU's print size; this is graded instead when the file cannot be read, or when neither the SKU nor `size` names a print size (a product type such as `poster`), against that product's minimum.

  greater than 0

- `file_size_bytes` integer

  File size in bytes

  at least 1

- `auto_fix` boolean

  Fix what the bleed findings name and store the result as a new asset on your account: a rectangle with `no_bleed` gets its edge pixels extended past the trim by the product's bleed; a die-cut with `content_in_bleed` or `touches_edge` gets a wider transparent canvas, and one with `no_cutline` (separate shapes) a white border that joins them. The fixed file is graded again and `auto_fix` in the response says what changed and its verdict; order it by the new asset id. Your file is never changed. Needs `sku`, a file that could be read whole, and the `assets:write` scope as well as `artwork:write` (a 403 insufficient_scope otherwise). Default false: nothing is stored.

### Request

`POST /api/v1/artwork/validate`

cURL:

```bash
curl -X POST "https://portal.printedsimply.com/api/v1/artwork/validate" \
  -H "x-api-key: ps_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: validate-artwork-$(date +%s)" \
  -d '{
    "file_url": "https://example.com/logo.png",
    "sku": "sticker-die-cut-3x3"
  }'
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/artwork/validate", {
  method: "POST",
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    "file_url": "https://example.com/logo.png",
    "sku": "sticker-die-cut-3x3"
  }),
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request
import uuid

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/artwork/validate",
    method="POST",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
        "Content-Type": "application/json",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    data=json.dumps({
        "file_url": "https://example.com/logo.png",
        "sku": "sticker-die-cut-3x3",
    }).encode(),
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/artwork/validate');
$body = <<<'JSON'
{
  "file_url": "https://example.com/logo.png",
  "sku": "sticker-die-cut-3x3"
}
JSON;
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
        'Content-Type: application/json',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS => $body,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

Artwork validation result

`data`

- `valid` boolean required

  True when no finding refuses the file and no check fails: with a `sku`, the order's verdict at its print size. Without a `sku` (or a `product_type` that is one), nothing is measured at a size (`size_unknown`), so it does not say the file prints at any: read `fits` and `largest_passing`; `warnings` says when no size in `fits` prints the file.

- `file_url` string required

- `product_type` string | null

  As you sent it.

- `resolved_product_type` string | null

  What it resolved to — "sticker-die-cut-3x3" resolves to "sticker-die-cut" — so a normalization is visible rather than silent.

- `product_specs` object | null

- `product_specs.name` string

- `product_specs.min_dpi` integer

- `product_specs.max_size` string

- `product_specs.bleed` string

- `product_specs.formats` string[]

- `checks` object[] required

  The verdict as named checks. On a read file: file_format, resolution, color_space and, for a die-cut SKU, cut_line (with the margin measured around the design), or for another SKU whose bleed the file lets us measure, bleed_area, each from the order's findings (a fail is an error the order refuses for). On an unread file (`verified: false`): the checks built from what you declared. file_size when you sent `file_size_bytes`.

- `checks[].name` string required

  Which check this is — `file_format`, `resolution`, `print_area`, `color`, `embedded_assets`. The set differs by what was checked; treat it as an open vocabulary.

- `checks[].status` string required

  `fail` blocks the print. `warn` does not — it is advice that the artwork will print better if you act on it. Only `fail` should stop a pipeline.

  One of `pass` `fail` `warn`

- `checks[].message` string required

  The finding in prose, safe to show a human.

- `warnings` string[]

- `suggestions` string[]

- `sku` string | null

  The SKU the file was graded against: `sku`, else `product_type`; null when neither was sent.

- `asset_id` string | null

  The asset you named, when you named the file by `asset_id`; `file_url` is then its URL.

- `verified` boolean

  True when the file was read and graded; false when it could not be read just now and the answer is from what you declared.

- `status` string

  The order's line status for this file: passed, warned, refused, or unverified (not read). Without a `sku` it is graded at no print size, so resolution plays no part in it: `fits` holds the status at each size.

  One of `passed` `warned` `refused` `unverified`

- `would_refuse` boolean

  Whether a live POST /v1/order of this SKU with this file would be refused 422 artwork_not_printable. Only a SKU our own plant makes is graded by the order, and an unread file is never refused. Without a `sku` there is no SKU to refuse, so it is false: each entry of `fits` answers it for its own SKU.

- `findings` object[]

  The order's own findings for this file (ArtworkLineVerdict's), with its codes and severities.

- `findings[].code` string required

  One of `unreachable` `not_an_image` `low_resolution` `below_recommended_resolution` `cmyk` `no_transparency` `touches_edge` `no_bleed` `content_in_bleed` `no_cutline` `size_unknown` `vector` `unverified` `format_not_accepted` `not_fetchable` `design_embeds_raster` `generated_design` `placement_clipped` `geometry_unverified`

- `findings[].severity` string required

  error refuses a live order; warning and info never do.

  One of `error` `warning` `info`

- `findings[].message` string required

- `width_px` integer | null

  Measured from the file; null when it was not read or is vector.

- `height_px` integer | null

  Measured from the file; null when it was not read or is vector.

- `print_size_in` number[] | null

  The print size resolution was measured at, in inches: the SKU's, else `size`'s. Null when neither names one.

  2 to 2 entries

- `effective_dpi` integer | null

  Pixels per inch with the file printed as large as the print size allows, as the order measures it.

- `min_dpi` integer | null

  The product's recommended minimum.

- `fits` object[] | null

  When neither `sku` nor `product_type` is sent: the file, read once, graded at a fixed set of sizes in each family (30 today), stickers, decals (the wall decal), magnets and posters in that order, smallest first, each family's last the largest our own plant prints of it. Sizes between two of them are orderable and not graded. Each entry is what the call naming that `sku` answers. Null when a `sku` or `product_type` was sent, and when the file could not be read (`verified: false`), since nothing was measured.

  at most 30 entries

- `fits[].sku` string required

  The size's SKU, as the price book spells it while our plant's own catalog is silent: a product GET /v1/products lists keeps the spelling listed there, and GET /v1/products/{sku} serves every one. Once the plant's catalog answers, its rows may spell or size products otherwise. The call naming this `sku` answers the same verdict.

- `fits[].family` string required

  The SKU's family, as its catalog row's `family` names it.

  One of `stickers` `decals` `magnets` `posters`

- `fits[].size` string required

  The SKU's print size, e.g. 3×3 in.

- `fits[].size_in` number[] required

  The same size in inches, [width, height].

  2 to 2 entries

- `fits[].status` string required

  The order's line status for this file at this SKU: passed, warned or refused. Never unverified: an unread file has no `fits`.

  One of `passed` `warned` `refused`

- `fits[].effective_dpi` integer | null required

  Pixels per inch at this size, as the order measures it; null for a PDF or SVG, which is not measured in pixels, and for a file that is not an image.

- `fits[].would_refuse` boolean required

  Whether a live POST /v1/order of this SKU with this file would be refused 422 artwork_not_printable.

- `largest_passing` object[] | null

  One entry per family in `fits`, in its order: the largest of that family's sizes in `fits` whose `status` is `passed`, the order's "prints well". A `warned` size prints, with a warning, and is not counted. Null exactly when `fits` is.

- `largest_passing[].family` string required

  The family, as `fits[].family` names it.

  One of `stickers` `decals` `magnets` `posters`

- `largest_passing[].sku` string | null required

  The SKU of the family's largest passing size; null when no size of the family passes.

- `largest_passing[].size` string | null required

  That SKU's print size, e.g. 4×4 in; null when no size passes.

- `largest_passing[].size_in` number[] | null required

  The same size in inches, [width, height]; null when no size passes.

  2 to 2 entries

- `largest_passing[].effective_dpi` integer | null required

  The file's resolution at that size; null when no size passes, and for a PDF or SVG.

- `auto_fix` object | any

  What `auto_fix: true` did: null when it was not sent.

- `auto_fix.applied` boolean required

  True when a fixed file was written and stored; false with `reason` when nothing was.

- `auto_fix.reason` string

  Why nothing was fixed (nothing to fix, no sku, a vector file, a file not read whole, too large to decode). Only when `applied` is false.

- `auto_fix.asset_id` string

  The new asset (`source: fixup`): order it as a line's `print_file`.

- `auto_fix.url` string

  The new asset's URL.

- `auto_fix.content_type` string

  One of `image/png` `image/jpeg`

- `auto_fix.width_px` integer

- `auto_fix.height_px` integer

- `auto_fix.changes` object[]

- `auto_fix.changes[].code` string required

  The finding this change answers.

  One of `no_bleed` `content_in_bleed` `touches_edge` `no_cutline`

- `auto_fix.changes[].description` string required

  What was done, in pixels and inches.

- `auto_fix.notes` string[]

  What the fix could not do, such as shapes too far apart to join with a border.

- `auto_fix.status` string

  The fixed file's own verdict for the same SKU.

  One of `passed` `warned` `refused` `unverified`

- `auto_fix.would_refuse` boolean

  Whether a live POST /v1/order of this SKU with the fixed file would be refused.

- `auto_fix.findings` object[]

  The fixed file's findings, graded by the order's grader.

- `auto_fix.findings[].code` string required

  One of `unreachable` `not_an_image` `low_resolution` `below_recommended_resolution` `cmyk` `no_transparency` `touches_edge` `no_bleed` `content_in_bleed` `no_cutline` `size_unknown` `vector` `unverified` `format_not_accepted` `not_fetchable` `design_embeds_raster` `generated_design` `placement_clipped` `geometry_unverified`

- `auto_fix.findings[].severity` string required

  One of `error` `warning` `info`

- `auto_fix.findings[].message` string required

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "valid": true,
    "file_url": "https://example.com/logo.png",
    "product_type": null,
    "resolved_product_type": "sticker-die-cut",
    "product_specs": {
      "name": "Die-Cut Sticker",
      "min_dpi": 300,
      "max_size": "17\" x 17\"",
      "bleed": "0.125\"",
      "formats": [
        "png",
        "pdf",
        "svg"
      ]
    },
    "checks": [
      {
        "name": "file_format",
        "status": "pass",
        "message": "PNG, 1200×1200 px, read from the file."
      },
      {
        "name": "resolution",
        "status": "pass",
        "message": "400 DPI at 3×3 in, at or above the 300 recommended."
      },
      {
        "name": "color_space",
        "status": "pass",
        "message": "SRGB, the colours previews and mockups show."
      },
      {
        "name": "cut_line",
        "status": "pass",
        "message": "Transparent background with a margin around the design: the cut follows its shape."
      }
    ],
    "warnings": [],
    "suggestions": [
      "Leave 0.125\" of bleed on every side; the file check cannot see it."
    ],
    "sku": "sticker-die-cut-3x3",
    "asset_id": null,
    "verified": true,
    "status": "passed",
    "would_refuse": false,
    "findings": [],
    "width_px": 1200,
    "height_px": 1200,
    "print_size_in": [
      3,
      3
    ],
    "effective_dpi": 400,
    "min_dpi": 300,
    "fits": null,
    "largest_passing": null
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `400` [validation_error](https://portal.printedsimply.com/docs/errors#validation_error)

  Invalid request body or parameters. When the request failed the operation's schema, `details` carries `formErrors` (problems with the request as a whole), `fieldErrors` (messages keyed by top-level field) and `errors`: one `{ pointer, code, message }` per refused value, `pointer` an RFC 6901 JSON pointer into the body (or into the object of query parameters, for a query parameter) — `/items/0/sku`, with `~` written `~0` and `/` written `~1`, and `""` for the request as a whole. POST /v1/orders/bulk answers `errors` as one `{ index, details }` per refused order, each `details` with its own pointers from the request body (`/orders/3/items/0/sku`); POST /v1/orders/multi-ship keeps `errors` as the `{ formErrors, fieldErrors }` it always published; POST /v1/mockups/generate and POST /v1/products/recommend keep their fields at the top of `details`, with `errors` beside them. A refusal of a rule the schema cannot state (an asset that does not exist, an expiry too soon) names it in `message` and carries no `errors`.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `403` [insufficient_scope](https://portal.printedsimply.com/docs/errors#insufficient_scope)

  insufficient_scope — `auto_fix: true` on a key whose scopes leave out `assets:write`: the fixed file would be stored as an asset. Nothing was read or stored; validate without `auto_fix`, or grant the scope.

- `413` [payload_too_large](https://portal.printedsimply.com/docs/errors#payload_too_large)

  payload_too_large — the request body exceeded this route's byte cap (256 KB unless the route raises it with a written reason). It is refused before it is parsed, so nothing was created. Send fewer rows, or use the endpoint's own asynchronous door where it has one.

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

**POST `/mockups/generate` Generate product mockup**

Composite artwork onto a product and get the picture back. `format: "svg"` (the default) answers a data URI, the same in test and live mode, with the artwork's own bytes inlined (`artwork_inlined: true`) so it paints anywhere, inside an `<img>` included: the artwork is read server-side through the same guarded, size-capped fetch the raster formats use. The inlined artwork is fitted to the print area's own pixels first (a PNG where it has transparency, else a JPEG), so the answer stays a bounded size; artwork that cannot be read safely, or is still over 512 KB once fitted, is referenced by its URL instead (`artwork_inlined: false`, with `artwork_inline_reason`), and an SVG loaded as an image does not load a referenced file. `thumbnail_url` is its own 300 px render, never a second copy of `mockup_url`. A product type that is one of our own plant's SKUs naming its size (POSTER-18X24, DECAL-WALL-24X36) is drawn at that size's proportions, in every format (`size_in`). `format: "png"` or `"jpeg"` answers `mockup_url` as a STORED, publicly fetchable image plus `content_type`, `width` and `height`: that is the form a storefront can publish, because a data URI is not something Shopify or TikTok will fetch and an SVG loaded as an image may not load the artwork it references. If storage is not configured the render still comes back, inline as a data URI, with `stored: false` and `storage_reason` saying why. A stored render also answers `asset_id` — the `Asset` row `DELETE /v1/assets/{id}` takes — so a caller can delete its own render without matching it by URL in `GET /v1/assets`; it is `null`, not absent, when `stored` is false. With `cut_line: true` on a die-cut SKU naming its size (STICKER-DIE-CUT-3X3), the mockup also shows the cut line, in every format, for a print proof: the outline of the artwork's transparent background, offset outward by the SKU's bleed, as a thin dashed magenta line (`cut_line` in the response says whether it was drawn; off by default, so a product photograph is unchanged).

**Credential**

An API key in `x-api-key`

**Scopes**

`mockups:write`

### Parameters

- `Idempotency-Key` header · string (uuid)

  Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422 `idempotency_key_reuse` rather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503 `idempotency_unavailable` with `Retry-After`, and nothing runs or is charged: retry with the same key.

### Request body application/json, required

- `artwork_url` string (uri) required

  URL of the artwork file, or a data: URI (the form /designs/generate answers). For a raster format an https URL is fetched and the bytes must be a PNG, JPEG, WebP or SVG — the extension and the served content type are not trusted.

- `product_type` string required

  Product type for the mockup (sticker, poster, magnet, …) or a SKU. A SKU of our own plant's that names its size is drawn at that size's proportions (`size_in`). Where the portal sells only our own plant's products, a product type it does not make (a tee, a mug) is a 400 validation_error with `details.product_type: ["not_offered"]`.

- `background` string

  Background style for the mockup

  One of `white` `transparent` `lifestyle`

  Default white

- `angle` string

  Viewing angle for the mockup

  One of `front` `angled` `flat-lay`

  Default front

- `format` string

  svg returns a data URI (unchanged default). png and jpeg return a stored, publicly fetchable image — the form a store listing can carry.

  One of `svg` `png` `jpeg`

  Default svg

- `size` integer

  Square edge in pixels for the raster formats. The SVG is always 1200.

  Default 1200 · 200 to 2000

- `cut_line` boolean

  On a die-cut SKU (STICKER-DIE-CUT-3X3), draw the cut line over the artwork: a thin dashed magenta line along the outline of the artwork's transparent background, offset outward by the SKU's bleed, where the plant's blade goes. Opt-in, for a print proof: off by default, because a mockup is what a seller publishes as a product photograph. No effect on a product cut along a rectangle. The response's `cut_line` says whether it was drawn.

  Default false

### Request

`POST /api/v1/mockups/generate`

cURL:

```bash
curl -X POST "https://portal.printedsimply.com/api/v1/mockups/generate" \
  -H "x-api-key: ps_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: generate-mockup-$(date +%s)" \
  -d '{
    "artwork_url": "https://example.com/logo.png",
    "product_type": "sticker",
    "background": "white",
    "format": "png",
    "size": 1200
  }'
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/mockups/generate", {
  method: "POST",
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    "artwork_url": "https://example.com/logo.png",
    "product_type": "sticker",
    "background": "white",
    "format": "png",
    "size": 1200
  }),
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request
import uuid

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/mockups/generate",
    method="POST",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
        "Content-Type": "application/json",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    data=json.dumps({
        "artwork_url": "https://example.com/logo.png",
        "product_type": "sticker",
        "background": "white",
        "format": "png",
        "size": 1200,
    }).encode(),
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/mockups/generate');
$body = <<<'JSON'
{
  "artwork_url": "https://example.com/logo.png",
  "product_type": "sticker",
  "background": "white",
  "format": "png",
  "size": 1200
}
JSON;
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
        'Content-Type: application/json',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS => $body,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

Mockup generated

`data`

- `mockup_url` string required

  The render. A hosted URL when `stored` is true, otherwise an inline data URI.

- `thumbnail_url` string

  A 300 px render of the same mockup: for `svg`, its own data URI with the artwork fitted to it; for a stored raster, the render's URL; for an unstored raster, a 300 px inline copy. Never a second full-size copy of `mockup_url`.

- `asset_id` string | null

  `ast_…` for the stored render — what `DELETE /v1/assets/{id}` takes. Null when `stored` is false, because there is no row to name.

- `product_type` string required

- `background` string

  One of `white` `transparent` `lifestyle`

- `angle` string

  One of `front` `angled` `flat-lay`

- `format` string

  One of `svg` `png` `jpeg`

- `content_type` string

  One per `format`. A narrower list than `POST /v1/assets` accepts: no render produces a PDF.

  One of `image/svg+xml` `image/png` `image/jpeg`

- `stored` boolean required

  False when this deployment has no asset storage; the render is then inline only.

- `storage_reason` string

  Why the render was not stored. Present only when `stored` is false.

- `width` integer

- `height` integer

- `dimensions` object

- `dimensions.width` integer

- `dimensions.height` integer

- `size_in` number[] | null

  The print's [width, height] in inches the product was drawn at, read from a SKU of our own plant's that names its size; null when the product type names none, and the family's own plate was drawn.

  2 to 2 entries

- `cut_line` object | null

  The die-cut line drawn on the mockup: null when `cut_line: true` was not sent, or the product is not cut around its artwork. On a die-cut SKU, `drawn` says whether the line is on the picture; `offset_in` is the SKU's bleed, the distance the line sits outside the artwork; `pieces` is how many separate outlines were drawn (more than one means the shapes are too far apart to cut as one sticker: see `validate_artwork`'s `no_cutline`); `not_drawn_reason`, when not drawn: artwork_unread (the file could not be read or decoded within the bound), no_print_size, no_transparency (cut as a rectangle), empty, too_complex or photo_plate.

- `cut_line.drawn` boolean required

- `cut_line.offset_in` number | null required

  The SKU's bleed in inches: how far outside the artwork the line runs.

- `cut_line.pieces` integer

  Present when `drawn`: separate outlines on the picture.

- `cut_line.not_drawn_reason` string

  Present when not `drawn`: why.

  One of `artwork_unread` `no_print_size` `no_transparency` `empty` `too_complex` `photo_plate`

- `artwork_inlined` boolean

  SVG only: true when the artwork's bytes are inside the SVG, so it paints anywhere, an `<img>` included; false when it is referenced by URL (see `artwork_inline_reason`).

- `artwork_inline_reason` string

  SVG only, when `artwork_inlined` is false: why the artwork is referenced — artwork_blocked, artwork_unreachable, artwork_too_large (over the 15 MB read limit, or still over 512 KB once fitted to the print area), artwork_unsupported_type or artwork_empty.

- `generated_at` string (date-time)

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "mockup_url": "https://assets.example.com/assets/1792076422000-945098792998-mockup-sticker-front.png",
    "thumbnail_url": "https://assets.example.com/assets/1792076422000-945098792998-mockup-sticker-front.png",
    "asset_id": "ast_MeOk7RY2Euc22EZ15k_2agc2",
    "product_type": "sticker",
    "background": "white",
    "angle": "front",
    "format": "png",
    "content_type": "image/png",
    "stored": true,
    "width": 1200,
    "height": 1200,
    "dimensions": {
      "width": 1200,
      "height": 1200
    },
    "size_in": null,
    "cut_line": null,
    "generated_at": "2026-10-15T15:00:22.000Z"
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `400` [validation_error](https://portal.printedsimply.com/docs/errors#validation_error)

  Invalid request body or parameters. When the request failed the operation's schema, `details` carries `formErrors` (problems with the request as a whole), `fieldErrors` (messages keyed by top-level field) and `errors`: one `{ pointer, code, message }` per refused value, `pointer` an RFC 6901 JSON pointer into the body (or into the object of query parameters, for a query parameter) — `/items/0/sku`, with `~` written `~0` and `/` written `~1`, and `""` for the request as a whole. POST /v1/orders/bulk answers `errors` as one `{ index, details }` per refused order, each `details` with its own pointers from the request body (`/orders/3/items/0/sku`); POST /v1/orders/multi-ship keeps `errors` as the `{ formErrors, fieldErrors }` it always published; POST /v1/mockups/generate and POST /v1/products/recommend keep their fields at the top of `details`, with `errors` beside them. A refusal of a rule the schema cannot state (an asset that does not exist, an expiry too soon) names it in `message` and carries no `errors`.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `413` [payload_too_large](https://portal.printedsimply.com/docs/errors#payload_too_large)

  payload_too_large — the request body exceeded this route's byte cap (256 KB unless the route raises it with a written reason). It is refused before it is parsed, so nothing was created. Send fewer rows, or use the endpoint's own asynchronous door where it has one.

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

- `503`

  Production system error

## Assets

**GET `/assets` List your uploaded assets**

Your uploaded assets, newest first. Pages by cursor (see Pagination): send back `meta.next_cursor` as `cursor` until `meta.has_more` is false. `next_cursor` and the `pagination` counts are also in the payload, where this list has always carried them. The deprecated `page` walk is still answered as before.

**Credential**

An API key in `x-api-key`

**Scopes**

`assets:read`

### Parameters

- `cursor` query · string

  Opaque cursor: send back `meta.next_cursor` from the previous page for the page after it; omit it for the first page. Good only on this list, with a key of the same account and mode (test or live), and with the same filters (only `limit` may change); an altered or foreign cursor, or one sent with other filters, is refused with 400 validation_error. Wins over `page` when both are sent.

- `limit` query · integer

  Assets per page: 20 by default, 100 at most. A value above 100 is clamped to 100 and one below 1 to 1, a fraction is truncated, and a missing, zero or non-numeric value falls back to 20; none is refused.

  Default 20

- `page` query · integer deprecated

  Deprecated: walk with `cursor`. The page number of the offset walk this operation always had, answered as before (`limit` 20 by default, 100 at most; `offset`, a row count, is accepted in its place). Ignored when `cursor` is sent, and a cursor walk's `pagination` carries no `page` or `pages`.

  Default 1

### Request

`GET /api/v1/assets`

cURL:

```bash
curl "https://portal.printedsimply.com/api/v1/assets" \
  -H "x-api-key: ps_test_YOUR_KEY"
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/assets", {
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
  },
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/assets",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
    },
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/assets');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

Assets, next_cursor (null on the last page), and pagination counts; meta carries has_more, next_cursor, limit and total

`data`

- `assets` object[]

- `assets[].id` string required

  `ast_…`. `POST /v1/order` accepts this directly as an item's `print_file`, in place of a URL.

- `assets[].filename` string required

- `assets[].url` string required

  Publicly reachable URL for the stored bytes.

- `assets[].content_type` string required

  One of `image/png` `image/jpeg` `image/svg+xml` `application/pdf`

- `assets[].size_bytes` integer required

- `assets[].source` string required

  Where the bytes came from: `upload` (sent through POST /v1/assets or the dashboard), `mockup` (a render from POST /v1/mockups/generate), `listing` (the photograph a checkout link gets on its first publish), `design` (artwork a design run generated), `carryover` (a listing image a listing import fetched and rehosted), `manifest` (a production batch's manifest), `fixup` (a copy of your artwork with its bleed, margin or outline fixed by POST /v1/artwork/validate with auto_fix). A fact, not a filter: every asset surface lists, exports and deletes a render exactly as an upload, and there is no `?source=` on the list. Files stored before the field existed were classified from their filenames and read `upload` where nothing said otherwise.

  One of `upload` `mockup` `listing` `design` `carryover` `manifest` `fixup`

- `assets[].created_at` string (date-time) required

- `next_cursor` string | null

  The same as `meta.next_cursor`: send it back as `cursor` for the next page. Null on the last page.

- `pagination` object

- `pagination.page` integer

- `pagination.limit` integer required

- `pagination.total` integer required

- `pagination.pages` integer

`meta`

- `page` integer deprecated

  The page number of the deprecated offset walk, on the lists that published it (orders and an order's events, on every request without `cursor`; products, on a request that sent `page`). Absent on a cursor walk, where no page number would be true.

- `total` integer

  Every row matching the request's filters, on the lists that count them (orders, an order's events, assets and products).

- `limit` integer

  The page size applied, which is not necessarily the one asked for: a larger `limit` is clamped. Absent only on a GET /products request without `cursor` that sent no `limit`, which answers every match.

- `pages` integer deprecated

  `total` over `limit`, rounded up, beside `page` (orders and an order's events). Absent on a cursor walk.

- `next_cursor` string | null required

  Send it back as `cursor` for the next page; null exactly when `has_more` is false. Opaque: pass it back unchanged. Good only on the list that returned it, with a key of the same account and mode (test or live), and with the same filters.

- `has_more` boolean required

  Whether another page follows this one. Decided from one row past this page, never from a page that came back short — so a page after `has_more: true` can come back empty only if rows were deleted in between, and the walk still ends at the first `has_more: false`.

- `since` string | null

  On GET /order/{id}/events only: where to ask from next — the position of the last event in this answer, or, when it has none, the position the request read from. Send it back as `since` (with `wait_seconds` to wait for the next event). Unlike `next_cursor` it is not null at the end of the history; it is null only when the order has no events and the request named no position. Opaque, and bound like a cursor to this order, account and key mode.

Example response:

```json
{
  "status": "success",
  "data": {
    "assets": [
      {
        "id": "ast_bptSUfSLn2FInuMnC5S4fVD1",
        "filename": "logo.png",
        "url": "https://assets.example.com/assets/1792076423000-a8e85f4abc4c-logo.png",
        "content_type": "image/png",
        "size_bytes": 70,
        "source": "upload",
        "created_at": "2026-10-15T15:00:23.000Z"
      },
      {
        "id": "ast_MeOk7RY2Euc22EZ15k_2agc2",
        "filename": "mockup-sticker-front.png",
        "url": "https://assets.example.com/assets/1792076422000-945098792998-mockup-sticker-front.png",
        "content_type": "image/png",
        "size_bytes": 679951,
        "source": "mockup",
        "created_at": "2026-10-15T15:00:22.000Z"
      }
    ],
    "next_cursor": null,
    "pagination": {
      "page": 1,
      "pages": 1,
      "limit": 20,
      "total": 2
    }
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a",
  "meta": {
    "limit": 20,
    "total": 2,
    "has_more": false,
    "next_cursor": null
  }
}
```

### Errors

- `400` [validation_error](https://portal.printedsimply.com/docs/errors#validation_error)

  Invalid request body or parameters. When the request failed the operation's schema, `details` carries `formErrors` (problems with the request as a whole), `fieldErrors` (messages keyed by top-level field) and `errors`: one `{ pointer, code, message }` per refused value, `pointer` an RFC 6901 JSON pointer into the body (or into the object of query parameters, for a query parameter) — `/items/0/sku`, with `~` written `~0` and `/` written `~1`, and `""` for the request as a whole. POST /v1/orders/bulk answers `errors` as one `{ index, details }` per refused order, each `details` with its own pointers from the request body (`/orders/3/items/0/sku`); POST /v1/orders/multi-ship keeps `errors` as the `{ formErrors, fieldErrors }` it always published; POST /v1/mockups/generate and POST /v1/products/recommend keep their fields at the top of `details`, with `errors` beside them. A refusal of a rule the schema cannot state (an asset that does not exist, an expiry too soon) names it in `message` and carries no `errors`.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

**POST `/assets` Upload an asset (base64)**

Uploads artwork or a document as base64. Allowed types: image/png, image/jpeg, image/svg+xml, application/pdf. 50 MB cap. Returns the hosted URL to use in print files, proofs, or mockups.

**Credential**

An API key in `x-api-key`

**Scopes**

`assets:write`

### Parameters

- `Idempotency-Key` header · string (uuid)

  Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422 `idempotency_key_reuse` rather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503 `idempotency_unavailable` with `Retry-After`, and nothing runs or is charged: retry with the same key.

### Request body application/json, required

- `filename` string required

  non-empty · at most 255 characters

- `content_type` string required

  One of `image/png` `image/jpeg` `image/svg+xml` `application/pdf`

- `data` string required

  Base64-encoded file contents. The 50 MB cap is on the decoded file; this bound is its base64 length (69,905,068 characters).

  non-empty · at most 69905068 characters

### Request

`POST /api/v1/assets`

cURL:

```bash
curl -X POST "https://portal.printedsimply.com/api/v1/assets" \
  -H "x-api-key: ps_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: upload-asset-$(date +%s)" \
  -d '{
    "filename": "logo.png",
    "content_type": "image/png",
    "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
  }'
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/assets", {
  method: "POST",
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    "filename": "logo.png",
    "content_type": "image/png",
    "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
  }),
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request
import uuid

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/assets",
    method="POST",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
        "Content-Type": "application/json",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    data=json.dumps({
        "filename": "logo.png",
        "content_type": "image/png",
        "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
    }).encode(),
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/assets');
$body = <<<'JSON'
{
  "filename": "logo.png",
  "content_type": "image/png",
  "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
}
JSON;
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
        'Content-Type: application/json',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS => $body,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

Asset stored; includes the hosted url

`data`

- `id` string required

  `ast_…`. `POST /v1/order` accepts this directly as an item's `print_file`, in place of a URL.

- `filename` string required

- `url` string required

  Publicly reachable URL for the stored bytes.

- `content_type` string required

  One of `image/png` `image/jpeg` `image/svg+xml` `application/pdf`

- `size_bytes` integer required

- `source` string required

  Where the bytes came from: `upload` (sent through POST /v1/assets or the dashboard), `mockup` (a render from POST /v1/mockups/generate), `listing` (the photograph a checkout link gets on its first publish), `design` (artwork a design run generated), `carryover` (a listing image a listing import fetched and rehosted), `manifest` (a production batch's manifest), `fixup` (a copy of your artwork with its bleed, margin or outline fixed by POST /v1/artwork/validate with auto_fix). A fact, not a filter: every asset surface lists, exports and deletes a render exactly as an upload, and there is no `?source=` on the list. Files stored before the field existed were classified from their filenames and read `upload` where nothing said otherwise.

  One of `upload` `mockup` `listing` `design` `carryover` `manifest` `fixup`

- `created_at` string (date-time) required

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "id": "ast_bptSUfSLn2FInuMnC5S4fVD1",
    "filename": "logo.png",
    "url": "https://assets.example.com/assets/1792076423000-a8e85f4abc4c-logo.png",
    "content_type": "image/png",
    "size_bytes": 70,
    "source": "upload",
    "created_at": "2026-10-15T15:00:23.000Z"
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `400` [validation_error](https://portal.printedsimply.com/docs/errors#validation_error)

  Invalid request body or parameters. When the request failed the operation's schema, `details` carries `formErrors` (problems with the request as a whole), `fieldErrors` (messages keyed by top-level field) and `errors`: one `{ pointer, code, message }` per refused value, `pointer` an RFC 6901 JSON pointer into the body (or into the object of query parameters, for a query parameter) — `/items/0/sku`, with `~` written `~0` and `/` written `~1`, and `""` for the request as a whole. POST /v1/orders/bulk answers `errors` as one `{ index, details }` per refused order, each `details` with its own pointers from the request body (`/orders/3/items/0/sku`); POST /v1/orders/multi-ship keeps `errors` as the `{ formErrors, fieldErrors }` it always published; POST /v1/mockups/generate and POST /v1/products/recommend keep their fields at the top of `details`, with `errors` beside them. A refusal of a rule the schema cannot state (an asset that does not exist, an expiry too soon) names it in `message` and carries no `errors`.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `413` [payload_too_large](https://portal.printedsimply.com/docs/errors#payload_too_large)

  payload_too_large — the request body exceeded this route's byte cap (256 KB unless the route raises it with a written reason). It is refused before it is parsed, so nothing was created. Send fewer rows, or use the endpoint's own asynchronous door where it has one.

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

**GET `/assets/{id}` Get an asset**

**Credential**

An API key in `x-api-key`

**Scopes**

`assets:read`

### Parameters

- `id` path · string required

### Request

`GET /api/v1/assets/{id}`

cURL:

```bash
curl "https://portal.printedsimply.com/api/v1/assets/ast_bptSUfSLn2FInuMnC5S4fVD1" \
  -H "x-api-key: ps_test_YOUR_KEY"
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/assets/ast_bptSUfSLn2FInuMnC5S4fVD1", {
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
  },
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/assets/ast_bptSUfSLn2FInuMnC5S4fVD1",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
    },
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/assets/ast_bptSUfSLn2FInuMnC5S4fVD1');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

Asset detail

`data`

- `id` string required

  `ast_…`. `POST /v1/order` accepts this directly as an item's `print_file`, in place of a URL.

- `filename` string required

- `url` string required

  Publicly reachable URL for the stored bytes.

- `content_type` string required

  One of `image/png` `image/jpeg` `image/svg+xml` `application/pdf`

- `size_bytes` integer required

- `source` string required

  Where the bytes came from: `upload` (sent through POST /v1/assets or the dashboard), `mockup` (a render from POST /v1/mockups/generate), `listing` (the photograph a checkout link gets on its first publish), `design` (artwork a design run generated), `carryover` (a listing image a listing import fetched and rehosted), `manifest` (a production batch's manifest), `fixup` (a copy of your artwork with its bleed, margin or outline fixed by POST /v1/artwork/validate with auto_fix). A fact, not a filter: every asset surface lists, exports and deletes a render exactly as an upload, and there is no `?source=` on the list. Files stored before the field existed were classified from their filenames and read `upload` where nothing said otherwise.

  One of `upload` `mockup` `listing` `design` `carryover` `manifest` `fixup`

- `created_at` string (date-time) required

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "id": "ast_bptSUfSLn2FInuMnC5S4fVD1",
    "filename": "logo.png",
    "url": "https://assets.example.com/assets/1792076423000-a8e85f4abc4c-logo.png",
    "content_type": "image/png",
    "size_bytes": 70,
    "source": "upload",
    "created_at": "2026-10-15T15:00:23.000Z"
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `404`

  Resource not found

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

**DELETE `/assets/{id}` Delete an asset**

Removes the stored file and its record.

**Credential**

An API key in `x-api-key`

**Scopes**

`assets:write`

### Parameters

- `id` path · string required

- `Idempotency-Key` header · string (uuid)

  Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422 `idempotency_key_reuse` rather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503 `idempotency_unavailable` with `Retry-After`, and nothing runs or is charged: retry with the same key.

### Request

`DELETE /api/v1/assets/{id}`

cURL:

```bash
curl -X DELETE "https://portal.printedsimply.com/api/v1/assets/ast_bptSUfSLn2FInuMnC5S4fVD1" \
  -H "x-api-key: ps_test_YOUR_KEY" \
  -H "Idempotency-Key: delete-asset-$(date +%s)"
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/assets/ast_bptSUfSLn2FInuMnC5S4fVD1", {
  method: "DELETE",
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
    "Idempotency-Key": crypto.randomUUID(),
  },
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request
import uuid

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/assets/ast_bptSUfSLn2FInuMnC5S4fVD1",
    method="DELETE",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/assets/ast_bptSUfSLn2FInuMnC5S4fVD1');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'DELETE',
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

Deleted

`data`

- `deleted` boolean

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "deleted": true
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `404`

  Resource not found

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

## Webhooks

Webhook endpoints are managed with your API key — register them, rotate their signing secrets, inspect deliveries, and replay a failed one, all without opening a browser. The same subscriptions appear in your [dashboard](https://portal.printedsimply.com/dashboard/webhooks).

PrintedSimply signs every delivery twice. Verify `X-PS-Signature-V2` (`t=<unix>,v1=<hex>`), which signs the timestamp with the body so a replayed request can be refused; `X-PS-Signature` (`sha256=<hex>`) is the original, body-only one.

**GET `/webhooks` List webhook subscriptions**

Your webhook endpoints, newest first, paged by cursor (see Pagination). Each carries its `mode` (all / test / live). Signing secrets are never returned — only rotation state (secret_rotated_at, previous_secret_expires_at, rotation_pending). Subscriptions are not deduplicated by URL: pass `url` to ask whether an endpoint is already subscribed in one call before POST /webhooks.

**Credential**

An API key in `x-api-key`

**Scopes**

`webhooks:manage`

### Parameters

- `limit` query · integer

  Subscriptions per page: 20 by default, 100 at most. A value above 100 is clamped to 100 and one below 1 to 1, a fraction is truncated, and a missing, zero or non-numeric value falls back to 20; none is refused.

  Default 20

- `cursor` query · string

  Opaque cursor: send back `meta.next_cursor` from the previous page for the page after it; omit it for the first page. Good only on this list, with a key of the same account and mode (test or live), and with the same filters (only `limit` may change); an altered or foreign cursor, or one sent with other filters, is refused with 400 validation_error. Wins over `page` when both are sent.

- `url` query · string (uri)

  Only subscriptions at this URL — exact match on the `url` as it was registered. POST /webhooks stores the URL exactly as sent and normalises nothing, so neither does this filter: `https://Example.com/hooks/` and `https://example.com/hooks` are different subscriptions. An empty page means none of your subscriptions is at that URL. Must be an absolute http:// or https:// URL; anything else is 400 validation_error. The cursor is bound to it: send the same `url` with every page of a walk.

### Request

`GET /api/v1/webhooks`

cURL:

```bash
curl "https://portal.printedsimply.com/api/v1/webhooks" \
  -H "x-api-key: ps_test_YOUR_KEY"
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/webhooks", {
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
  },
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/webhooks",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
    },
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/webhooks');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

webhooks[] + next_cursor; meta carries has_more and next_cursor

`data`

- `webhooks` object[]

- `webhooks[].id` string required

- `webhooks[].url` string required

- `webhooks[].events` string[] required

- `webhooks[].active` boolean required

- `webhooks[].mode` string required

  Which order mode this subscription accepts. Normalised on the way out, so a stored value this build does not know reads as what it is actually routed as (`all`) rather than as the stored string.

  One of `all` `test` `live`

- `webhooks[].created_at` string (date-time) required

- `webhooks[].secret_rotated_at` string (date-time) | null

- `webhooks[].previous_secret_expires_at` string (date-time) | null

  During a rotation both secrets verify until this moment. Sign-check against both, or accept the older one, until it passes.

- `webhooks[].rotation_pending` boolean

  True while the previous secret is still inside its window.

- `webhooks[].inbox` boolean required

  True for the account's hosted test inbox (POST /v1/webhooks/inbox): it receives test events only and keeps them for GET /v1/webhooks/inbox rather than POSTing them, its `url` is the dashboard page that shows them, and its url and mode cannot be changed.

- `next_cursor` string | null

  The same as `meta.next_cursor`: send it back as `cursor` for the next page. Null on the last.

`meta`

- `page` integer deprecated

  The page number of the deprecated offset walk, on the lists that published it (orders and an order's events, on every request without `cursor`; products, on a request that sent `page`). Absent on a cursor walk, where no page number would be true.

- `total` integer

  Every row matching the request's filters, on the lists that count them (orders, an order's events, assets and products).

- `limit` integer

  The page size applied, which is not necessarily the one asked for: a larger `limit` is clamped. Absent only on a GET /products request without `cursor` that sent no `limit`, which answers every match.

- `pages` integer deprecated

  `total` over `limit`, rounded up, beside `page` (orders and an order's events). Absent on a cursor walk.

- `next_cursor` string | null required

  Send it back as `cursor` for the next page; null exactly when `has_more` is false. Opaque: pass it back unchanged. Good only on the list that returned it, with a key of the same account and mode (test or live), and with the same filters.

- `has_more` boolean required

  Whether another page follows this one. Decided from one row past this page, never from a page that came back short — so a page after `has_more: true` can come back empty only if rows were deleted in between, and the walk still ends at the first `has_more: false`.

- `since` string | null

  On GET /order/{id}/events only: where to ask from next — the position of the last event in this answer, or, when it has none, the position the request read from. Send it back as `since` (with `wait_seconds` to wait for the next event). Unlike `next_cursor` it is not null at the end of the history; it is null only when the order has no events and the request named no position. Opaque, and bound like a cursor to this order, account and key mode.

Example response:

```json
{
  "status": "success",
  "data": {
    "webhooks": [
      {
        "id": "whk_hCyrY24j_gUrQWxJ7ji6s9st",
        "url": "https://example.com/hooks/orders",
        "events": [
          "order.shipped",
          "order.delivered"
        ],
        "active": true,
        "mode": "test",
        "created_at": "2026-10-15T15:00:26.000Z",
        "secret_rotated_at": null,
        "previous_secret_expires_at": null,
        "rotation_pending": false,
        "inbox": false
      }
    ],
    "next_cursor": null
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a",
  "meta": {
    "limit": 20,
    "has_more": false,
    "next_cursor": null
  }
}
```

### Errors

- `400` [validation_error](https://portal.printedsimply.com/docs/errors#validation_error)

  Invalid request body or parameters. When the request failed the operation's schema, `details` carries `formErrors` (problems with the request as a whole), `fieldErrors` (messages keyed by top-level field) and `errors`: one `{ pointer, code, message }` per refused value, `pointer` an RFC 6901 JSON pointer into the body (or into the object of query parameters, for a query parameter) — `/items/0/sku`, with `~` written `~0` and `/` written `~1`, and `""` for the request as a whole. POST /v1/orders/bulk answers `errors` as one `{ index, details }` per refused order, each `details` with its own pointers from the request body (`/orders/3/items/0/sku`); POST /v1/orders/multi-ship keeps `errors` as the `{ formErrors, fieldErrors }` it always published; POST /v1/mockups/generate and POST /v1/products/recommend keep their fields at the top of `details`, with `errors` beside them. A refusal of a rule the schema cannot state (an asset that does not exist, an expiry too soon) names it in `message` and carries no `errors`.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

**POST `/webhooks` Create a webhook subscription**

Subscribe an HTTPS endpoint to events. The response carries `secret` — the signing secret, shown exactly once here and on rotation, never on a later read. Use `"*"` in events to receive everything, and `mode` to scope the endpoint to sandbox or live traffic. A test key subscribes only to test traffic: its default mode is test, and live or all answers 403 live_key_required. It changes only test subscriptions, and its delivery log carries sandbox and account events only.

**Credential**

An API key in `x-api-key`

**Scopes**

`webhooks:manage`

### Parameters

- `Idempotency-Key` header · string (uuid)

  Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422 `idempotency_key_reuse` rather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503 `idempotency_unavailable` with `Retry-After`, and nothing runs or is charged: retry with the same key.

### Request body application/json, required

- `url` string (uri) required

  Destination URL. It must use HTTPS, or http:// to localhost or 127.0.0.1 outside production — the hosted API is production, so it accepts HTTPS only.

- `events` string[] required

  Event names to receive, or ["*"] for all of them. See /docs/webhooks for the catalog. On this portal a subscription names `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`, `*`; any other event is refused with 400 event_not_available.

  at least 1 entry

- `mode` string

  Which traffic this endpoint receives. "test" delivers only sandbox events, "live" only real ones, "all" (the default) both. Account-level events — catalog syncs, delivery-failure notices, design proofs — arrive on every subscription regardless. Every mode-bearing event carries `test_mode` in its payload, stamped from the same decision that routed it.

  One of `all` `test` `live`

  Default all

### Request

`POST /api/v1/webhooks`

cURL:

```bash
curl -X POST "https://portal.printedsimply.com/api/v1/webhooks" \
  -H "x-api-key: ps_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: create-webhook-$(date +%s)" \
  -d '{
    "url": "https://example.com/hooks/orders",
    "events": [
      "order.shipped",
      "order.delivered"
    ],
    "mode": "test"
  }'
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/webhooks", {
  method: "POST",
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    "url": "https://example.com/hooks/orders",
    "events": [
      "order.shipped",
      "order.delivered"
    ],
    "mode": "test"
  }),
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request
import uuid

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/webhooks",
    method="POST",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
        "Content-Type": "application/json",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    data=json.dumps({
        "url": "https://example.com/hooks/orders",
        "events": [
            "order.shipped",
            "order.delivered",
        ],
        "mode": "test",
    }).encode(),
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/webhooks');
$body = <<<'JSON'
{
  "url": "https://example.com/hooks/orders",
  "events": [
    "order.shipped",
    "order.delivered"
  ],
  "mode": "test"
}
JSON;
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
        'Content-Type: application/json',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS => $body,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

The subscription and its signing secret in the clear. This is one of only TWO moments the secret is ever returned — this and a rotation. It is stored sealed and no read gives it back; store it now, and if you lose it, rotate.

`data`

- `id` string required

- `url` string required

- `events` string[] required

- `active` boolean required

- `mode` string required

  Which order mode this subscription accepts. Normalised on the way out, so a stored value this build does not know reads as what it is actually routed as (`all`) rather than as the stored string.

  One of `all` `test` `live`

- `created_at` string (date-time) required

- `secret_rotated_at` string (date-time) | null

- `previous_secret_expires_at` string (date-time) | null

  During a rotation both secrets verify until this moment. Sign-check against both, or accept the older one, until it passes.

- `rotation_pending` boolean

  True while the previous secret is still inside its window.

- `inbox` boolean required

  True for the account's hosted test inbox (POST /v1/webhooks/inbox): it receives test events only and keeps them for GET /v1/webhooks/inbox rather than POSTing them, its `url` is the dashboard page that shows them, and its url and mode cannot be changed.

- `secret` string required

  The signing secret, in the clear, and the only time it is returned for this subscription other than a rotation. It is stored sealed and no read gives it back. Store it now; if you lose it, rotate.

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "id": "whk_SV8d5wGm2kUnSwx_RhZfY9OE",
    "url": "https://example.com/hooks/orders",
    "events": [
      "order.shipped",
      "order.delivered"
    ],
    "active": true,
    "mode": "test",
    "created_at": "2026-10-10T08:09:14.588Z",
    "secret_rotated_at": null,
    "previous_secret_expires_at": null,
    "rotation_pending": false,
    "inbox": false,
    "secret": "f727cc1c27bffcc21ba9b993e38905166961014d23af696723f996abd4889764"
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `400` [validation_error](https://portal.printedsimply.com/docs/errors#validation_error) [event_not_available](https://portal.printedsimply.com/docs/errors#event_not_available)

  Invalid request body or parameters. When the request failed the operation's schema, `details` carries `formErrors` (problems with the request as a whole), `fieldErrors` (messages keyed by top-level field) and `errors`: one `{ pointer, code, message }` per refused value, `pointer` an RFC 6901 JSON pointer into the body (or into the object of query parameters, for a query parameter) — `/items/0/sku`, with `~` written `~0` and `/` written `~1`, and `""` for the request as a whole. POST /v1/orders/bulk answers `errors` as one `{ index, details }` per refused order, each `details` with its own pointers from the request body (`/orders/3/items/0/sku`); POST /v1/orders/multi-ship keeps `errors` as the `{ formErrors, fieldErrors }` it always published; POST /v1/mockups/generate and POST /v1/products/recommend keep their fields at the top of `details`, with `errors` beside them. A refusal of a rule the schema cannot state (an asset that does not exist, an expiry too soon) names it in `message` and carries no `errors`. event_not_available — `events` names an event this portal does not offer (another family's, or one it never raises: `order.rerouted`); refused before anything is saved. `details.events` names each one and `details.available` lists the events a subscription can name here.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `403` [live_key_required](https://portal.printedsimply.com/docs/errors#live_key_required)

  live_key_required — a test key subscribes only to test traffic. Omit mode (a test key's subscription defaults to test) or send mode "test"; use a live key for live or all.

- `413` [payload_too_large](https://portal.printedsimply.com/docs/errors#payload_too_large)

  payload_too_large — the request body exceeded this route's byte cap (256 KB unless the route raises it with a written reason). It is refused before it is parsed, so nothing was created. Send fewer rows, or use the endpoint's own asynchronous door where it has one.

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

**GET `/webhooks/{id}` Retrieve a webhook subscription**

One subscription: url, events, active, mode, and rotation state. Never the signing secret.

**Credential**

An API key in `x-api-key`

**Scopes**

`webhooks:manage`

### Parameters

- `id` path · string required

### Request

`GET /api/v1/webhooks/{id}`

cURL:

```bash
curl "https://portal.printedsimply.com/api/v1/webhooks/whk_SV8d5wGm2kUnSwx_RhZfY9OE" \
  -H "x-api-key: ps_test_YOUR_KEY"
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/webhooks/whk_SV8d5wGm2kUnSwx_RhZfY9OE", {
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
  },
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/webhooks/whk_SV8d5wGm2kUnSwx_RhZfY9OE",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
    },
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/webhooks/whk_SV8d5wGm2kUnSwx_RhZfY9OE');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

The subscription. Never the secret — only create and rotate return that.

`data`

- `id` string required

- `url` string required

- `events` string[] required

- `active` boolean required

- `mode` string required

  Which order mode this subscription accepts. Normalised on the way out, so a stored value this build does not know reads as what it is actually routed as (`all`) rather than as the stored string.

  One of `all` `test` `live`

- `created_at` string (date-time) required

- `secret_rotated_at` string (date-time) | null

- `previous_secret_expires_at` string (date-time) | null

  During a rotation both secrets verify until this moment. Sign-check against both, or accept the older one, until it passes.

- `rotation_pending` boolean

  True while the previous secret is still inside its window.

- `inbox` boolean required

  True for the account's hosted test inbox (POST /v1/webhooks/inbox): it receives test events only and keeps them for GET /v1/webhooks/inbox rather than POSTing them, its `url` is the dashboard page that shows them, and its url and mode cannot be changed.

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "id": "whk_hCyrY24j_gUrQWxJ7ji6s9st",
    "url": "https://example.com/hooks/orders",
    "events": [
      "order.shipped",
      "order.delivered"
    ],
    "active": true,
    "mode": "test",
    "created_at": "2026-10-15T15:00:26.000Z",
    "secret_rotated_at": null,
    "previous_secret_expires_at": null,
    "rotation_pending": false,
    "inbox": false
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `404`

  Resource not found

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

**PATCH `/webhooks/{id}` Update a webhook subscription**

Change the endpoint URL, the subscribed events, the test/live scope, or pause it with active: false. Send at least one field.

**Credential**

An API key in `x-api-key`

**Scopes**

`webhooks:manage`

### Parameters

- `id` path · string required

- `Idempotency-Key` header · string (uuid)

  Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422 `idempotency_key_reuse` rather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503 `idempotency_unavailable` with `Retry-After`, and nothing runs or is charged: retry with the same key.

### Request body application/json, required

- `url` string (uri)

  New destination URL. It must use HTTPS, or http:// to localhost or 127.0.0.1 outside production — the hosted API is production, so it accepts HTTPS only.

- `events` string[]

  On this portal a subscription names `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`, `*`; any other event is refused with 400 event_not_available. An event this subscription already names is kept, whatever its family.

  at least 1 entry

- `active` boolean

  false pauses delivery: events already queued for this endpoint are held, not sent, and go out where their retry schedule left off once it is true again. Events raised while it is paused are not queued for it; replay them with POST /v1/events/{eventId}/replay after resuming.

- `mode` string

  Which traffic this endpoint receives. "test" delivers only sandbox events, "live" only real ones, "all" (the default) both. Account-level events — catalog syncs, delivery-failure notices, design proofs — arrive on every subscription regardless. Every mode-bearing event carries `test_mode` in its payload, stamped from the same decision that routed it.

  One of `all` `test` `live`

  Default all

### Request

`PATCH /api/v1/webhooks/{id}`

cURL:

```bash
curl -X PATCH "https://portal.printedsimply.com/api/v1/webhooks/whk_SV8d5wGm2kUnSwx_RhZfY9OE" \
  -H "x-api-key: ps_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: update-webhook-$(date +%s)" \
  -d '{
    "events": [
      "order.shipped",
      "order.delivered",
      "order.cancelled"
    ]
  }'
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/webhooks/whk_SV8d5wGm2kUnSwx_RhZfY9OE", {
  method: "PATCH",
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    "events": [
      "order.shipped",
      "order.delivered",
      "order.cancelled"
    ]
  }),
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request
import uuid

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/webhooks/whk_SV8d5wGm2kUnSwx_RhZfY9OE",
    method="PATCH",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
        "Content-Type": "application/json",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    data=json.dumps({
        "events": [
            "order.shipped",
            "order.delivered",
            "order.cancelled",
        ],
    }).encode(),
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/webhooks/whk_SV8d5wGm2kUnSwx_RhZfY9OE');
$body = <<<'JSON'
{
  "events": [
    "order.shipped",
    "order.delivered",
    "order.cancelled"
  ]
}
JSON;
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'PATCH',
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
        'Content-Type: application/json',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS => $body,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

The subscription after the update.

`data`

- `id` string required

- `url` string required

- `events` string[] required

- `active` boolean required

- `mode` string required

  Which order mode this subscription accepts. Normalised on the way out, so a stored value this build does not know reads as what it is actually routed as (`all`) rather than as the stored string.

  One of `all` `test` `live`

- `created_at` string (date-time) required

- `secret_rotated_at` string (date-time) | null

- `previous_secret_expires_at` string (date-time) | null

  During a rotation both secrets verify until this moment. Sign-check against both, or accept the older one, until it passes.

- `rotation_pending` boolean

  True while the previous secret is still inside its window.

- `inbox` boolean required

  True for the account's hosted test inbox (POST /v1/webhooks/inbox): it receives test events only and keeps them for GET /v1/webhooks/inbox rather than POSTing them, its `url` is the dashboard page that shows them, and its url and mode cannot be changed.

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "id": "whk_hCyrY24j_gUrQWxJ7ji6s9st",
    "url": "https://example.com/hooks/orders",
    "events": [
      "order.shipped",
      "order.delivered",
      "order.cancelled"
    ],
    "active": true,
    "mode": "test",
    "created_at": "2026-10-15T15:00:26.000Z",
    "secret_rotated_at": null,
    "previous_secret_expires_at": null,
    "rotation_pending": false,
    "inbox": false
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `400` [validation_error](https://portal.printedsimply.com/docs/errors#validation_error) [event_not_available](https://portal.printedsimply.com/docs/errors#event_not_available)

  Invalid request body or parameters. When the request failed the operation's schema, `details` carries `formErrors` (problems with the request as a whole), `fieldErrors` (messages keyed by top-level field) and `errors`: one `{ pointer, code, message }` per refused value, `pointer` an RFC 6901 JSON pointer into the body (or into the object of query parameters, for a query parameter) — `/items/0/sku`, with `~` written `~0` and `/` written `~1`, and `""` for the request as a whole. POST /v1/orders/bulk answers `errors` as one `{ index, details }` per refused order, each `details` with its own pointers from the request body (`/orders/3/items/0/sku`); POST /v1/orders/multi-ship keeps `errors` as the `{ formErrors, fieldErrors }` it always published; POST /v1/mockups/generate and POST /v1/products/recommend keep their fields at the top of `details`, with `errors` beside them. A refusal of a rule the schema cannot state (an asset that does not exist, an expiry too soon) names it in `message` and carries no `errors`. event_not_available — `events` names an event this portal does not offer (another family's, or one it never raises: `order.rerouted`); refused before anything is saved. `details.events` names each one and `details.available` lists the events a subscription can name here.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `403` [live_key_required](https://portal.printedsimply.com/docs/errors#live_key_required)

  live_key_required — a test key cannot move a subscription to live or all traffic. A test key changes only test-mode subscriptions; any other answers 404.

- `404`

  Resource not found

- `413` [payload_too_large](https://portal.printedsimply.com/docs/errors#payload_too_large)

  payload_too_large — the request body exceeded this route's byte cap (256 KB unless the route raises it with a written reason). It is refused before it is parsed, so nothing was created. Send fewer rows, or use the endpoint's own asynchronous door where it has one.

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

**DELETE `/webhooks/{id}` Delete a webhook subscription**

Removes the endpoint and its delivery history, including events queued for it but not yet delivered. To replace a compromised secret without losing those, rotate instead.

**Credential**

An API key in `x-api-key`

**Scopes**

`webhooks:manage`

### Parameters

- `id` path · string required

- `Idempotency-Key` header · string (uuid)

  Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422 `idempotency_key_reuse` rather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503 `idempotency_unavailable` with `Retry-After`, and nothing runs or is charged: retry with the same key.

### Request

`DELETE /api/v1/webhooks/{id}`

cURL:

```bash
curl -X DELETE "https://portal.printedsimply.com/api/v1/webhooks/whk_SV8d5wGm2kUnSwx_RhZfY9OE" \
  -H "x-api-key: ps_test_YOUR_KEY" \
  -H "Idempotency-Key: delete-webhook-$(date +%s)"
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/webhooks/whk_SV8d5wGm2kUnSwx_RhZfY9OE", {
  method: "DELETE",
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
    "Idempotency-Key": crypto.randomUUID(),
  },
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request
import uuid

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/webhooks/whk_SV8d5wGm2kUnSwx_RhZfY9OE",
    method="DELETE",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/webhooks/whk_SV8d5wGm2kUnSwx_RhZfY9OE');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'DELETE',
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

{ id, deleted: true }

`data`

- `id` string
- `deleted` boolean

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "id": "whk_hCyrY24j_gUrQWxJ7ji6s9st",
    "deleted": true
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `404`

  Resource not found

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

**POST `/webhooks/{id}/rotate-secret` Rotate a webhook signing secret**

Issues a new signing secret and keeps the outgoing one valid for grace_hours (default 24, max 168). Deliveries in the window are signed with both, so you can deploy the new secret with no dropped events. The new secret is returned once, here.

**Credential**

An API key in `x-api-key`

**Scopes**

`webhooks:manage`

### Parameters

- `id` path · string required

- `Idempotency-Key` header · string (uuid)

  Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422 `idempotency_key_reuse` rather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503 `idempotency_unavailable` with `Retry-After`, and nothing runs or is charged: retry with the same key.

### Request body application/json

- `grace_hours` integer

  How long the outgoing secret keeps verifying. 0 cuts over immediately.

  Default 24 · 0 to 168

### Request

`POST /api/v1/webhooks/{id}/rotate-secret`

cURL:

```bash
curl -X POST "https://portal.printedsimply.com/api/v1/webhooks/whk_SV8d5wGm2kUnSwx_RhZfY9OE/rotate-secret" \
  -H "x-api-key: ps_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: rotate-webhook-secret-$(date +%s)" \
  -d '{
    "grace_hours": 24
  }'
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/webhooks/whk_SV8d5wGm2kUnSwx_RhZfY9OE/rotate-secret", {
  method: "POST",
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    "grace_hours": 24
  }),
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request
import uuid

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/webhooks/whk_SV8d5wGm2kUnSwx_RhZfY9OE/rotate-secret",
    method="POST",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
        "Content-Type": "application/json",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    data=json.dumps({
        "grace_hours": 24,
    }).encode(),
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/webhooks/whk_SV8d5wGm2kUnSwx_RhZfY9OE/rotate-secret');
$body = <<<'JSON'
{
  "grace_hours": 24
}
JSON;
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
        'Content-Type: application/json',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS => $body,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

The new secret in the clear — the second and last time you see it — with grace_hours and previous_secret_expires_at bounding the window in which BOTH secrets verify. Accept either until then.

`data`

- `id` string required

- `url` string required

- `events` string[] required

- `active` boolean required

- `mode` string required

  Which order mode this subscription accepts. Normalised on the way out, so a stored value this build does not know reads as what it is actually routed as (`all`) rather than as the stored string.

  One of `all` `test` `live`

- `created_at` string (date-time) required

- `secret_rotated_at` string (date-time) | null

- `previous_secret_expires_at` string (date-time) | null

  During a rotation both secrets verify until this moment. Sign-check against both, or accept the older one, until it passes.

- `rotation_pending` boolean

  True while the previous secret is still inside its window.

- `inbox` boolean required

  True for the account's hosted test inbox (POST /v1/webhooks/inbox): it receives test events only and keeps them for GET /v1/webhooks/inbox rather than POSTing them, its `url` is the dashboard page that shows them, and its url and mode cannot be changed.

- `secret` string required

  The NEW secret, in the clear. As on create, this is the last time you will see it.

- `grace_hours` integer required

  How long the outgoing secret keeps verifying, measured from `secret_rotated_at` and restated here so a receiver knows its window without arithmetic. Both secrets verify until `previous_secret_expires_at`; accept either until then. Zero means cut over now, with no overlap written at all.

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "id": "whk_hCyrY24j_gUrQWxJ7ji6s9st",
    "url": "https://example.com/hooks/orders",
    "events": [
      "order.shipped",
      "order.delivered",
      "order.cancelled"
    ],
    "active": true,
    "mode": "test",
    "created_at": "2026-10-15T15:00:26.000Z",
    "secret_rotated_at": "2026-10-15T15:00:36.000Z",
    "previous_secret_expires_at": "2026-10-16T15:00:36.000Z",
    "rotation_pending": true,
    "inbox": false,
    "secret": "df33a8999390b44d0827dc3897e680cce9f376136ee934601cb44cc44f0cead0",
    "grace_hours": 24
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `400` [validation_error](https://portal.printedsimply.com/docs/errors#validation_error)

  Invalid request body or parameters. When the request failed the operation's schema, `details` carries `formErrors` (problems with the request as a whole), `fieldErrors` (messages keyed by top-level field) and `errors`: one `{ pointer, code, message }` per refused value, `pointer` an RFC 6901 JSON pointer into the body (or into the object of query parameters, for a query parameter) — `/items/0/sku`, with `~` written `~0` and `/` written `~1`, and `""` for the request as a whole. POST /v1/orders/bulk answers `errors` as one `{ index, details }` per refused order, each `details` with its own pointers from the request body (`/orders/3/items/0/sku`); POST /v1/orders/multi-ship keeps `errors` as the `{ formErrors, fieldErrors }` it always published; POST /v1/mockups/generate and POST /v1/products/recommend keep their fields at the top of `details`, with `errors` beside them. A refusal of a rule the schema cannot state (an asset that does not exist, an expiry too soon) names it in `message` and carries no `errors`.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `404`

  Resource not found

- `413` [payload_too_large](https://portal.printedsimply.com/docs/errors#payload_too_large)

  payload_too_large — the request body exceeded this route's byte cap (256 KB unless the route raises it with a written reason). It is refused before it is parsed, so nothing was created. Send fewer rows, or use the endpoint's own asynchronous door where it has one.

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

**DELETE `/webhooks/{id}/rotate-secret` End an open rotation window**

Stops honouring the outgoing secret now, rather than at its expiry — what you call once the new secret is deployed and the old one leaked. Idempotent: closing an already-closed window succeeds.

**Credential**

An API key in `x-api-key`

**Scopes**

`webhooks:manage`

### Parameters

- `id` path · string required

- `Idempotency-Key` header · string (uuid)

  Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422 `idempotency_key_reuse` rather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503 `idempotency_unavailable` with `Retry-After`, and nothing runs or is charged: retry with the same key.

### Request

`DELETE /api/v1/webhooks/{id}/rotate-secret`

cURL:

```bash
curl -X DELETE "https://portal.printedsimply.com/api/v1/webhooks/whk_SV8d5wGm2kUnSwx_RhZfY9OE/rotate-secret" \
  -H "x-api-key: ps_test_YOUR_KEY" \
  -H "Idempotency-Key: end-webhook-secret-rotation-$(date +%s)"
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/webhooks/whk_SV8d5wGm2kUnSwx_RhZfY9OE/rotate-secret", {
  method: "DELETE",
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
    "Idempotency-Key": crypto.randomUUID(),
  },
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request
import uuid

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/webhooks/whk_SV8d5wGm2kUnSwx_RhZfY9OE/rotate-secret",
    method="DELETE",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/webhooks/whk_SV8d5wGm2kUnSwx_RhZfY9OE/rotate-secret');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'DELETE',
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

The subscription with rotation_pending false: the outgoing secret stops verifying immediately. Any receiver still signing with it starts failing now.

`data`

- `id` string required

- `url` string required

- `events` string[] required

- `active` boolean required

- `mode` string required

  Which order mode this subscription accepts. Normalised on the way out, so a stored value this build does not know reads as what it is actually routed as (`all`) rather than as the stored string.

  One of `all` `test` `live`

- `created_at` string (date-time) required

- `secret_rotated_at` string (date-time) | null

- `previous_secret_expires_at` string (date-time) | null

  During a rotation both secrets verify until this moment. Sign-check against both, or accept the older one, until it passes.

- `rotation_pending` boolean

  True while the previous secret is still inside its window.

- `inbox` boolean required

  True for the account's hosted test inbox (POST /v1/webhooks/inbox): it receives test events only and keeps them for GET /v1/webhooks/inbox rather than POSTing them, its `url` is the dashboard page that shows them, and its url and mode cannot be changed.

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "id": "whk_hCyrY24j_gUrQWxJ7ji6s9st",
    "url": "https://example.com/hooks/orders",
    "events": [
      "order.shipped",
      "order.delivered",
      "order.cancelled"
    ],
    "active": true,
    "mode": "test",
    "created_at": "2026-10-15T15:00:26.000Z",
    "secret_rotated_at": "2026-10-15T15:00:36.000Z",
    "previous_secret_expires_at": null,
    "rotation_pending": false,
    "inbox": false
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `404`

  Resource not found

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

**POST `/events/{eventId}/replay` Replay a stored event to your webhooks**

Re-enqueues one event from your feed to every active subscription that accepts its mode, with the STORED envelope verbatim — same id, so a receiver that dedupes on X-PS-Event-Id sees the same logical event again rather than a new one. This is the push half of the catch-up loop GET /v1/events is the pull half of: after an outage, read what you missed, then replay what your endpoint needs to process. Visibility follows the feed exactly — an event on the other side of your key's test/live line is a 404, and account-level events replay to both sides. enqueued: 0 is an honest answer meaning no active subscription accepts this event's mode; the fix is a webhook, not a retry.

**Credential**

An API key in `x-api-key`

**Scopes**

`webhooks:manage`

### Parameters

- `eventId` path · string required

  The event_id from GET /v1/events — the envelope's id, the X-PS-Event-Id value.

- `Idempotency-Key` header · string (uuid)

  Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422 `idempotency_key_reuse` rather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503 `idempotency_unavailable` with `Retry-After`, and nothing runs or is charged: retry with the same key.

### Request

`POST /api/v1/events/{eventId}/replay`

cURL:

```bash
curl -X POST "https://portal.printedsimply.com/api/v1/events/evt_F5S_u7olBRx4IBTVZNXoH-8d/replay" \
  -H "x-api-key: ps_test_YOUR_KEY" \
  -H "Idempotency-Key: replay-event-$(date +%s)"
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/events/evt_F5S_u7olBRx4IBTVZNXoH-8d/replay", {
  method: "POST",
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
    "Idempotency-Key": crypto.randomUUID(),
  },
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request
import uuid

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/events/evt_F5S_u7olBRx4IBTVZNXoH-8d/replay",
    method="POST",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/events/evt_F5S_u7olBRx4IBTVZNXoH-8d/replay');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

Replay result: the event name, its event_id, and how many queue rows were enqueued.

`data`

- `replayed` boolean

- `event` string

  One of `order.created` `order.paid` `order.in_production` `order.shipped` `order.delivered` `order.cancelled` `order.failed` `order.updated` `order.rerouted` `order.quality_check` `order.issue` `order.approval_requested` `order.approval_approved` `order.approval_declined` `order.approval_expired` `campaign.opened` `campaign.order_added` `campaign.closed` `campaign.batch_in_production` `campaign.completed` `design.proof_sent` `design.changes_requested` `design.approved` `return.requested` `return.approved` `return.remake_created` `return.resolved` `return.rejected` `drop.opened` `drop.sale` `drop.closed` `drop.sold_out` `checkout_link.sale` `gift.claimed` `gift.expired` `product.updated` `catalog.synced` `webhook.disabled` `autopilot.run.created` `autopilot.run.awaiting_approval` `autopilot.run.completed` `autopilot.run.failed` `autopilot.run.cancelled` `design_run.item_ready` `design_run.completed` `guarantee.issued` `guarantee.honored` `guarantee.breached` `guarantee.remedied` `test.ping`

- `event_id` string | null

- `enqueued` integer

  How many subscriptions the replay was queued to. Zero is an honest answer: no active subscription accepts this event’s mode, and the fix is a webhook rather than a retry.

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "replayed": true,
    "event": "order.cancelled",
    "event_id": "evt_F5S_u7olBRx4IBTVZNXoH-8d",
    "enqueued": 1
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `404`

  Resource not found

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

**GET `/webhooks/deliveries` List webhook delivery attempts**

The webhook event log: recent delivery attempts across your endpoints, newest first, paged by cursor (see Pagination). Newest by when the event happened (the body's `timestamp`), then by when the attempt was made: an order's events list in the order they happened even when their attempts were recorded in the same millisecond, and the attempts at one event sit together, its latest first. Filter by webhook_id, event_id, or success. A row is one attempt; `event_id` is the logical event it was an attempt at (the value sent as `X-PS-Event-Id`), so the retry ladder's rows and every later redelivery of one event share it.

**Credential**

An API key in `x-api-key`

**Scopes**

`webhooks:manage`

### Parameters

- `webhook_id` query · string

- `event_id` query · string

  Every attempt at one logical event — the X-PS-Event-Id value.

- `success` query · string

  Filter to successful or failed attempts. Exactly `true` or `false` — any other spelling returns 400 validation_error rather than quietly applying no filter.

  One of `true` `false`

- `limit` query · integer

  Attempts per page: 20 by default, 100 at most. A value above 100 is clamped to 100 and one below 1 to 1, a fraction is truncated, and a missing, zero or non-numeric value falls back to 20; none is refused.

  Default 20

- `cursor` query · string

  Opaque cursor: send back `meta.next_cursor` from the previous page for the page after it; omit it for the first page. Good only on this list, with a key of the same account and mode (test or live), and with the same filters (only `limit` may change); an altered or foreign cursor, or one sent with other filters, is refused with 400 validation_error. Wins over `page` when both are sent.

### Request

`GET /api/v1/webhooks/deliveries`

cURL:

```bash
curl "https://portal.printedsimply.com/api/v1/webhooks/deliveries" \
  -H "x-api-key: ps_test_YOUR_KEY"
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/webhooks/deliveries", {
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
  },
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/webhooks/deliveries",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
    },
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/webhooks/deliveries');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

Deliveries (id, event, event_id, status_code, success, failure_cause) + next_cursor; meta carries has_more and next_cursor

`data`

- `deliveries` object[]

- `deliveries[].id` string required

- `deliveries[].webhook_id` string required

- `deliveries[].event` string required

  One of `order.created` `order.paid` `order.in_production` `order.shipped` `order.delivered` `order.cancelled` `order.failed` `order.updated` `order.rerouted` `order.quality_check` `order.issue` `order.approval_requested` `order.approval_approved` `order.approval_declined` `order.approval_expired` `campaign.opened` `campaign.order_added` `campaign.closed` `campaign.batch_in_production` `campaign.completed` `design.proof_sent` `design.changes_requested` `design.approved` `return.requested` `return.approved` `return.remake_created` `return.resolved` `return.rejected` `drop.opened` `drop.sale` `drop.closed` `drop.sold_out` `checkout_link.sale` `gift.claimed` `gift.expired` `product.updated` `catalog.synced` `webhook.disabled` `autopilot.run.created` `autopilot.run.awaiting_approval` `autopilot.run.completed` `autopilot.run.failed` `autopilot.run.cancelled` `design_run.item_ready` `design_run.completed` `guarantee.issued` `guarantee.honored` `guarantee.breached` `guarantee.remedied` `test.ping`

- `deliveries[].event_id` string | null

  Null only for attempts recorded before the log carried the id.

- `deliveries[].status_code` integer | null

- `deliveries[].success` boolean required

- `deliveries[].failure_cause` string | null

  Why a failed attempt failed, and so what to fix: `dns`, `refused`, `timeout`, `tls`, `reset`, `redirect`, `unauthorized` and the other endpoint-side causes are your URL's; `egress`, `signing` and `internal` are ours, and the attempt is retried on schedule. Null when delivered, and on a failure logged with neither a cause nor a status.

  One of `dns` `unsafe_url` `refused` `timeout` `tls` `reset` `network` `redirect` `unauthorized` `http_4xx` `http_5xx` `inbox_refused` `egress` `signing` `internal`

- `deliveries[].created_at` string (date-time) required

- `next_cursor` string | null

  The same as `meta.next_cursor`: send it back as `cursor` for the next page. Null on the last.

`meta`

- `page` integer deprecated

  The page number of the deprecated offset walk, on the lists that published it (orders and an order's events, on every request without `cursor`; products, on a request that sent `page`). Absent on a cursor walk, where no page number would be true.

- `total` integer

  Every row matching the request's filters, on the lists that count them (orders, an order's events, assets and products).

- `limit` integer

  The page size applied, which is not necessarily the one asked for: a larger `limit` is clamped. Absent only on a GET /products request without `cursor` that sent no `limit`, which answers every match.

- `pages` integer deprecated

  `total` over `limit`, rounded up, beside `page` (orders and an order's events). Absent on a cursor walk.

- `next_cursor` string | null required

  Send it back as `cursor` for the next page; null exactly when `has_more` is false. Opaque: pass it back unchanged. Good only on the list that returned it, with a key of the same account and mode (test or live), and with the same filters.

- `has_more` boolean required

  Whether another page follows this one. Decided from one row past this page, never from a page that came back short — so a page after `has_more: true` can come back empty only if rows were deleted in between, and the walk still ends at the first `has_more: false`.

- `since` string | null

  On GET /order/{id}/events only: where to ask from next — the position of the last event in this answer, or, when it has none, the position the request read from. Send it back as `since` (with `wait_seconds` to wait for the next event). Unlike `next_cursor` it is not null at the end of the history; it is null only when the order has no events and the request named no position. Opaque, and bound like a cursor to this order, account and key mode.

Example response:

```json
{
  "status": "success",
  "data": {
    "deliveries": [
      {
        "id": "ccbkfrz5r3gcby08k4iqiuxwb",
        "webhook_id": "whk_hCyrY24j_gUrQWxJ7ji6s9st",
        "event": "order.shipped",
        "event_id": "evt_ey8SQMTGI1sCP-c0JpwB3AyX",
        "status_code": 200,
        "success": true,
        "failure_cause": null,
        "created_at": "2026-10-15T15:00:32.000Z"
      },
      {
        "id": "cs5qsuszabsomxv2bdyxe25e1",
        "webhook_id": "whk_hCyrY24j_gUrQWxJ7ji6s9st",
        "event": "order.cancelled",
        "event_id": "evt_F5S_u7olBRx4IBTVZNXoH-8d",
        "status_code": 200,
        "success": true,
        "failure_cause": null,
        "created_at": "2026-10-15T15:00:32.000Z"
      }
    ],
    "next_cursor": null
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a",
  "meta": {
    "limit": 20,
    "has_more": false,
    "next_cursor": null
  }
}
```

### Errors

- `400` [validation_error](https://portal.printedsimply.com/docs/errors#validation_error)

  Invalid request body or parameters. When the request failed the operation's schema, `details` carries `formErrors` (problems with the request as a whole), `fieldErrors` (messages keyed by top-level field) and `errors`: one `{ pointer, code, message }` per refused value, `pointer` an RFC 6901 JSON pointer into the body (or into the object of query parameters, for a query parameter) — `/items/0/sku`, with `~` written `~0` and `/` written `~1`, and `""` for the request as a whole. POST /v1/orders/bulk answers `errors` as one `{ index, details }` per refused order, each `details` with its own pointers from the request body (`/orders/3/items/0/sku`); POST /v1/orders/multi-ship keeps `errors` as the `{ formErrors, fieldErrors }` it always published; POST /v1/mockups/generate and POST /v1/products/recommend keep their fields at the top of `details`, with `errors` beside them. A refusal of a rule the schema cannot state (an asset that does not exist, an expiry too soon) names it in `message` and carries no `errors`.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

**GET `/webhooks/deliveries/{id}` Retrieve a webhook delivery**

One delivery attempt including the `payload` that was sent — the list endpoint omits payloads. Use this to inspect the exact body your endpoint received, or to forward it somewhere else (this is how `printedsimply listen` replays events to localhost). `event_id` is the logical event this attempt belongs to, and equals `payload.id`.

**Credential**

An API key in `x-api-key`

**Scopes**

`webhooks:manage`

### Parameters

- `id` path · string required

### Request

`GET /api/v1/webhooks/deliveries/{id}`

cURL:

```bash
curl "https://portal.printedsimply.com/api/v1/webhooks/deliveries/ccbkfrz5r3gcby08k4iqiuxwb" \
  -H "x-api-key: ps_test_YOUR_KEY"
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/webhooks/deliveries/ccbkfrz5r3gcby08k4iqiuxwb", {
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
  },
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/webhooks/deliveries/ccbkfrz5r3gcby08k4iqiuxwb",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
    },
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/webhooks/deliveries/ccbkfrz5r3gcby08k4iqiuxwb');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

The attempt, with the exact body that was POSTed so a receiver that lost one can replay it against its own handler.

`data`

- `id` string required

- `webhook_id` string required

- `event` string required

  One of `order.created` `order.paid` `order.in_production` `order.shipped` `order.delivered` `order.cancelled` `order.failed` `order.updated` `order.rerouted` `order.quality_check` `order.issue` `order.approval_requested` `order.approval_approved` `order.approval_declined` `order.approval_expired` `campaign.opened` `campaign.order_added` `campaign.closed` `campaign.batch_in_production` `campaign.completed` `design.proof_sent` `design.changes_requested` `design.approved` `return.requested` `return.approved` `return.remake_created` `return.resolved` `return.rejected` `drop.opened` `drop.sale` `drop.closed` `drop.sold_out` `checkout_link.sale` `gift.claimed` `gift.expired` `product.updated` `catalog.synced` `webhook.disabled` `autopilot.run.created` `autopilot.run.awaiting_approval` `autopilot.run.completed` `autopilot.run.failed` `autopilot.run.cancelled` `design_run.item_ready` `design_run.completed` `guarantee.issued` `guarantee.honored` `guarantee.breached` `guarantee.remedied` `test.ping`

- `event_id` string | null

  Null only for attempts recorded before the log carried the id.

- `status_code` integer | null

- `success` boolean required

- `failure_cause` string | null

  Why a failed attempt failed, and so what to fix: `dns`, `refused`, `timeout`, `tls`, `reset`, `redirect`, `unauthorized` and the other endpoint-side causes are your URL's; `egress`, `signing` and `internal` are ours, and the attempt is retried on schedule. Null when delivered, and on a failure logged with neither a cause nor a status.

  One of `dns` `unsafe_url` `refused` `timeout` `tls` `reset` `network` `redirect` `unauthorized` `http_4xx` `http_5xx` `inbox_refused` `egress` `signing` `internal`

- `created_at` string (date-time) required

- `payload` object required

  The exact body that was POSTed, so a receiver that lost one can replay it against its own handler. Its `id` is the same value as `event_id` beside it — surfaced as its own key so reading the log does not mean reaching into an opaque body for the dedupe key.

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "id": "ccbkfrz5r3gcby08k4iqiuxwb",
    "webhook_id": "whk_hCyrY24j_gUrQWxJ7ji6s9st",
    "event": "order.shipped",
    "event_id": "evt_ey8SQMTGI1sCP-c0JpwB3AyX",
    "payload": {
      "id": "evt_ey8SQMTGI1sCP-c0JpwB3AyX",
      "data": {
        "status": "shipped",
        "event_id": "evt_o0ONdH8-7LUjFVrEVoIyufJ0",
        "order_id": "test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk",
        "provider": "printedsimply",
        "tracking": {
          "url": "https://portal.printedsimply.com/track/eyJ2IjoxLCJ0IjoidHJhY2tpbmciLCJ0b2siOiJ0cmtfQXF1SkZxRExEa2lzSzZKaC1IQUJIejlMIiwiZXhwIjoiMjAyNy0wMS0xM1QxNTowMDozMi4wMDBaIn0.50de0b297f5ed5a39f7b618a2e6f8138f8d50b31a2daabe2709af8f2d7265b25",
          "number": "TRK-TEST-JCWX8ET5IGBT",
          "carrier": "UPS"
        },
        "test_mode": true,
        "tracking_url": "https://portal.printedsimply.com/track/eyJ2IjoxLCJ0IjoidHJhY2tpbmciLCJ0b2siOiJ0cmtfQXF1SkZxRExEa2lzSzZKaC1IQUJIejlMIiwiZXhwIjoiMjAyNy0wMS0xM1QxNTowMDozMi4wMDBaIn0.50de0b297f5ed5a39f7b618a2e6f8138f8d50b31a2daabe2709af8f2d7265b25",
        "portal_order_id": "ord_wVK9ZiG-VYiPrlSeBUBmrUVk",
        "tracking_number": "TRK-TEST-JCWX8ET5IGBT",
        "tracking_carrier": "UPS",
        "estimated_delivery": {
          "latest": "2026-10-29",
          "earliest": "2026-10-23",
          "ships_by": "2026-10-19",
          "is_estimate": true
        }
      },
      "event": "order.shipped",
      "timestamp": "2026-10-15T15:00:32.000Z"
    },
    "status_code": 200,
    "success": true,
    "failure_cause": null,
    "created_at": "2026-10-15T15:00:32.000Z"
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `404`

  Resource not found

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

**POST `/webhooks/deliveries/{id}/redeliver` Replay a webhook delivery**

Re-sends the stored payload to your endpoint with a fresh signature. Safe to retry (Idempotency-Key supported).

**Credential**

An API key in `x-api-key`

**Scopes**

`webhooks:manage`

### Parameters

- `id` path · string required

- `Idempotency-Key` header · string (uuid)

  Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422 `idempotency_key_reuse` rather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503 `idempotency_unavailable` with `Retry-After`, and nothing runs or is charged: retry with the same key.

### Request

`POST /api/v1/webhooks/deliveries/{id}/redeliver`

cURL:

```bash
curl -X POST "https://portal.printedsimply.com/api/v1/webhooks/deliveries/ccbkfrz5r3gcby08k4iqiuxwb/redeliver" \
  -H "x-api-key: ps_test_YOUR_KEY" \
  -H "Idempotency-Key: redeliver-webhook-$(date +%s)"
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/webhooks/deliveries/ccbkfrz5r3gcby08k4iqiuxwb/redeliver", {
  method: "POST",
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
    "Idempotency-Key": crypto.randomUUID(),
  },
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request
import uuid

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/webhooks/deliveries/ccbkfrz5r3gcby08k4iqiuxwb/redeliver",
    method="POST",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/webhooks/deliveries/ccbkfrz5r3gcby08k4iqiuxwb/redeliver');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

The result of making the attempt. A 200 here means the attempt was MADE — read redelivered to learn whether your endpoint accepted it.

`data`

- `delivery_id` string required

  The attempt that was replayed. A redelivery does not create a new id here.

- `redelivered` boolean required

  Whether YOUR endpoint accepted it this time. False is a successful call to this operation reporting a failed delivery — the 200 is about the attempt being made, not about it landing.

- `status_code` integer | null required

  What your endpoint answered. Null when it could not be reached at all.

- `error` string | null required

  Why it did not land. Null when `redelivered` is true.

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "delivery_id": "ccbkfrz5r3gcby08k4iqiuxwb",
    "redelivered": true,
    "status_code": 200,
    "error": null
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `404`

  Resource not found

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

**GET `/webhooks/inbox` Read the hosted test inbox**

The account's hosted test inbox and the requests it kept, newest first, paged by cursor (see Pagination). The inbox keeps its newest 100, each for at most 7 days, so a walk is a few pages at most. Each entry is exactly what an endpoint would have received for one delivery attempt — every header as signed (`X-PS-Signature-V2` among them) and the raw `body`, byte for byte — so a signature check can be run against it with the secret POST /v1/webhooks/inbox returned. A test key only: the inbox receives test events and nothing else, and a live key is 400 `test_mode_only`. 404 `not_found` until the inbox is made.

**Credential**

An API key in `x-api-key`

**Scopes**

`webhooks:manage`

### Parameters

- `limit` query · integer

  Entries per page: 20 by default, 100 at most. A value above 100 is clamped to 100 and one below 1 to 1, a fraction is truncated, and a missing, zero or non-numeric value falls back to 20; none is refused.

  Default 20

- `cursor` query · string

  Opaque cursor: send back `meta.next_cursor` from the previous page for the page after it; omit it for the first page. Good only on this list, with a key of the same account and mode (test or live), and with the same filters (only `limit` may change); an altered or foreign cursor, or one sent with other filters, is refused with 400 validation_error. Wins over `page` when both are sent.

### Request

`GET /api/v1/webhooks/inbox`

cURL:

```bash
curl "https://portal.printedsimply.com/api/v1/webhooks/inbox" \
  -H "x-api-key: ps_test_YOUR_KEY"
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/webhooks/inbox", {
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
  },
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/webhooks/inbox",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
    },
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/webhooks/inbox');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

The inbox subscription (`inbox: true`) and a page of its kept requests, newest first; meta carries has_more and next_cursor

`data`

- `webhook` object required

- `webhook.id` string required

- `webhook.url` string required

- `webhook.events` string[] required

- `webhook.active` boolean required

- `webhook.mode` string required

  Which order mode this subscription accepts. Normalised on the way out, so a stored value this build does not know reads as what it is actually routed as (`all`) rather than as the stored string.

  One of `all` `test` `live`

- `webhook.created_at` string (date-time) required

- `webhook.secret_rotated_at` string (date-time) | null

- `webhook.previous_secret_expires_at` string (date-time) | null

  During a rotation both secrets verify until this moment. Sign-check against both, or accept the older one, until it passes.

- `webhook.rotation_pending` boolean

  True while the previous secret is still inside its window.

- `webhook.inbox` boolean required

  True for the account's hosted test inbox (POST /v1/webhooks/inbox): it receives test events only and keeps them for GET /v1/webhooks/inbox rather than POSTing them, its `url` is the dashboard page that shows them, and its url and mode cannot be changed.

- `entries` object[] required

  One page of the requests the inbox kept, newest first. The inbox keeps its newest 100, each for at most 7 days.

  at most 100 entries

- `entries[].id` string required

- `entries[].event` string required

  The event name, as sent in `X-PS-Event`.

  One of `order.created` `order.paid` `order.in_production` `order.shipped` `order.delivered` `order.cancelled` `order.failed` `order.updated` `order.rerouted` `order.quality_check` `order.issue` `order.approval_requested` `order.approval_approved` `order.approval_declined` `order.approval_expired` `campaign.opened` `campaign.order_added` `campaign.closed` `campaign.batch_in_production` `campaign.completed` `design.proof_sent` `design.changes_requested` `design.approved` `return.requested` `return.approved` `return.remake_created` `return.resolved` `return.rejected` `drop.opened` `drop.sale` `drop.closed` `drop.sold_out` `checkout_link.sale` `gift.claimed` `gift.expired` `product.updated` `catalog.synced` `webhook.disabled` `autopilot.run.created` `autopilot.run.awaiting_approval` `autopilot.run.completed` `autopilot.run.failed` `autopilot.run.cancelled` `design_run.item_ready` `design_run.completed` `guarantee.issued` `guarantee.honored` `guarantee.breached` `guarantee.remedied` `test.ping`

- `entries[].event_id` string required

  The logical event this attempt delivered: the `X-PS-Event-Id` value, and the body's `id`.

- `entries[].received_at` string (date-time) required

- `entries[].headers` object required

  Every header the signer produced for this attempt, name to value: the signature headers, the event, its id, the attempt and the timestamp.

- `entries[].body` string required

  The raw body, byte for byte as signed — a string, not parsed JSON, so a signature check runs against exactly these bytes.

`meta`

- `page` integer deprecated

  The page number of the deprecated offset walk, on the lists that published it (orders and an order's events, on every request without `cursor`; products, on a request that sent `page`). Absent on a cursor walk, where no page number would be true.

- `total` integer

  Every row matching the request's filters, on the lists that count them (orders, an order's events, assets and products).

- `limit` integer

  The page size applied, which is not necessarily the one asked for: a larger `limit` is clamped. Absent only on a GET /products request without `cursor` that sent no `limit`, which answers every match.

- `pages` integer deprecated

  `total` over `limit`, rounded up, beside `page` (orders and an order's events). Absent on a cursor walk.

- `next_cursor` string | null required

  Send it back as `cursor` for the next page; null exactly when `has_more` is false. Opaque: pass it back unchanged. Good only on the list that returned it, with a key of the same account and mode (test or live), and with the same filters.

- `has_more` boolean required

  Whether another page follows this one. Decided from one row past this page, never from a page that came back short — so a page after `has_more: true` can come back empty only if rows were deleted in between, and the walk still ends at the first `has_more: false`.

- `since` string | null

  On GET /order/{id}/events only: where to ask from next — the position of the last event in this answer, or, when it has none, the position the request read from. Send it back as `since` (with `wait_seconds` to wait for the next event). Unlike `next_cursor` it is not null at the end of the history; it is null only when the order has no events and the request named no position. Opaque, and bound like a cursor to this order, account and key mode.

Example response:

```json
{
  "status": "success",
  "data": {
    "webhook": {
      "id": "whk_Pxpo7Z0TfZI3H2ZY7yS2tukP",
      "url": "https://portal.printedsimply.com/dashboard/webhooks/inbox",
      "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"
      ],
      "active": true,
      "mode": "test",
      "created_at": "2026-10-15T15:00:49.000Z",
      "secret_rotated_at": null,
      "previous_secret_expires_at": null,
      "rotation_pending": false,
      "inbox": true
    },
    "entries": [
      {
        "id": "ce51qmztk5pep7spdwg9rg58q",
        "event": "order.shipped",
        "event_id": "evt_6GV9KxH-xHgHOcS13DqpCgDS",
        "received_at": "2026-10-15T15:00:49.000Z",
        "headers": {
          "X-MF-Event": "order.shipped",
          "X-PS-Event": "order.shipped",
          "Content-Type": "application/json",
          "X-MF-Event-Id": "evt_6GV9KxH-xHgHOcS13DqpCgDS",
          "X-PS-Event-Id": "evt_6GV9KxH-xHgHOcS13DqpCgDS",
          "X-MF-Signature": "sha256=ff059fc56400150adea84edb9f5ce87df993fa67c28c136cbfaae542c4ac05f0",
          "X-MF-Timestamp": "1792076449",
          "X-PS-Signature": "sha256=ff059fc56400150adea84edb9f5ce87df993fa67c28c136cbfaae542c4ac05f0",
          "X-PS-Timestamp": "1792076449",
          "X-MF-Signature-V2": "t=1792076449,v1=7e591194144fe43fdd5e55fbaae9a09ef3f297f62c15578829a73d4019c90ac0",
          "X-PS-Signature-V2": "t=1792076449,v1=7e591194144fe43fdd5e55fbaae9a09ef3f297f62c15578829a73d4019c90ac0",
          "X-MF-Delivery-Attempt": "1",
          "X-PS-Delivery-Attempt": "1"
        },
        "body": "{\"id\":\"evt_6GV9KxH-xHgHOcS13DqpCgDS\",\"data\":{\"order_id\":\"test_ord_tVI4josqx9ZnMqHu-aBSFxsn\",\"test_mode\":true,\"tracking_number\":\"9400111899223197428490\"},\"event\":\"order.shipped\",\"timestamp\":\"2026-10-15T15:00:49.000Z\"}"
      }
    ]
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a",
  "meta": {
    "limit": 20,
    "has_more": false,
    "next_cursor": null
  }
}
```

### Errors

- `400` [test_mode_only](https://portal.printedsimply.com/docs/errors#test_mode_only) [validation_error](https://portal.printedsimply.com/docs/errors#validation_error)

  A live key (test_mode_only): the inbox is read with a test key. Or a cursor this list did not issue to this account (validation_error).

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `404`

  Resource not found

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

**POST `/webhooks/inbox` Create the hosted test inbox**

Make the account's hosted test inbox: a test-only subscription to every event this API offers, whose deliveries are kept for you to read with GET /v1/webhooks/inbox instead of being POSTed anywhere — a test order's events, signed and in full, with no endpoint, tunnel or install of your own. Takes no body. One inbox per account: a later call answers the same inbox with `created: false` and `secret: null` (rotate its secret with POST /v1/webhooks/{id}/rotate-secret if you lost it). Its `url` is the dashboard page that shows it and is never dialled; its url and mode cannot be changed. A test key only: a live key is 400 `test_mode_only`, and a body field (a `url`, a `mode`) is 400 `validation_error` — to receive events at a URL of your own, use POST /v1/webhooks.

**Credential**

An API key in `x-api-key`

**Scopes**

`webhooks:manage`

### Parameters

- `Idempotency-Key` header · string (uuid)

  Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422 `idempotency_key_reuse` rather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503 `idempotency_unavailable` with `Retry-After`, and nothing runs or is charged: retry with the same key.

### Request body application/json

### Request

`POST /api/v1/webhooks/inbox`

cURL:

```bash
curl -X POST "https://portal.printedsimply.com/api/v1/webhooks/inbox" \
  -H "x-api-key: ps_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: create-webhook-inbox-$(date +%s)" \
  -d '{}'
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/webhooks/inbox", {
  method: "POST",
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({}),
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request
import uuid

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/webhooks/inbox",
    method="POST",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
        "Content-Type": "application/json",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    data=json.dumps({}).encode(),
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/webhooks/inbox');
$body = <<<'JSON'
{}
JSON;
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
        'Content-Type: application/json',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS => $body,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

The inbox, and on the call that made it the signing secret in the clear — the one time it is returned, other than a rotation.

`data`

- `id` string required

- `url` string required

- `events` string[] required

- `active` boolean required

- `mode` string required

  Which order mode this subscription accepts. Normalised on the way out, so a stored value this build does not know reads as what it is actually routed as (`all`) rather than as the stored string.

  One of `all` `test` `live`

- `created_at` string (date-time) required

- `secret_rotated_at` string (date-time) | null

- `previous_secret_expires_at` string (date-time) | null

  During a rotation both secrets verify until this moment. Sign-check against both, or accept the older one, until it passes.

- `rotation_pending` boolean

  True while the previous secret is still inside its window.

- `inbox` boolean required

  True for the account's hosted test inbox (POST /v1/webhooks/inbox): it receives test events only and keeps them for GET /v1/webhooks/inbox rather than POSTing them, its `url` is the dashboard page that shows them, and its url and mode cannot be changed.

- `created` boolean required

  True when this call made the inbox; false when the account already had one, which is answered as it is.

- `secret` string | null required

  The signing secret, in the clear, on the call that made the inbox and never again (null on every later call). It is stored sealed and no read gives it back. Store it now; if you lose it, rotate.

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "id": "whk_Pxpo7Z0TfZI3H2ZY7yS2tukP",
    "url": "https://portal.printedsimply.com/dashboard/webhooks/inbox",
    "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"
    ],
    "active": true,
    "mode": "test",
    "created_at": "2026-10-15T15:00:49.000Z",
    "secret_rotated_at": null,
    "previous_secret_expires_at": null,
    "rotation_pending": false,
    "inbox": true,
    "created": true,
    "secret": "95677446d44ce8482bae8540120310988cf90314cce8db3fed338d0918c5c296"
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `400` [test_mode_only](https://portal.printedsimply.com/docs/errors#test_mode_only) [validation_error](https://portal.printedsimply.com/docs/errors#validation_error)

  A live key (test_mode_only), or a field in the body (validation_error): the inbox takes none.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `413` [payload_too_large](https://portal.printedsimply.com/docs/errors#payload_too_large)

  payload_too_large — the request body exceeded this route's byte cap (256 KB unless the route raises it with a written reason). It is refused before it is parsed, so nothing was created. Send fewer rows, or use the endpoint's own asynchronous door where it has one.

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

**DELETE `/webhooks/inbox/entries` Empty the hosted test inbox**

Delete every request the account's hosted test inbox kept, and keep the inbox: its id, `url`, events and signing secret are unchanged, so the next test event lands in an empty inbox you can verify with the secret you saved. Only this account's inbox entries are deleted, never a subscription, its queued events or its delivery log. Takes no body. Answers the inbox's id and how many entries were removed, 0 when it was already empty (so a retry is harmless; an `Idempotency-Key` replays the first answer). A test key only: a live key is 400 `test_mode_only`. 404 `not_found` until the inbox is made with POST /v1/webhooks/inbox. Deleting the inbox itself is DELETE /v1/webhooks/{id}.

**Credential**

An API key in `x-api-key`

**Scopes**

`webhooks:manage`

### Parameters

- `Idempotency-Key` header · string (uuid)

  Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422 `idempotency_key_reuse` rather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503 `idempotency_unavailable` with `Retry-After`, and nothing runs or is charged: retry with the same key.

### Request

`DELETE /api/v1/webhooks/inbox/entries`

cURL:

```bash
curl -X DELETE "https://portal.printedsimply.com/api/v1/webhooks/inbox/entries" \
  -H "x-api-key: ps_test_YOUR_KEY" \
  -H "Idempotency-Key: empty-webhook-inbox-$(date +%s)"
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/webhooks/inbox/entries", {
  method: "DELETE",
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
    "Idempotency-Key": crypto.randomUUID(),
  },
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request
import uuid

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/webhooks/inbox/entries",
    method="DELETE",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/webhooks/inbox/entries');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'DELETE',
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

The inbox's id and the number of entries removed

`data`

- `webhook_id` string required

  The inbox's subscription id, unchanged: the inbox stays, with its url and signing secret.

- `removed` integer required

  Entries deleted: at most the 100 the inbox keeps, and 0 when it was already empty.

  at least 0

`meta`

- `page` integer

- `total` integer

- `limit` integer

  The page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.

- `pages` integer

  `total` over `limit`, rounded up.

- `next_cursor` string | null

  On a cursor-paged operation: send this back as `cursor` for the next page. Null on the last page. Absent on operations that do not page by cursor.

- `has_more` boolean

  On a list operation whose `meta` is `ListMeta`: whether another page follows this one.

Example response:

```json
{
  "status": "success",
  "data": {
    "webhook_id": "whk_Pxpo7Z0TfZI3H2ZY7yS2tukP",
    "removed": 1
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

### Errors

- `400` [test_mode_only](https://portal.printedsimply.com/docs/errors#test_mode_only)

  A live key (test_mode_only): the inbox is emptied with a test key.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `404`

  Resource not found

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

A webhook needs a public URL to POST to. If you do not have one — a script in a notebook, a job behind NAT — pull the same events instead. This is also how a subscriber catches up after being down longer than the retry ladder: `event_id` is the same value as the `X-PS-Event-Id` header, so you can dedupe across both. Events are kept 30 days.

**GET `/events` List account events**

The same events your webhooks receive, pulled instead of pushed — for a client with no public URL to POST to (an agent, a notebook, a job behind NAT), and as the catch-up path when your endpoint was down longer than the retry ladder. `event_id` equals the `X-PS-Event-Id` header on a delivered copy and the `id` inside the webhook body, so a receiver that both subscribes and polls dedupes on it. `data` is the webhook body's `data` verbatim. Newest first, paged by cursor (see Pagination); `next_cursor` is also beside the rows, where this list has always carried it. A test key sees test events, a live key sees live ones, and account-level events (catalog syncs, design proofs) are returned to both.

**Credential**

An API key in `x-api-key`

**Scopes**

`events:read`

### Parameters

- `event` query · string

  One event name, e.g. `order.shipped`. An unknown name is refused rather than ignored. The list is the events this portal offers; a name outside it that an event in your history carries (one received before the switch) is still accepted.

  One of `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` `test.ping`

- `from` query · string (date-time)

- `to` query · string (date-time)

- `limit` query · integer

  Events per page: 20 by default, 100 at most. A value above 100 is clamped to 100 and one below 1 to 1, a fraction is truncated, and a missing, zero or non-numeric value falls back to 20; none is refused.

  Default 20

- `cursor` query · string

  Opaque cursor: send back `meta.next_cursor` from the previous page for the page after it; omit it for the first page. Good only on this list, with a key of the same account and mode (test or live), and with the same filters (only `limit` may change); an altered or foreign cursor, or one sent with other filters, is refused with 400 validation_error. Wins over `page` when both are sent.

### Request

`GET /api/v1/events`

cURL:

```bash
curl "https://portal.printedsimply.com/api/v1/events" \
  -H "x-api-key: ps_test_YOUR_KEY"
```

TypeScript:

```typescript
const res = await fetch("https://portal.printedsimply.com/api/v1/events", {
  headers: {
    "x-api-key": "ps_test_YOUR_KEY",
  },
  signal: AbortSignal.timeout(30_000),
})
const { data } = await res.json()
```

Python:

```python
import json
import urllib.request

req = urllib.request.Request(
    "https://portal.printedsimply.com/api/v1/events",
    headers={
        "x-api-key": "ps_test_YOUR_KEY",
    },
)
with urllib.request.urlopen(req, timeout=30) as res:
    data = json.load(res)["data"]
```

PHP:

```php
<?php
$ch = curl_init('https://portal.printedsimply.com/api/v1/events');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => [
        'x-api-key: ps_test_YOUR_KEY',
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
if ($raw === false) {
    throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
    $reply = json_decode($raw, true);
    $error = $reply['error'] ?? [];
    throw new RuntimeException(sprintf(
        '%d %s: %s (request_id %s)',
        $status,
        $error['code'] ?? 'error',
        $error['message'] ?? $raw,
        $reply['request_id'] ?? 'none'
    ));
}
$data = json_decode($raw, true)['data'];
```

### Response `200`

events (id, event, event_id, test_mode, created_at, data) + next_cursor + retention_days; meta carries has_more and next_cursor

`data`

- `events` object[]

- `events[].id` string required

- `events[].event` string required

  One of `order.created` `order.paid` `order.in_production` `order.shipped` `order.delivered` `order.cancelled` `order.failed` `order.updated` `order.rerouted` `order.quality_check` `order.issue` `order.approval_requested` `order.approval_approved` `order.approval_declined` `order.approval_expired` `campaign.opened` `campaign.order_added` `campaign.closed` `campaign.batch_in_production` `campaign.completed` `design.proof_sent` `design.changes_requested` `design.approved` `return.requested` `return.approved` `return.remake_created` `return.resolved` `return.rejected` `drop.opened` `drop.sale` `drop.closed` `drop.sold_out` `checkout_link.sale` `gift.claimed` `gift.expired` `product.updated` `catalog.synced` `webhook.disabled` `autopilot.run.created` `autopilot.run.awaiting_approval` `autopilot.run.completed` `autopilot.run.failed` `autopilot.run.cancelled` `design_run.item_ready` `design_run.completed` `guarantee.issued` `guarantee.honored` `guarantee.breached` `guarantee.remedied`

- `events[].event_id` string | null

- `events[].test_mode` boolean required

- `events[].created_at` string (date-time) required

- `events[].data` any

  The event's payload, verbatim. Null when none was recorded.

- `next_cursor` string | null

  The same as `meta.next_cursor`: send it back as `cursor` for the next page. Null on the last.

- `retention_days` integer

  How long an event stays readable here.

`meta`

- `page` integer deprecated

  The page number of the deprecated offset walk, on the lists that published it (orders and an order's events, on every request without `cursor`; products, on a request that sent `page`). Absent on a cursor walk, where no page number would be true.

- `total` integer

  Every row matching the request's filters, on the lists that count them (orders, an order's events, assets and products).

- `limit` integer

  The page size applied, which is not necessarily the one asked for: a larger `limit` is clamped. Absent only on a GET /products request without `cursor` that sent no `limit`, which answers every match.

- `pages` integer deprecated

  `total` over `limit`, rounded up, beside `page` (orders and an order's events). Absent on a cursor walk.

- `next_cursor` string | null required

  Send it back as `cursor` for the next page; null exactly when `has_more` is false. Opaque: pass it back unchanged. Good only on the list that returned it, with a key of the same account and mode (test or live), and with the same filters.

- `has_more` boolean required

  Whether another page follows this one. Decided from one row past this page, never from a page that came back short — so a page after `has_more: true` can come back empty only if rows were deleted in between, and the walk still ends at the first `has_more: false`.

- `since` string | null

  On GET /order/{id}/events only: where to ask from next — the position of the last event in this answer, or, when it has none, the position the request read from. Send it back as `since` (with `wait_seconds` to wait for the next event). Unlike `next_cursor` it is not null at the end of the history; it is null only when the order has no events and the request named no position. Opaque, and bound like a cursor to this order, account and key mode.

Example response:

```json
{
  "status": "success",
  "data": {
    "events": [
      {
        "id": "c3sx7f4gaslmi2lt9d4gu6cca",
        "event": "order.cancelled",
        "event_id": "evt_F5S_u7olBRx4IBTVZNXoH-8d",
        "test_mode": true,
        "created_at": "2026-10-15T15:00:21.000Z",
        "data": {
          "status": "cancelled",
          "event_id": "evt_PA21dQPJmlEc0sS9bhCLg5l_",
          "order_id": "test_ord_JyZLq7sUxTZxc7qjzI76ZQYU",
          "provider": "printedsimply",
          "test_mode": true,
          "portal_order_id": "ord_JyZLq7sUxTZxc7qjzI76ZQYU"
        }
      },
      {
        "id": "cpxetfsy9xdfbm20z1s61pn2j",
        "event": "order.updated",
        "event_id": "evt_T6LdoYs-r7UK7oBoe6PVn6dG",
        "test_mode": true,
        "created_at": "2026-10-15T15:00:17.000Z",
        "data": {
          "changes": {
            "action": "address_changed",
            "ship_to": {
              "zip": "78702",
              "city": "Austin",
              "name": "Jane Doe",
              "state": "TX",
              "country": "US",
              "address1": "456 Oak Ave"
            },
            "total_cents": 7799,
            "total_delta_cents": 0
          },
          "event_id": "evt_ORmnOh7KCgTFDahmHVzDhdg9",
          "order_id": "test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk",
          "test_mode": true,
          "portal_order_id": "ord_wVK9ZiG-VYiPrlSeBUBmrUVk"
        }
      },
      {
        "id": "cypad8vwky282y6c5jaqkkpgx",
        "event": "order.updated",
        "event_id": "evt_Ze8JBOtDDevHmgQRbq6jY6TC",
        "test_mode": true,
        "created_at": "2026-10-15T15:00:14.000Z",
        "data": {
          "changes": {
            "item": {
              "id": "itm_vNx36IWd8kx2dCQqkFbsTVg3",
              "sku": "magnet-4x4",
              "quantity": 5
            },
            "action": "item_updated",
            "total_cents": 9034,
            "total_delta_cents": -5640
          },
          "event_id": "evt_gwqAUgOZe7itGr-n6zvxYDi0",
          "order_id": "test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk",
          "test_mode": true,
          "portal_order_id": "ord_wVK9ZiG-VYiPrlSeBUBmrUVk"
        }
      },
      {
        "id": "cxii3vd3gq0upj60blxhlx76a",
        "event": "order.created",
        "event_id": "evt_pfvUVLY9cBbI0i5Mv1kauVL-",
        "test_mode": true,
        "created_at": "2026-10-15T15:00:10.000Z",
        "data": {
          "items": [
            {
              "sku": "sticker-die-cut-3x3",
              "quantity": 100
            }
          ],
          "status": "paid",
          "order_id": "test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk",
          "provider": "merchfarm",
          "test_mode": true,
          "created_at": "2026-10-15T15:00:10.000Z",
          "total_cents": 7299,
          "portal_order_id": "ord_wVK9ZiG-VYiPrlSeBUBmrUVk"
        }
      },
      {
        "id": "c956sb2eftr8jj7tu4m0dw6h5",
        "event": "order.paid",
        "event_id": "evt_NPH58Tcs_h19qfEXVf9xMang",
        "test_mode": true,
        "created_at": "2026-10-15T15:00:10.000Z",
        "data": {
          "status": "paid",
          "order_id": "test_ord_wVK9ZiG-VYiPrlSeBUBmrUVk",
          "provider": "merchfarm",
          "test_mode": true,
          "amount_cents": 7299,
          "portal_order_id": "ord_wVK9ZiG-VYiPrlSeBUBmrUVk"
        }
      }
    ],
    "next_cursor": null,
    "retention_days": 30
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a",
  "meta": {
    "limit": 20,
    "has_more": false,
    "next_cursor": null
  }
}
```

### Errors

- `400` [validation_error](https://portal.printedsimply.com/docs/errors#validation_error)

  Invalid request body or parameters. When the request failed the operation's schema, `details` carries `formErrors` (problems with the request as a whole), `fieldErrors` (messages keyed by top-level field) and `errors`: one `{ pointer, code, message }` per refused value, `pointer` an RFC 6901 JSON pointer into the body (or into the object of query parameters, for a query parameter) — `/items/0/sku`, with `~` written `~0` and `/` written `~1`, and `""` for the request as a whole. POST /v1/orders/bulk answers `errors` as one `{ index, details }` per refused order, each `details` with its own pointers from the request body (`/orders/3/items/0/sku`); POST /v1/orders/multi-ship keeps `errors` as the `{ formErrors, fieldErrors }` it always published; POST /v1/mockups/generate and POST /v1/products/recommend keep their fields at the top of `details`, with `errors` beside them. A refusal of a rule the schema cannot state (an asset that does not exist, an expiry too soon) names it in `message` and carries no `errors`.

- `401` [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

  Missing, invalid or expired API key: `missing_api_key` (none sent), `invalid_api_key` (unknown or revoked), `key_expired` (past the expiry set when it was created; create a new key).

- `429` [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

  Rate limit exceeded. Two windows can produce this status and `error.code` says which: `rate_limit_exceeded` for the per-minute sliding window, `daily_quota_exceeded` for the plan's daily quota. Both carry `Retry-After`; the daily one also carries `X-Daily-Limit` and `X-Daily-Remaining`. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.

See the [Webhooks guide](https://portal.printedsimply.com/docs/webhooks) for event types, signature verification, and retry policies.

## Common response format

A success comes back in one envelope: `status`, the operation's `data`, and the `request_id` (also the `X-Request-Id` header) to quote when you write to us. A list returns its rows in `data` and how to page on in `meta` ([Pagination](https://portal.printedsimply.com/docs/api-reference#pagination)). As the document's own examples give them:

One resource:

```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"
}
```

A list:

```json
{
  "status": "success",
  "data": [ … ],
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a",
  "meta": {
    "page": 1,
    "pages": 1,
    "limit": 20,
    "total": 1,
    "has_more": false,
    "next_cursor": null
  }
}
```

The discovery index and a CSV export are the exceptions: each operation says what it answers with.

## Error handling

An error carries an HTTP status and a structured body: branch on `error.code`, never the message. Here `POST /api/v1/order` was sent a line of quantity 0 and no address, and `details.fieldErrors` names each field it refused:

`POST /api/v1/order → 400`

```
{
  "status": "error",
  "error": {
    "code": "validation_error",
    "message": "Request body failed validation.",
    "doc_url": "https://portal.printedsimply.com/docs/errors#validation_error",
    "suggestion": "Check the details field for specific validation failures. Refer to the endpoint reference for required fields.",
    "details": {
      "formErrors": [],
      "fieldErrors": {
        "address_to": [
          "Invalid input: expected object, received undefined"
        ],
        "items": [
          "Quantity must be at least 1"
        ]
      },
      "errors": [
        {
          "pointer": "/address_to",
          "code": "invalid_type",
          "message": "Invalid input: expected object, received undefined"
        },
        {
          "pointer": "/items/0/quantity",
          "code": "too_small",
          "message": "Quantity must be at least 1"
        }
      ]
    }
  },
  "request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}
```

**Every status the API answers with (14), and the codes each can carry**

- `200` OK — the request did what it asked.

- `400` Bad request — the body or a parameter did not validate; nothing was done.

  [validation_error](https://portal.printedsimply.com/docs/errors#validation_error) [already_cancelled](https://portal.printedsimply.com/docs/errors#already_cancelled) [pricing_error](https://portal.printedsimply.com/docs/errors#pricing_error) [unknown_sku](https://portal.printedsimply.com/docs/errors#unknown_sku) [test_mode_only](https://portal.printedsimply.com/docs/errors#test_mode_only) [already_terminal](https://portal.printedsimply.com/docs/errors#already_terminal) [event_not_available](https://portal.printedsimply.com/docs/errors#event_not_available)

- `401` Unauthorized — no credential, or one that is invalid, expired or revoked.

  [invalid_api_key](https://portal.printedsimply.com/docs/errors#invalid_api_key) [missing_api_key](https://portal.printedsimply.com/docs/errors#missing_api_key) [key_expired](https://portal.printedsimply.com/docs/errors#key_expired)

- `402` Payment required — the charge was refused or a spend cap stopped it; nothing was charged.

  [no_payment_method](https://portal.printedsimply.com/docs/errors#no_payment_method) [payment_failed](https://portal.printedsimply.com/docs/errors#payment_failed) [authentication_required](https://portal.printedsimply.com/docs/errors#authentication_required) [fraud_review](https://portal.printedsimply.com/docs/errors#fraud_review) [spend_cap_exceeded](https://portal.printedsimply.com/docs/errors#spend_cap_exceeded) `spend_cap_unenforceable`

- `403` Forbidden — the credential cannot do this (its scope, its mode, or the account's).

  [sandbox_only](https://portal.printedsimply.com/docs/errors#sandbox_only) [insufficient_scope](https://portal.printedsimply.com/docs/errors#insufficient_scope) [live_key_required](https://portal.printedsimply.com/docs/errors#live_key_required)

- `404` Not found — nothing of that id on this account.

  [order_not_found](https://portal.printedsimply.com/docs/errors#order_not_found)

- `409` Conflict — the resource's state does not allow it any more.

  [duplicate_reference](https://portal.printedsimply.com/docs/errors#duplicate_reference) [idempotency_conflict](https://portal.printedsimply.com/docs/errors#idempotency_conflict) [not_cancellable](https://portal.printedsimply.com/docs/errors#not_cancellable) [already_cancelled](https://portal.printedsimply.com/docs/errors#already_cancelled) [production_reconciliation_required](https://portal.printedsimply.com/docs/errors#production_reconciliation_required) [order_not_modifiable](https://portal.printedsimply.com/docs/errors#order_not_modifiable) [shopper_paid_order](https://portal.printedsimply.com/docs/errors#shopper_paid_order) [letter_report_refused](https://portal.printedsimply.com/docs/errors#letter_report_refused) [invalid_state](https://portal.printedsimply.com/docs/errors#invalid_state) [concurrent_modification](https://portal.printedsimply.com/docs/errors#concurrent_modification) [cursor_source_changed](https://portal.printedsimply.com/docs/errors#cursor_source_changed)

- `413` Payload too large — the body is over this operation's byte cap; it was not read.

  [payload_too_large](https://portal.printedsimply.com/docs/errors#payload_too_large)

- `422` Unprocessable — a valid request the API refuses for what it asks; the code says why.

  [variable_data_invalid](https://portal.printedsimply.com/docs/errors#variable_data_invalid) [variable_data_unavailable](https://portal.printedsimply.com/docs/errors#variable_data_unavailable) [artwork_not_printable](https://portal.printedsimply.com/docs/errors#artwork_not_printable) [artwork_required](https://portal.printedsimply.com/docs/errors#artwork_required) [destination_unavailable](https://portal.printedsimply.com/docs/errors#destination_unavailable) [below_cost_floor](https://portal.printedsimply.com/docs/errors#below_cost_floor) [address_invalid](https://portal.printedsimply.com/docs/errors#address_invalid) `max_provider_cost_required` [sku_not_available](https://portal.printedsimply.com/docs/errors#sku_not_available) [provider_not_available](https://portal.printedsimply.com/docs/errors#provider_not_available)

- `429` Too many requests — a rate window or the daily quota; wait for Retry-After.

  [rate_limit_exceeded](https://portal.printedsimply.com/docs/errors#rate_limit_exceeded) [daily_quota_exceeded](https://portal.printedsimply.com/docs/errors#daily_quota_exceeded)

- `500` Server error — the code says whether anything happened; some must not be retried.

  [internal_error](https://portal.printedsimply.com/docs/errors#internal_error) [order_error](https://portal.printedsimply.com/docs/errors#order_error) [refund_failed](https://portal.printedsimply.com/docs/errors#refund_failed)

- `501` Not implemented — whoever makes this order does not support the change.

  [not_supported_by_provider](https://portal.printedsimply.com/docs/errors#not_supported_by_provider)

- `502` Bad gateway — the production system did not answer or refused.

  [fulfillment_error](https://portal.printedsimply.com/docs/errors#fulfillment_error) [internal_error](https://portal.printedsimply.com/docs/errors#internal_error)

- `503` Unavailable — the service it needs is not taking requests right now.

  [provider_unavailable](https://portal.printedsimply.com/docs/errors#provider_unavailable) [pricing_error](https://portal.printedsimply.com/docs/errors#pricing_error) [unknown_sku](https://portal.printedsimply.com/docs/errors#unknown_sku) [reference_check_unavailable](https://portal.printedsimply.com/docs/errors#reference_check_unavailable) [idempotency_unavailable](https://portal.printedsimply.com/docs/errors#idempotency_unavailable)

See the full [error reference](https://portal.printedsimply.com/docs/errors) for all error codes and their meanings.

## Pagination

A list that returns `next_cursor` pages by cursor: ask for a page, then send the `next_cursor` it returned as `cursor` until it comes back `null` — on a list whose `meta` is `ListMeta`, until `meta.has_more` is false. Pass a cursor back unchanged: it works only on the list, the account and the key mode it was returned to.

```
curl "https://portal.printedsimply.com/api/v1/orders?limit=50&cursor=NEXT_CURSOR" \
  -H "x-api-key: ps_test_YOUR_KEY"
```

[`GET /orders`](https://portal.printedsimply.com/docs/api-reference/listOrders) (`meta.next_cursor`), [`GET /order/{id}/events`](https://portal.printedsimply.com/docs/api-reference/getOrderEvents) (`meta.next_cursor`), [`GET /order/{id}/timeline`](https://portal.printedsimply.com/docs/api-reference/getOrderTimeline) (`meta.next_cursor`), [`GET /products`](https://portal.printedsimply.com/docs/api-reference/listProducts) (`meta.next_cursor`), [`GET /events`](https://portal.printedsimply.com/docs/api-reference/listEvents) (`data.next_cursor`), [`GET /assets`](https://portal.printedsimply.com/docs/api-reference/listAssets) (`data.next_cursor`), [`GET /webhooks`](https://portal.printedsimply.com/docs/api-reference/listWebhooks) (`data.next_cursor`), [`GET /webhooks/deliveries`](https://portal.printedsimply.com/docs/api-reference/listWebhookDeliveries) (`data.next_cursor`), [`GET /webhooks/inbox`](https://portal.printedsimply.com/docs/api-reference/getWebhookInbox) (`meta.next_cursor`).

These also take a `page` number, deprecated where the list pages by cursor and answered as before: `page` (from 1) with `limit`, and say where they are in `meta` (`page`, `pages`, `total`):

[`GET /orders`](https://portal.printedsimply.com/docs/api-reference/listOrders), [`GET /order/{id}/events`](https://portal.printedsimply.com/docs/api-reference/getOrderEvents), [`GET /products`](https://portal.printedsimply.com/docs/api-reference/listProducts), [`GET /assets`](https://portal.printedsimply.com/docs/api-reference/listAssets).

[Reliability contract](https://portal.printedsimply.com/docs/reliability) · [Rate limits](https://portal.printedsimply.com/docs/rate-limits) · [Webhooks](https://portal.printedsimply.com/docs/webhooks) · [Error reference](https://portal.printedsimply.com/docs/errors) · Email us at developers@printedsimply.com
