Two-way sync between a Frappe/ERPNext site and a Medusa v2 backend.
Site-agnostic: no assumptions about which doctypes you sync, no customer-specific fields, nothing to edit in Python to add a doctype. Install it on any ERPNext site, point it at a Medusa backend, and configure the mappings from the Desk UI.
Not necessarily — and that's worth being honest about.
Medusa can drive a stock Frappe site on its own: it writes through
/api/resource/<Doctype> with an API key, and reads either by polling
or via Frappe's built-in Webhook doctype. If that covers you, use it.
Medusync earns its place when you want:
| Stock Frappe | With Medusync | |
|---|---|---|
| Add a synced doctype | Hand-write a Jinja payload template per doctype, on the Medusa side | Pick it from a dropdown in Desk |
| Outbound field selection | Whatever the template hard-codes | A field map, or "send all" |
| Deletes | No inbound delete handling | on_trash → <doctype>.deleted |
| Retries | 3 fixed inline attempts | Configurable, backed off, on the queue |
| Idempotency | None — a retry re-applies | Deduped on event_id |
| Audit | Webhook Request Log (outbound only) | Both directions, with payloads |
| Backfill existing rows | Touch every record by hand | One bench execute |
bench get-app medusync <repo-url>
bench --site <site> install-app medusync
bench --site <site> migrate
sudo supervisorctl restart all # or: bench restartThat last line is not optional on a running site. Adding an app to
sites/apps.txt makes every request import it, but gunicorn workers
started before the install cannot see a newly pip-installed package.
Skip the restart and the entire site returns HTTP 500 —
ModuleNotFoundError: No module named 'medusync' — until you do it.
That is the whole site, not just the sync.
If you installed by pointing bench at a local directory rather than
get-app, install the package into the bench env first:
./env/bin/pip install -e apps/medusyncIf install-app fails partway with "Module import failed for Medusync
Settings", the Module Def existed but the doctype loader had a stale
module map. Recover with:
bench --site <site> clear-cache && bench --site <site> migrateThen open Medusync Settings and set:
- Medusa URL — e.g.
https://admin.example.com(no trailing slash) - Inbound Secret — must equal the Medusa plugin's
webhook_secret - Outbound Secret — must equal the Medusa plugin's
frappe_to_medusa_secret
Leave Enable Sync off until the mappings are in place. Installing the app never starts moving data on its own.
Medusync Mapping → New:
| Field | Meaning |
|---|---|
| Document Type | The doctype to sync |
| Direction | To Medusa, From Medusa, or Two-way |
| Trigger On | Which Frappe events push outbound (one per line) |
| Condition | Optional Python expression over doc |
| Key Field | The field that identifies this record to Medusa |
| Field Map | Frappe field ↔ Medusa path, with a per-field direction |
| Send All Fields | Skip the field map and send everything |
Per-field direction is how you say "Frappe owns the address, Medusa owns the email" without splitting the mapping in two.
| Path | Auth | Purpose |
|---|---|---|
/api/method/medusync.api.receive |
HMAC | Apply one inbound event |
/api/method/medusync.api.health |
none | Liveness probe |
Both directions sign the raw request body with HMAC-SHA256 and send
it in X-Medusa-Signature. Hex is emitted; hex or base64 is accepted,
so a site part-way through migrating off Frappe's native Webhook rows
(which sign base64) keeps working.
Inbound envelope:
{
"event": "customer.updated",
"event_id": "medusa:cus_01H...:1723459200",
"doctype": "Customer",
"key_field": "email_id",
"key_value": "someone@example.com",
"data": { "customer_name": "Alex Fern", "mobile_no": "+91..." }
}event_id is the idempotency key: a repeat is answered skipped, not
applied twice.
Hooks only fire on change, so a new mapping syncs nothing until each record is touched. To replay what already exists:
bench --site <site> execute medusync.backfill.run --kwargs "{'mapping': 'Customers to Medusa', 'limit': 50, 'dry_run': True}"Drop dry_run once the counts look right.
medusync's core is domain-neutral. Domain behaviour ships as handler
packs under medusync/handlers/<pack>/. One is shipped: commerce —
stock levels, prices, delivery notes, invoices and order metadata, which
is what every store has. A site chooses its packs in site_config.json:
"medusync_handler_packs": ["commerce"]When the key is absent, commerce loads, which is what a site without an
opinion wants. An empty list loads nothing: inbound events then go through
Medusync Mapping rows only, and a mapped push is skipped until a pack that
provides an upsert is configured.
The switch works in both directions. hooks.py names no business
doctype at all — it binds one wildcard handler for the six document
events, and that handler asks the configured packs which of their outbound
hooks apply. A site running no pack runs no domain code either way.
A deployment with behaviour of its own adds a pack rather than editing
this one: a module under medusync/handlers/<name>/ exposing register(),
MAPPED_UPSERT and OUTBOUND_HOOKS, named in that site's config. Nothing
client-specific is shipped here.
A delivery that fails is parked with a Next Attempt At on its Medusync
Log row (30 s, then 120 s, 270 s …) and picked up by a once-a-minute
scheduler sweep (medusync.tasks.retry_due), so the scheduler must be
running. Max Attempts in the settings bounds the total.
One ERPNext can serve several Medusa stores. Each is a Medusync Site record holding that store's URL and its own pair of shared secrets, so a leak or an outage at one store cannot touch another. A mapping with no site applies to every enabled site; one pinned to a site applies only there.
An inbound request is attributed to a site by its signature, not by anything the caller claims, so a site cannot impersonate another by setting a header.
Upgrading from the single-connection setup needs no work: the migration
copies the Single's URL and secrets onto a site called default.
Every signed body carries a version, a timestamp and an origin:
{
"v": 2,
"kind": "event",
"event": "customer.updated",
"event_id": "frappe:Customer:CUST-0001:2026-09-04 10:00:00:default",
"ts": 1788474641,
"origin": {
"system": "erpnext",
"site_id": "default",
"correlation_id": "1722607afc9a4e6b95ae85bf1c0746ce"
},
"data": { "email_id": "someone@example.com" }
}kind says what the body holds: event (a business event), mapped (an
already-mapped upsert with doctype, key and payload) or mapping (the
mapping configuration itself). A body with no v is read as v1 and still
applies, so the two apps can be upgraded one at a time.
medusync/envelope.py and the plugin's envelope.ts are mirrors — change
them together.
A mapping is one configuration living in two systems. Both copies share a
mapping_uid and carry a version; saving on either side sends
mapping.upserted to the other. The higher version wins, and on a tie
ERPNext wins, because ERPNext owns which documents may sync at all and
the two decisions must not disagree. A deleted mapping disables the far
copy rather than destroying it, so records already correlated by it stay
traceable.
A mapping arriving under a uid this site has never seen is created switched off, whatever the sender says: first contact between two systems that each already had mappings must not enable a rule nobody reviewed here. Updates to a uid already held apply as sent.
The canonical form carries what both sides understand: identity, version, direction, the key pair and the field pairs. Options that exist on one side only — Send All Fields here, the Medusa event list there — do not travel, so a mapping that relies on them needs that part set again on the far side.
ERPNext decides which documents are allowed to sync. A mapping says a DocType can; the selector on the document says whether this one does, and to which stores:
| Stores connected | What the document shows |
|---|---|
| one | Sync with Medusa, a single checkbox |
| several | Medusa Sites, a checkbox per store |
The field that does not apply is hidden rather than removed, so going from one store to several never loses what was already chosen.
Which DocTypes carry the selector is Medusync Settings → Sync Selection. The Catalogue DocType (Item on a stock ERPNext) always does; list any others alongside it. Changing the catalogue moves the selector onto the new DocType and tells every connected store, so the plugin's "link to an existing product" search looks in the right place.
Everything excluded is also written to Medusync Exclusion, the central Don't Sync list, and an entry added there by hand updates the document. Two views of one decision: an operator can never be shown a ticked box for a document the system refuses to sync. Entries the checkbox created are cleaned up by the checkbox; entries a person added are theirs to remove.
Defaults are deliberate. Turning selection on must not silently stop a store that was syncing perfectly well, so a document nobody has touched is allowed, and an empty store list falls back to the checkbox.
One ERPNext site holds stock in several warehouses; each store keeps its own stock locations. The pairing is per store, on Medusync Site → Warehouses:
| Column | Means |
|---|---|
| Warehouse | the ERPNext warehouse whose sellable stock this store sees |
| Medusa Stock Location ID | where it lands over there; blank lets the store choose |
| Enabled | untick to stop sending, without forgetting the pairing |
The same warehouse can appear on several stores under different location ids, and one store can draw on several warehouses. What travels is sellable stock, not raw quantity:
sellable = max(0, actual - reserved - safety_stock)
ERPNext stays the single reservation authority, so a Medusa store is never told about stock that a Sales Order has already promised.
A store with no rows falls back to Medusync Settings → Inventory Source Warehouse, exactly as before the map existed. Rows that exist but are switched off are still a map: a store that unticked its only warehouse is saying "send me nothing", not "go back to the global default".
Prices are two-way by default, but each Price List decides for itself, per store, on Medusync Site → Price Lists:
| Column | Means |
|---|---|
| Price List | the ERPNext list |
| Direction | Two-way, To Medusa, From Medusa, or Don't Sync |
| Role | Base Price (the price on the shelf) or Tier Price (a B2B tier) |
| Medusa Tier Code | which tier, for a Tier Price |
The role is per store because the same list means different things in different places: a wholesale list can be the shelf price on a trade store and a customer tier on the retail one. A cost list marked Don't Sync stays documented and moves nothing.
Only the outbound half is wired today: To Medusa and Two-way send,
From Medusa and Don't Sync send nothing. Medusa writing a price back
into an Item Price is not built yet, so From Medusa currently means
"ERPNext keeps out of it" rather than "Medusa drives it".
A store with no rows keeps what it had: the Settings selling price list
as its base price, and the medusa_customer_tier field on Price List for
its tiers.
Two things a storefront cannot work out for itself.
Source. Once a web order and a phone order are both Sales Orders they
are indistinguishable. Medusa sends its sales channel, it lands in Sales Order.medusa_order_source, and submitting the order reports it back as
order.source.set so the store can say which it was. An order raised in
ERPNext reports erpnext.
Payments. Money that arrives by transfer, cheque or UPI never touches
Medusa. A submitted Payment Entry sends order.payment.set for each
order it is allocated against, carrying what was allocated to that
order rather than the size of the whole receipt. The store files each one
under the Payment Entry that produced it, so three transfers against one
order are three receipts and a re-send overwrites only its own. Cancelling
the Payment Entry marks its receipt cancelled and drops it from the total;
the row stays, because somebody will ask where the money went.
Both land in the Medusa order's metadata, not in Medusa payment records. ERPNext is the accounting authority here, and a payment record no captured transaction backs would put a figure in the storefront ledger that nothing reconciles.
Two rules that no mapping can sign away:
- An update to a record that already exists here is skipped, unless
Medusync Settings → Medusa May Update Catalogue Fields is on.
Carrying
titlein both directions is a reasonable mapping to configure; the person who configured it was thinking about new products, not about the description someone in purchasing wrote. - A Medusa delete never deletes the Item. It unlinks: the record keeps its stock, its purchase history and its ledger entries, and simply stops claiming a Medusa product. There is no setting for this. Disabling would be nearly as bad — ERPNext would stop letting anyone transact against it.
Creating is not affected: whether a Medusa-origin product may become a record at all is the plugin's product policy (off / link / create).
A refusal comes back as a skip, not a failure, so the sender records "nothing to do" and stops. Returning an error would put the event into a retry loop that could never succeed.
A mapping is a small program somebody types into a grid, and until now the only feedback it gave was a log row after a real document was saved. That is a poor place to discover a wrong field name and a worse one to discover a condition that excludes everything.
Open a Medusync Mapping and use Test:
| Button | Does |
|---|---|
| Show a Sample Record | a real record of that DocType, or one made up from the form's own definition when the site has none |
| Rehearse | both directions, reported, nothing written |
| Rehearse and Enable | the same, and switches the mapping on if it held up |
| Send a Test Event | a real signed request to every store, which checks it and answers without writing |
Leaving here reports the payload that would travel, the events that would fire, which stores would receive it, and whether the condition passes. A mapping that is enabled, valid and silent is the hardest fault to see from outside, so "no document events selected", "the condition excludes this record" and "every store is excluded" are named rather than left for somebody to notice later.
Arriving here reports whether the payload would create or update, the field-by-field difference, and anything ERPNext itself would object to. It runs the doctype's own validation inside a savepoint that is always rolled back, so a mandatory field or a bad Select shows up here instead of as a queue of failures.
Both halves ask the same code the real paths ask: outbound uses the same
payload builder and the same condition and selection checks, inbound uses
the same plan apply_inbound executes. A studio that reasons
independently is worse than none, because it is believed.
Switching a mapping on requires a rehearsal that matches it. What counts as "matches" is a signature over what the mapping does — DocType, direction, key, field map — so a pass survives ticking Enabled and does not survive somebody adding a field afterwards. Renaming it costs nothing.
Only the transition is gated. A mapping already running keeps running whatever is edited on it; retro-fitting the rule would stop a working site on the next save of anything.
The far side cannot switch on a mapping this site has not rehearsed. It
arrives, its fields are applied, and Enabled stays off — the same rule
as first contact. It is refused quietly rather than with an error, because
an error would put the sender into a retry loop.
A test event is real traffic with dry_run in the envelope: signed,
inside the replay window, checked by the far side, and stopped before the
write. That is the only check that can prove the shared secret, the
network and the far side's own verdict, which between them are most of the
reasons a sync fails.
The rows it leaves are marked Test Run, and everything that reads the log skips them: the retry sweep will not retry one, a rehearsed success never suppresses a genuine event as a duplicate, and a rehearsal does not count as having reached a store. They are pruned after a day whatever the retention setting says.
Three, because three is what a store needs before anything else is worth
configuring: Customers, Catalogue and Orders. They carry fixed
identifiers (default:customer and friends) so both systems agree which
mapping is which without asking each other, and the catalogue one follows
whatever Medusync Settings → Catalogue DocType says.
They arrive switched off, always. A mapping goes live only after a rehearsal that matches it, and that rule is exactly what makes restoring them safe: the configuration comes back without changing what the site does until somebody has looked at each one.
Medusync Settings → Default Mappings Version records which set is
installed. Applying a newer set to a site that has edited the old one is
a harder problem — it has to decide what "edited" means — and is not what
restoring does.
A reset throws away configuration somebody spent a week getting right, so the question worth answering is not what it does but who may ask. Nobody, alone.
Open the Medusync Site and use Danger Zone → Hard Reset. Each system generates a secret and shows it once; each has to be handed the other's. A secret is 32 random bytes, lives three minutes, works once, and is stored only as a hash — the receiver redacts the body of a verify before it writes its audit row, whatever the payload-logging setting says. A secret that reaches a log has a much longer life than three minutes.
Only a side holding both proofs resets: the far side proved it holds ours, and we proved we hold theirs.
| Keeps | every business document, and every medusa_* id on it |
Medusync Site records and both their secrets |
|
| the Don't Sync list, and the warehouse and price-list maps | |
| Restores | the three shipped mappings, switched off |
| Switches off | every other mapping — kept, not deleted |
| Clears | Medusync Log, and the loop-prevention breadcrumbs |
Keeping the cross-system ids is the important one. A reset that took
medusa_customer_id with it would leave both systems holding the same
customers and no longer knowing it, which is worse than the configuration
mistake anyone was trying to fix. Keeping the store's secrets means
recovering from a bad configuration does not also mean re-pairing. Keeping
exclusions means the reset cannot quietly resume syncing a document
somebody deliberately stopped.
Afterwards nothing is running. Rehearse and enable what you want back.
An inbound write is an ordinary document save, so it would fire the
outbound hook and push the change straight back. Medusync sets
frappe.flags.medusync_inbound for the duration of an inbound apply
and the outbound hook checks it.
The flag is per-request, so on its own it cannot stop the echo a
background worker sends a moment later, after the request is gone. An
inbound write therefore also leaves a short-lived breadcrumb on each
document it touched: "this was last changed by correlation C, which came
from medusa:site-a". Anything sent about that document within the window
is stamped echo_of, and the far side drops what it recognises as its
own. The breadcrumb expires, so a person editing the same document a
minute later is never mistaken for an echo.
- Log retention.
Medusync Logstores whatever your mapping carries — on a Customer mapping, that is personal data. Retention defaults to 180 days; set Log Payload Bodies off on sites where a second copy of that data is not acceptable. - Delivery is queued by default so a slow Medusa never blocks a user's save. Turn it off only while debugging.
- Deletes are opt-in per mapping, and off by default.
- The outbound hook is a wildcard across every doctype. It returns immediately when no mapping matches, from a request-local cache.
MIT