Medusa v2 payment provider plugin for the NMI Gateway — card + ACH/eCheck + Apple/Google Pay via the NmiPayments tokenization component
A payment provider for Medusa v2 that runs card, ACH/eCheck, Apple Pay, and Google Pay through an NMI merchant account.
Card numbers and bank account numbers are tokenized in the shopper's browser by NMI and never reach your Medusa server. Your backend receives a single-use token and charges it through NMI's Payment API (). Card and wallet payments resolve while the shopper waits. ACH does not, so the provider treats it as an asynchronous flow and lets a settlement webhook finish the job.
npm install medusa-payment-nmiYou can also install straight from GitHub. The script runs , so is built during install:
npm install github:Kaelbroersma/medusa-payment-nmiThis is a standard Medusa plugin built with , so it follows the official exports layout. resolves a single payment module provider, and the package root resolves all of them at once.
In :
1module.exports = defineConfig({2 modules: [3 {4 resolve: "@medusajs/medusa/payment",5 options: {6 providers: [7 {8 resolve: "medusa-payment-nmi",9 options: {10 securityKey: process.env.NMI_SECURITY_KEY,11 tokenizationKey: process.env.NMI_TOKENIZATION_KEY,12 webhookSecret: process.env.NMI_WEBHOOK_SECRET,13 captureMethod: "auth",14 secCode: "WEB",15 sandbox: process.env.NODE_ENV !== "production",16 },17 },18 ],19 },20 },21 ],22})Copy for the variable names. The three keys live in the NMI Merchant Portal under Settings, in Security Keys and Webhooks.
Registering a provider does not expose it at checkout. Open the Medusa admin, go to Settings, then Regions, pick a region, and add the NMI providers you want shoppers to see. Most stores enable one or two.
Copy the components you need out of into your Next.js app. There are two collection styles and they are covered in detail under Collecting card and bank details.
ACH will sit in forever without this. See Webhooks.
The package ships four providers that share one NMI account and one block of config. Resolving registers all four, and you decide per region which ones appear at checkout.
| Identifier | Checkout option | Lifecycle |
|---|---|---|
| Credit card | Synchronous. Runs or per . | |
| Bank account (ACH/eCheck) | Asynchronous. Submits a sale now, settlement webhook captures. | |
| Apple Pay / Google Pay | Synchronous. Charges exactly like a card token. Needs wallet setup in the NMI portal. | |
| One option covering all of the above | Branches on the value the storefront writes onto the session. |
Split providers give each method its own radio button, its own webhook route, and its own enable/disable switch per region. The unified provider gives you one checkout option and lets NMI's payment element handle the method picker inside it. Pick the split providers if you want control over the checkout layout, and the unified one if you want the shortest path to a working payment step.
To register only one variant, resolve its subpath instead of the package root:
{ resolve: "medusa-payment-nmi/providers/nmi-card", options: { /* ... */ } }Medusa stores a provider as , so the four ids are , , , and . If you add an key to the provider config, Medusa appends it ( produces and friends). Confirm what your store actually exposes with before you hardcode an id in the storefront.
| Option | Required | Default | Notes |
|---|---|---|---|
| Yes | Private API key used server side for . Never send it to the browser. | ||
| Yes | Public key. The provider hands it to the storefront through the payment session. | ||
| Yes | Webhook signing key, used to verify the HMAC on every inbound event. | ||
| No | Card and wallet only. holds the funds, charges immediately. | ||
| No | ACH SEC code. , , , or . | ||
| No | Routes both the API calls and the storefront's Collect.js script to . |
All three keys are validated at boot. A missing one throws a with the name of the option, so a bad deploy fails fast instead of failing at the first checkout.
1initiatePayment -> session.data { tokenizationKey, sandbox, amount, currency_code }2browser tokenizes -> single-use token from NMI (24 hour lifetime, one submission)3initiatePaymentSession -> session.data gains { payment_token, payment_method, billing }4cart.complete -> authorizePayment charges the token via transact.php5 card/wallet: authorized or captured, right now6 ACH: authorized, settlement pending7webhook -> ACH settlement captures, an ACH return fails itmoves no money. Its only job is to hand the storefront the public tokenization key and the sandbox flag so the browser can load Collect.js from the matching gateway host.
The storefront then writes the token back onto the same session with a second call, which merges into . When the cart completes, reads that data and charges the token.
One detail worth knowing before you debug anything: Medusa's cart completion calls with no context, and the payment module forwards only to the provider. The session data is the only channel you have. Anything the charge needs, including the billing address, has to be on that object by the time the cart completes.
decides what happens the moment the token is charged.
(the default). The provider sends . NMI places a hold on the card, Medusa marks the payment , and no money moves until something calls capture. That capture happens when you capture the payment in the admin, or through your own fulfillment workflow, and it issues an NMI against the stored . This is the right default for physical goods, where you should not take the money before the box ships. Authorizations do expire, on a window set by the card brand and your processor, so capture within a few days.
. The provider sends , one call that authorizes and captures together. Medusa records the payment as immediately. Use it for digital goods or anything that ships instantly. There is nothing left to capture afterwards.
ACH ignores the setting entirely. An eCheck debit is always submitted as a sale and is always asynchronous, because the ACH network settles in batches over the following days. The provider returns to mean "the debit was accepted," and is deliberately a no-op for ACH so an admin click cannot double-submit. The settlement webhook is what moves it to . If ACH payments never leave , your webhook is not wired up.
Wallet tokens behave exactly like card tokens, so follows too.
| Collect.js inline hosted fields | NMI payment element | |
|---|---|---|
| Components | , | |
| Backend provider | , | |
| Extra npm dependency | None | |
| Layout | Yours. You write the labels, the grid, the error text. | NMI's, with an prop for styling. |
| Wallets | Not covered by these components | Built in |
| Method picker | You build it | Built in |
| Good for | Checkouts with an existing design system | Getting a working payment step quickly |
Both approaches tokenize inside an iframe served by NMI, so the card number and the bank account number stay out of your DOM and out of your server logs. Talk to your acquirer about which PCI DSS self-assessment questionnaire applies to your integration; that answer depends on your whole checkout, not just this plugin.
Collect.js loads from your gateway host with the public tokenization key attached, and tells it which of your empty s to fill. It injects one iframe per sensitive input. You keep the label, the border, the spacing, and the error message. NMI keeps the keystrokes.
handles the script loading and the configure call. The two field components are thin wrappers around it.
| Field key | Component | Element id in the shipped component | Holds |
|---|---|---|---|
| Card number | |||
| Expiry, | |||
| Security code | |||
| Name on the account | |||
| Routing number | |||
| Account number |
also renders two ordinary elements for account type (checking or savings) and holder type (personal or business). Those are not sensitive, so they stay in your page as normal React state and ride along in the token payload.
The hook configures Collect.js with , , and set to for cards or for bank accounts. It also pins and . If you sell outside the US, change those two lines in when you copy it.
The components expose a ref with and , so your existing Place Order button drives tokenization instead of a second button appearing inside the form.
1const fieldsRef = useRef<NmiFieldsHandle>(null)2const [submitting, setSubmitting] = useState(false)3
4async function handleToken(data: Record<string, unknown>) {5 await sdk.store.payment.initiatePaymentSession(cart, {6 provider_id: "pp_nmi-card",7 data, // { payment_token, payment_method: "card" }8 })9 const res = await sdk.store.cart.complete(cart.id)10 if (res.type === "order") {11 window.location.href = `/order/confirmed/${res.order.id}`12 }13 setSubmitting(false)14}15
16{selected === "pp_nmi-card" && (17 <NmiCardFields ref={fieldsRef} session={activeSession} onToken={handleToken} />18)}19
20<button21 disabled={submitting || !fieldsRef.current?.isValid}22 onClick={() => {23 setSubmitting(true)24 fieldsRef.current?.requestToken()25 }}26>27 Place order28</button>works the same way against . Its payload carries two extra keys, and .
The prop is the active payment session. The components read and from it, both of which put there. If the session has no tokenization key yet, the components render a short "Payment session not ready" message rather than mounting a broken form.
Your stylesheet stops at the iframe boundary. A rule on styles the box around the input, not the input itself. To reach inside, pass CSS objects that Collect.js applies within its own document:
1<NmiCardFields2 ref={fieldsRef}3 session={activeSession}4 onToken={handleToken}5 googleFont="Inter:400"6 fieldClassName="h-11 rounded-md border border-neutral-700 px-3"7 customCss={{8 base: {9 "font-family": "Inter, sans-serif",10 "font-size": "15px",11 color: "#e5e5e5",12 "background-color": "#171717",13 },14 focus: { color: "#ffffff" },15 invalid: { color: "#dc2626" },16 placeholder: { color: "#737373" },17 }}18/>Two traps here, both of which cost real time to find.
The iframe document has its own white background. On a dark checkout, the text you type turns light grey on white and looks blank until you set an explicit in .
Fonts do not cross the frame boundary either. Loading Inter in your app does nothing for the hosted input. Pass so Collect.js loads the family inside its own document, then reference the family name in .
Collect.js reports validity per field as the shopper types, and the hook aggregates that into a single boolean. It only turns true once every mounted field has reported valid and Collect.js has confirmed the iframes are installed, which is why disabling the submit button on is safe from the first render.
Calling triggers . The token comes back through the callback and lands in your handler. If NMI returns a response with no token, the components surface an error message and the shopper can correct the fields and try again.
Tokens are single use and NMI expires them 24 hours after creation. In practice this only matters if you tokenize on one page and complete the cart much later; if the charge fails with a missing token, tokenize again rather than retrying the old one.
Collect.js is a single page-level global and does not survive being configured twice. Call a second time, which is exactly what happens when a shopper toggles from card to bank, and it rebuilds the iframes but never rewires the validation and token events. The form looks fine and is completely dead.
works around this by tearing the script out of the page on unmount, so the next mount loads it fresh from browser cache and always gets a working first configure. For that to hold, render only the selected method's component and let React unmount the other one. Do not render both and hide one with CSS.
On init, Collect.js checks whether the browser supports the Payment Request API and logs a reading "Could not create PaymentRequestAbstraction" when the merchant account has no wallets provisioned. It is harmless for a card and ACH integration, but the Next.js dev overlay promotes any to a full-screen error, which makes it look like checkout crashed.
The hook filters that one message, and only in development. In production nothing global is patched and the gateway script runs exactly as shipped, which is the posture you want for a script that touches payment data.
wraps from NMI's official React package. One component renders the method picker, the fields, and the pay button, and it covers Apple Pay and Google Pay alongside card and ACH.
npm install @nmipayments/nmi-pay-react1{session.provider_id === "pp_nmi" && (2 <NmiPaymentElement3 session={session}4 onToken={async (data) => {5 await sdk.store.payment.initiatePaymentSession(cart, {6 provider_id: session.provider_id,7 data, // { payment_token, payment_method }8 })9 const res = await sdk.store.cart.complete(cart.id)10 if (res.type === "order") {11 window.location.href = `/order/confirmed/${res.order.id}`12 }13 }}14 onError={(e) => console.error(e)}15 />16)}The wrapper reads the tokenization key off the session, passes the element a list of , and derives the method from the payment event so the backend knows which lifecycle to run. Card, Apple Pay, and Google Pay all report as ; a bank payment reports as .
Apple Pay and Google Pay need to be enabled in the NMI Merchant Portal first, and Apple Pay additionally requires domain registration there. Until that is done the element will show the wallet buttons only on devices that support them, or not at all.
Field styling comes from the component's own prop rather than from Collect.js CSS objects. See NMI's component documentation for the shape.
Everything the backend needs at authorize time has to be on . Each call merges into it.
| Key | Written by | Required | Notes |
|---|---|---|---|
| Public key for the browser. | |||
| Tells the components which gateway host to load Collect.js from. | |||
| , , | is sent to NMI as both and so webhooks can be matched back to the session. | ||
| Storefront | Yes | The single-use token. Without it, returns instead of charging. | |
| Storefront | Yes for | or . The unified provider branches on it and defaults to . | |
| Storefront | ACH | or . | |
| Storefront | ACH | or . | |
| Storefront, server side | Recommended | Cardholder address for AVS. See below. | |
| , , | Storefront | Optional | Display metadata, passed through to . |
The provider sends the cardholder billing address on every card and ACH sale or auth, so NMI's Address Verification Service has something to check. There is no accept or reject logic in this package. Enforcement belongs in the NMI Merchant Portal, where you can tune AVS rules without a redeploy, and a hard reject arrives as a normal decline.
Because the payment module gives the provider no customer context at authorize time, the address has to travel on the session data. Read it from the cart on the server, never from the browser:
1// storefront: in your submitPayment / placeOrder action2const cart = await retrieveCart()3const a = cart.billing_address4
5await sdk.store.payment.initiatePaymentSession(cart, {6 provider_id: providerId,7 data: {8 payment_token: token,9 payment_method: method,10 ...(a && {11 billing: {12 first_name: a.first_name,13 last_name: a.last_name,14 company: a.company,15 address_1: a.address_1,16 address_2: a.address_2,17 city: a.city,18 province: a.province,19 postal_code: a.postal_code,20 country_code: a.country_code,21 phone: a.phone,22 email: cart.email,23 },24 }),25 },26})Use Medusa's snake_case address keys; the provider maps them to NMI's field names and uppercases the country code. If first name, last name, street, city, province, or postal code is missing, the whole billing block is dropped rather than sent with blanks, and the charge goes through without AVS for that order.
NMI's answers come back on as and , which makes them queryable later. On a decline the full gateway result is attached to the thrown as , so those two codes are reachable there too.
AVS is a card-side control. The address is sent on ACH as well, which is harmless and helps fraud scoring.
If the storefront puts , , and on the session, the provider copies them onto after authorization so receipts and the admin can render something like "Visa 1111". None of these keys contain a real card number.
The shipped does not set them. Collect.js returns a object alongside the token, but what it contains varies by account and integration, so the component keeps its payload to the two keys the backend actually requires. If you want the display metadata, widen the payload in your copy of the component:
1// NmiCardFields.tsx, inside the useCollectJs call2onToken: (response: CollectJsResponse) =>3 onToken({4 payment_token: response.token,5 payment_method: "card",6 card_type: response.card?.type, // e.g. "visa"7 card_last4: response.card?.number?.slice(-4), // the number arrives masked8 }),Log the object once against your own account before relying on either field.
Medusa exposes one webhook route per registered provider, at . Registering the package root creates all four:
| Provider | Route | Configure it in the portal? |
|---|---|---|
| Yes, if you use the unified provider. | ||
| Yes. ACH cannot complete without it. | ||
| Optional. | ||
| Optional. |
Every route exists whether or not you point NMI at it, and every route runs the same verification and mapping. What differs is whether you need it. ACH is the only asynchronous provider, so a split setup needs the destination or payments sit in forever. Card and wallet payments learn their outcome during the request, so their routes are useful only if you want a second record of the outcome, or if you reverse transactions from the NMI portal rather than the Medusa admin and want Medusa to hear about it.
Setting an on the provider config appends it to the path, so gives and so on.
In the NMI Merchant Portal, under Settings then Webhooks, add the URL, paste the signing key you configured as , and subscribe to:
1transaction.sale.success transaction.sale.failure2transaction.auth.success transaction.capture.success3transaction.refund.success transaction.void.success4settlement.batch.completeThe same event means different things for a card and for a bank debit, so the handler looks at to tell them apart.
| NMI event | Card | ACH |
|---|---|---|
| authorized | authorized | |
| captured | authorized (accepted, not settled) | |
| captured | captured | |
| captured | captured | |
| captured | captured | |
| canceled | canceled | |
| ignored | failed (this is how a return arrives) |
Every request is verified before any of that happens. NMI signs with a header, and the handler recomputes with your signing key and compares in constant time. A mismatch returns , which means the event is ignored silently. If a webhook seems to do nothing at all, check the signing key first, then check that nothing in front of Medusa is re-encoding the request body.
NMI requires a public HTTPS endpoint, so for local development tunnel to your backend with or .
On asynchronous outcomes. Medusa's built-in payment webhook subscriber acts on the and outcomes. ACH settlement therefore works out of the box. Returns and voids are detected and mapped correctly by this provider, but and webhook outcomes do not auto-transition the payment in current Medusa core. If you need automated reconciliation for returns, subscribe to the event and handle it yourself.
Capture sends an NMI against the stored . For ACH it is a no-op, since settlement is what captures those.
Refund sends an NMI . NMI can only refund a settled transaction, which means a same-day reversal has to be a void instead. Rather than making you know that, the provider retries a failed full-amount refund as a void, so the Refund button in the admin works before the settlement batch runs. The result is marked with on so you can tell the two apart afterwards. Partial refunds cannot be voided, because a void is all or nothing, so those surface the original NMI error.
Cancel sends a void, which is the correct pre-settlement reversal.
Network failures and NMI's 4xx gateway response codes are retried up to twice with exponential backoff. Declines are not retried; they throw an carrying the response code and the full gateway result.
Set and both sides switch hosts together. The backend talks to , and because puts the flag on the session, the storefront components load Collect.js from too. Use the keys from your sandbox account, not your live ones.
NMI keeps the current test card numbers, test routing and account numbers, and the trigger amounts for forcing declines in its developer documentation. Those values change occasionally, so read them from NMI rather than copying them out of a blog post.
For ACH specifically, a sandbox settlement will not arrive on its own schedule the way it does in production. Test the settlement path by replaying a event at your webhook endpoint with a valid signature.
| Symptom | Cause | Fix |
|---|---|---|
| on a bank payment | The token was looked up in the card token space. defaults to . | Make sure is on the session, and that you are using or , not . |
| An amount arrived as something other than a plain number. Medusa's stringifies to . | The provider coerces every shape it knows about. If you write an amount onto the session yourself, write a plain number of dollars. | |
| Fields render but the form is dead after switching payment method | Collect.js was configured twice on one page. | Render only the selected method's component so the other unmounts. See Mount one form at a time. |
| Full-screen Next.js error about | Collect.js probing for wallet support that the account does not have. | Harmless. filters it in development. |
| Typed text invisible inside the fields | The iframe document's own background is white. | Set and in . |
| Your font does not apply to the inputs | Fonts do not cross the iframe boundary. | Pass and reference the family in . |
| Webhook returns 200 but nothing happens | Signature verification failed, which returns . | Confirm matches the portal, and that no proxy is rewriting the raw body. |
| ACH payments stay forever | No settlement webhook. | Subscribe to and point it at . |
| returns | No on the session. | The storefront never wrote the token back, or wrote it to a different provider's session. |
Saved cards, meaning NMI's Customer Vault. Medusa's account holder methods are implemented as no-ops around a synthetic id, so the checkout step that expects them succeeds, but nothing is stored at NMI and shoppers re-enter their details each time. Adding it is straightforward and has simply not been needed yet.
Multi-currency stores need a second look. The provider does not send a field to , so every charge settles in whatever currency your NMI account is configured for, regardless of the cart's currency. A cart priced at 40 EUR is submitted as an amount of 40.00 and charged as 40 of the account currency. If you sell in one currency, which is the common case, this is exactly right and there is nothing to do. If you sell in several, treat this plugin as single-currency for now and open an issue.
The shipped storefront components hardcode and in the Collect.js config. Those two values feed NMI's Apple Pay and Google Pay payment request and are inert for the card and ACH fields, which pass or and never build a wallet request. Change them when you copy the files if you sell elsewhere or if you surface wallets through Collect.js.
1npm install # runs medusa plugin:build via prepare2npm run dev # medusa plugin:develop, watches and publishes to the local registry3npm test # vitest4npm run typecheckTo try local changes inside a real Medusa app, use the local plugin workflow:
1# in this repo2npx medusa plugin:publish3
4# in your Medusa app5npx medusa plugin:add medusa-payment-nmiThis is an independent plugin. The author is not affiliated with, endorsed by, or supported by NMI or Network Merchants LLC, and "NMI" is their trademark, used here only to say what the plugin talks to.
The documentation above was written from two sources: this plugin's own source code and NMI's public developer documentation. Gateway behavior can differ by merchant account, processor, and portal configuration, and NMI's documentation is the authority on their side of the integration. Where this README and NMI disagree, believe NMI and your own sandbox. For support with the gateway itself, contact NMI. For problems with the plugin, open an issue on this repository.
MIT