@rx-ventures/medusa-plugin-shopify-sync
v0.4.8
Published
Medusa v2 plugin that syncs customers, orders, products, and discounts from Shopify into Medusa — with manual triggers and webhooks.
Readme
@rx-ventures/medusa-plugin-shopify-sync
Medusa v2 plugin that syncs customers, orders, products, and discounts from a Shopify store into Medusa, with manual triggers (admin UI buttons) and inbound webhook receivers.
Compatibility: Medusa v2.13.x, Node >= 20.
What it does
| Domain | Manual sync | Webhook receivers |
| --- | --- | --- |
| Customers | ✅ paginated | ✅ customers/{create,update,delete,enable,disable} |
| Orders | ✅ paginated | ✅ orders/{create,updated,cancelled,paid,fulfilled,partially_fulfilled,edited} + refunds/create |
| Products | ✅ images downloaded from Shopify CDN and re-uploaded through Medusa's File module | ✅ products/{create,update,delete} |
| Discounts | ✅ paginated | manual only by design |
| Inventory | — | ✅ inventory_levels/{update,connect,disconnect} |
| Fulfillments | — | ✅ fulfillments/{create,update} — runs reconcile on parent order, writes fulfillment chain |
| Draft orders | — | ⚠️ logged only |
Every webhook delivery — successful or failed — writes a row to
shopify_webhook_log. The admin Shopify webhook logs page (top-level,
separate from Settings) shows them with filters by entity / topic /
action / status code, auto-refreshes every 5 seconds, and renders
clickable chips that open the affected Medusa entity.
Install
yarn add @rx-ventures/medusa-plugin-shopify-syncGenerate an encryption key (used to encrypt the Shopify access token at rest with AES-256-GCM):
openssl rand -hex 32Add it to your Medusa application's environment:
SHOPIFY_SYNC_ENCRYPTION_KEY=<the 64-char hex you just generated>⚠️ Use the same value across deploys. If it changes, the saved Shopify token in your DB becomes undecryptable and you'll need to re-paste it once via the admin UI.
Register the plugin in your medusa-config.ts:
import { defineConfig } from "@medusajs/framework/utils"
module.exports = defineConfig({
// ...
plugins: [
{
resolve: "@rx-ventures/medusa-plugin-shopify-sync",
options: {
encryption_key: process.env.SHOPIFY_SYNC_ENCRYPTION_KEY,
// shopify_api_version: "2026-01" // override Shopify Admin GraphQL version
// webhook_base_url: process.env.MEDUSA_BACKEND_URL // for webhook auto-registration
},
},
],
})Run migrations:
yarn medusa db:migrateThis creates two tables in your Medusa DB:
shopify_config— singleton row holding the (encrypted) Shopify credentials, webhook secret, and last-sync timestamps.shopify_webhook_log— one row per inbound webhook delivery for the admin log page.
Usage
Start your Medusa app and open the admin dashboard.
1. Configure the connection
Navigate to Settings → Shopify Sync.
- Paste your store URL (bare domain or full URL — both work).
- Paste your Shopify Admin API access token. Required scopes:
read_customers, write_customers, read_orders, write_orders, read_products, write_products, read_discounts, write_webhooks. - Click Save, then Test connection to verify.
2. Run the manual sync
From the same Settings page, run each entity in this recommended order:
- Customers — populates Medusa customers from Shopify (email-dedup, address sync, metafield flatten).
- Products — downloads images from Shopify and re-uploads to your Medusa file service. Variant matching by SKU.
- Discounts — creates Medusa promotions under a single
shopify_discounts_synccampaign. - Orders — full historical import. Customers, orders' addresses, line items, summary, payment chain, and discount line-item adjustments. Uses direct SQL writes for the order tables since Medusa has no public Admin API for "import historical order with payments + adjustments".
For Customers and Orders the section exposes batch controls (max batches + items per batch) so you can dry-run on a small slice before a full import.
3. Register webhooks
In Settings → Shopify Sync → Webhook subscriptions on Shopify, paste your public Medusa URL (your production URL or an ngrok tunnel for local dev) and click Register all defaults. The plugin appends each topic's path automatically:
| Topic family | URL appended |
| --- | --- |
| CUSTOMERS_* | /hooks/shopify/customers |
| ORDERS_* + REFUNDS_CREATE | /hooks/shopify/orders |
| PRODUCTS_* | /hooks/shopify/products |
| FULFILLMENTS_* | /hooks/shopify/fulfillments |
| INVENTORY_LEVELS_* | /hooks/shopify/inventory |
| DRAFT_ORDERS_* | /hooks/shopify/draft-orders |
This is additive only — never deletes existing subscriptions on the same store, so other integrations are safe.
4. Watch webhook activity
Open Shopify webhook logs (top-level admin page). Auto-refreshes every 5 s. Filter by:
- Entity (customers / orders / products / fulfillments / inventory / draft_orders)
- Topic (every supported topic)
- Action (created / updated / deleted / skipped / errored / unauthorized)
- Status code (200 / 400 / 401 / 404 / 500)
Each row shows the topic, action, duration, payload summary, error
message, and clickable chips that open the affected Medusa entity in
admin (Open order ord_…, Open customer cus_…, etc.). Fulfillment
log rows link the parent order.
Configuration reference
Plugin options
| Option | Type | Default | Purpose |
| --- | --- | --- | --- |
| encryption_key | string | process.env.SHOPIFY_SYNC_ENCRYPTION_KEY | 64-char hex (32 bytes) AES-256-GCM key for the at-rest Shopify access token. Required (one of plugin option or env). |
| shopify_api_version | string | "2026-01" | Override Shopify Admin GraphQL API version. |
| webhook_base_url | string | MEDUSA_BACKEND_URL env | Public URL Shopify can reach /hooks/shopify/... at. Used for webhook auto-registration. |
Required env vars in your Medusa application
| Var | Required | Purpose |
| --- | --- | --- |
| SHOPIFY_SYNC_ENCRYPTION_KEY | yes | The 64-char hex key. Same value across deploys. |
| MEDUSA_BACKEND_URL | recommended | Public URL — used as the default base for webhook registration. |
The store URL and Shopify access token are saved through the admin UI (encrypted at rest) — not via env vars — so you can rotate without redeploying.
Admin API surface
| Method | Path | Purpose |
| --- | --- | --- |
| GET | /admin/shopify-sync/config | Redacted config (no plaintext token) |
| POST | /admin/shopify-sync/config | Save store URL / token / enabled / rotate webhook secret |
| POST | /admin/shopify-sync/connection-check | Hits Shopify { shop { name } } |
| GET | /admin/shopify-sync/webhooks | Current Shopify subscriptions + curated topic list |
| POST | /admin/shopify-sync/webhooks | Create one subscription |
| DELETE | /admin/shopify-sync/webhooks/:numericId | Delete one |
| POST | /admin/shopify-sync/webhooks/register-all | Bulk register the default set (additive only) |
| POST | /admin/shopify-sync/customers/sync | Manual sync — body { cursor?, batchSize? } |
| POST | /admin/shopify-sync/orders/sync | Same shape |
| POST | /admin/shopify-sync/products/sync | Same shape |
| POST | /admin/shopify-sync/discounts/sync | Same shape |
| GET | /admin/shopify-sync/webhook-logs | Paginated, filterable by entity / topic / action / status_code / limit / offset |
Public webhook surface
These routes are public (no admin auth) and are what you register in Shopify:
POST /hooks/shopify/customers
POST /hooks/shopify/orders
POST /hooks/shopify/products
POST /hooks/shopify/fulfillments
POST /hooks/shopify/inventory
POST /hooks/shopify/draft-ordersEach route routes by the X-Shopify-Topic header, so the same URL
accepts every topic in its family.
Database compatibility
The plugin's order import path uses raw SQL (necessary because Medusa
has no public Admin API for historical order import). It works on any
Postgres compatible with Medusa, including managed services. SSL is
auto-enabled when the connection string contains sslmode=require,
neon.tech, or supabase.co.
⚠️ Schema-tied: re-validate after any major Medusa version upgrade. Minor / patch upgrades have been stable.
License
MIT — see LICENSE.
