Overview
Icon for Printful

Printful

Print-on-demand fulfillment via Printful

@legenki/print2medusa

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 Copy to clipboard@legenki/print2medusa. MIT licensed.

Requirements

  • Node.js ≥ 20
  • Medusa ≥ 2.18.0 (peer dependency Copy to clipboard^2.18.0)
  • Printful store on the Manual order / API platform with a private token (Copy to clipboardorders, Copy to clipboardsync_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 Copy to clipboardmedusa-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: [

Then migrate:

npx medusa db:migrate

See Copy to clipboardexamples/basic-store/ for a fuller snippet.

What it does (MVP)

Feature How Product sync Admin Sync Now or Copy to clipboardPOST /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 Copy to clipboardprintful_product_link / Copy to clipboardprintful_variant_link (+ metadata IDs) Orders On Copy to clipboardpayment.captured → creates Printful order with Copy to clipboardsync_variant_id Fulfillment provider Select Printful shipping option in Admin locations Status Copy to clipboardGET /admin/printful/status + product list widget Admin page Printful in the sidebar: sales, sync history, webhook health, stuck-sync recovery Design parameters Per product: class, technique, where the design goes, base colours with hex Mockup prompts Paste-ready prompts per product and colour, in three shapes for the three product classes Merch bundles One Medusa product that expands into several Printful items when the order is placed 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 Copy to clipboardprintful_order_link.medusa_order_id before the Printful API is called.
  • Shipping Copy to clipboardprovince is normalized to the 2-letter Copy to clipboardstate_code Printful expects for US/CA.
  • Printful redelivers webhooks by design. Each event is stored under a derived Copy to clipboardevent_id carrying a unique index, so a redelivery is absorbed rather than producing a second fulfillment. Delivery metadata (Copy to clipboardretries, Copy to clipboardstore) 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 (Copy to clipboardpackage_shipped, Copy to clipboardorder_failed, Copy to clipboardorder_canceled, Copy to clipboardpackage_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 Copy to clipboard200, and the workflow re-reads Copy to clipboardGET /orders/{id} from Printful for the authoritative state.

The secret is in the URL path

Printful API v1's webhook configuration accepts only Copy to clipboardurl, Copy to clipboardtypes and Copy to clipboardparams — 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 Copy to clipboard/hooks/printful/.
  • Rotate it on any suspected log exposure, and on staff offboarding.

Rotating the secret

  1. Change Copy to clipboardwebhookSecret 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 Copy to clipboardevent_id, so rotation is safe to perform in production.

Copy to clipboardGET /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 (Copy to clipboard404 bad token, Copy to clipboard400 malformed payload, Copy to clipboard500 storage failure) are logged with the secret replaced by Copy to clipboard[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 Copy to clipboardpackage_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",

Copy to clipboarddependencies: ["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 Copy to clipboardundefined rather than failing, so the plugin logs an error at startup instead.

Give Copy to clipboardfallbackShippingRates 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 Copy to clipboardshippingRateCacheTtlSeconds (default 600)
  2. A cached quote past that but within Copy to clipboardshippingRateStaleSeconds (default 86400)
  3. The flat rate from Copy to clipboardfallbackShippingRates

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 (Copy to clipboardrate_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

Copy to clipboardPOST /admin/printful/sync returns Copy to clipboard202 {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 Copy to clipboard409 with the running sync's Copy to clipboardstarted_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 Copy to clipboardrunning 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 Copy to clipboardsyncStaleMinutes (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 Copy to clipboarddraft, 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 Copy to clipboardprintful_availability_status in metadata, and discontinued products get Copy to clipboardprintful_discontinued unless Copy to clipboardonDiscontinued: "ignore".

Sold-out sizes are still orderable in Medusa cart APIs (Copy to clipboardmanage_inventory is false for POD). Hide or disable them in your storefront by reading Copy to clipboardprintful_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 (Copy to clipboardonRemovedFromPrintful: "unpublish"). Use Copy to clipboard"ignore" to leave publication alone. The plugin never deletes products. Partial syncs with Copy to clipboardlimit 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 Copy to clipboardprintful_cost_*, Copy to clipboardprintful_retail_* and Copy to clipboardprintful_margin in order metadata, scaled by the currency's own subunit — $12.34 stores as Copy to clipboard1234, ¥1500 stores as Copy to clipboard1500. Copy to clipboardprintful_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 Copy to clipboardprintful_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.

The Printful admin page

A Printful section in the sidebar, gathering what the widgets could not:

  • Sales — paid orders and profit from Printful's own reporting
  • Webhooks — when the last event arrived, how many are waiting or failed
  • Recent syncs — status, counters, and live progress while one runs
  • Stuck sync recovery — see below

Two of these behave differently on purpose. Webhook health never calls Printful: a panel whose job is reporting whether Printful reaches you must still render when Printful is down, so it answers from local rows. Sales does call Printful, because the figures do not exist locally — and an outage answers with an empty panel rather than blanking the page.

When a sync gets stuck

If Medusa dies mid-sync, the run stays Copy to clipboardrunning and nothing else can start until it is reclaimed — up to Copy to clipboardsyncStaleMinutes, one hour by default. The page notices and offers to clear it.

Clearing is deliberately awkward, because it is destructive. The request must carry a typed confirmation, and the server re-checks the heartbeat: if the sync is actually alive, it refuses and tells you so. A wrong guess costs a message, never a killed sync.

A run cleared by a person is recorded as Copy to clipboardcleared_by_operator with how long it had been silent, so it is never confused with one the timeout reaped.

Design parameters

Each synced variant carries what the design becomes on that product, read from Printful's public catalog during sync. The admin page shows it per product.

Products fall into three classes, derived from their techniques and placements rather than a hardcoded list — so a product you add later is classified rather than misfiled:

Class What it means for a design Apparel Ink on fabric. Base colour and material drive how the design reads. Embroidery Thread, not ink. A cap supporting only Copy to clipboardEMBROIDERY must never be described as printed. Print media Paper and vinyl. Physical size matters; there is no base colour.

The class matters more than it looks. A dad hat and a t-shirt are both "apparel" in a catalogue sense, but a design on one is stitched and on the other is printed — and its placement is Copy to clipboardembroidery_front rather than Copy to clipboardfront, so anything filtering for front-and-back drops it silently.

Parameters live in variant metadata under Copy to clipboardprintful_design, and the panel is built from them without calling Printful.

What it does not show: print area dimensions and DPI. Those need an authenticated endpoint whose response schema Printful does not publish, so the panel says where a design goes rather than how large the printable region is.

Mockup prompts

The Mockup prompts panel turns design parameters into prompts you paste into an image model. Pick a style, say what the artwork is, copy one per product.

The plugin writes prompts and stops there. Generation happens wherever you paste them — which is why no API keys, rate limits or image storage live in this repo. Printful's own mockup generator is a different tool: it renders a product on a plain background, right for a catalogue thumbnail and wrong for an editorial mockup.

Prompts come in three shapes because the products differ in kind:

  • Apparel — a model wearing it, the fabric named, styled with a companion garment chosen by how light the base colour is
  • Embroidery — thread, never ink, and framed head-and-shoulders rather than full-length. The dad hat supports Copy to clipboardEMBROIDERY only; a prompt calling it printed would describe something Printful cannot make
  • Print media — an object on a wall. No model, no fabric, no base colour

Colour is the axis the variations run along, and colours are picked for spread across lightness rather than catalogue order — five near-identical heathers would produce five near-identical images.

Nothing is invented. A clause is dropped rather than defaulted when Printful did not report the fact: the cap has no material in the catalog, so its prompt says nothing about fabric.

Merch bundles

A bundle is an ordinary Medusa product — its own page, price and images — whose variant records which member variants it contains:

// on the bundle variant's metadata
{
"printful_bundle_members": [
{ "variant_id": "variant_01J...", "quantity": 1 },
{ "variant_id": "variant_01K...", "quantity": 2 },
],
}

Printful has no notion of a bundle; it fulfils individual items. So when the order is placed the bundle line is replaced by its members, with each member's quantity multiplied by how many bundles were bought. Two bundles each holding two stickers order four stickers.

Composition is read from the order line, captured at purchase. Editing a bundle after a sale does not change what an already-placed order ships.

A bundle is stricter about stock than a plain product. A product is drafted only when every variant is gone, since any remaining variant is still sellable. A bundle promises to ship all of it, so one sold-out member takes it off sale — and puts it back when the member returns. Only a full sync reconciles bundles: under a Copy to clipboardlimit most members go unrefreshed, and stale metadata would draft bundles on last week's stock.

The Bundles panel on the Printful admin page lists each bundle, its members, and which member is unavailable when one is.

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 Copy to clipboardnpm test:

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

Copy to clipboardnpm 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.

Releasing

Publishing runs from CI on a version tag, so the tarball is always built from a checkout that passed the full suite rather than from a maintainer's laptop:

npm version 0.8.2 --no-git-tag-version # edit CHANGELOG first
git commit -am "docs: release 0.8.2"
git tag -a v0.8.2 -m "0.8.2"
git push origin main --follow-tags

The workflow refuses to publish if the tag and Copy to clipboardpackage.json disagree, or if that version is already on npm. It publishes with Copy to clipboard--provenance, so npm records which repository and workflow built the package.

Needs an Copy to clipboardNPM_TOKEN repository secret — a granular automation token scoped to this package, not a classic token with account-wide write.

In a host Medusa app:

npx medusa plugin:add @legenki/print2medusa

Roadmap

See ROADMAP.md for the planned path from Copy to clipboard0.2.0 (webhooks and order status) through Copy to clipboard1.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 (Copy to clipboardhttps://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 Copy to clipboardapiToken Printful private token (required) Copy to clipboardstoreId Copy to clipboardX-PF-Store-Id for account-level tokens Copy to clipboardautoSubmitOrders Confirm orders for fulfillment (default true) Copy to clipboardcreateOnOrderPlaced Also create Printful order on Copy to clipboardorder.placed Copy to clipboardallowPartialOrders Allow orders that mix Printful + non-Printful items Copy to clipboardmarkupPercent Markup on retail prices during sync Copy to clipboarddefaultCurrency Fallback currency code Copy to clipboardwebhookSecret Shared secret for the Printful webhook path (see Webhooks) Copy to clipboardsyncStaleMinutes Minutes before a running sync is presumed dead and reclaimed (default 60) Copy to clipboardonDiscontinued Copy to clipboard"flag" (default) marks discontinued products, Copy to clipboard"ignore" omits the marker Copy to clipboardonRemovedFromPrintful Copy to clipboard"unpublish" (default) drafts products gone from Printful, Copy to clipboard"ignore" leaves them

License

MIT © Andy Legenki

You may also like

Browse all integrations

Build your own

Develop your own custom integration

Build your own integration with our API to speed up your processes. Make your integration available via npm for it to be shared in our Library with the broader Medusa community.

gift card interface

Ready to build your custom commerce setup?