npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@legenki/print2medusa

v0.8.0

Published

Printful → Medusa v2 plugin: product sync, auto fulfillment, and admin tools

Readme

@legenki/print2medusa

npm version npm downloads license

Printful → Medusa v2 plugin: sync Store Products, auto-create Printful orders on payment capture, and a Fulfillment Provider for admin shipping options.

Published on npm as @legenki/print2medusa. MIT licensed.

Requirements

  • Node.js ≥ 20
  • Medusa ≥ 2.18.0 (peer dependency ^2.18.0)
  • Printful store on the Manual order / API platform with a private token (orders, sync_products scopes)

Install

npm install @legenki/print2medusa

Or add it to a Medusa app the plugin-native way:

npx medusa plugin:add @legenki/print2medusa

Register the plugin and fulfillment provider in medusa-config.ts:

plugins: [
  {
    resolve: "@legenki/print2medusa",
    options: {
      apiToken: process.env.PRINTFUL_API_TOKEN,
      storeId: process.env.PRINTFUL_STORE_ID, // required for account-level tokens
      // autoSubmitOrders: true,
      // createOnOrderPlaced: false,
      // allowPartialOrders: false,
      // markupPercent: 30,
      // defaultCurrency: "USD",

      // Live shipping rates. `fallbackShippingRates` is required when this is
      // on — it is what a cart prices at if Printful is unreachable.
      liveShippingRates: true,
      fallbackShippingRates: { STANDARD: 700, PRINTFUL_RETURN: 700 },
    },
  },
],
modules: [
  {
    resolve: "@medusajs/medusa/fulfillment",
    // Required for live rates: the provider resolves each cart line to its
    // Printful catalog variant through `query`. Without this every quote
    // silently falls back to the flat rate above.
    dependencies: ["query"],
    options: {
      providers: [
        {
          resolve: "@medusajs/medusa/fulfillment-manual",
          id: "manual",
        },
        {
          resolve: "@legenki/print2medusa/providers/printful-fulfillment",
          id: "printful",
          options: {
            apiToken: process.env.PRINTFUL_API_TOKEN,
            storeId: process.env.PRINTFUL_STORE_ID,
          },
        },
      ],
    },
  },
],

Then migrate:

npx medusa db:migrate

See examples/basic-store/ for a fuller snippet.

What it does (MVP)

| Feature | How | | -------------------- | ----------------------------------------------------------------------------------------- | | Product sync | Admin Sync Now or POST /admin/printful/sync → runs in the background, one at a time | | Stock awareness | Variants Printful reports as unavailable unpublish the product; restock republishes it | | Removal handling | A full sync drafts products that vanished from Printful; a re-add republishes them | | Shipping fidelity | The method the customer paid for is confirmed with Printful and sent on the order | | Order economics | Printful's cost and your margin on the Admin order page | | Links | printful_product_link / printful_variant_link (+ metadata IDs) | | Orders | On payment.captured → creates Printful order with sync_variant_id | | Fulfillment provider | Select Printful shipping option in Admin locations | | Status | GET /admin/printful/status + product list widget | | Shipment tracking | Printful webhooks → Medusa fulfillment + shipment per parcel, with tracking | | Order visibility | Printful status and per-parcel tracking on the Admin order page |

Idempotency

  • Re-sync updates existing products via link tables (no duplicates) and upserts variants — price and assortment changes in Printful reach Medusa; manually-added Medusa variants are left untouched.
  • Concurrent / re-fired payment events will not create a second Printful order: the order is claimed insert-first via a unique index on printful_order_link.medusa_order_id before the Printful API is called.
  • Shipping province is normalized to the 2-letter state_code Printful expects for US/CA.
  • Printful redelivers webhooks by design. Each event is stored under a derived event_id carrying a unique index, so a redelivery is absorbed rather than producing a second fulfillment. Delivery metadata (retries, store) is excluded from that id — otherwise the same event would hash differently on each attempt.
  • Events for one order are serialized with a transaction-scoped advisory lock, so two events cannot both pass the "shipment not yet recorded" check and each create a fulfillment for one parcel.

Webhooks

Printful notifies the store of fulfillment progress (package_shipped, order_failed, order_canceled, package_returned) at:

POST /hooks/printful/<webhookSecret>

Set the secret as a plugin option, then register the endpoint with Printful:

options: {
  apiToken: process.env.PRINTFUL_API_TOKEN,
  webhookSecret: process.env.PRINTFUL_WEBHOOK_SECRET, // long, random
}
curl -X POST https://your-store.com/admin/printful/webhook \
  -H 'content-type: application/json' \
  -d '{"base_url":"https://your-store.com"}'

