Overview
Icon for Vonpay

Vonpay

Cards, Apple Pay and Google Pay payments

vonpay-medusa

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.

Requirements

  • Medusa 2.15.5 or later (tested on 2.15.5 through 2.21.x).
  • Node.js 20.19+ or 22.12+.
  • A storefront built on Medusa's Next.js starter (Next.js 15 or later), for the card step.
  • A Vonpay account: a secret key (Copy to clipboardvp_sk_test_… / Copy to clipboardvp_sk_live_…) and a publishable key (Copy to clipboardvp_pk_test_… / Copy to clipboardvp_pk_live_…) from the Vonpay dashboard.
  • For production: Redis, and a Medusa worker process (see Production settings).

Start with your test keys. Switch to live keys when the setup check passes.

Install (server)

In your Medusa server folder:

npm install vonpay-medusa

Then register the plugin and its payment provider in Copy to clipboardmedusa-config.ts. This is a complete file for a new store; if you already have one, add the Copy to clipboardplugins entry, the payment module entry and Copy to clipboardadmin.backendUrl to it. If you already configure the payment module (for another provider), add the Vonpay entry to its existing Copy to clipboardproviders list.

import { loadEnv, defineConfig, Modules } from "@medusajs/framework/utils"
loadEnv(process.env.NODE_ENV || "development", process.cwd())
// Refuse to start without a setting rather than run on a guessable default.
const required = (name: string): string => {
const v = process.env[name]
if (!v) throw new Error(`${name} is not set`)
return v
}
module.exports = defineConfig({
projectConfig: {
databaseUrl: required("DATABASE_URL"),
http: {
storeCors: required("STORE_CORS"),
adminCors: required("ADMIN_CORS"),
authCors: required("AUTH_CORS"),
jwtSecret: required("JWT_SECRET"),
cookieSecret: required("COOKIE_SECRET"),

With the id Copy to clipboardvonpay, Medusa names the provider Copy to clipboardpp_vonpay_vonpay. You will use that name in the admin, and the setup check reports it.

Set these in the server's Copy to clipboard.env (never in Copy to clipboardmedusa-config.ts or any file you commit):

Variable Required What it is Copy to clipboardDATABASE_URL yes Medusa's Postgres connection. Copy to clipboardJWT_SECRET, Copy to clipboardCOOKIE_SECRET yes Medusa's own signing secrets for admin and customer sessions. Each a long random value (for example Copy to clipboardopenssl rand -hex 32), never a default such as Copy to clipboardsupersecret. Copy to clipboardSTORE_CORS, Copy to clipboardADMIN_CORS, Copy to clipboardAUTH_CORS yes Medusa's allowed origins (your storefront, your admin). Copy to clipboardVONPAY_SECRET_KEY yes Your Vonpay secret key, Copy to clipboardvp_sk_test_… or Copy to clipboardvp_sk_live_…. Copy to clipboardVONPAY_MODE yes Copy to clipboardtest or Copy to clipboardlive. Must match the key. Copy to clipboardVONPAY_STOREFRONT_URL yes Your storefront's address, such as Copy to clipboardhttps://shop.example.com. Must be Copy to clipboardhttps; Copy to clipboardhttp://localhost is allowed with test keys. Shoppers come back here from their bank's 3-D Secure check. Copy to clipboardVONPAY_WEBHOOK_SECRET after setup The signing secret Vonpay shows when you register your notification endpoint. Leave it empty for the first start (see Payment notifications). Copy to clipboardVONPAY_SHOPPER_IP_SECRET optional With one-click offers: a long random value of your own (Copy to clipboardopenssl rand -hex 32), the SAME value in the storefront's environment. It proves the shopper's address on offer charges is real (see Copy to clipboardupsells, "The shopper's address on offer charges"). Never your Vonpay key or the webhook secret. Copy to clipboardMEDUSA_BACKEND_URL recommended Your Medusa server's public address, such as Copy to clipboardhttps://api.example.com. Lets the setup check confirm the notification endpoint is this store's. Copy to clipboardVONPAY_UPDATE_CHECK no Copy to clipboardoff turns off the update notice (for a server with no outbound internet).

Then create the plugin's tables and start the server:

npx medusa db:migrate
npx medusa develop # or your usual start command

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 Copy to clipboardhttps. The error names the problem, never the value.

Turn it on in your regions

Medusa offers a payment provider only in the regions you add it to. Until you do, checkout never shows Vonpay.

  1. In the Medusa admin, open Settings → Regions.
  2. Edit each region you sell in.
  3. Add Copy to clipboardpp_vonpay_vonpay under payment providers, and save.

The setup check fails ("regions") while no region offers Vonpay.

Payment notifications

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:

  1. Install and start the server without Copy to clipboardVONPAY_WEBHOOK_SECRET. Payments already work. Every notification is refused until the secret is set, and the setup check fails Copy to clipboardwebhook_secret. Vonpay does not send a refused notification again, so finish these steps before you take live payments.
  2. Register the endpoint in the Vonpay dashboard. The address is your server's public address followed by Copy to clipboard/hooks/payment/vonpay_vonpay: Choose all events (this includes Copy to clipboardpayment_intent.authorized, which Apple Pay and Google Pay need). Only if Vonpay runs your subscriptions (Copy to clipboardsubscriptions: "vonpay"), also add the five subscription events, which "all events" does not include: Copy to clipboardsubscription.created, Copy to clipboardsubscription.renewed, Copy to clipboardsubscription.payment_failed, Copy to clipboardsubscription.cancelled and Copy to clipboardsubscription.updated.
https://<your backend>/hooks/payment/vonpay_vonpay
  1. Copy the signing secret into Copy to clipboardVONPAY_WEBHOOK_SECRET.
  2. Restart the server and the worker.
  3. 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.

Storefront

In your storefront folder:

npm install vonpay-medusa # or pnpm add / yarn add
npx vonpay-medusa setup-storefront

The command expects Medusa's Next.js starter layout. It:

  • writes Copy to clipboardsrc/lib/vonpay/actions.ts and Copy to clipboardsrc/lib/vonpay/index.tsx (the small files that connect the package to your storefront);
  • adds the 3-D Secure return page at Copy to clipboardcheckout/vonpay-return;
  • adds the My saved cards account page, the one-click offer pages (Copy to clipboardorder/[id]/offer and Copy to clipboardorder/[id]/offer/return), and the route Apple Pay needs at Copy to clipboard/.well-known/apple-developer-merchantid-domain-association;
  • adds Copy to clipboardvonpay-medusa to Copy to clipboardtranspilePackages in your Next config and to Tailwind's Copy to clipboardcontent;
  • names the payment method "Credit card" and puts the card form in the checkout's pay button;
  • offers to turn on automatic update pull requests (see Keeping it up to date).

Each step runs only if it is still needed, so the command is safe to run again. Copy to clipboard--dry-run 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 Copy to clipboard--force; those steps print as "manual", with the change to make by hand.

After it runs:

  • Set Copy to clipboardNEXT_PUBLIC_VONPAY_PUBLISHABLE_KEY in the storefront's environment to your publishable key (Copy to clipboardvp_pk_…). Never put the secret key in the storefront. Without a valid publishable key the checkout shows "Card payments are unavailable right now."
  • Make sure the server's Copy to clipboardVONPAY_STOREFRONT_URL is this storefront's address.
  • Add a "Saved cards" link to your account menu, pointing at Copy to clipboard/account/saved-cards. The menu is your own file, so the command prints where instead of editing it.

The card form appears in the Payment step, directly above Place order (no separate Review step). It loads Vonpay's vora.js from Copy to clipboardhttps://js.vonpay.com/v1/vora.js, so Vonpay's card-form fixes reach your store without a release.

Content Security Policy

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 (Copy to clipboard/<country>/order/<id>/offer and Copy to clipboard/offer/return). The package gives you the list:

import { readVonpayBrowserConfig, vonpayCspSources } from "vonpay-medusa/storefront/shared"
const vp = vonpayCspSources(readVonpayBrowserConfig())
// Add vp.script, vp.connect, vp.frame, vp.img, vp.font and vp.style
// to script-src, connect-src, frame-src, img-src, font-src and style-src.

Do not use Copy to clipboard'strict-dynamic' in Copy to clipboardscript-src: browsers then ignore the host list. Also allow your Medusa backend in Copy to clipboardconnect-src. The list is bundled with the package; to follow Vonpay's live list between releases, call Copy to clipboardrefreshVoraCspList() (same import) after the response, for example with Copy to clipboardevent.waitUntil(refreshVoraCspList()) in your middleware. A page never waits for it.

Production settings

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 Copy to clipboardmedusa-config.ts, next to the payment module:

// in defineConfig({ ... })
projectConfig: {
// ...your existing settings
redisUrl: process.env.REDIS_URL,
workerMode: process.env.MEDUSA_WORKER_MODE as "shared" | "worker" | "server",
},
admin: {
disable: process.env.DISABLE_MEDUSA_ADMIN === "true", // add this line to your admin block
backendUrl: process.env.MEDUSA_BACKEND_URL,
},
modules: [
// ...the payment module from above
{ key: Modules.EVENT_BUS, resolve: "@medusajs/medusa/event-bus-redis", options: { redisUrl: process.env.REDIS_URL } },
{ key: Modules.WORKFLOW_ENGINE, resolve: "@medusajs/medusa/workflow-engine-redis", options: { redis: { redisUrl: process.env.REDIS_URL } } },
{
key: Modules.LOCKING,
resolve: "@medusajs/medusa/locking",
options: {
providers: [
{ resolve: "@medusajs/medusa/locking-redis", id: "locking-redis", is_default: true, options: { redisUrl: process.env.REDIS_URL } },

Medusa already includes the Redis caching provider; with pnpm (whose strict layout hides packages you did not add yourself), add Copy to clipboard@medusajs/caching-redis at your exact Medusa version. Then run two processes: the server with Copy to clipboardMEDUSA_WORKER_MODE=server, and the worker with Copy to clipboardMEDUSA_WORKER_MODE=worker and Copy to clipboardDISABLE_MEDUSA_ADMIN=true.

Check your setup

From your Medusa server folder:

npx vonpay-medusa-check

In an image built with Copy to clipboardmedusa build, run it from Copy to clipboard.medusa/server, 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; Copy to clipboard--strict also fails when a check could not run. It checks:

  • provider: the Vonpay provider is registered and enabled.
  • webhook_secret: the signing secret is set.
  • regions: at least one region offers Copy to clipboardpp_vonpay_vonpay.
  • recovery: the recovery job is on.
  • key: Vonpay accepts your secret key. Read-only; nothing changes.
  • endpoint: a notification endpoint for Copy to clipboard/hooks/payment/vonpay_vonpay is registered and active. The check names the address. If it cannot confirm the address is this store's (set Copy to clipboardadmin.backendUrl, or use the admin button), it warns and sends no test notification.
  • subscriptions: with Copy to clipboardsubscriptions: "vonpay", the module is registered and the endpoint receives the five subscription events.
  • wallets, wallet_domains: with Copy to clipboardwallets: true, the endpoint receives Copy to clipboardpayment_intent.authorized, and your storefront's domain is registered and verified.
  • descriptor: your statement descriptor setting in Vonpay reads the field the plugin sends: PASS when they match (and what feeds it), WARN when they differ. Nothing is shown when neither uses a per-payment ending.
  • event_bus, workflow_engine, locking: the production settings. Each fails or warns when missing (shared locking with Postgres also passes).
  • caching: WARNs when Redis caching is missing; the notification test then cannot run.
  • worker_mode: the command cannot see a worker. The admin button warns when it runs on a server-only process; that is expected, as long as your worker is running.
  • processed: with test keys only, Vonpay sends a test notification and the check waits for the store to verify and process it (needs Redis caching). With live keys it sends nothing.

Options

These go in the provider's Copy to clipboardoptions in Copy to clipboardmedusa-config.ts, next to the four settings from Install. All are optional.

Copy to clipboardrecovery (default Copy to clipboardtrue)

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. Copy to clipboardrecovery: false turns it off, and the setup check fails while it is off.

Copy to clipboardholdRecheck (default Copy to clipboardtrue)

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. Copy to clipboardholdRecheck: false turns it off; those rows then wait for a person.

Copy to clipboardsavedCards (default off)

Copy to clipboard"recurring" or Copy to clipboard"unscheduled" 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:

  • On a subscription cart (a cart line your subscription plugin marks as a subscription, as ReorderJS does): Copy to clipboardrecurring, because the renewals charge it on a fixed schedule.
  • On any other cart: Copy to clipboardunscheduled, a card kept on file and charged later with no fixed schedule.

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 Copy to clipboardrenewal_declaration_missing.) Vonpay notes that on some processor connections (network tokens) only Copy to clipboardrecurring is accepted for later charges; an Copy to clipboardunscheduled 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).

Copy to clipboardsavedCardsTerms (needs Copy to clipboardsavedCards)

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 Copy to clipboardsavedCardsTerms unset).

If you sell subscriptions with only the checkbox, the setup check warns (Copy to clipboardsaved_cards_terms): 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 (Copy to clipboardsave_consent_recorded, with ids and the wording's fingerprint, never the wording or an email), because payment data can be read from the storefront.

Copy to clipboardsubscriptions (default off)

Who runs your subscription renewals. Exactly one, so a customer is never charged by two systems.

  • Copy to clipboard"store": your own subscription plugin (such as ReorderJS) renews, and this plugin charges the saved card when it asks. Needs Copy to clipboardsavedCards.
  • Copy to clipboard"vonpay": Vonpay's own subscription billing renews, and this plugin charges no renewal itself. Run Copy to clipboardnpx medusa db:migrate (Medusa loads the plugin's subscriptions module itself when the plugin is listed under Copy to clipboardplugins; otherwise register it as below), 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.
modules: [
// ...
{ resolve: "vonpay-medusa/modules/vonpay-subscriptions" },
],

Renewal charges (Copy to clipboard"store"). The plugin charges only when all of these hold; anything else is refused and nothing is charged:

  • the payment belongs to an existing order, has no cart, and was opened with Copy to clipboarddata.off_session === true (the shape ReorderJS 1.1.0 uses);
  • saved cards are on, and the session's customer is the order's customer;
  • that customer has a Vonpay buyer created by this plugin, and the card is one that may be charged while the customer is away, in the store's current mode (test or live);
  • right before the charge, Vonpay confirms it does not bill this store's subscriptions itself and supports charges started without the shopper. Any other answer, or none, means nothing is charged (logged as Copy to clipboardrenewal_subscription_billing), and the next attempt asks again.

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.

Copy to clipboardwallets (default Copy to clipboardfalse)

Copy to clipboardtrue 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 Copy to clipboardsetup-storefront, then run in the server folder:

npx vonpay-medusa-check --wallet-domains

It registers the host of Copy to clipboardstorefrontUrl 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 Copy to clipboardpayment_intent.authorized ("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 Copy to clipboardNEXT_PUBLIC_VONPAY_WALLET_CHALLENGE=modal in the storefront's environment and rebuild it. It works only with test keys and vora.js 1.43.0 or later (what Copy to clipboardhttps://js.vonpay.com/v1/vora.js serves): with live keys, or any value other than Copy to clipboardmodal or Copy to clipboardredirect, 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, Copy to clipboardvonpayCspSources adds what the pop-up needs only when it is on. Do not use it on a live store.

Copy to clipboardupsells (default off)

One-click offers after the order:

upsells: { email: {} }

Then set up the offers in the Medusa admin: Orders → One-click offers. Add an offer (a name only you see and a product variant id), switch it on or off, put the offers in order, or remove one, with no redeploy. Up to 20 offers, each 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. A shopper already paying for an offer (at their bank's check, say) finishes even if you switch it off.

Who sees it. Each offer's Who sees it narrows who is shown it; leave it empty and every shopper is. All the conditions you set must match:

  • Products / collections — the shopper bought one of these products (or a product from one of these collections) at checkout. An offer they add afterwards never counts, so one offer can't unlock another.
  • Customers — guests only, or signed-in customers only. A guest who types a member's email at checkout is still a guest here.
  • Customer groups — the signed-in shopper is in one of these groups.
  • Amount paid at checkout (tax and shipping included), one row per currency with an optional least and most, typed as in your store ("49.95", "5000" for yen). An order in a currency you didn't list doesn't see the offer.

Conditions never change a price. When you change them, the order page's shipping hold ends at once for an order no offer can reach any more.

If they say No thanks. Each offer can have one downsell: another product variant (usually a smaller or cheaper option) shown only after the shopper says "No thanks" to that offer, under the heading "How about this instead?". It uses the offer's own Who sees it, and it counts toward the three offers per order. After a "No thanks" with no downsell (or on the downsell itself), the shopper sees your next offer, up to three, then their order. A variant is one thing in the list: an offer, or one offer's downsell — the admin refuses anything else.

Wording. Each offer can have its own headline (up to 80 characters, in place of "Add this to your order?") and pitch (up to 300, in place of the product's description under its name; a phone shows about two lines). Plain text only: line breaks become spaces, and characters that change the reading direction are refused. Leave both empty for the standard wording. The payment line, the buttons and every result message stay the same, and a downsell keeps "How about this instead?" with its own product's description.

Changing or translating the page's words (developers). The offer page, its loading page and the return page take a Copy to clipboardcopy prop: any line of Copy to clipboardUPSELL_COPY (exported from Copy to clipboardvonpay-medusa/storefront), changed or translated; a line you leave out keeps the standard one. The lines with a price or an order number are functions, and Next.js can't pass a function from a server page to these browser components, so set Copy to clipboardcopy in the browser-side file the setup command wrote, Copy to clipboardsrc/lib/vonpay/index.tsx (it starts with Copy to clipboard"use client"):

const offerCopy: Partial<VonpayUpsellCopy> = { heading: "Ajouter à votre commande ?", add: (price) => `Ajouter — ${price}`, noThanks: "Non merci, voir ma commande" }
export function VonpayUpsellOffer(props: …) {
return <PackagedUpsellOffer {...props} copy={offerCopy} actions={…} ui={upsellUi} />
}

Do the same for Copy to clipboardVonpayUpsellOfferLoading and Copy to clipboardVonpayUpsellReturn in that file. A line that fails (throws, or gives back something that isn't text) shows the standard line instead.

Keep what the standard lines promise the shopper: a decline says nothing was added and the order is unchanged; an unclear result says not to pay again; nothing counts down or pressures. An offer's own headline and pitch from the admin still win over Copy to clipboardheading and the product's description.

The offers live in the plugin's own tables, created by Copy to clipboardnpx medusa db:migrate (run it after every update, as the install steps say); until it has run, no offer is shown and the setup check fails with that fix.

Offers in medusa-config (1.0.x). Copy to clipboardupsells: { variantIds: ["variant_…", …] } still works: until you create the first offer in the admin, those variants are the offers, in that order. The admin page lists them with an Import from medusa-config button; once any offer exists in the admin, the admin's list is used and Copy to clipboardvariantIds is ignored (the setup check warns while both exist — remove Copy to clipboardvariantIds then). Copy to clipboardvariantIds is optional from 1.1. Rolling back to 1.0.x needs Copy to clipboardvariantIds in the option again.

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 Copy to clipboardshopperIpHeader in Copy to clipboardcreateVonpayStorefrontServer (the setup command writes it from the Copy to clipboardVONPAY_SHOPPER_IP_HEADER environment variable) to the request header your hosting sets to the visitor's own address, one value it writes itself: Copy to clipboardx-real-ip on Vercel, Copy to clipboardcf-connecting-ip 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 Copy to clipboardx-forwarded-for (its first entry can be typed by anyone; a header holding a list is ignored). Then set the same random value as Copy to clipboardVONPAY_SHOPPER_IP_SECRET in BOTH the storefront's environment (the setup command passes it as Copy to clipboardshopperIpSecret; server-only, never Copy to clipboardNEXT_PUBLIC_) and the Medusa server's (option Copy to clipboardshopperIpSecret): 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 Copy to clipboardshopper_ip_sig_rejected 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 Copy to clipboardVONPAY_SHOPPER_IP_SECRET is set on both sides. The address is never logged or stored by the plugin.

A repeated return from the bank still gets the offer (1.0.4). The offer page needs a pass the storefront gets while the cart is still open. From 1.0.4 the card step also gets it when the shopper presses Pay, before they can leave for their bank's check, so a return that is repeated (a lost answer, a reload) still finds the offer. Upgrading a storefront set up before 1.0.4: add this to Copy to clipboardsrc/lib/vonpay/actions.ts (the setup command leaves an existing file as it is); without it the checkout works exactly as before.

export async function prepareVonpayOffer() {
return vonpay.prepareVonpayOffer()
}

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. Copy to clipboardguests: false 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.

"Added to your order" email

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 Copy to clipboardemail channel, so you need an email provider configured in Copy to clipboardmedusa-config (for example SendGrid or Resend); without one nothing is sent and the setup check warns. Copy to clipboardemail: false turns it off (send your own). Copy to clipboardemail: { template: "<id>" } uses your provider's template instead of the plugin's wording; the template receives Copy to clipboardstore_name, Copy to clipboardorder_id, Copy to clipboardorder_display_id, Copy to clipboarditem, Copy to clipboardamount, Copy to clipboardcurrency, Copy to clipboardprice, Copy to clipboardsubject and Copy to clipboardtext.

Wait before shipping: the offer window

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.

Statement descriptor: Copy to clipboardstatementDescriptor (default off)

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:

statementDescriptor: { key: "order_id", from: ["product_title", "store_name"] }

Copy to clipboardkey is the field your Vonpay rule reads: Copy to clipboardorder_id, Copy to clipboardsubscription_id, Copy to clipboardcustomer_id, or your own field name. Copy to clipboardfrom 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:

Copy to clipboardfrom What is sent Checkout payment One-click offer Renewal Copy to clipboard"product_title" The product's title. Several lines: the first line's title plus "+N" for the rest (Copy to clipboardBlue Mug +2). the cart's, when the payment step opens the offered product the renewal order's Copy to clipboard"store_name" Your Medusa store's name yes yes yes Copy to clipboard"order_number" The order number as the admin shows it (Copy to clipboard#1001) no (no order yet) yes yes Copy to clipboard"customer_reference" The plugin's own reference for the customer (Copy to clipboardmedusa_live_cus_…), never their name or email signed-in shoppers only signed-in shoppers only yes Copy to clipboard"subscription_reference" The subscription id your subscription plugin records on the renewal order (ReorderJS does) no no when recorded Copy to clipboard{ text: "ACME COFFEE" } Fixed text: 5 to 22 plain characters (no accents or emoji, none of Copy to clipboard< > \ ' " *); last in the list yes yes yes

When it shows:

  • One-click offer charges and renewals use it now. The exception is an offer paid with Apple Pay or Google Pay: those offers are held and then taken rather than charged directly, so they carry no ending and show your plain descriptor.
  • Checkout payments (card and Apple Pay / Google Pay) start using it automatically once Vonpay's next checkout release is live. Until then they show your plain descriptor. The plugin already sends the value, as it was when the shopper reached the payment step (for example, the product title of the cart at that moment). A cart change that does not start a new payment keeps that earlier value.
  • What finally prints depends on your processor connection. Some connections print no per-payment ending at all, and some print only a short name. Vonpay drops accented letters and emoji (Copy to clipboardCafé prints as Copy to clipboardCaf) and cuts the ending to fit (usually under 22 characters), so put the most telling words first.

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. Copy to clipboardkey must be a plain field name (a letter, then letters, digits, Copy to clipboard_, Copy to clipboard. or Copy to clipboard-). Names that suggest personal data (for example Copy to clipboardemail, Copy to clipboardphone, Copy to clipboardaddress, Copy to clipboardcard) and names the plugin or Vonpay already use (Copy to clipboardmedusa_…, Copy to clipboardmirror, Copy to clipboardorder, Copy to clipboardsession_id, Copy to clipboardcheckout_type) are refused when the server starts; the error says which word it found (for example Copy to clipboardcontains "tel"). The setup check compares this option with your Vonpay setting.

Held payments (admin)

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:

  • Refund the buyer (the button shows the amount it will refund);
  • Check with Vonpay to bring the row in line with Vonpay's refund record (moves no money);
  • Mark resolved once you have dealt with it elsewhere (you confirm by typing the Vonpay session id);
  • Release a held wallet payment, or Complete the order from it;
  • Open order to use Medusa's own Capture or Cancel.

Refunds made in the Vonpay dashboard are not mirrored into Medusa. Refund from Medusa to keep the two in step.

Keeping it up to date

Medusa cannot update itself: your store is your own code, built and deployed by you. To update:

  1. Install the new version in both the server and the storefront folders (otherwise two versions run), then run Copy to clipboardnpx medusa db:migrate in the server folder.
  2. Redeploy the server, the worker and the storefront.

The package's code, including the storefront card step, comes with the update. The small files Copy to clipboardsetup-storefront wrote into your storefront do not change. Running Copy to clipboardsetup-storefront 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 (Copy to clipboardhttps://www.vonpay.com/medusa/update.json). Your store sends nothing about itself, and an unsigned or unreachable file is never shown. Copy to clipboardVONPAY_UPDATE_CHECK=off on the server turns it off.

Automatic update pull requests. Copy to clipboardnpx vonpay-medusa setup-storefront offers to turn them on (default yes; Copy to clipboard--auto-updates or Copy to clipboard--no-auto-updates to choose without the question). It adds Copy to clipboard.github/dependabot.yml, 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 Copy to clipboarddependabot.yml is never edited.

Or set it up yourself. Renovate (Copy to clipboardrenovate.json):

{
"extends": ["config:recommended"],
"packageRules": [
{ "matchPackageNames": ["vonpay-medusa"], "matchUpdateTypes": ["patch"], "automerge": true, "schedule": ["at any time"] }
]
}

Or Dependabot (Copy to clipboard.github/dependabot.yml; turn on auto-merge for its pull requests if you like):

version: 2
updates:
- package-ecosystem: npm
directory: "/" # one entry per folder with a lock file that installs vonpay-medusa
schedule: { interval: daily }
allow: [{ dependency-name: vonpay-medusa }]

Troubleshooting

Checkout does not offer Vonpay. Add Copy to clipboardpp_vonpay_vonpay 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 Copy to clipboardnode_modules 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, Copy to clipboardnext 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 Copy to clipboardwebhook_secret, 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 Copy to clipboardadmin.backendUrl (Copy to clipboardMEDUSA_BACKEND_URL) 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 Copy to clipboardVONPAY_WEBHOOK_SECRET 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." Copy to clipboardNEXT_PUBLIC_VONPAY_PUBLISHABLE_KEY is missing or not a publishable key (Copy to clipboardvp_pk_…). 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 Copy to clipboardoptions_secret_key_invalid or Copy to clipboardoptions_mode_key_mismatch (mode and key do not match).

Wallet buttons do not show. Check Copy to clipboardwallets: true, run Copy to clipboardnpx vonpay-medusa-check --wallet-domains and read its Copy to clipboardwallet_domains line. The buttons appear only on devices that support the wallet.

License

MIT. See LICENSE.

You may also like

Browse all integrations

Build your own

Develop your own custom integration

Build your own integration with our API to speed up your processes. Make your integration available via npm for it to be shared in our Library with the broader Medusa community.

gift card interface

Ready to build your custom commerce setup?