# Guide to Printify Webhooks: Features and Best Practices

Printify webhooks tell your app what is happening in a merchant's shop: an order is created, sent to production, shipped or delivered, a line item is cancelled, a product is published or deleted, or the shop disconnects. If you run a custom storefront or an app connected to Printify, webhooks are how you keep orders, tracking and listings in sync without polling the API.

This guide covers how Printify webhooks are created and signed, the events and their payloads, Printify's retry and blocking rule, and the practices that keep a Printify receiver safe and reliable.

## What are Printify webhooks?

A Printify webhook is a subscription to one event type, called a topic, in one shop. There is no dashboard for them: you create, list, change and delete webhooks through the Public API, under `/v1/shops/{shop_id}/webhooks.json`, with a token that has the `webhooks.read` and `webhooks.write` scopes.

When the event happens, Printify sends a `POST` with a JSON body to the webhook's URL. If you gave the webhook a secret, the request also carries an `X-Pfy-Signature` header: an HMAC-SHA256 of the raw body, keyed with your secret. The secret is optional, and a webhook created without one sends no signature.

## Printify webhook features

| Feature | Details |
| --- | --- |
| Configuration | Public API only, one webhook per shop and topic |
| Method | `POST` with a JSON body |
| Signature header | `X-Pfy-Signature`, formatted `sha256=<digest>` |
| Algorithm | HMAC-SHA256 of the raw request body, hex-encoded |
| Secret | Optional, chosen by you when you create or modify the webhook |
| Replay window | None. No timestamp is signed |
| Expected response | `200 OK` |
| Retries | Up to 3 on a `4xx` or `5xx` response |
| Blocking | After the retries, the webhook is blocked for 1 hour and nothing is sent to its URL |
| Testing | A simulate endpoint sends a test event to a webhook |

Printify documents no response timeout, delivery ID header or source IP addresses. Its docs mention allowlisting Printify's IP address as an option, but don't publish one.

## Common events

Printify sends 12 events, named in the body's `type` field. No header carries the event type, but because each webhook has a single topic, the webhook's URL can identify it too.

| Resource | Event | Fires when |
| --- | --- | --- |
| Order | `order:created` | An order is created |
| Order | `order:updated` | An order's status changes |
| Order | `order:line-item:canceled` | A single line item is cancelled, by the merchant, support or the system |
| Order | `order:sent-to-production` | An order is sent to production |
| Order | `order:shipment:created` | Some or all items have been fulfilled |
| Order | `order:shipment:delivered` | Some or all items have been delivered |
| Product | `product:created` | A product is created |
| Product | `product:updated` | A product is updated |
| Product | `product:deleted` | A product is deleted |
| Product | `product:publish:started` | Publishing a product to the storefront has started |
| Shop | `shop:disconnected` | The shop is disconnected |
| Personalisation | `personalization-preview-task:processed` | A personalisation preview task completes, is corrupt or fails |

Every payload has four top-level keys: `id`, `type`, `created_at` and `resource`. Here is Printify's `order:shipment:created` example:

```json
{
  "id": "653b6be8-2ff7-4ab5-a7a6-6889a8b3bbf5",
  "type": "order:shipment:created",
  "created_at": "2022-05-17 15:00:00+00:00",
  "resource": {
    "id": "5a96f649b2439217d070f507",
    "type": "order",
    "data": {
      "shop_id": 815256,
      "shipped_at": "2022-05-17 15:00:00+00:00",
      "carrier": {
        "code": "USPS",
        "tracking_number": "9400110200828911663274",
        "tracking_url": "https://example.com/track/9400110200828911663274"
      },
      "skus": [
        "6202"
      ]
    }
  }
}

```

`id` identifies the event, and `resource.id` the order, product, shop or preview task it concerns. `resource.data` changes with the event: `order:updated` carries the new `status`, `order:line-item:canceled` carries `line_item_id` and a `status_details` reason such as `out_of_stock`, and `shop:disconnected` sends no data. If the cancelled line item was the order's last, Printify also sends `order:updated` for the order.