The payload is treated as a trigger, not a source of truth: the endpoint stores the event, answers 200, and the workflow re-reads GET /orders/{id} from Printful for the authoritative state.

The secret is in the URL path

Printful API v1's webhook configuration accepts only url, types and params — there is no custom-header support — so the shared secret has to travel as a path segment. That has consequences worth planning around.

Treat the secret as rotatable, and expect it in access logs. Any reverse proxy, load balancer, or CDN in front of Medusa logs request paths by default, and that is entirely outside this plugin's control. Anyone who can read those logs can forge webhook deliveries.

Mitigations, in rough order of value:

  • Scope it. The secret only authenticates Printful's callback. It grants no API access, and because payloads are re-verified against Printful's API, a forged delivery cannot invent a shipment — at worst it triggers a redundant re-read.
  • Strip it at the proxy. If your proxy supports rewriting logged paths, mask the segment after /hooks/printful/.
  • Rotate it on any suspected log exposure, and on staff offboarding.

Rotating the secret

  1. Change webhookSecret to a new random value and restart Medusa.
  2. Re-register with Printful so it stops calling the old URL:
    curl -X POST https://your-store.com/admin/printful/webhook \
      -H 'content-type: application/json' \
      -d '{"base_url":"https://your-store.com"}'

Printful keeps one webhook configuration per store, so step 2 replaces the previous URL outright — the old secret stops being accepted as soon as Medusa restarts. Deliveries in flight during the swap are retried by Printful, and duplicate events are absorbed by the stored event_id, so rotation is safe to perform in production.

GET /admin/printful/webhook shows the registered URL with the secret masked, so the admin UI can confirm the configuration without re-exposing the token.

Request logging

Errors raised by this route (404 bad token, 400 malformed payload, 500 storage failure) are logged with the secret replaced by [redacted], since Medusa's error handler logs the request path verbatim.

One gap remains and cannot be closed from plugin code: errors thrown by Medusa's global body parser — an oversized body or malformed JSON — reach the error handler without running any route-scoped middleware, so those log lines contain the real path. The endpoint's body limit is therefore raised to 1 MB, well above the largest realistic delivery (a 50-line-item package_shipped measures ~262 KB; the framework default of 100 KB is in fact exceeded by roughly a 25-item order), so genuine Printful traffic does not reach that path. This is another reason to treat the secret as rotatable.

Live shipping rates

Printful quotes shipping for the destination and cart contents instead of you setting a flat price by hand.

plugins: [
  {
    resolve: "@legenki/print2medusa",
    options: {
      apiToken: process.env.PRINTFUL_API_TOKEN,
      liveShippingRates: true,
      fallbackShippingRates: { STANDARD: 500 }, // minor units
    },
  },
],
modules: [
  {
    resolve: "@medusajs/medusa/fulfillment",
    // Required. The provider reads Printful variant ids from variant metadata
    // through Query, and Medusa only bridges modules a provider declares.
    dependencies: ["query"],
    options: {
      providers: [
        {
          resolve: "@legenki/print2medusa/providers/printful-fulfillment",
          id: "printful",
          options: { apiToken: process.env.PRINTFUL_API_TOKEN },
        },
      ],
    },
  },
],

dependencies: ["query"] is not optional. Without it the provider cannot resolve Printful variant ids, and every quote quietly falls back to the flat rate. Medusa resolves an undeclared dependency to undefined rather than failing, so the plugin logs an error at startup instead.

Give fallbackShippingRates an entry for every method you offer. A method with no entry prices at zero rather than blocking checkout: Medusa cannot complete a cart whose shipping price fails to resolve, so an underpriced delivery is the lesser harm. The plugin logs an error each time it happens.

What happens when Printful is unreachable

Checkout still completes. Prices fall back in this order:

  1. A cached quote inside shippingRateCacheTtlSeconds (default 600)
  2. A cached quote past that but within shippingRateStaleSeconds (default 86400)
  3. The flat rate from fallbackShippingRates

A day-old real quote beats a constant someone typed once, which is why the stale tier outranks the flat rate. One Printful call serves every shipping option on a cart — the whole response is cached, and each option is picked from it locally.

Limits worth knowing

  • A method override is sent only when Printful confirmed it live at selection (rate_source: "live"). Fallback pricing still lets checkout complete; the order then lets Printful pick the method. See the 0.7.0 changelog.
  • Return options are never priced live. Printful quotes outbound shipping only, so a return shipping option must be given a flat admin price.
  • Rates are quoted in the cart's currency by asking Printful to convert. If a quote comes back in another currency it is discarded rather than converted.

Catalog sync

POST /admin/printful/sync returns 202 {sync_id} right away and the sync runs in the background, so a large catalog no longer holds the request open. The widget polls progress while it runs.

