@legenki/print2medusa
v0.8.0
Published
Printful → Medusa v2 plugin: product sync, auto fulfillment, and admin tools
Maintainers
Readme
@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 @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_productsscopes)
Install
npm install @legenki/print2medusaOr add it to a Medusa app the plugin-native way:
npx medusa plugin:add @legenki/print2medusaRegister 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:migrateSee 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_idbefore the Printful API is called. - Shipping
provinceis normalized to the 2-letterstate_codePrintful expects for US/CA. - Printful redelivers webhooks by design. Each event is stored under a derived
event_idcarrying 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
- Change
webhookSecretto a new random value and restart Medusa. - 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:
- A cached quote inside
shippingRateCacheTtlSeconds(default 600) - A cached quote past that but within
shippingRateStaleSeconds(default 86400) - 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
- Create products in Printful (Store Products).
- Open Medusa Admin → Products list → Printful → Sync Now.
- Configure a location shipping option using the Printful fulfillment provider.
- 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 typecheckThe 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:integrationnpm 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/print2medusaRoadmap
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
