Medusa plugin
plugins/medusa is a Medusa v2 plugin (@nerdcash/medusa-payment-zcash) that takes Zcash through your own copy of this service. It adds a payment provider, a signed webhook route, and an order widget in the admin.
How it works
- The customer picks Zcash at checkout. Medusa creates a payment session, and the provider calls
POST /payment-sessionwith the cart total and areturn_url. It stores the issued address and the pay page link (pay_url) in the session data. - The storefront completes the cart. The session is pending authorization, so Medusa places the order with payment still outstanding, and the storefront sends the customer to
pay_url. - The service sends a signed webhook to
/hooks/zcasheach time the address’s total changes. The plugin checks theX-SignatureHMAC, finds the session by its address, ignores stale events, and confirms the total withGET /payments/{id}. Once the total covers the order, it runs Medusa’sprocessPaymentWorkflow, which authorizes and captures the payment. A partial payment is recorded but captures nothing. - The pay page sends the customer back to your storefront.
Edge cases the plugin handles:
- Waiting for confirmations. When the service sets
REQUIRED_CONFIRMATIONS, the session is authorized as soon astotal_received(mempool included) covers it, so checkout can finish, but the plugin captures only once the lastreceived_by_confirmationselement covers it. A capture from the admin is refused until then too. Until then it logs pending confirmation. A reorg that undoes those confirmations after capture sets the hold described below. When the service doesn’t setREQUIRED_CONFIRMATIONS, the array is absent and the plugin usestotal_receivedas before. - Funds drop after capture (an unconfirmed transaction expires, or a reorg): the plugin sets the order’s
zcash_payment_holdmetadata, shows it in the admin widget, and emitszcash_payment.hold_changedso your fulfillment code can wait. When the funds return and the service confirms the full total, the hold is cleared. Releasing a hold never captures again or re-runs order placement or fulfillment. - Holds you set yourself are kept. If
zcash_payment_holdalready has a value, the plugin leaves it alone, and it only clears the exact message it wrote. - A payment’s amount is fixed once its address is issued. If the cart total changes, start a new payment session; the old address is not reused or re-priced.
- Payment for a canceled order is never captured. The plugin records it in
zcash_payment_reviewmetadata, shown in the widget, for you to reconcile. If a cancellation lands while a capture is in flight, the order is flagged the same way so you can refund it. - Stale or replayed events are ignored by their event version. Overlapping deliveries for one session are serialized with Medusa’s locking module, and the version is stored only after the update succeeds, so a failed update is retried.
- State is kept in session metadata, which storefront clients can’t write, and amounts are converted with exact decimal math.
Setup
Requires Medusa 2.22 or later and Node.js 20+.
-
Run the service as in Deployment, with
ZCASH_WEBHOOK_URLpointing at your Medusa server’s/hooks/zcashroute and a randomZCASH_WEBHOOK_SECRET. SetPUBLIC_BASE_URLandRETURN_URL_ALLOWED_ORIGINS(your storefront’s origin) as described in Exposing the page. See the WooCommerce page for choosingWEBHOOK_REPORT_CONFIRMATIONS; the same trade-off applies. -
Build and install the plugin. It isn’t on npm yet:
cd plugins/medusa && npm ci && npm run build && npm pack cd /path/to/your-medusa-app && npm install /path/to/nerdcash-medusa-payment-zcash-0.1.0.tgz -
Register it in
medusa-config.ts:plugins: [{ resolve: "@nerdcash/medusa-payment-zcash", options: {} }], modules: [ { resolve: "@medusajs/medusa/payment", options: { providers: [ { resolve: "@nerdcash/medusa-payment-zcash/providers/zcash", id: "zcash", options: { serviceUrl: "http://zcash-payment-service:8787", // private address webhookSecret: process.env.ZCASH_WEBHOOK_SECRET, zecRate: { usd: "0.025" }, // ZEC per unit of store currency returnUrl: "https://shop.example/order/confirmed?session_id={session_id}", }, }, ], }, }, ],Option Meaning serviceUrlWhere Medusa reaches the service. It does not need to be public. webhookSecretThe same value as ZCASH_WEBHOOK_SECRET.zecRateA decimal string, or a map by currency code. Required unless the region prices in ZEC. The plugin does not fetch prices from any third party; keep it current yourself. returnUrlWhere the pay page returns the customer. {session_id}is replaced with the payment session id. A storefront may passreturn_urlin the session data instead.payBaseUrlOnly if the service does not set PUBLIC_BASE_URL. -
Run
npx medusa db:migrate, then enable the providerpp_zcash_zcashon your regions in the admin (Settings → Regions).
Storefront
After initiatePaymentSession with provider_id: "pp_zcash_zcash", complete the cart as usual, then redirect the customer to the session’s data.pay_url. The order’s payment_status turns captured once the payment arrives. plugins/medusa/e2e/app/src/api/demo-store contains a minimal working storefront.
Testing
Nothing below needs a wallet, an exchange account, or test coins.
Unit tests cover amount conversion, signature checks, webhook parsing, and the webhook decision rules (capture, partial, hold, release, review, stale):
cd plugins/medusa && npm ci && npm run typecheck && npm test && npm run build
End-to-end test (plugins/medusa/e2e) starts Postgres, a Medusa store with the plugin installed, and this service with Docker Compose, then drives a headless Chromium through checkout:
cd plugins/medusa/e2e
npm ci && npx playwright install --with-deps chromium # once per machine
./run.sh # KEEP_STACK=1 leaves it running on http://localhost:9000
The service runs on testnet with ZCASH_DEV_SIMULATED_PAYMENTS=true, which turns on POST /dev/simulate-payment. The test:
- places an order and checks the pay page shows the right amount;
- simulates a partial payment, then the rest, and checks the browser returns to the store and the order is captured exactly once;
- sends signed webhooks for a drop in funds (the order is held), a stale event (ignored), and the recovery (the hold is released with no second capture);
- pays a canceled order and checks it is flagged for review, not captured;
- simulates one payment at 0, 1, and 2 confirmations (the stack sets
REQUIRED_CONFIRMATIONS=2) and checks it is captured only at the last; - checks that the webhook rejects a bad signature with
401, acknowledges an unknown address, and that the service refuses a foreignreturn_url.
CI runs both on every pull request (.github/workflows/medusa-plugin.yml) and keeps screenshots, including the admin widget, as a build artifact.
What the tests do not cover
- The chain. Simulated payments skip lightwalletd and trial decryption, and their confirmations are simulated rather than mined. A real testnet payment is still worth doing by hand before a release.