# Troubleshooting guide

Source: https://portal.printedsimply.com/docs/troubleshooting

Common issues and how to resolve them. If you still need help, contact us at developers@printedsimply.com.

## Authentication failures

### Symptoms

- All requests return `401 Unauthorized`
- Error code `missing_api_key` or `invalid_api_key`

### Diagnosis

- Verify you are using a supported header: `x-api-key` or `Authorization: Bearer` (a bare `api-key` header is not read)
- Check key format: test keys start with `ps_test_`, live keys with `ps_live_`
- Confirm the key has not been revoked in your [dashboard](https://portal.printedsimply.com/dashboard/api-keys)
- Ensure no trailing whitespace or newlines in the key value

### Resolution

Set the header correctly in your request:

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

# Equivalent — the same credential as a Bearer token:
# -H "Authorization: Bearer ps_test_YOUR_KEY_HERE"

# Common mistake — a header name we do not read:
# -H "api-key: ps_test_..."                ← WRONG
```

## Rate limit issues

### Symptoms

- Requests return `429 Too Many Requests`
- Error code `rate_limit_exceeded`

### Diagnosis

- Check the `X-RateLimit-Remaining` header on responses to monitor your usage
- 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
- Use `X-RateLimit-Reset` to know when the window resets

### Resolution

Implement exponential backoff with jitter:

```
async function fetchWithRetry(url: string, options: RequestInit, maxRetries = 3) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
  const response = await fetch(url, options);

  if (response.status === 429) {
    // Retry-After is in seconds, on both kinds of 429. (X-RateLimit-Reset is
    // a Unix timestamp in milliseconds, not seconds.)
    const retryAfter = Number(response.headers.get("Retry-After"));
    const waitMs = retryAfter > 0
      ? retryAfter * 1000
      : Math.pow(2, attempt) * 1000 + Math.random() * 1000;

    console.log(`Rate limited. Retrying in ${Math.round(waitMs / 1000)}s...`);
    await new Promise((resolve) => setTimeout(resolve, waitMs));
    continue;
  }

  return response;
}

throw new Error("Max retries exceeded");
}
```

## Webhook delivery problems

### Symptoms

- Webhook events are not arriving at your endpoint
- Webhook delivery logs show failed attempts
- Signature verification is failing

### Diagnosis

- The endpoint URL must use HTTPS, or http:// to localhost or 127.0.0.1 outside production
- Verify it is publicly reachable: delivery refuses any hostname that resolves to a loopback, private or link-local address
- Your endpoint must respond with a 2xx status within 10 seconds (delivery makes up to 7 attempts over about 10 hours)
- Check [webhook delivery logs](https://portal.printedsimply.com/dashboard/webhooks) for specific error details
- Ensure you are reading the raw request body for signature verification (not parsed JSON) — in Express, register the webhook route before `express.json()`

### Resolution

Verify `X-PS-Signature-V2` over the raw body on every webhook. A missing or malformed header, a stale timestamp or a signature of the wrong length returns `false`; it never throws, so a bad request is a 401 rather than a 500 that is retried:

```
import crypto from "crypto";
import express from "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: string, header: string | null | undefined, secret: string): boolean {
  let t: string | null = null;
  const v1s: string[] = [];
  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, and a JSON parser that runs first leaves an object behind.
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!)) {
    res.status(401).send("Invalid signature");
    return;
  }

  // Every delivery is { id, event, timestamp, data }; the order is in data.
  // Skip an X-PS-Event-Id you have already handled: delivery is at-least-once.
  const event = JSON.parse(rawBody);
  console.log(event.event, event.data.order_id);
  res.sendStatus(200);
});
```

## Artwork validation errors

### Symptoms

- Artwork validation endpoint returns failures
- Orders rejected due to artwork not meeting specifications

### Diagnosis

- Minimum DPI depends on the product — see the table below; `POST /v1/artwork/validate` checks a file against it
- Dimensions must match the product specifications
- Send RGB (sRGB) artwork: a CMYK file prints, but previews and mockups show it converted to RGB, so it may not match what you approved on screen.
- Files over 100 MB fail the size check, for every product

### Requirements by product

| Product | Min DPI | Max print size | Bleed | Formats |
| --- | --- | --- | --- | --- |
| Die-Cut Sticker | 300 | 17" x 17" | 0.125" | PNG, PDF, SVG |
| Kiss-Cut Sticker | 300 | 17" x 17" | 0.125" | PNG, PDF, SVG |
| Sticker Sheet | 300 | 8.5" x 11" | 0.125" | PNG, PDF |
| Roll Sticker | 300 | 4" x 4" | 0.0625" | PNG, PDF |
| Vinyl Decal | 150 | 48" x 48" | 0.25" | PNG, PDF, SVG, TIFF |
| Wall Decal | 150 | 96" x 96" | 0.25" | PNG, PDF, SVG, TIFF |
| Magnet | 300 | 12" x 12" | 0.125" | PNG, PDF |
| Poster | 150 | 36" x 48" | 0.125" | PNG, PDF, TIFF |

### Resolution

Always validate artwork before creating an order:

```
curl -X POST https://portal.printedsimply.com/api/v1/artwork/validate \
-H "Content-Type: application/json" \
-H "x-api-key: ps_test_YOUR_KEY" \
-d '{
  "file_url": "https://example.com/design.png",
  "product_type": "sticker-die-cut",
  "width_px": 900,
  "height_px": 900,
  "dpi": 300,
  "file_size_bytes": 2500000
}'
```

## Order status questions

### Standard order flow

`created` → `in_production` → `shipped` → `delivered`

### Webhooks vs polling

- Use webhooks for real-time notifications. Register your endpoint in the [dashboard](https://portal.printedsimply.com/dashboard/webhooks) and listen for order.* events.
- Use polling only as a fallback. Call `GET /api/v1/order/{id}` at reasonable intervals (no more than once per minute).
- Use the `GET /api/v1/order/{id}/events` endpoint to see the full event timeline for an order.

### Cancellation window

Orders can be cancelled up until they enter `in_production` (the `created` and `paid` states). Once an order enters production, it cannot be cancelled through the API. Contact support for orders already in production.

## SDK / CLI issues

### SDKs and the CLI are not yet published

The official SDKs and command-line tool (including packages such as `@printedsimply/sdk` and `@printedsimply/cli`) are still in development and are not available on npm yet. Any install, update, or version commands referencing them will not work today.

### In the meantime

You can use the PrintedSimply REST API directly over HTTP — see the REST and authentication sections above for troubleshooting requests. If you would like early access to the SDKs or CLI, [get in touch](https://portal.printedsimply.com/contact).

Still stuck? [Error reference](https://portal.printedsimply.com/docs/errors) · [Full API docs](https://portal.printedsimply.com/docs) · Email us at developers@printedsimply.com
