medusa-payment-provider
v0.0.9
Published
Universal payment provider plugin for Medusa v2 — Razorpay, Stripe, PayPal, PayU, Juspay, PhonePe, Paytm, Cashfree, HDFC, PagLocal (PayGlocal), plus 1-click UPI identifiers.
Maintainers
Readme
medusa-payment-provider
Universal payment provider plugin for Medusa v2. Supports 10 gateways plus 1-click UPI identifiers for Indian methods:
| Provider | Identifier | Flow |
|----------|------------|------|
| Cashfree | cashfree | Server-side order + JS SDK |
| Cashfree UPI | cashfree_upi | Order Pay UPI link (intent) |
| HDFC SmartGateway | hdfc | Payment page session |
| HDFC UPI | hdfc_upi | UPI-only payment page session |
| Razorpay | razorpay | Orders API + Checkout JS |
| Razorpay UPI | razorpay_upi | UPI QR Codes API (inline QR / upi://) |
| Stripe | stripe | PaymentIntent + Elements |
| PayPal | paypal | Checkout order + capture |
| PayU | payu | Redirect + SHA512 hash |
| PayU UPI | payu_upi | Redirect with pg=UPI / bankcode=INTENT |
| Juspay | juspay | Smart-router payment page |
| Juspay UPI | juspay_upi | UPI-only payment page session |
| PhonePe | phonepe | Redirect + checksum (PAY_PAGE) |
| PhonePe UPI | phonepe_upi | UPI_INTENT deep link |
| Paytm | paytm | txnToken + payment page |
| Paytm UPI | paytm_upi | UPI UPIPUSH (intent) |
| PagLocal (PayGlocal) | paglocal | PayCollect GPI redirect |
*_upi identifiers are separate Medusa payment providers so checkout can show 1-click UPI next to the hosted-page option. They reuse the same gateway credentials as the parent provider. No extra environment variables.
Installation
npm install medusa-payment-providerCopy .env.example to your host .env and fill provider credentials.
Registration
All providers (unified)
// medusa-config.ts
{
resolve: "medusa-payment-provider",
options: {
},
}The unified package registers hosted-page and *_upi services. Enable the ones you want on the INR region in Admin (pp_phonepe_upi, pp_razorpay_upi, …).
Single provider (selective)
{
resolve: "medusa-payment-provider/providers/razorpay",
id: "razorpay",
options: {
key_id: process.env.RAZORPAY_KEY_ID,
key_secret: process.env.RAZORPAY_KEY_SECRET,
webhook_secret: process.env.RAZORPAY_WEBHOOK_SECRET,
},
}Repeat for stripe, paypal, payu, juspay, phonepe, paytm, cashfree, hdfc, paglocal.
1-click UPI (same options as the parent gateway):
{
resolve: "medusa-payment-provider/providers/phonepe-upi",
id: "phonepe_upi",
options: {
merchant_id: process.env.PHONEPE_MERCHANT_ID,
salt_key: process.env.PHONEPE_SALT_KEY,
salt_index: process.env.PHONEPE_SALT_INDEX,
environment:
process.env.PHONEPE_ENVIRONMENT === "production" ? "production" : "sandbox",
},
}Also: razorpay-upi, cashfree-upi, payu-upi, paytm-upi, juspay-upi, hdfc-upi.
PagLocal (PayGlocal PayCollect)
{
resolve: "medusa-payment-provider/providers/paglocal",
id: "paglocal",
options: {
merchant_id: process.env.PAGLOCAL_MERCHANT_ID,
private_key: process.env.PAGLOCAL_PRIVATE_KEY?.replace(/\\n/g, "\n"),
key_id: process.env.PAGLOCAL_KEY_ID,
webhook_merchant_id: process.env.PAGLOCAL_MERCHANT_ID,
environment:
process.env.PAGLOCAL_ENVIRONMENT === "production" ? "production" : "sandbox",
},
}Configure the PayGlocal webhook URL with PayGlocal support: https://<host>/webhooks/paglocal.
1-click UPI (storefront contract)
UPI Intent is mobile-first. Desktop checkouts should show a QR or the HTTPS payment_url fallback.
Every *_upi initiate writes:
{
flow: "upi_intent",
upi_intent_url?: string, // upi:// or app deep link when the gateway returns one
payment_url?: string, // HTTPS fallback / hosted form / Checkout JS
// plus existing gateway ids (order_id, merchant_transaction_id, ...)
}Storefront:
- Enable
pp_{id}_upion the region (e.g.pp_phonepe_upi). - If
session.data.flow === "upi_intent"andupi_intent_urlis set, navigate there on mobile (window.location.href = upi_intent_url). - Otherwise use
payment_url(or PayU form fields / Razorpay Checkout JS with UPI preselected). - After return, call existing
authorizePayment; webhooks still complete the session.
PayU UPI has no upi:// URL from the server: auto-post the hosted form (pg=UPI, bankcode=INTENT). Hash is unchanged (pg / bankcode are not hashed).
Webhooks
Plugin routes (recommended for signature verification)
Configure your gateway to POST to:
| Provider | URL |
|----------|-----|
| Razorpay | https://<host>/webhooks/razorpay |
| Razorpay UPI | https://<host>/webhooks/razorpay_upi |
| Stripe | https://<host>/webhooks/stripe |
| PayPal | https://<host>/webhooks/paypal |
| PayU | https://<host>/webhooks/payu |
| PayU UPI | https://<host>/webhooks/payu_upi |
| Juspay | https://<host>/webhooks/juspay |
| Juspay UPI | https://<host>/webhooks/juspay_upi |
| PhonePe | https://<host>/webhooks/phonepe |
| PhonePe UPI | https://<host>/webhooks/phonepe_upi |
| Paytm | https://<host>/webhooks/paytm |
| Paytm UPI | https://<host>/webhooks/paytm_upi |
| Cashfree | https://<host>/webhooks/cashfree |
| Cashfree UPI | https://<host>/webhooks/cashfree_upi |
| HDFC | https://<host>/webhooks/hdfc |
| HDFC UPI | https://<host>/webhooks/hdfc_upi |
| PagLocal (PayGlocal) | https://<host>/webhooks/paglocal |
Routes preserve req.rawBody for HMAC verification and always respond 200 (gateways retry on failure).
Gateways typically allow one dashboard webhook URL:
- UPI-only region (parent identifier not registered): point the gateway at
/webhooks/{id}_upi. - Hosted + UPI both enabled: keep
/webhooks/{id}.getWebhookActionAndDatais inherited; Medusa then authorizes bysession_idusing the session’s own provider id.
Medusa built-in hook
Medusa also exposes /hooks/payment/{provider} which emits a delayed event. Plugin routes call IPaymentModuleService.getWebhookActionAndData() directly.
Amount handling
Medusa v2 passes payment amounts in major currency units (e.g. 100.50 = ₹100.50), not paise/cents.
| Providers | Medusa | Gateway API | |-----------|--------|-------------| | Cashfree, PayPal, PayU, Paytm, Juspay, PagLocal, HDFC | major | major (decimal string or number) | | Stripe, Razorpay, PhonePe | major | minor (smallest unit; converted via currency exponent) |
Currency exponents: 0 (JPY, KRW, VND, …), 3 (BHD, JOD, KWD, OMR, TND), 2 (default, including unknown codes). Conversion uses string/integer scaling with round half-up — never native float × 100.
Session data writes amount plus amount_unit: "major". Unmarked legacy session amounts are not trusted for money decisions (prefer gateway-converted or current Medusa amount). Abandon/recreate in-flight checkouts created before this fix.
Known issue (ops): if this plugin ran in production with the previous wrong unit assumption, some charges may have been ~100× too low. Finance should reconcile gateway settlements vs order totals for that window — no automatic re-charge in this plugin.
Staging checklist before prod: one charge + full refund + partial refund + webhook per family (major-unit vs minor-unit providers). Initiate logs include medusa_amount, gateway_amount, amount_unit for anomaly grepping. For UPI, also complete one mobile intent payment + desktop fallback per enabled *_upi provider.
Order cancel / refund (Razorpay)
- Refund: Resolves
payment_idfrom session data or Razorpay order payments. Already-refunded / fully-refunded gateway states are treated as success so MedusacancelOrderWorkflowcan finish. Missing gateway payment (never paid) is a no-op success. - Cancel (uncaptured): Attempts to void authorized payments via full refund when Razorpay reports
authorized; otherwise marks the session canceled locally without throwing.
Development
cd medusa-payment-provider
npm install
./node_modules/.bin/medusa plugin:build
npm testCompatibility
Medusa >= 2.4.0 (tested with @medusajs/[email protected]).
