Vonpay payment provider for Medusa v2
Vonpay payments for Medusa v2. One package adds card payments to your Medusa store and its Next.js storefront, with 3-D Secure. It can also add Apple Pay and Google Pay, saved cards for signed-in customers, renewal charges for your subscription plugin, and one-click offers after the order. Your Medusa admin gets a Held payments page for anything that needs a person, a setup check, and a notice when a new version is out.
The same package goes into your Medusa server and your storefront, so one update covers both.
Start with your test keys. Switch to live keys when the setup check passes.
In your Medusa server folder:
Then register the plugin and its payment provider in . This is a complete file for a new store; if you already have one, add the entry, the payment module entry and to it. If you already configure the payment module (for another provider), add the Vonpay entry to its existing list.
With the id , Medusa names the provider . You will use that name in the admin, and the setup check reports it.
Set these in the server's (never in or any file you commit):
| Variable | Required | What it is |
|---|---|---|
| yes | Medusa's Postgres connection. | |
| , | yes | Medusa's own signing secrets for admin and customer sessions. Each a long random value (for example ), never a default such as . |
| , , | yes | Medusa's allowed origins (your storefront, your admin). |
| yes | Your Vonpay secret key, or . | |
| yes | or . Must match the key. | |
| yes | Your storefront's address, such as . Must be ; is allowed with test keys. Shoppers come back here from their bank's 3-D Secure check. | |
| after setup | The signing secret Vonpay shows when you register your notification endpoint. Leave it empty for the first start (see Payment notifications). | |
| optional | With one-click offers: a long random value of your own (), the SAME value in the storefront's environment. It proves the shopper's address on offer charges is real (see , "The shopper's address on offer charges"). Never your Vonpay key or the webhook secret. | |
| recommended | Your Medusa server's public address, such as . Lets the setup check confirm the notification endpoint is this store's. | |
| no | turns off the update notice (for a server with no outbound internet). |
Then create the plugin's tables and start the server:
The server refuses to start if a required setting is missing or malformed: a key that is not a secret key, a mode that does not match the key, or a storefront address that is not . The error names the problem, never the value.
Medusa offers a payment provider only in the regions you add it to. Until you do, checkout never shows Vonpay.
The setup check fails ("regions") while no region offers Vonpay.
Vonpay sends your server a notification when something happens to a payment. Your store accepts only notifications signed with your endpoint's signing secret, and Vonpay gives you that secret only after you register the endpoint. So the order is:
Install and start the server without . Payments already work. Every notification is refused until the secret is set, and the setup check fails . Vonpay does not send a refused notification again, so finish these steps before you take live payments.
Register the endpoint in the Vonpay dashboard. The address is your server's public address followed by :
https://<your backend>/hooks/payment/vonpay_vonpayChoose all events (this includes , which Apple Pay and Google Pay need). Only if Vonpay runs your subscriptions (), also add the five subscription events, which "all events" does not include: , , , and .
Copy the signing secret into .
Restart the server and the worker.
Run the setup check (see Check your setup).
Keep one endpoint per store. If two active endpoints point at the same path, Vonpay sends every event to both, and the setup check warns.
The shopper's own checkout page creates the order, never a notification. If a shopper pays and closes the page first, the plugin's recovery job creates it about 10 minutes after the payment.
In your storefront folder:
The command expects Medusa's Next.js starter layout. It:
Each step runs only if it is still needed, so the command is safe to run again. writes nothing. It never edits a file it does not recognise, and never replaces an existing file that differs from what it would write unless you pass ; those steps print as "manual", with the change to make by hand.
After it runs:
The card form appears in the Payment step, directly above Place order (no separate Review step). It loads Vonpay's vora.js from , so Vonpay's card-form fixes reach your store without a release.
Medusa's starter sends no Content Security Policy. If you add one, the pages that show the card form need Vonpay's sources: the checkout, the 3-D Secure return page, and the offer pages ( and ). The package gives you the list:
Do not use in : browsers then ignore the host list. Also allow your Medusa backend in . The list is bundled with the package; to follow Vonpay's live list between releases, call (same import) after the response, for example with in your middleware. A page never waits for it.
A production Medusa store runs a server and a separate worker that share Redis. The plugin's background jobs and payment notifications run on the worker, and both processes must share one lock so two of them never complete the same checkout at once. Add these modules to , next to the payment module:
Medusa already includes the Redis caching provider; with pnpm (whose strict layout hides packages you did not add yourself), add at your exact Medusa version. Then run two processes: the server with , and the worker with and .
From your Medusa server folder:
npx vonpay-medusa-checkIn an image built with , run it from , where the built config lives. The same check runs from the Check Vonpay setup button on the admin's Held payments page.
Each line is PASS, WARN, FAIL or SKIP (could not be checked), with what to do. The command exits with code 1 when anything fails; also fails when a check could not run. It checks:
These go in the provider's in , next to the four settings from Install. All are optional.
Every 5 minutes, on the worker, the plugin looks for unfinished checkouts (10 minutes to 24 hours old) that Vonpay reports paid. For each one it runs Medusa's own payment step and confirms an order exists. If Medusa still makes no order, the payment goes on the Held payments page as "Paid, order not created". It never refunds.
This is the only thing that completes the order for a shopper who pays and closes the page. Leave it on. turns it off, and the setup check fails while it is off.
When a cart changes during payment, Vonpay may not close a just-declined payment straight away, and the payment goes on the Held payments page as "outcome unknown". Every 5 minutes, on the worker, the plugin asks Vonpay again. Once Vonpay closes it with nothing charged, it leaves the list ("closed automatically"). It never refunds and never touches a payment that may carry money. turns it off; those rows then wait for a person.
or turns on saved cards for signed-in customers. Both values do the same thing: the cart decides what Vonpay tells the card networks about each card saved:
Vonpay applies this only when the payment really saves the card with the shopper's agreement. Each card keeps what it was saved with, and a renewal repeats that card's own value, so changing this option never changes cards already saved. (A card saved before Vonpay recorded this falls back to the option's value, and the plugin logs .) Vonpay notes that on some processor connections (network tokens) only is accepted for later charges; an card there may be declined when charged later.
With it on, a signed-in customer's checkout shows Vonpay's "Save my card for future orders" checkbox, never pre-ticked. Guests never see it. A customer keeps at most 10 saved cards; at 10, checkout stops offering to save and asks them to remove one. On a subscription cart the checkbox is required and the limit is 50. The same card saved twice (same brand, last four digits and expiry) counts as one card.
Customers see and remove their cards on the My saved cards page. Each card appears once, even if it was saved by several payments. A card they did not agree to have shown back is listed as "A saved card", with no brand or digits (such cards cannot be matched, so each is listed on its own). Removing a card revokes it at Vonpay for good, including every other saved copy of the same card the customer holds; a subscription that was renewing on one of those copies then needs a new card. The plugin sends Vonpay only your own customer reference, never the customer's email. When you delete a customer in Medusa, the plugin revokes their cards and erases any email Vonpay still holds for them (sent by plugin versions before 2026-10-19).
Consent by your own terms instead of the checkbox. The value is your exact wording: plain text, 20 to 1,000 characters, saying the card is kept and charged for the renewals. For example: "Your card is saved and charged automatically for this subscription's renewals until you cancel."
With it set, checkout shows no checkbox. On a subscription cart the page shows "By placing your order you agree to:" and your wording, above the Apple Pay / Google Pay buttons when they show, otherwise just above the pay button. Completing that checkout saves the card for the renewals. A one-time purchase saves nothing. The plugin records your configured wording with each payment it applied to, when the payment is confirmed. It records what the store was configured to show, not a capture of the shopper's screen, so change the wording deliberately. Cards saved this way are listed as "A saved card". This needs vora.js 1.43.0 or later; with an older one, a subscription cart shows the required checkbox as well as your wording.
Your responsibility with terms mode. Rules for saving a card for renewals and for automatic renewals (the card networks' stored-credential rules; in the US, state automatic-renewal laws such as California's and the federal ROSCA) expect clear terms next to the action, the shopper's agreement, and a record. The plugin supplies the placement, the lead-in and the record; the wording is yours. It must state the price (or how it is set), how often it renews, that it continues until cancelled, and how to cancel. Send your own confirmation email with those terms after the order, keep cancellation simple, and have your wording reviewed by your legal counsel: whether completing checkout under the terms counts as consent where you sell is a legal question this plugin cannot answer. Outside the US other rules apply. If in doubt, keep the checkbox (leave unset).
If you sell subscriptions with only the checkbox, the setup check warns (): the checkbox alone does not show the price, how often, any trial or how to cancel. Each recorded payment is also written to the server log (, with ids and the wording's fingerprint, never the wording or an email), because payment data can be read from the storefront.
Who runs your subscription renewals. Exactly one, so a customer is never charged by two systems.
: your own subscription plugin (such as ReorderJS) renews, and this plugin charges the saved card when it asks. Needs .
: Vonpay's own subscription billing renews, and this plugin charges no renewal itself. Register the plugin's subscriptions module, run , and add the five subscription events to your endpoint:
Vonpay's subscription notifications then appear in the admin under Orders → Vonpay subscriptions. They do not create or change orders yet.
Renewal charges (). The plugin charges only when all of these hold; anything else is refused and nothing is charged:
Each order is charged at most once. An attempt whose result is unknown, that Vonpay reports as not yet settled, or whose amount or buyer does not match goes on the Held payments page, and every later attempt on that order is refused. Connection errors are retried up to three times with the same request key. A declined attempt leaves the next one free. If Vonpay charged a renewal but the store stopped before Medusa recorded it, the recovery job finishes it without charging again; if it cannot, the renewal is listed as "Renewal charged, order unpaid": mark the order paid in Medusa, and do not charge again.
In ReorderJS: a held or refused renewal comes back as "not authorized", so ReorderJS records a failed payment and opens a dunning case, even when a held charge may in fact have gone through. Check the payment in the Vonpay dashboard before acting on a failed renewal.
Known gap in ReorderJS 1.1.0 (its code, not this plugin's): its automatic retry of a failed renewal never reaches any payment provider, so a renewal whose first attempt fails is not charged again, and "Retry now" on its dunning screen fails the same way. "Mark recovered" and "Mark unrecovered" only change the case status. Collect the payment another way first, and mark a case recovered only once the money has arrived. The first renewal attempt is unaffected.
adds Apple Pay and Google Pay buttons above the card form. A wallet tap only holds the money; the order is created, and then the plugin takes the money (Medusa shows the payment as captured). If the order cannot be created, or the cart changes, the hold is released: nothing was taken, so nothing is refunded. A shopper who taps and closes the page still gets an order, about 10 minutes later, from the recovery job. If taking the money fails, the plugin retries for 2 hours, and the Held payments page shows it.
The buttons show only where the shopper's device supports the wallet and your storefront's domain is registered. To register it (once, and again if the storefront's address changes or you switch from test to live keys), deploy the storefront with the Apple Pay route from , then run in the server folder:
npx vonpay-medusa-check --wallet-domainsIt registers the host of for Apple Pay and Google Pay, saves Apple's domain file in your store, and asks Vonpay to verify both. Your storefront then serves that file. Make sure your endpoint receives ("all events" does).
For testing only — the bank check in a pop-up. When a wallet payment needs a bank check, the shopper normally goes to the bank's page and comes back. To try Vonpay's preview pop-up instead (the bank's page opens over your checkout), set in the storefront's environment and rebuild it. It works only with test keys and vora.js 1.43.0 or later (what serves): with live keys, or any value other than or , it is ignored (one warning in the server log) and the bank check stays a full page. While the pop-up is open the card form cannot be used. If the shopper closes it and Vonpay confirms nothing was charged, the checkout says so and they can try again or pay by card; if Vonpay cannot confirm that yet, the checkout shows "We couldn't confirm a payment" with Check again, never a new payment. If you use the Content Security Policy above, adds what the pop-up needs only when it is on. Do not use it on a live store.
One-click offers after the order:
lists 1 to 20 of your product variants, priced by Medusa with tax included. Right after paying, the shopper lands on the offer page: one offer at a time, up to three per order, for about an hour, with "Add to my order" and "No thanks". An accepted item joins the original order as an order edit with its own payment, refundable on its own. "No thanks" leaves the order unchanged.
Card payers get true one-click. With upsells on, the payment step says "Your card may be charged for any offer you accept on the next page." That card is kept for this purchase only: it is never charged while the shopper is away, is not shown under My saved cards, does not count toward the card limit, and is removed once that order's offer has ended (about 90 minutes after the order, see below; a newer order's card waits for its own offer to end). Apple Pay and Google Pay payers see the wallet button on the offer: the tap holds the amount, then the item is added and the hold taken, or the hold is released if the item cannot be added.
The shopper's address on offer charges. Your store's server makes the offer charge, so Vonpay never sees the shopper; without their address, the processor's fraud checks look at a server instead. Your storefront can pass it on: set in (the setup command writes it from the environment variable) to the request header your hosting sets to the visitor's own address, one value it writes itself: on Vercel, behind Cloudflare. It must be a header your host overwrites on every request; a header a visitor can send through unchanged lets them choose the address you report. Do not use (its first entry can be typed by anyone; a header holding a list is ignored). Then set the same random value as in BOTH the storefront's environment (the setup command passes it as ; server-only, never ) and the Medusa server's (option ): the storefront signs the address with it, and the store ignores any address that is not signed, so someone calling your store directly cannot choose it. Only a public address is sent; private, local or malformed values are dropped. Either setting left unset, offer charges carry no address (the setup check WARNs; the store logs when an address arrives that it cannot believe). Upgrading from 1.0.1: 1.0.1 sent the address unsigned; from 1.0.2 it is used only once is set on both sides. The address is never logged or stored by the plugin.
Guest shoppers get offers too, by default: the checkout names them by the email they type, and their card is kept for that purchase's offers only, then removed once the offer ends. A guest's card is never added to an existing customer's account because the guest typed that customer's email. turns offers off for guests: a guest then pays exactly as with upsells off and sees no offer page. Signed-in customers are unaffected either way.
If the offer needs the shopper's bank to approve it, they go to their bank and come back to the offer page. If they leave the bank page unfinished, the offer page says the check isn't finished and keeps checking on its own; "No thanks" then tells them nothing is charged unless they finish it. If the bank approves before the offer ends, the item is still added and they get the email below.
Every offer has a clear end. It stops about 90 minutes after the order, or as soon as the order is fulfilled, whichever comes first. From then on nothing is added to that order and no more offers are shown. A bank check still open at that point is closed on the store's side (the order change is undone). In the rare case the bank approves it afterwards, the plugin refunds that charge in full automatically; the admin's Held payments page lists it for the record.
When an offer's item is added, the plugin emails the shopper once: "{Store}: added to your order #{n}", "{item} — {price} was added to your order #{n} and charged to the same payment method. It ships with the rest of your order." It goes out through Medusa's own notifications on the channel, so you need an email provider configured in (for example SendGrid or Resend); without one nothing is sent and the setup check warns. turns it off (send your own). uses your provider's template instead of the plugin's wording; the template receives , , , , , , , and .
An offer can add an item to the order for about an hour after it is placed (up to its 90-minute end while a shopper's bank check is still open). So that a parcel never leaves without that item, the order page in the admin shows "Wait to fulfil until {time} — an offer may still be added", and Medusa's Fulfil is refused until then — from the moment the order is placed. Fulfil is also refused while a shopper has paid for an offer whose item could not be added: check it on the Held payments page (add the item in Medusa or refund it in Vonpay), then use Ship now anyway. Ship now anyway on that banner lets you fulfil straight away (it is recorded on the order); fulfilling ends the order's offers. If the plugin cannot read the order's offer state, Fulfil is never blocked.
The line on your customer's card statement has two parts. You set both in the Vonpay dashboard: your fixed brand line, and the rule for a short per-payment ending: which payment field it is read from (order id, subscription id, customer id, or a field name of your own) and what shows when that field is missing. This option tells the plugin what to put in that field, so shoppers recognise the charge:
is the field your Vonpay rule reads: , , , or your own field name. is where the value comes from, one source or up to three in order: the first one that exists for that payment is sent. If none exists, nothing is sent and your rule's "when missing" setting applies. The sources:
| What is sent | Checkout payment | One-click offer | Renewal | |
|---|---|---|---|---|
| The product's title. Several lines: the first line's title plus "+N" for the rest (). | the cart's, when the payment step opens | the offered product | the renewal order's | |
| Your Medusa store's name | yes | yes | yes | |
| The order number as the admin shows it () | no (no order yet) | yes | yes | |
| The plugin's own reference for the customer (), never their name or email | signed-in shoppers only | signed-in shoppers only | yes | |
| The subscription id your subscription plugin records on the renewal order (ReorderJS does) | no | no | when recorded | |
| Fixed text: 5 to 22 plain characters (no accents or emoji, none of ); last in the list | yes | yes | yes |
When it shows:
No personal data is ever sent: there is no source for a shopper's name, email, phone or address, and a value that looks like an email address or a phone number (a product title, say) is left out. must be a plain field name (a letter, then letters, digits, , or ). Names that suggest personal data (for example , , , ) and names the plugin or Vonpay already use (, , , , ) are refused when the server starts; the error says which word it found (for example ). The setup check compares this option with your Vonpay setting.
The admin's Orders → Held payments page lists every payment the plugin could not finish on its own, with what happened and what to do. For example: paid but no order was created, charged and then the cart changed, a wallet payment held with no order, a renewal or offer charge that needs a decision, or a cancelled order whose refund did not go through. Any signed-in admin can use it.
Depending on the row, you can:
Refunds made in the Vonpay dashboard are not mirrored into Medusa. Refund from Medusa to keep the two in step.
Medusa cannot update itself: your store is your own code, built and deployed by you. To update:
The package's code, including the storefront card step, comes with the update. The small files wrote into your storefront do not change. Running again adds anything new and lists, as "manual", any existing file that differs from what it would write now.
The admin tells you. When a newer version is out, the orders page shows "vonpay-medusa X is available" (or "Security fix: …") with the exact install command and a "What changed" link. The notice comes from a file Vonpay signs, checked from your server about twice a day (). Your store sends nothing about itself, and an unsigned or unreachable file is never shown. on the server turns it off.
Automatic update pull requests. offers to turn them on (default yes; or to choose without the question). It adds , a daily check for vonpay-medusa in every folder of the repository that installs it (for a workspace with one lock file at the top, that top folder), and a small workflow that turns on auto-merge for our patch (bug and security fix) releases, so they merge once your required checks pass. Turn on Settings → General → Allow auto-merge in GitHub. Minor and major releases always wait for a person. An existing is never edited.
Or set it up yourself. Renovate ():
Or Dependabot (; turn on auto-merge for its pull requests if you like):
Checkout does not offer Vonpay. Add to each region.
"Cannot find module '@medusajs/framework/utils'". Usually npm with the server and storefront in one project, especially after changing Medusa versions. Delete and the lock file, then install again. The package lists Medusa, Next.js and React as optional on purpose, because the same package serves both the server and the storefront.
The storefront's production build fails with React error #31. A known issue in Medusa's Next.js starter, not in this package: under npm, is installed next to the backend's React 18. Use pnpm, as Medusa's starter does, or give the storefront its own install.
The setup check fails , or notifications are refused. Follow Payment notifications, then restart the server and the worker.
The endpoint check warns that it is "not this store's address". Set () to your server's public address, and make sure the endpoint registered at Vonpay uses that same address.
The test notification is delivered but "not processed". Check that the worker is running, that matches the endpoint's signing secret, and that the server and worker share the same Redis.
The checkout says "Card payments are unavailable right now." is missing or not a publishable key (). Set it and rebuild the storefront.
The server refuses to start. A setting is missing or malformed, and the error names which one, such as or (mode and key do not match).
Wallet buttons do not show. Check , run and read its line. The buttons appear only on devices that support the wallet.
MIT. See LICENSE.
npm install vonpay-medusa1import { loadEnv, defineConfig, Modules } from "@medusajs/framework/utils"2
3loadEnv(process.env.NODE_ENV || "development", process.cwd())4
5// Refuse to start without a setting rather than run on a guessable default.6const required = (name: string): string => {7 const v = process.env[name]8 if (!v) throw new Error(`${name} is not set`)9 return v10}11
12module.exports = defineConfig({13 projectConfig: {14 databaseUrl: required("DATABASE_URL"),15 http: {16 storeCors: required("STORE_CORS"),17 adminCors: required("ADMIN_CORS"),18 authCors: required("AUTH_CORS"),19 jwtSecret: required("JWT_SECRET"),20 cookieSecret: required("COOKIE_SECRET"),21 },22 },23 admin: {24 // Public backend address: lets the setup check confirm the endpoint is this store's.25 backendUrl: process.env.MEDUSA_BACKEND_URL,26 },27 plugins: [{ resolve: "vonpay-medusa", options: {} }],28 modules: [29 {30 key: Modules.PAYMENT,31 resolve: "@medusajs/medusa/payment",32 options: {33 providers: [34 {35 resolve: "vonpay-medusa/providers/vonpay",36 id: "vonpay",37 options: {38 secretKey: process.env.VONPAY_SECRET_KEY,39 mode: process.env.VONPAY_MODE, // "test" or "live"40 storefrontUrl: process.env.VONPAY_STOREFRONT_URL,41 webhookSecret: process.env.VONPAY_WEBHOOK_SECRET, // empty until you register the endpoint42 shopperIpSecret: process.env.VONPAY_SHOPPER_IP_SECRET, // optional: see "The shopper's address on offer charges"43 },44 },45 ],46 },47 },48 ],49})1npx medusa db:migrate2npx medusa develop # or your usual start command1npm install vonpay-medusa # or pnpm add / yarn add2npx vonpay-medusa setup-storefront1import { readVonpayBrowserConfig, vonpayCspSources } from "vonpay-medusa/storefront/shared"2
3const vp = vonpayCspSources(readVonpayBrowserConfig())4// Add vp.script, vp.connect, vp.frame, vp.img, vp.font and vp.style5// to script-src, connect-src, frame-src, img-src, font-src and style-src.1// in defineConfig({ ... })2projectConfig: {3 // ...your existing settings4 redisUrl: process.env.REDIS_URL,5 workerMode: process.env.MEDUSA_WORKER_MODE as "shared" | "worker" | "server",6},7admin: {8 disable: process.env.DISABLE_MEDUSA_ADMIN === "true", // add this line to your admin block9 backendUrl: process.env.MEDUSA_BACKEND_URL,10},11modules: [12 // ...the payment module from above13 { key: Modules.EVENT_BUS, resolve: "@medusajs/medusa/event-bus-redis", options: { redisUrl: process.env.REDIS_URL } },14 { key: Modules.WORKFLOW_ENGINE, resolve: "@medusajs/medusa/workflow-engine-redis", options: { redis: { redisUrl: process.env.REDIS_URL } } },15 {16 key: Modules.LOCKING,17 resolve: "@medusajs/medusa/locking",18 options: {19 providers: [20 { resolve: "@medusajs/medusa/locking-redis", id: "locking-redis", is_default: true, options: { redisUrl: process.env.REDIS_URL } },21 ],22 },23 },24 {25 key: Modules.CACHING,26 resolve: "@medusajs/medusa/caching",27 options: {28 providers: [29 { resolve: "@medusajs/caching-redis", id: "caching-redis", is_default: true, options: { redisUrl: process.env.REDIS_URL } },30 ],31 },32 },33],1modules: [2 // ...3 { resolve: "vonpay-medusa/modules/vonpay-subscriptions" },4],upsells: { variantIds: ["variant_…", "variant_…"], email: {} }statementDescriptor: { key: "order_id", from: ["product_title", "store_name"] }1{2 "extends": ["config:recommended"],3 "packageRules": [4 { "matchPackageNames": ["vonpay-medusa"], "matchUpdateTypes": ["patch"], "automerge": true, "schedule": ["at any time"] }5 ]6}1version: 22updates:3 - package-ecosystem: npm4 directory: "/" # one entry per folder with a lock file that installs vonpay-medusa5 schedule: { interval: daily }6 allow: [{ dependency-name: vonpay-medusa }]