# Error Scenarios

Source: https://portal.printedsimply.com/docs/error-scenarios

What happens when things go wrong — and exactly what to do about it.

The samples below call the REST API with `fetch` (Node 18 and later, nothing to install), placing the same order every quickstart places, through this helper. Every call works with a test key (`ps_test_`) for $0. A declined card, unprintable artwork and a cancel production refuses happen only to a live order.

```
const API = "https://portal.printedsimply.com/api/v1"
const order = {
  "items": [
    { "sku": "sticker-die-cut-3x3", "quantity": 100,
      "artwork_url": "https://printedsimply.com/samples/sticker-3x3.png" }
  ],
  "address_to": {
    "name": "Jane Doe", "address1": "123 Main St",
    "city": "Austin", "state": "TX", "zip": "78701", "country": "US"
  },
  "shipping": { "method": "standard" }
}

// Every call: the key in x-api-key, JSON both ways. Resolves to { data } on
// success, or to { status, error } (the error envelope) when it is refused.
async function api(method, path, body, idempotencyKey) {
  const res = await fetch(API + path, {
    method,
    headers: {
      'x-api-key': process.env.PRINTEDSIMPLY_API_KEY,
      'Content-Type': 'application/json',
      ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  })
  const json = await res.json()
  return res.ok ? { data: json.data } : { status: res.status, error: json.error }
}
```

## Payment fails mid-order

### Cause

Card declined, insufficient funds, or 3DS authentication required.

### API Response

```
{
  "status": "error",
  "error": {
    "code": "payment_failed",
    "message": "Card was declined.",
    "doc_url": "https://portal.printedsimply.com/docs/errors#payment_failed",
    "suggestion": "Update your payment method at printedsimply.com/dashboard/billing."
  },
  "request_id": "b3f1c2de-9a47-4e0b-8f5d-2c6a1e7d9b40"
}
```

### What to Do

- The order is NOT created — no charge occurs
- If a charge was attempted and failed, any hold is automatically released
- Check the error code: 'authentication_required' means the card needs 3DS verification. On a live order, error.details carries payment_intent and client_secret: approve it with Stripe.js handleNextAction, then resend the order with payment_intent and a new Idempotency-Key
- Prompt your user to update their payment method or try a different card

### Code Example

```
const placed = await api('POST', '/order', order, order.reference)

if (placed.error?.code === 'payment_failed') {
  // Nothing was charged: show the decline and ask for another card
  console.log('Payment failed:', placed.error.message)
}
if (placed.error?.code === 'authentication_required' && placed.error.details?.client_secret) {
  // Nothing was charged: the total is held as an authorization.
  const { details } = placed.error
  const stripe = await loadStripe(details.publishable_key)
  const { error: declined } = await stripe.handleNextAction({ clientSecret: details.client_secret })
  if (!declined) {
    // The same order with payment_intent, under a new Idempotency-Key
    await api('POST', '/order', { ...order, payment_intent: details.payment_intent }, `${order.reference}-3ds`)
  }
}
```

## Rate limit exceeded

### Cause

More requests in the window than your plan allows. Rate limiting is enforced per account, across all keys — Free 60 requests per 60-second sliding window. Issuing more keys does not raise the budget.

### API Response

```
HTTP/1.1 429 Too Many Requests
Retry-After: 45
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1711234567000

{
  "status": "error",
  "error": {
    "code": "rate_limit_exceeded",
    "message": "Rate limit exceeded. Try again later.",
    "doc_url": "https://portal.printedsimply.com/docs/errors#rate_limit_exceeded",
    "suggestion": "The limit is 60 requests per 60 seconds. Check the X-RateLimit-Reset header for when you can retry."
  },
  "request_id": "b3f1c2de-9a47-4e0b-8f5d-2c6a1e7d9b40"
}
```

### What to Do