One sync at a time. A second request gets 409 with the running sync's started_at, and the nightly job skips quietly rather than piling on. This is enforced by a partial unique index in Postgres, not by a check-then-insert, so double-clicking Sync Now cannot start two.

A killed process is recovered lazily. If Medusa dies mid-sync, the log row stays running and the widget keeps showing a sync that is no longer alive. Nothing sweeps on a timer: the next sync attempt — manual, or the nightly job — reclaims any claim whose heartbeat is older than syncStaleMinutes (default 60) and proceeds. Products created but not yet linked are deleted on rollback, so a crash leaves no half-imported products behind.

Stock

A product whose variants Printful all reports as unavailable is set to draft, and republished when it comes back. The plugin only republishes what it unpublished itself — a product you set to draft by hand stays draft. Variants carry printful_availability_status in metadata, and discontinued products get printful_discontinued unless onDiscontinued: "ignore".

Sold-out sizes are still orderable in Medusa cart APIs (manage_inventory is false for POD). Hide or disable them in your storefront by reading printful_availability_status — see the storefront availability guide.

Products removed from Printful

After a full sync, linked products that no longer appear in the Printful store list are unpublished by default (onRemovedFromPrintful: "unpublish"). Use "ignore" to leave publication alone. The plugin never deletes products. Partial syncs with limit skip this pass.

Order economics

Printful returns what it charged along with the created order, so the plugin stores it on the Medusa order rather than making a second API call. The order page shows the Printful cost, the retail total, and the margin between them. The figures are refreshed whenever a webhook re-reads the order, because Printful finalizes shipping and fees at fulfillment.

Amounts are stored in minor units under printful_cost_*, printful_retail_* and printful_margin in order metadata, scaled by the currency's own subunit — $12.34 stores as 1234, ¥1500 stores as 1500. printful_money_scale records which rule produced them; orders written before 0.6.0 carry no marker and were scaled by 100 whatever their currency.

Margin is only shown when both figures are in the same currency. If Printful bills in USD while the order is in EUR, both totals are stored and the margin is withheld — converting would need an exchange rate this plugin does not have, and a margin built on a guessed rate is worse than none.

The order page deliberately shows only the two totals and the margin, not the per-fee breakdown. Those three are always written together from one response, so they cannot disagree; the individual printful_cost_* fee keys are refreshed per-key and a fee absent from a later response keeps its previous value, so a breakdown need not sum to the total.

Admin usage

  1. Create products in Printful (Store Products).
  2. Open Medusa Admin → Products list → Printful → Sync Now.
  3. Configure a location shipping option using the Printful fulfillment provider.
  4. Place a test order and capture payment → Printful receives the order.

Local plugin development

npm install
npm run build
npm run dev          # watch + yalc publish
npm test             # unit only — no database needed
npm run typecheck

The integration suite runs against a real Postgres, so it is a separate command rather than part of npm test:

createdb print2medusa_test
DATABASE_URL=postgres://localhost:5432/print2medusa_test npm run test:integration

npm run test:all runs both. The integration tests cover what unit tests cannot: that the sync claim is atomic under concurrent inserts, and that a redelivered webhook produces one row rather than two — both of which depend on real unique-index behaviour.

In a host Medusa app:

npx medusa plugin:add @legenki/print2medusa

Roadmap

See ROADMAP.md for the planned path from 0.2.0 (webhooks and order status) through 1.0.0 (stable API and Printful v2 migration), including the testing strategy for each release.

Architecture notes

  • Printful is source of truth for products; Medusa holds a copy + links.
  • Printful API v1 (https://api.printful.com).
  • Long-running sync runs as a Medusa workflow (not a blocking HTTP body only—route awaits the workflow today; can be queued later).
  • Webhooks carry their secret in the URL path because Printful v1 supports no custom headers — see Webhooks.
  • Multi-store polish and Printful API v2: see ROADMAP.md.

Options

| Option | Description | | ----------------------- | ---------------------------------------------------------------------------------- | | apiToken | Printful private token (required) | | storeId | X-PF-Store-Id for account-level tokens | | autoSubmitOrders | Confirm orders for fulfillment (default true) | | createOnOrderPlaced | Also create Printful order on order.placed | | allowPartialOrders | Allow orders that mix Printful + non-Printful items | | markupPercent | Markup on retail prices during sync | | defaultCurrency | Fallback currency code | | webhookSecret | Shared secret for the Printful webhook path (see Webhooks) | | syncStaleMinutes | Minutes before a running sync is presumed dead and reclaimed (default 60) | | onDiscontinued | "flag" (default) marks discontinued products, "ignore" omits the marker | | onRemovedFromPrintful | "unpublish" (default) drafts products gone from Printful, "ignore" leaves them |

License

MIT © Andy Legenki