Gareth Wilson Gareth Wilson

Guide to Printify Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationPublic API only, one webhook per shop and topic
MethodPOST with a JSON body
Signature headerX-Pfy-Signature, formatted sha256=<digest>
AlgorithmHMAC-SHA256 of the raw request body, hex-encoded
SecretOptional, chosen by you when you create or modify the webhook
Replay windowNone. No timestamp is signed
Expected response200 OK
RetriesUp to 3 on a 4xx or 5xx response
BlockingAfter the retries, the webhook is blocked for 1 hour and nothing is sent to its URL
TestingA 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.

ResourceEventFires when
Orderorder:createdAn order is created
Orderorder:updatedAn order's status changes
Orderorder:line-item:canceledA single line item is cancelled, by the merchant, support or the system
Orderorder:sent-to-productionAn order is sent to production
Orderorder:shipment:createdSome or all items have been fulfilled
Orderorder:shipment:deliveredSome or all items have been delivered
Productproduct:createdA product is created
Productproduct:updatedA product is updated
Productproduct:deletedA product is deleted
Productproduct:publish:startedPublishing a product to the storefront has started
Shopshop:disconnectedThe shop is disconnected
Personalisationpersonalization-preview-task:processedA 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:

{
  "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:

{
  "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.
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.

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:

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 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.

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.

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 POSTs, 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 sits in front of your receiver, verifies Printify's signature, and holds each event until your service has processed it.

Get started with Hookdeck for free and handle Printify webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.