- Wait the number of seconds in the Retry-After header before retrying. It is set on both 429s, including the daily quota, which X-RateLimit-Reset (the per-minute window, as a Unix timestamp in milliseconds) does not describe
- Implement exponential backoff: wait 1s, then 2s, then 4s
- Batch operations where possible (use /orders/bulk for multi-order submissions)
- Contact us if you consistently need higher limits

### Code Example

```
async function fetchWithRetry(url, options, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    const res = await fetch(url, options)
    if (res.status === 429) {
      // Retry-After is in seconds, on both kinds of 429.
      const retryAfter = Number(res.headers.get('Retry-After'))
      const waitMs = retryAfter > 0 ? retryAfter * 1000 : Math.pow(2, i) * 1000
      await new Promise(r => setTimeout(r, Math.max(waitMs, 1000)))
      continue
    }
    return res
  }
  throw new Error('Rate limit exceeded after retries')
}
```

## Webhook delivery fails

### Cause

Your endpoint returned a non-2xx status, timed out (>10s), or was unreachable.

### API Response

```
// One failed attempt, as GET /v1/webhooks/deliveries?success=false lists it
{
  "id": "cmg2k4r7v0003qz8hx1y5w2ab",
  "webhook_id": "whk_SV8d5wGm2kUnSwx_RhZfY9OE",
  "event": "order.shipped",
  "event_id": "evt_q8Tz2LmV0cXk4RbN7sWd1HjP",
  "status_code": 500,
  "success": false,
  "created_at": "2026-10-10T08:24:51.032Z"
}
```

### What to Do

- PrintedSimply makes up to 7 attempts over about 10 hours, waiting 1 minute, 5 minutes, 15 minutes, 1 hour, 3 hours, then 6 hours between them
- A 4xx other than 408 or 429 is treated as a permanent rejection and is not retried
- After the final attempt fails, the delivery is marked as failed
- Check your webhook delivery logs in Dashboard > Webhooks for details
- Use the 'Retry' button to manually redeliver a failed webhook
- Ensure your endpoint responds within 10 seconds and returns 2xx
- Verify X-PS-Signature-V2 and answer 401 when it does not match: a 401 reads as a signature failure in the delivery log

### Code Example

```
const crypto = require('crypto')
const express = require('express')
const app = express()

const TOLERANCE_SECONDS = 300;

// header is the raw value of X-PS-Signature-V2, e.g. "t=1754222400,v1=abc123..."
// During a secret rotation the header carries one v1 per live secret
// ("t=...,v1=<new>,v1=<old>"), so collect them all and accept ANY match.
// A missing header (undefined or null) is refused like a malformed one.
function verifyWebhook(rawBody, header, secret) {
  let t = null;
  const v1s = [];
  for (const part of (header || '').split(',')) {
    const i = part.indexOf('=');
    if (i <= 0) continue;
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === 't') t = value;
    else if (key === 'v1') v1s.push(value);
  }
  if (!/^\d+$/.test(t || '') || v1s.length === 0) return false;
  if (!v1s.every((v) => /^[0-9a-f]+$/i.test(v))) return false;

  // Reject a captured request replayed outside the window. This check is the
  // reason to prefer V2 — a body-only signature never expires.
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
  if (age > TOLERANCE_SECONDS) return false;

  // The timestamp is signed along with the body, so it cannot be swapped out.
  const expected = crypto
    .createHmac('sha256', secret)
    .update(t + '.' + rawBody)
    .digest('hex');
  // timingSafeEqual requires equal-length buffers
  return v1s.some(
    (v) =>
      v.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(v), Buffer.from(expected))
  );
}

// Register this route before app.use(express.json()): the signature covers the raw bytes.
app.post('/webhooks/printedsimply', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = Buffer.isBuffer(req.body) ? req.body.toString('utf8') : ''
  if (!verifyWebhook(rawBody, req.get('X-PS-Signature-V2'), process.env.PRINTEDSIMPLY_WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature')
  }
  // Respond quickly, then do the work. Delivery is at-least-once: skip an
  // X-PS-Event-Id you have already handled.
  res.status(200).send('OK')
  processOrderUpdate(JSON.parse(rawBody)).catch(console.error)
})
```