`product:publish:started` carries an `action` of `create`, `update` or `delete`. For `create` and `update`, `publish_details` says which parts of the product to publish:

```json
{
  "id": "653b6be8-2ff7-4ab5-a7a6-6889a8b3bbf5",
  "type": "product:publish:started",
  "created_at": "2022-05-17 15:00:00+00:00",
  "resource": {
    "id": "5cb87a8cd490a2ccb256cec4",
    "type": "product",
    "data": {
      "shop_id": 815256,
      "publish_details": {
        "title": true,
        "variants": false,
        "description": true,
        "tags": true,
        "images": false,
        "key_features": false,
        "shipping_template": true
      },
      "action": "create",
      "out_of_stock_publishing": 0
    }
  }
}

```

A storefront integration reports the outcome back through the API's `publishing_succeeded.json` or `publishing_failed.json` endpoint for the product.

Note that `created_at` and the other timestamps use a space between date and time (`2022-05-17 15:00:00+00:00`) rather than ISO 8601's `T`. Some strict date parsers reject them.

## Setting up Printify webhooks

1. Find your shop's ID with `GET /v1/shops.json`.
2. Generate a secret, for example with `openssl rand -hex 20` as Printify suggests, and store it server-side as `PRINTIFY_WEBHOOK_SECRET`.
3. Create one webhook per topic you need, with the request below.
4. Test it with `POST /v1/shops/{shop_id}/webhooks/{webhook_id}/simulate`. Whatever you send in the request body comes back in the event's `resource` property.

```bash
curl -X POST "https://api.printify.com/v1/shops/$SHOP_ID/webhooks.json" \
  -H "Authorization: Bearer $PRINTIFY_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"topic\":\"order:shipment:created\",\"url\":\"https://example.com/webhooks/printify\",\"secret\":\"$PRINTIFY_WEBHOOK_SECRET\"}"

```

A webhook's `topic` can't be changed, but you can change its URL or secret with `PUT /v1/shops/{shop_id}/webhooks/{webhook_id}.json`. Deleting one requires a `host` query parameter that matches the webhook URL's host, which stops you from deleting another app's webhooks by mistake.

If you receive through Hookdeck, create a Printify source, use its URL as each webhook's `url`, and paste the secret into the source's **Webhook Signing Secret** field. Use the same secret for every topic that points at the source. Verification in Hookdeck is optional: leave it off if you created your webhooks without a secret.

## Securing Printify webhooks

Compute an HMAC-SHA256 of the raw request body with your secret, encode it as hex, and compare it in constant time with the part of `X-Pfy-Signature` after `sha256=`. Use the secret as a plain UTF-8 string, without decoding it, and verify against the original bytes, not re-serialised JSON.

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

function verifyPrintifySignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;
  const value = signatureHeader.trim();
  if (!value.startsWith('sha256=')) return false;

  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const received = Buffer.from(value.slice('sha256='.length).toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/printify', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.PRINTIFY_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyPrintifySignature(req.body, req.get('x-pfy-signature'), secret)) {
    return res.sendStatus(401);
  }

  let body;
  try {
    body = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  // Route on body.type. Deduplicate on body.id.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac

def verify_printify_signature(raw_body: bytes, signature_header, secret: str) -> bool:
    if not signature_header or not secret:
        return False
    value = signature_header.strip()
    if not value.startswith("sha256="):
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    received = value[len("sha256="):].lower().encode("utf-8")
    return hmac.compare_digest(received, expected.encode("utf-8"))

# In FastAPI: verify_printify_signature(await request.body(),
#     request.headers.get("x-pfy-signature"), os.environ["PRINTIFY_WEBHOOK_SECRET"])

```

Only the body is signed, so a captured request can be replayed and still verify. Deduplicate on the event `id` and confirm important state, such as an order's status, with the API before acting on it.

Be careful with error codes while you set this up. A `401` from a misconfigured secret counts as a failure like any other `4xx`, so after three retries Printify blocks the webhook for an hour. Use the simulate endpoint to confirm verification passes before you point real traffic at the endpoint.

> **Make Printify webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-Pfy-Signature` header, answers Printify with a `200` straight away, and durably queues every order, shipment and product event before your service sees it.

## Printify webhook limitations and pain points

### An hour of silence after three failures

**The Problem:** A short outage or a bad deploy stops all deliveries to a webhook for an hour, and events that happen during that hour never reach you.

**Why It Happens:** On a `4xx` or `5xx` response Printify retries only three times, then blocks the webhook for an hour and sends nothing to its URL until the block lifts.

**Workarounds:**

* Return `200` as soon as the request is verified and stored, and do the work afterwards.
* Never return an error for an event type you don't handle. Acknowledge it and ignore it.
* After an outage, reconcile with the API: list recent orders and products and compare them with what you've stored.

**How Hookdeck Can Help:** Hookdeck answers Printify as soon as each event arrives, so your service's downtime doesn't count as a failed delivery and can't get the webhook blocked. Hookdeck then retries delivery to your service on a schedule you set and lets you replay anything that failed.

### One webhook per topic

**The Problem:** Covering every event means creating and maintaining a dozen webhooks per shop, and an app connected to many shops multiplies that.

**Why It Happens:** Each webhook subscribes to exactly one topic, in one shop, and its topic can't be changed. There is no dashboard, so everything goes through the API.

**Workarounds:**

* Script webhook creation, and point every topic at the same URL, routing on `type`.
* Use the list endpoint to check each shop's webhooks match what you expect, and recreate any that are missing.

**How Hookdeck Can Help:** Point every webhook at one Hookdeck source, then use filters on `type` to route each event to the right destination, without changing the webhooks in Printify.

### Signing is optional and has no replay protection

**The Problem:** A webhook created without a secret can't be authenticated, and a signed request can be replayed.

**Why It Happens:** The `secret` property is optional, and the signature covers only the body, with no timestamp.

**Workarounds:**

* Always create webhooks with a secret. To add one to an existing webhook, update it with `PUT`.
* Deduplicate on the event `id`.

**How Hookdeck Can Help:** Hookdeck checks `X-Pfy-Signature` on every request, and its deduplication can drop repeats of the same `id` before they reach your service.

### Thin payloads

**The Problem:** Your handler knows an order changed, but not what the order now contains.

**Why It Happens:** Most events carry only a few fields. `order:created` and `order:sent-to-production` are documented as sending no resource data, and product events carry only `shop_id`.

**Workarounds:**

* Treat events as notifications: fetch the order or product from the API by `resource.id`.
* Read `resource.data` defensively. Printify's own examples for `order:created` and `order:sent-to-production` include a `shop_id` in `data` even though the event tables say no data is sent.

**How Hookdeck Can Help:** Hookdeck's queue absorbs bursts, such as a batch of orders going to production, and its rate limiting lets you pace delivery so your follow-up API calls stay within Printify's limits.

## Best practices

### Verify against the raw body

Capture the body as bytes, verify `X-Pfy-Signature`, and only then parse it.

### Acknowledge fast and process asynchronously

Store the event and return `200` before calling the Printify API or updating your storefront, so a slow dependency never turns into a blocked webhook. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Make handlers idempotent

Use the event `id` as the key, and make updates safe to apply twice, for example by setting an order's status rather than appending to a history. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Fetch the full resource

Use `resource.id` to retrieve the current order or product from the API rather than relying on the event's few fields.

### Test with the simulate endpoint

Send a simulated event after creating or changing a webhook, and after rotating a secret, to confirm your endpoint verifies and answers `200`.

## Conclusion

Printify webhooks are JSON `POST`s, one topic per webhook, created through the API and signed with a hex HMAC-SHA256 of the body in `X-Pfy-Signature` when you set a secret. Always set one, verify against the raw body, deduplicate on the event `id`, fetch full resources from the API, and acknowledge quickly, because three failed retries block the webhook for an hour.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, verifies Printify's signature, and holds each event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Printify webhooks reliably in minutes.