Printful → Medusa v2 plugin: product sync, auto fulfillment, and admin tools
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 . MIT licensed.
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 :
1plugins: [2 {3 resolve: "@legenki/print2medusa",4 options: {5 apiToken: process.env.PRINTFUL_API_TOKEN,6 storeId: process.env.PRINTFUL_STORE_ID, // required for account-level tokens7 // autoSubmitOrders: true,8 // createOnOrderPlaced: false,9 // allowPartialOrders: false,10 // markupPercent: 30,11 // defaultCurrency: "USD",12
13 // Live shipping rates. `fallbackShippingRates` is required when this is14 // on — it is what a cart prices at if Printful is unreachable.15 liveShippingRates: true,16 fallbackShippingRates: { STANDARD: 700, PRINTFUL_RETURN: 700 },17 },18 },19],20modules: [21 {22 resolve: "@medusajs/medusa/fulfillment",23 // Required for live rates: the provider resolves each cart line to its24 // Printful catalog variant through `query`. Without this every quote25 // silently falls back to the flat rate above.26 dependencies: ["query"],27 options: {28 providers: [29 {30 resolve: "@medusajs/medusa/fulfillment-manual",31 id: "manual",32 },33 {34 resolve: "@legenki/print2medusa/providers/printful-fulfillment",35 id: "printful",36 options: {37 apiToken: process.env.PRINTFUL_API_TOKEN,38 storeId: process.env.PRINTFUL_STORE_ID,39 },40 },41 ],42 },43 },44],Then migrate:
npx medusa db:migrateSee for a fuller snippet.
| Feature | How |
|---|---|
| Product sync | Admin Sync Now or → 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 | / (+ metadata IDs) |
| Orders | On → creates Printful order with |
| Fulfillment provider | Select Printful shipping option in Admin locations |
| 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 |
Printful notifies the store of fulfillment progress (, , , ) at:
POST /hooks/printful/<webhookSecret>Set the secret as a plugin option, then register the endpoint with Printful:
1options: {2 apiToken: process.env.PRINTFUL_API_TOKEN,3 webhookSecret: process.env.PRINTFUL_WEBHOOK_SECRET, // long, random4}1curl -X POST https://your-store.com/admin/printful/webhook \2 -H 'content-type: application/json' \3 -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 , and the workflow re-reads from Printful for the authoritative state.
Printful API v1's webhook configuration accepts only , and — 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:
1curl -X POST https://your-store.com/admin/printful/webhook \2 -H 'content-type: application/json' \3 -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 , so rotation is safe to perform in production.
shows the registered URL with the secret masked, so the admin UI can confirm the configuration without re-exposing the token.
Errors raised by this route ( bad token, malformed payload, storage failure) are logged with the secret replaced by , 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 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.
Printful quotes shipping for the destination and cart contents instead of you setting a flat price by hand.
1plugins: [2 {3 resolve: "@legenki/print2medusa",4 options: {5 apiToken: process.env.PRINTFUL_API_TOKEN,6 liveShippingRates: true,7 fallbackShippingRates: { STANDARD: 500 }, // minor units8 },9 },10],11modules: [12 {13 resolve: "@medusajs/medusa/fulfillment",14 // Required. The provider reads Printful variant ids from variant metadata15 // through Query, and Medusa only bridges modules a provider declares.16 dependencies: ["query"],17 options: {18 providers: [19 {20 resolve: "@legenki/print2medusa/providers/printful-fulfillment",21 id: "printful",22 options: { apiToken: process.env.PRINTFUL_API_TOKEN },23 },24 ],25 },26 },27],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 rather than failing, so the plugin logs an error at startup instead.
Give 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.
Checkout still completes. Prices fall back in this order:
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.
returns 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 with the running sync's , 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 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 (default 60) and proceeds. Products created but not yet linked are deleted on rollback, so a crash leaves no half-imported products behind.
A product whose variants Printful all reports as unavailable is set to , 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 in metadata, and discontinued products get unless .
Sold-out sizes are still orderable in Medusa cart APIs ( is false for POD). Hide or disable them in your storefront by reading — see the storefront availability guide.
After a full sync, linked products that no longer appear in the Printful store list are unpublished by default (). Use to leave publication alone. The plugin never deletes products. Partial syncs with skip this pass.
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 , and in order metadata, scaled by the currency's own subunit — $12.34 stores as , ¥1500 stores as . 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 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.
A Printful section in the sidebar, gathering what the widgets could not:
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.
If Medusa dies mid-sync, the run stays and nothing else can start until it is reclaimed — up to , 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 with how long it had been silent, so it is never confused with one the timeout reaped.
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 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 rather than , so anything filtering for front-and-back drops it silently.
Parameters live in variant metadata under , 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.
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:
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.
A bundle is an ordinary Medusa product — its own page, price and images — whose variant records which member variants it contains:
1// on the bundle variant's metadata2{3 "printful_bundle_members": [4 { "variant_id": "variant_01J...", "quantity": 1 },5 { "variant_id": "variant_01K...", "quantity": 2 },6 ],7}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 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.
1npm install2npm run build3npm run dev # watch + yalc publish4npm test # unit only — no database needed5npm run typecheckThe integration suite runs against a real Postgres, so it is a separate command rather than part of :
1createdb print2medusa_test2DATABASE_URL=postgres://localhost:5432/print2medusa_test npm run test:integrationruns 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.
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:
1npm version 0.8.2 --no-git-tag-version # edit CHANGELOG first2git commit -am "docs: release 0.8.2"3git tag -a v0.8.2 -m "0.8.2"4git push origin main --follow-tagsThe workflow refuses to publish if the tag and disagree, or if that version is already on npm. It publishes with , so npm records which repository and workflow built the package.
Needs an 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/print2medusaSee ROADMAP.md for the planned path from (webhooks and order status) through (stable API and Printful v2 migration), including the testing strategy for each release.
| Option | Description |
|---|---|
| Printful private token (required) | |
| for account-level tokens | |
| Confirm orders for fulfillment (default true) | |
| Also create Printful order on | |
| Allow orders that mix Printful + non-Printful items | |
| Markup on retail prices during sync | |
| Fallback currency code | |
| Shared secret for the Printful webhook path (see Webhooks) | |
| Minutes before a running sync is presumed dead and reclaimed (default 60) | |
| (default) marks discontinued products, omits the marker | |
| (default) drafts products gone from Printful, leaves them |
MIT © Andy Legenki