## Invalid artwork submitted

### Cause

A live order's artwork cannot be printed as it is: nothing is at the link (404 or 410), the link returns something that is not an image, or the file has too few pixels for the print size.

### API Response

```
{
  "status": "error",
  "error": {
    "code": "artwork_not_printable",
    "message": "The artwork on one line of this order cannot be printed as it is, so the order was not placed and nothing was charged. details.problems says what each one needs.",
    "doc_url": "https://portal.printedsimply.com/docs/errors#artwork_not_printable",
    "suggestion": "details.problems names each line by item_index with a code: unreachable (the artwork URL answered 404 or 410), not_an_image (it returned a page or other non-image bytes) or low_resolution (too few pixels for the print size; the message says how many it needs). Nothing was charged and the Idempotency-Key is free: fix the files and send the order again. A test key (ps_test_*) is never refused for artwork; its response carries the same verdict in artwork_check.would_refuse.",
    "details": {
      "problems": [
        {
          "code": "low_resolution",
          "item_index": 0,
          "message": "items[0] (sticker-die-cut-3x3): 216×216 px is 72 DPI at 3×3 in, below the 225 DPI a die-cut sticker can be printed from (300 recommended). It needs at least 675 px on the long side; 900 prints at the recommended resolution."
        }
      ],
      "problem_count": 1,
      "artwork_check": {
        "lines": [
          {
            "item_index": 0,
            "sku": "sticker-die-cut-3x3",
            "status": "refused",
            "width_px": 216,
            "height_px": 216,
            "findings": [
              {
                "code": "low_resolution",
                "severity": "error",
                "message": "items[0] (sticker-die-cut-3x3): 216×216 px is 72 DPI at 3×3 in, below the 225 DPI a die-cut sticker can be printed from (300 recommended). It needs at least 675 px on the long side; 900 prints at the recommended resolution."
              }
            ],
            "print_size_in": [
              3,
              3
            ],
            "effective_dpi": 72,
            "min_dpi": 300
          }
        ]
      }
    }
  },
  "request_id": "b3f1c2de-9a47-4e0b-8f5d-2c6a1e7d9b40"
}
```

### What to Do

- The order is NOT placed and nothing is charged; the Idempotency-Key is free to send again once the file is fixed
- details.problems names each line by item_index, with a code and what the file needs
- A test key is never refused for artwork: its response carries the same verdict in artwork_check, with would_refuse, so rehearse with a test key
- Check a file against the product before ordering with POST /artwork/validate
- Upload a file with POST /assets and send its id as print_file, or link the file itself (PNG, PDF or SVG) as artwork_url

### Code Example

```
// Check the file against the product before ordering
const check = await api('POST', '/artwork/validate', {
  file_url: order.items[0].artwork_url,
  product_type: order.items[0].sku,
  width_px: 1200,
  height_px: 1200,
})
if (check.data && !check.data.valid) {
  console.log('Fix first:', check.data.checks.filter((c) => c.status === 'fail'))
}

const placed = await api('POST', '/order', order, order.reference)
if (placed.error?.code === 'artwork_not_printable') {
  for (const problem of placed.error.details.problems) {
    console.log(`items[${problem.item_index}] ${problem.code}: ${problem.message}`)
  }
}
```

## Cancellation refused by production

### Cause

The order is past the cancel window (it has gone into production), or the fulfillment system refused to cancel a live order.

### API Response

```
{
  "status": "error",
  "error": {
    "code": "cancellation_failed",
    "message": "Could not cancel order in production system.",
    "doc_url": "https://portal.printedsimply.com/docs/errors#cancellation_failed",
    "suggestion": "The order may have already entered production. Contact support if this persists."
  },
  "request_id": "b3f1c2de-9a47-4e0b-8f5d-2c6a1e7d9b40"
}
```

