# Webhooks

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

Get HMAC-signed, real-time notifications the moment an order changes — no polling, and every delivery is replayable.

## The contract

**Signed**

Every delivery carries `X-PS-Signature-V2`: `t=<unix>,v1=<hex>`, an HMAC-SHA256 of `<t>.<raw body>` under your endpoint's secret. Refuse a `t` more than 5 minutes from your clock and compare in constant time — [the verifier](https://portal.printedsimply.com/docs/webhooks#signature-verification).

**Retried**

A timeout, a refused connection, a 5xx, 408 or 429 is retried, up to 7 attempts over about 10 hours, waiting between them:

1. 1 minute
2. 5 minutes
3. 15 minutes
4. 1 hour
5. 3 hours
6. 6 hours

Any other 4xx is final. [Retry policy](https://portal.printedsimply.com/docs/webhooks#retry-policy)

**Logged**

Every attempt is kept with its status, its cause and the body we sent, in the [delivery log](https://portal.printedsimply.com/dashboard/webhooks/logs) and at `GET /v1/webhooks/deliveries`.

**Replayable**

Re-send any delivery (`POST /v1/webhooks/deliveries/{id}/redeliver`) or any event in your feed (`POST /v1/events/{eventId}/replay`), signed afresh, under the same event id.

**One id per event**

The envelope's `id`, sent as `X-PS-Event-Id`, is the same on every retry and replay. Store it and skip one you have handled.

## Your first event

Three steps with the test key from the [quickstart](https://portal.printedsimply.com/docs/quickstarts/curl): subscribe, place and ship a $0 test order, read what arrives. Nothing is charged or printed.

### 1. Subscribe your handler

Point `url` at your handler — a deployed one, or the one on your laptop through an HTTPS tunnel ([Local development](https://portal.printedsimply.com/docs/webhooks#local-development)). `mode: "test"` sends it sandbox events only; it is a test key's default, and a test key asking for live traffic is refused.

curl:

```bash
export PRINTEDSIMPLY_API_KEY=ps_test_...    # the quickstart's test key

curl -X POST https://portal.printedsimply.com/api/v1/webhooks \
  -H "x-api-key: $PRINTEDSIMPLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://your-app.example.com/webhooks/printedsimply",
       "events":["order.created","order.shipped"],
       "mode":"test"}'
```

Response:

```json
{
  "status": "success",
  "data": {
    "id": "whk_5Gm2Rk8Tq4Vw7Yb3Nc6Hd9Jf",
    "url": "https://your-app.example.com/webhooks/printedsimply",
    "events": [
      "order.created",
      "order.shipped"
    ],
    "active": true,
    "mode": "test",
    "created_at": "2026-10-10T15:00:00.000Z",
    "secret_rotated_at": null,
    "previous_secret_expires_at": null,
    "rotation_pending": false,
    "inbox": false,
    "secret": "9c2e7f4a1b8d3e6f0a5c9b2d7e4f1a8c3b6d9e2f5a8c1b4d7e0f3a6c9b2e5d8f"
  },
  "request_id": "req_3Lw8Pn5Tk2Xv9Qd6"
}
```

`secret` is in this response and in no later one: store it now. Your handler verifies every delivery with it.

### 2. Make it happen

Place the quickstart's $0 test order, and your endpoint receives `order.created`. Placed it before? Run it again: the sample sends a new Idempotency-Key each run and no `reference`, so it is a new order. A replayed key returns the first order, a reference sent twice is refused 409 `duplicate_reference`, and neither raises an event. Then ship it now rather than waiting for the sandbox's clock:

curl:

```bash
# The order_id your $0 test order answered with
ORDER_ID=test_ord_8Kq2vX9mP4nRw7Tb5Lc3dEhA

curl -X POST https://portal.printedsimply.com/api/v1/order/$ORDER_ID/simulate \
  -H "x-api-key: $PRINTEDSIMPLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"target_state":"shipped"}'
```

### 3. Read it

`order.shipped` as your handler receives it — the body, and the request it arrives in:

order.shipped:

```json
{
  "id": "evt_Qm7Wd2Xk9Lp4Rt6Vb8Nc3Hs5",
  "event": "order.shipped",
  "timestamp": "2026-10-10T15:04:05.120Z",
  "data": {
    "event_id": "evt_Fz3Jh8Ty1Ku6Mp9Ga4Se2Wd7",
    "order_id": "test_ord_8Kq2vX9mP4nRw7Tb5Lc3dEhA",
    "portal_order_id": "ord_8Kq2vX9mP4nRw7Tb5Lc3dEhA",
    "provider": "printedsimply",
    "reference": "order-1001",
    "status": "shipped",
    "tracking_number": "TRK-TEST-7D4K20QX81MZ",
    "tracking_carrier": "USPS",
    "tracking_url": "https://portal.printedsimply.com/track/trk_Vb6Nq1Xe8Rm3Kt5Wz2Hc9Lp",
    "tracking": {
      "number": "TRK-TEST-7D4K20QX81MZ",
      "carrier": "USPS",
      "url": "https://portal.printedsimply.com/track/trk_Vb6Nq1Xe8Rm3Kt5Wz2Hc9Lp"
    },
    "estimated_delivery": {
      "earliest": "2026-10-20",
      "latest": "2026-10-26",
      "ships_by": "2026-10-14",
      "is_estimate": true
    },
    "test_mode": true
  }
}
```

Request:

```http
POST /webhooks/printedsimply HTTP/1.1
Content-Type: application/json
X-PS-Signature: sha256=5c17b4986cab6c68e663dbbcc186883de1a5461bfd49a32e495c41e1fca2d17e
X-PS-Signature-V2: t=1791644647,v1=9654f6cb155f348959b85ef125fe01a2ef6e75705b5b9618e4b426b59b49abfe
X-PS-Timestamp: 1791644647
X-PS-Event: order.shipped
X-PS-Event-Id: evt_Qm7Wd2Xk9Lp4Rt6Vb8Nc3Hs5
X-PS-Delivery-Attempt: 1
X-MF-Signature: sha256=5c17b4986cab6c68e663dbbcc186883de1a5461bfd49a32e495c41e1fca2d17e
X-MF-Signature-V2: t=1791644647,v1=9654f6cb155f348959b85ef125fe01a2ef6e75705b5b9618e4b426b59b49abfe
X-MF-Timestamp: 1791644647
X-MF-Event: order.shipped
X-MF-Event-Id: evt_Qm7Wd2Xk9Lp4Rt6Vb8Nc3Hs5
X-MF-Delivery-Attempt: 1
```

- **Dedupe on id** — the value of the `X-PS-Event-Id` header, and `event_id` in the delivery log and in `GET /v1/events`. It is the same on every retry and every replay.
- `data.event_id` is an older id some order events still carry for receivers that read it. It is not the event's id: it differs from `id`, and not every event carries it, so dedupe on `id` alone.
- `tracking_number`, `tracking_carrier` and `tracking_url` are the documented tracking fields; `tracking` holds the same three as an object, kept for receivers built on it.
- `test_mode: true` — a sandbox order. A live order's events carry `false`.

**Going live** is a key swap: send the same subscribe request with your `ps_live_` key and `"mode": "live"`, and that endpoint receives your real orders' events and never the sandbox's.

## Overview

Webhooks let you subscribe to events that happen in your PrintedSimply account. When an event occurs, we send an HTTP `POST` request to the URL you configure, with a JSON payload describing the event.

Every webhook request is signed twice. `X-PS-Signature-V2` (value `t=<unix>,v1=<hex>`) is an HMAC-SHA256 over `<t>.<raw body>`. Because the timestamp is part of the signed material, you can reject a request that was captured and replayed later — **verify this one**. `X-PS-Signature` (value `sha256=<hex>`) is the original body-only signature. It still ships on every request so existing integrations keep working, but a body-only signature stays valid forever and cannot detect a replay.

Each delivery also includes `X-PS-Event`, `X-PS-Timestamp`, `X-PS-Delivery-Attempt`, and `X-PS-Event-Id` headers. The event id also appears as `id` in the payload, and is the same value across our retry attempts and any manual redelivery — store it and skip an id you have already processed, so a retry never runs your handler twice.

Every `X-PS-*` header is also sent under its deprecated `X-MF-*` name with the same value (`X-MF-Signature-V2`, `X-MF-Event-Id` and the rest), so a receiver written before the PrintedSimply rename keeps verifying unchanged. Read `X-PS-*` in new code; the SDKs' verifiers read it first and fall back to the old name.

## Event types

Order lifecycle

| Event | Description |
| --- | --- |
| `order.created` | Fired when a new order is submitted |
| `order.paid` | Fired when payment is successfully captured |
| `order.in_production` | Fired when the order enters production at the fulfillment provider |
| `order.shipped` | Fired when the order ships with tracking information |
| `order.delivered` | Fired when the carrier confirms delivery |
| `order.cancelled` | Fired when an order is cancelled |
| `order.failed` | Fired when an order that was accepted will not be produced — `error_code` is `payment_failed` (a payment taken after the order was accepted was declined or never funded) |
| `order.updated` | Fired when a placed order changes — a line added, a quantity changed, a line cancelled, the shipping service changed, the destination address changed, a letter reported missing was reshipped (`letter_reshipped`, carrying the replacement's ids), or the fulfilment provider assigned its own id to an order accepted under a pending id (`provider_order_id_assigned`: `order_id` is the new id and `changes.previous_order_id` the one it replaces, which keeps resolving). `changes.action` says which |
| `order.quality_check` | Fired when the order enters quality inspection before shipping |
| `order.issue` | Fired when fulfillment flags an issue with the order (hold, defect, or error) — `issue_reason` says why when the facility did: `artwork_unprintable`, `address_undeliverable`, `shipment_exception`, `on_hold` or `cancellation_pending`; it is absent when no reason fits. A test key raises it with POST /v1/order/{id}/simulate |
| `order.approval_requested` | Fired when an order placed with `approval: "required"` is held for a person's approval — nothing is charged or sent to production. `approval_url` is the signed page the person approves or declines it on, with no account; it stops deciding at `expires_at` (72 hours), and the order is then cancelled |
| `order.approval_approved` | Fired when a person approved a held order on its approval page and the order was placed and charged — `order.created` and `order.paid` fire beside it. `order_id` is the placed order's own id; `portal_order_id` never changes. `decided_by` is the name they typed, when they typed one |
| `order.approval_declined` | Fired when a person declined a held order on its approval page: the order is cancelled and nothing was charged |
| `order.approval_expired` | Fired when a held order's approval link expired with no decision: the order is cancelled and nothing was charged |

The catalog and your endpoints

The catalog and your webhook endpoints emit their own events too. Subscribe to any subset — pass the names in `events`, or `["*"]` for everything.

| Family | Events |
| --- | --- |
| Catalog | `product.updated`, `catalog.synced` |
| Webhooks | `webhook.disabled` |

## Setting up webhooks

Register endpoints with your API key — no dashboard round trip, which is what lets your own code subscribe to the events its own orders produce. Everything below needs the `webhooks:manage` scope. The same subscriptions appear in the [dashboard](https://portal.printedsimply.com/dashboard/webhooks), so either surface can manage what the other created.

curl:

```bash
# Subscribe an endpoint. The response carries "secret" — shown once.
# A test key subscribes to test traffic; with your ps_live_ key, send "mode":"live".
curl -X POST https://portal.printedsimply.com/api/v1/webhooks \
  -H "x-api-key: $PRINTEDSIMPLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/hooks/printedsimply",
       "events":["order.shipped","order.delivered"],
       "mode":"test"}'

# Everything you are subscribed to (never the secrets)
curl https://portal.printedsimply.com/api/v1/webhooks \
  -H "x-api-key: $PRINTEDSIMPLY_API_KEY"

# Pause without losing the subscription or its history
curl -X PATCH https://portal.printedsimply.com/api/v1/webhooks/whk_5Gm2Rk8Tq4Vw7Yb3Nc6Hd9Jf \
  -H "x-api-key: $PRINTEDSIMPLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"active":false}'
```

The signing secret is returned **exactly once**, on create and on rotation — every later read of a subscription omits it. If you lose it, rotate rather than re-read.

Pausing with `active: false` holds what is already queued for the endpoint: nothing is sent, and the held events go out where their retry schedule left off once you set it back to `true`. Events raised while it is paused are not queued for it, so replay the ones you need from the event feed after resuming. An endpoint we switch off after repeated failures is held the same way.

### Requirements

- The endpoint URL must use HTTPS, or http:// to localhost or 127.0.0.1 outside production
- It must also be publicly reachable: delivery refuses any hostname that resolves to a loopback, private or link-local address, so an endpoint inside your own network never receives one
- Your server must respond with a `2xx` status code within 10 seconds

## Test and live traffic

Every subscription has a `mode`: `all` (the default for a live key), `test`, or `live`. A `test` endpoint receives sandbox events and nothing else, so the tunnel on your laptop never sees a real customer's order. A `live` endpoint is the mirror image: your production handler stops being woken every few minutes by whoever is working through the quickstart.

A test key stays on its own side of that line. A subscription it creates is `test` unless it says otherwise, and asking for `live` or `all` answers 403 `live_key_required`. It can change, rotate or delete only test subscriptions, and its delivery log shows sandbox events and account events only. It can still list every subscription the account has. A live key manages all of them.

curl:

```bash
# A tunnel that only ever sees the sandbox
curl -X POST https://portal.printedsimply.com/api/v1/webhooks \
  -H "x-api-key: $PRINTEDSIMPLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.invalid/dev","events":["*"],"mode":"test"}'

# Re-scope one you already have (live or all needs your ps_live_ key)
curl -X PATCH https://portal.printedsimply.com/api/v1/webhooks/whk_5Gm2Rk8Tq4Vw7Yb3Nc6Hd9Jf \
  -H "x-api-key: $PRINTEDSIMPLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"mode":"live"}'
```

What arrives:

```json
// A sandbox order, on a mode:"test" or mode:"all" subscription
{
  "id": "evt_5f...",
  "event": "order.created",
  "timestamp": "2026-08-08T14:03:11.204Z",
  "data": {
    "order_id": "test_ord_8Kq2vX9mP4nRw7Tb5Lc3dEhA",
    "portal_order_id": "ord_8Kq2vX9mP4nRw7Tb5Lc3dEhA",
    "status": "paid",
    "total_cents": 2800,
    "test_mode": true
  }
}

// A catalog sync — account-level, so it reaches every subscription
// and carries no test_mode at all.
{
  "id": "evt_a1...",
  "event": "catalog.synced",
  "timestamp": "2026-08-08T14:05:00.000Z",
  "data": { "provider": "merchfarm", "product_count": 412 }
}
```

**Every order event carries `test_mode`** in its `data`, stamped from the same decision that routed it — so a handler on a `mode: "all"` subscription can still branch on arrival, and the flag can never disagree with which endpoints were chosen.

Some events belong to neither side — a catalog sync (`catalog.synced`, `product.updated`). They are facts about your account rather than a rehearsal of anything, so they reach **every** subscription whatever its mode, and they carry no `test_mode` rather than an invented one.

Subscriptions created before this field existed are `all`, which is exactly the traffic they receive today — nothing you are relying on stopped arriving.

## Signature verification

Verify every webhook against the `X-PS-Signature-V2` header using the webhook secret from your dashboard. Its value is `t=<unix-seconds>,v1=<hex>`, where the hex digest is an HMAC-SHA256 of `<t>.<raw body>` — the timestamp, a literal dot, then the **raw** request body (not the parsed JSON).

Reject the request if `t` is more than five minutes from your own clock, then compare the digests in constant time. Skipping the timestamp check leaves you replayable: the signature alone proves the body came from us, not that it was sent to you just now.

The older `X-PS-Signature` header (`sha256=<hex>`, an HMAC-SHA256 of the raw body alone) still ships on every request and existing integrations remain supported — but it has no replay protection, so move to V2 when you can.

Node.js:

```javascript
const crypto = require('crypto');

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

Python:

```python
import hmac
import hashlib
import time

TOLERANCE_SECONDS = 300

# header is the raw X-PS-Signature-V2 value, 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 (None) is refused like a malformed one.
def verify_webhook(raw_body: bytes, header: str, secret: str) -> bool:
    t, v1s = "", []
    for part in (header or "").split(","):
        if "=" not in part:
            continue
        key, value = part.strip().split("=", 1)
        if key == "t":
            t = value
        elif key == "v1":
            v1s.append(value)
    # A Unix timestamp is ten digits. Refusing a longer one also keeps int()
    # from raising on a header padded past Python's digit limit.
    if not (t.isascii() and t.isdigit()) or len(t) > 12 or not v1s:
        return False

    # Reject a captured request replayed outside the window. This check is the
    # reason to prefer V2 — a body-only signature never expires.
    if abs(int(time.time()) - int(t)) > TOLERANCE_SECONDS:
        return False

    # The timestamp is signed along with the body, so it cannot be swapped out.
    expected = hmac.new(
        secret.encode(),
        t.encode() + b"." + raw_body,
        hashlib.sha256,
    ).hexdigest()
    # Compared as bytes: compare_digest raises on a str with non-ASCII characters.
    return any(hmac.compare_digest(v1.encode(), expected.encode()) for v1 in v1s)
```

## Rotating the signing secret

Rotate a webhook's signing secret with `POST /v1/webhooks/{id}/rotate-secret`, or from the dashboard. You get a new secret back once; the outgoing one stays valid for a grace window (24 hours by default, up to 7 days via `grace_hours`). No deliveries are lost and the subscription keeps its history — you never have to delete and recreate an endpoint.

curl:

```bash
# New secret now, old one still verifying for 24h (max 168)
curl -X POST \
  https://portal.printedsimply.com/api/v1/webhooks/whk_5Gm2Rk8Tq4Vw7Yb3Nc6Hd9Jf/rotate-secret \
  -H "x-api-key: $PRINTEDSIMPLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"grace_hours": 24}'

# Once the new secret is deployed, close the window early
curl -X DELETE \
  https://portal.printedsimply.com/api/v1/webhooks/whk_5Gm2Rk8Tq4Vw7Yb3Nc6Hd9Jf/rotate-secret \
  -H "x-api-key: $PRINTEDSIMPLY_API_KEY"
```

During the window every delivery is signed with **both** secrets, and `X-PS-Signature-V2` carries one `v1` per secret — `t=<unix>,v1=<new>,v1=<outgoing>`. Accept the request if **any** `v1` matches your secret; the examples above already do. Receivers still on the single-valued `X-PS-Signature` header get the outgoing secret's digest in `X-PS-Signature-Previous` for the duration of the window.

Deploy the new secret to your endpoint, then close the window early with `DELETE /v1/webhooks/{id}/rotate-secret` (“Finish now” in the dashboard). Do that as soon as you can when you are rotating because a secret leaked — until the window closes, the old secret still verifies.

## Retry policy

Each delivery waits up to 10 seconds for a `2xx` response. If it times out, the connection fails, or it returns a 5xx, 408 or 429, we retry — **up to 7 attempts over about 10 hours**, from a durable queue that a dispatcher drains every minute, so an endpoint that is down for a deploy still receives everything it missed. Any other `4xx` is treated as a permanent rejection and is not retried.

| Attempt | Earliest, after the previous attempt |
| --- | --- |
| 1 (initial) | — |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 15 minutes |
| 5 | 1 hour |
| 6 | 3 hours |
| 7 (final) | 6 hours |

You are notified either way a delivery ends — when the final attempt fails, and when the ladder stops early because the response can never succeed. Both mark the delivery failed and raise the same notice in your dashboard, debounced to one per endpoint per hour, so an endpoint answering `404` is not quieter than one answering `500`. You can inspect every attempt and **replay any failed delivery** on demand from the [webhook dashboard](https://portal.printedsimply.com/dashboard/webhooks) — a fresh signature is generated for the replay.

An endpoint that keeps failing is eventually **disabled automatically**: after **20 consecutive events** end in permanent failure, the subscription is switched off and a `webhook.disabled` event is written to your account feed — announced there, in your dashboard notifications, and to any other active subscription, because the endpoint it is about can no longer hear it. Any successful delivery resets the count. Fix the endpoint, re-enable the subscription (which also resets the count), and replay what you missed from the [events feed](https://portal.printedsimply.com/dashboard/webhooks/events).

## Local development

We only deliver to publicly reachable endpoints — `localhost`, loopback, and private IP ranges are refused at delivery time, which is what stops a webhook URL from being aimed at internal infrastructure. Registration says the same thing in the scheme: the endpoint URL must use HTTPS, or http:// to localhost or 127.0.0.1 outside production. Either way, the URL you are actually developing against is not one this API can reach.

**No server yet?** Make your account's [hosted test inbox](https://portal.printedsimply.com/dashboard/webhooks/inbox): it receives your test events, never live ones, and keeps each exactly as an endpoint would receive it — headers, signature and raw body — with nothing to install and no tunnel.

**Use an HTTPS tunnel.** Run your handler, give it a public HTTPS URL with a tunnel, and subscribe that URL with your test key and `mode: "test"` (“Test only” in the [dashboard](https://portal.printedsimply.com/dashboard/webhooks)). Deliveries reach your laptop signed exactly as production signs them, and no real customer's order ever does. When the tunnel's URL changes, update the subscription's `url` (`PATCH /v1/webhooks/{id}`) rather than making another.

Tunnel:

```bash
# 1. Your handler, running on your machine, e.g.
#    http://localhost:3000/webhooks/printedsimply

# 2. A public HTTPS URL for it — either tool works
cloudflared tunnel --url http://localhost:3000
#   or: ngrok http 3000

# 3. Subscribe the URL it printed, for test traffic only
export PRINTEDSIMPLY_API_KEY=ps_test_...
curl -X POST https://portal.printedsimply.com/api/v1/webhooks \
  -H "x-api-key: $PRINTEDSIMPLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://YOUR-TUNNEL-HOST/webhooks/printedsimply",
       "events":["*"],
       "mode":"test"}'
```

### Or replay the delivery log with `printedsimply listen`

Every attempt records its payload whether or not it succeeded. Point a subscription at a placeholder host you do not control (`https://example.invalid/webhooks`) and the events pile up in your log, bodies included. Our CLI reads that log and replays each new entry to your machine, with no tunnel. It is not published to npm yet; until it is, use a tunnel.

Create that placeholder subscription with `mode: "test"` (see [Test and live traffic](https://portal.printedsimply.com/docs/webhooks#test-and-live)). Otherwise the log it reads — and therefore your laptop — fills with your production order traffic alongside the sandbox events you are actually debugging.

Listen:

```bash
export PRINTEDSIMPLY_API_KEY=ps_test_...
# @printedsimply/cli reads PRINTEDSIMPLY_API_KEY; hand it the API host too
export PRINTEDSIMPLY_BASE_URL=https://portal.printedsimply.com/api/v1

printedsimply listen --forward-to http://localhost:3000/webhooks/printedsimply
```

Output:

```bash
Forwarded requests are signed with this local secret:
  whsec_2f1c9a...

Listening for deliveries → http://localhost:3000/webhooks/printedsimply

  ✓ order.created                → 200
  ✓ order.shipped                → 200
  ✗ order.cancelled              → 500 (handler rejected)
```

Filtering & replay:

```bash
# Only one subscription, only two event types
printedsimply listen --forward-to http://localhost:3000/webhooks/printedsimply \
  --webhook-id whk_5Gm2Rk8Tq4Vw7Yb3Nc6Hd9Jf --events order.created,order.shipped

# Re-send the five most recent deliveries on startup
printedsimply listen --forward-to http://localhost:3000/webhooks/printedsimply --replay-last 5

# Pin the signing secret so it survives restarts
printedsimply listen --forward-to http://localhost:3000/webhooks/printedsimply \
  --secret whsec_mylocaldev
```

Forwarded requests carry the full production header set and are signed the same way, so the handler you debug locally is the handler that ships — no `if (isDev) skipVerification()` branch. The secret printed at startup is generated locally and is **not** your endpoint's real signing secret; a dev machine should never need production credentials to exercise a webhook handler. Set it as your webhook secret while developing, then swap in the real one when you deploy.

Deliveries are forwarded in the order their events happened, so `order.created` reaches your handler before `order.shipped`. Each _event_ is forwarded once per run, not each attempt — the placeholder subscription fails by design, so one event is up to 7 rows in the log, and the CLI collapses them on `event_id` exactly as your production handler does. If your handler returns a 5xx, the failure is printed and the CLI moves on rather than looping on it and burying the next event. Use `--replay-last` to send it again.

## Debugging deliveries

Every attempt is recorded with its response status and the payload that was sent. The [delivery log](https://portal.printedsimply.com/dashboard/webhooks/logs) shows all of it: expand a row to read the exact body, and hit **Retry Delivery** on a failed one to re-send it with a fresh signature.

The same three operations are available over the API, so you can wire them into your own tooling:

curl:

```bash
# The delivery log — attempts and outcomes, no payloads
curl https://portal.printedsimply.com/api/v1/webhooks/deliveries?success=false \
  -H "x-api-key: $PRINTEDSIMPLY_API_KEY"

# Every attempt at one event — your dedupe key, from our side
curl https://portal.printedsimply.com/api/v1/webhooks/deliveries?event_id=evt_9pQ2m4Kd \
  -H "x-api-key: $PRINTEDSIMPLY_API_KEY"

# One delivery, payload included (DELIVERY_ID is a row's "id" above)
curl https://portal.printedsimply.com/api/v1/webhooks/deliveries/$DELIVERY_ID \
  -H "x-api-key: $PRINTEDSIMPLY_API_KEY"

# Replay it with a fresh signature
curl -X POST \
  https://portal.printedsimply.com/api/v1/webhooks/deliveries/$DELIVERY_ID/redeliver \
  -H "x-api-key: $PRINTEDSIMPLY_API_KEY"
```

Reading a failure

- `status_code` of `null` — we never got a response: the endpoint timed out (10s), refused the connection, or failed TLS.
- `401` or `403` — usually signature verification. Check that you are verifying the _raw_ body, and that you accept any `v1` in the header during a rotation.
- Any other `4xx` — treated as a permanent rejection and not retried. Fix the handler, then replay.
- Repeated `5xx` — retried, up to 7 attempts over about 10 hours. Each attempt is its own log row, sharing one `X-PS-Event-Id` — which the log reports as `event_id`, so three rows are visibly one event rather than three failures.

## Best practices

- Always verify signatures — never process a webhook without validating the HMAC signature first.
- Respond quickly — return a `200` immediately and process the event asynchronously (e.g. via a job queue).
- Handle duplicates — deduplicate on the envelope's `id` (the `X-PS-Event-Id` header), which is stable across retries and replays. The delivery log reports it as `event_id`, so you can reconcile your dedupe store against ours.
- Use the raw body — verify the signature against the raw request body, not parsed/re-serialised JSON.
- Monitor delivery logs — check the dashboard regularly for failed deliveries and address endpoint issues promptly.
- Subscribe selectively — only subscribe to the event types you need to reduce noise and processing overhead.

[API reference](https://portal.printedsimply.com/docs/api-reference) · [Troubleshooting](https://portal.printedsimply.com/docs/troubleshooting) · Email us at developers@printedsimply.com