### What to Do

- A live order is cancelled with the fulfillment system first and refunded only if it accepts. A 502 cancellation_failed means it refused: the order is still live and nothing was refunded
- An order can be cancelled only before it goes into production: pending_payment, paid or accepted. From in_production on, the cancel answers 409 not_cancellable without asking; one already cancelled answers 400 already_cancelled
- A test order follows the same window, with nothing to refund
- Contact support at support@printedsimply.com for a live order the fulfillment system would not cancel
- Subscribe to 'order.in_production' to know when the cancel window has closed

### Code Example

```
// orderId: the order_id POST /order returned
const { data: current } = await api('GET', `/order/${orderId}`)

// Only an order not yet in production can be cancelled
if (!['pending_payment', 'paid', 'accepted'].includes(current.status)) {
  console.log(`Cannot cancel — order is ${current.status}`)
} else {
  const cancelled = await api('DELETE', `/order/${orderId}/cancel`)
  if (cancelled.error?.code === 'cancellation_failed') {
    // Still live, nothing refunded: production would not stop it
    console.log('Fulfillment refused the cancel — contact support')
  } else if (cancelled.data) {
    console.log('Order cancelled; a live order is refunded')
  }
}
```

## When a placed order is flagged

An order the facility cannot finish moves to `issue_flagged` and sends `order.issue`. When the facility's status says why, the order and the event carry `issue_reason`. It is null on the order, and absent from the event, when no reason below fits. The flag clears when the facility moves the order on.

| issue_reason | What happened | What to do |
| --- | --- | --- |
| `artwork_unprintable` | The facility could not print the file the order sent: it would not open, or its format, size or resolution does not fit the product. | Check the file with POST /v1/artwork/validate. A flagged order takes no changes, so cancel it with DELETE /v1/order/{id}/cancel and place it again with a fixed file, or contact support with the order id. |
| `address_undeliverable` | The carrier or the facility cannot deliver to the shipping address: it could not be verified, or the parcel came back. | Confirm the address with the buyer. A flagged order takes no address change, so cancel it and place it again with the corrected address, or contact support with the order id. |
| `shipment_exception` | The parcel is in trouble in transit: a failed delivery, a carrier exception, or a lost or damaged package. | Watch for the next status: the flag clears when the facility moves the order on. If it does not, contact support with the order id and the tracking number. |
| `on_hold` | The facility is holding the order and it is not moving. | Wait for the next status: the flag clears when the order moves on. Contact support with the order id if it stays on hold. |
| `cancellation_pending` | A cancellation or refund was requested or attempted at the facility and has not finished, so the order is neither running nor cancelled. | Wait for order.cancelled, or for the order to move on if the cancellation was refused. Do not place a replacement until one of them arrives. |

### Rehearse it with a test key

Simulate the flag on a test order and the order takes the same status and `issue_reason` a live one would, and `order.issue` is sent to your test-mode webhooks. Leave out `issue_reason` to rehearse a flag with none. The sandbox clock stops on a flag; simulate a later state (for example `shipped`) to move the order on, or cancel it. A live key is refused with `test_mode_only`.

```
curl -X POST https://portal.printedsimply.com/api/v1/order/test_ord_abc123/simulate \
  -H "x-api-key: ps_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"target_state": "issue_flagged", "issue_reason": "artwork_unprintable"}'
```

```
// order.issue: branch on why, and keep a default for a flag with no reason
if (event.event === 'order.issue') {
  switch (event.data.issue_reason) {
    case 'artwork_unprintable':
      await askCustomerForNewArtwork(event.data.order_id)
      break
    case 'address_undeliverable':
      await askCustomerToConfirmAddress(event.data.order_id)
      break
    default:
      // shipment_exception, on_hold, cancellation_pending, or no issue_reason
      await flagForReview(event.data.order_id)
  }
}
```
