Overview
Icon for NMI

NMI

Accept card, ACH, and wallet payments via NMI Gateway

medusa-payment-nmi

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 (Copy to clipboardtransact.php). 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.

Contents

  • Requirements
  • Install
  • Quick start
  • Choosing providers
  • Configuration
  • How a payment moves through the system
  • Authorize first or charge once
  • Collecting card and bank details
    • Two ways to collect
    • Collect.js inline hosted fields
    • The unified payment element
    • What the storefront writes onto the session
  • Billing address and AVS
  • Showing the card on receipts
  • Webhooks
  • ACH reconciliation
  • Captures, refunds, and voids
  • Testing against the sandbox
  • Troubleshooting
  • Not supported yet
  • Local development
  • Disclaimer

Requirements

  • Medusa Copy to clipboard>= 2.5 (the package declares Copy to clipboard@medusajs/framework as a peer dependency)
  • Node Copy to clipboard>= 20
  • An NMI merchant account with three keys from the Merchant Portal: a private security key, a public tokenization key, and a webhook signing key

Install

npm install medusa-payment-nmi

You can also install straight from GitHub. The Copy to clipboardprepare script runs Copy to clipboardmedusa plugin:build, so Copy to clipboard.medusa/server is built during install:

npm install github:Kaelbroersma/medusa-payment-nmi

This is a standard Medusa plugin built with Copy to clipboardmedusa plugin:build, so it follows the official exports layout. Copy to clipboardmedusa-payment-nmi/providers/nmi resolves a single payment module provider, and the package root resolves all of them at once.

Quick start

1. Register the provider

In Copy to clipboardmedusa-config.ts:

module.exports = defineConfig({
modules: [
{
resolve: "@medusajs/medusa/payment",
options: {
providers: [
{
resolve: "medusa-payment-nmi",
options: {
securityKey: process.env.NMI_SECURITY_KEY,
tokenizationKey: process.env.NMI_TOKENIZATION_KEY,
webhookSecret: process.env.NMI_WEBHOOK_SECRET,
captureMethod: "auth",
secCode: "WEB",
sandbox: process.env.NODE_ENV !== "production",
},
},
],
},
},

Copy Copy to clipboard.env.example for the variable names. The three keys live in the NMI Merchant Portal under Settings, in Security Keys and Webhooks.

2. Enable it for a region

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.

3. Collect the payment details

Copy the components you need out of Copy to clipboardstorefront/ into your Next.js app. There are two collection styles and they are covered in detail under Collecting card and bank details.

4. Point NMI at your webhook

ACH will sit in Copy to clipboardauthorized forever without this. See Webhooks.

Choosing providers

The package ships four providers that share one NMI account and one block of config. Resolving Copy to clipboard"medusa-payment-nmi" registers all four, and you decide per region which ones appear at checkout.

Identifier Checkout option Lifecycle Copy to clipboardnmi-card Credit card Synchronous. Runs Copy to clipboardauth or Copy to clipboardsale per Copy to clipboardcaptureMethod. Copy to clipboardnmi-ach Bank account (ACH/eCheck) Asynchronous. Submits a sale now, settlement webhook captures. Copy to clipboardnmi-wallet Apple Pay / Google Pay Synchronous. Charges exactly like a card token. Needs wallet setup in the NMI portal. Copy to clipboardnmi One option covering all of the above Branches on the Copy to clipboardpayment_method 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 Copy to clipboardnmi 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: { /* ... */ } }

Provider ids

Medusa stores a provider as Copy to clipboardpp_<identifier>, so the four ids are Copy to clipboardpp_nmi, Copy to clipboardpp_nmi-card, Copy to clipboardpp_nmi-ach, and Copy to clipboardpp_nmi-wallet. If you add an Copy to clipboardid key to the provider config, Medusa appends it (Copy to clipboard{ resolve: "medusa-payment-nmi", id: "primary" } produces Copy to clipboardpp_nmi_primary and friends). Confirm what your store actually exposes with Copy to clipboardGET /store/payment-providers before you hardcode an id in the storefront.

Configuration

Option Required Default Notes Copy to clipboardsecurityKey Yes Private API key used server side for Copy to clipboardtransact.php. Never send it to the browser. Copy to clipboardtokenizationKey Yes Public key. The provider hands it to the storefront through the payment session. Copy to clipboardwebhookSecret Yes Webhook signing key, used to verify the HMAC on every inbound event. Copy to clipboardcaptureMethod No Copy to clipboard"auth" Card and wallet only. Copy to clipboard"auth" holds the funds, Copy to clipboard"sale" charges immediately. Copy to clipboardsecCode No Copy to clipboard"WEB" ACH SEC code. Copy to clipboardWEB, Copy to clipboardPPD, Copy to clipboardCCD, or Copy to clipboardTEL. Copy to clipboardsandbox No Copy to clipboardfalse Routes both the API calls and the storefront's Collect.js script to Copy to clipboardsandbox.nmi.com.

All three keys are validated at boot. A missing one throws a Copy to clipboardMedusaError with the name of the option, so a bad deploy fails fast instead of failing at the first checkout.

How a payment moves through the system

initiatePayment -> session.data { tokenizationKey, sandbox, amount, currency_code }
browser tokenizes -> single-use token from NMI (24 hour lifetime, one submission)
initiatePaymentSession -> session.data gains { payment_token, payment_method, billing }
cart.complete -> authorizePayment charges the token via transact.php
card/wallet: authorized or captured, right now
ACH: authorized, settlement pending
webhook -> ACH settlement captures, an ACH return fails it

Copy to clipboardinitiatePayment moves 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 Copy to clipboardinitiatePaymentSession call, which merges into Copy to clipboardsession.data. When the cart completes, Copy to clipboardauthorizePayment reads that data and charges the token.

One detail worth knowing before you debug anything: Medusa's cart completion calls Copy to clipboardauthorizePaymentSessionStep({ id }) with no context, and the payment module forwards only Copy to clipboard{ data: session.data, context: { idempotency_key } } 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.

Authorize first or charge once

Copy to clipboardcaptureMethod decides what happens the moment the token is charged.

Copy to clipboard"auth" (the default). The provider sends Copy to clipboardtype=auth. NMI places a hold on the card, Medusa marks the payment Copy to clipboardauthorized, 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 Copy to clipboardcapture against the stored Copy to clipboardtransactionid. 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.

Copy to clipboard"sale". The provider sends Copy to clipboardtype=sale, one call that authorizes and captures together. Medusa records the payment as Copy to clipboardcaptured 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 Copy to clipboardauthorized to mean "the debit was accepted," and Copy to clipboardcapturePayment is deliberately a no-op for ACH so an admin click cannot double-submit. The settlement webhook is what moves it to Copy to clipboardcaptured. If ACH payments never leave Copy to clipboardauthorized, your webhook is not wired up.

Wallet tokens behave exactly like card tokens, so Copy to clipboardnmi-wallet follows Copy to clipboardcaptureMethod too.

Collecting card and bank details

Two ways to collect

Collect.js inline hosted fields NMI payment element Components Copy to clipboardNmiCardFields, Copy to clipboardNmiAchFields Copy to clipboardNmiPaymentElement Backend provider Copy to clipboardnmi-card, Copy to clipboardnmi-ach Copy to clipboardnmi Extra npm dependency None Copy to clipboard@nmipayments/nmi-pay-react Layout Yours. You write the labels, the grid, the error text. NMI's, with an Copy to clipboardappearance 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 inline hosted fields

Collect.js loads from your gateway host with the public tokenization key attached, and Copy to clipboardCollectJS.configure() tells it which of your empty Copy to clipboarddivs 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.

Copy to clipboarduse-collect-js.ts handles the script loading and the configure call. The two field components are thin wrappers around it.

The fields

Field key Component Element id in the shipped component Holds Copy to clipboardccnumber Copy to clipboardNmiCardFields Copy to clipboard#nmi-ccnumber Card number Copy to clipboardccexp Copy to clipboardNmiCardFields Copy to clipboard#nmi-ccexp Expiry, Copy to clipboardMM / YY Copy to clipboardcvv Copy to clipboardNmiCardFields Copy to clipboard#nmi-cvv Security code Copy to clipboardcheckname Copy to clipboardNmiAchFields Copy to clipboard#nmi-checkname Name on the account Copy to clipboardcheckaba Copy to clipboardNmiAchFields Copy to clipboard#nmi-checkaba Routing number Copy to clipboardcheckaccount Copy to clipboardNmiAchFields Copy to clipboard#nmi-checkaccount Account number

Copy to clipboardNmiAchFields also renders two ordinary Copy to clipboard<select> 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 Copy to clipboardvariant: "inline", Copy to clipboardstyleSniffer: false, and Copy to clipboardpaymentType set to Copy to clipboard"cc" for cards or Copy to clipboard"ck" for bank accounts. It also pins Copy to clipboardcountry: "US" and Copy to clipboardcurrency: "USD". If you sell outside the US, change those two lines in Copy to clipboarduse-collect-js.ts when you copy it.

Wiring it up

The components expose a ref with Copy to clipboardrequestToken() and Copy to clipboardisValid, so your existing Place Order button drives tokenization instead of a second button appearing inside the form.

const fieldsRef = useRef<NmiFieldsHandle>(null)
const [submitting, setSubmitting] = useState(false)
async function handleToken(data: Record<string, unknown>) {
await sdk.store.payment.initiatePaymentSession(cart, {
provider_id: "pp_nmi-card",
data, // { payment_token, payment_method: "card" }
})
const res = await sdk.store.cart.complete(cart.id)
if (res.type === "order") {
window.location.href = `/order/confirmed/${res.order.id}`
}
setSubmitting(false)
}
{selected === "pp_nmi-card" && (
<NmiCardFields ref={fieldsRef} session={activeSession} onToken={handleToken} />
)}
<button

Copy to clipboardNmiAchFields works the same way against Copy to clipboardpp_nmi-ach. Its Copy to clipboardonToken payload carries two extra keys, Copy to clipboardaccount_type and Copy to clipboardaccount_holder_type.

The Copy to clipboardsession prop is the active payment session. The components read Copy to clipboardsession.data.tokenizationKey and Copy to clipboardsession.data.sandbox from it, both of which Copy to clipboardinitiatePayment 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.

Styling the inputs

Your stylesheet stops at the iframe boundary. A rule on Copy to clipboard#nmi-ccnumber styles the box around the input, not the input itself. To reach inside, pass CSS objects that Collect.js applies within its own document:

<NmiCardFields
ref={fieldsRef}
session={activeSession}
onToken={handleToken}
googleFont="Inter:400"
fieldClassName="h-11 rounded-md border border-neutral-700 px-3"
customCss={{
base: {
"font-family": "Inter, sans-serif",
"font-size": "15px",
color: "#e5e5e5",
"background-color": "#171717",
},
focus: { color: "#ffffff" },
invalid: { color: "#dc2626" },
placeholder: { color: "#737373" },
}}
/>

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 Copy to clipboardbackground-color in Copy to clipboardcustomCss.base.

Fonts do not cross the frame boundary either. Loading Inter in your app does nothing for the hosted input. Pass Copy to clipboardgoogleFont="Inter:400" so Collect.js loads the family inside its own document, then reference the family name in Copy to clipboardcustomCss.base["font-family"].

Validation and the token request

Collect.js reports validity per field as the shopper types, and the hook aggregates that into a single Copy to clipboardisValid 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 Copy to clipboardisValid is safe from the first render.

Calling Copy to clipboardrequestToken() triggers Copy to clipboardCollectJS.startPaymentRequest(). The token comes back through the callback and lands in your Copy to clipboardonToken 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.

Mount one form at a time

Collect.js is a single page-level global and does not survive being configured twice. Call Copy to clipboardconfigure() 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.

Copy to clipboarduse-collect-js.ts 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.

The wallet probe console error

On init, Collect.js checks whether the browser supports the Payment Request API and logs a Copy to clipboardconsole.error 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 Copy to clipboardconsole.error 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.

The unified payment element

Copy to clipboardNmiPaymentElement wraps Copy to clipboard<NmiPayments> 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-react
{session.provider_id === "pp_nmi" && (
<NmiPaymentElement
session={session}
onToken={async (data) => {
await sdk.store.payment.initiatePaymentSession(cart, {
provider_id: session.provider_id,
data, // { payment_token, payment_method }
})
const res = await sdk.store.cart.complete(cart.id)
if (res.type === "order") {
window.location.href = `/order/confirmed/${res.order.id}`
}
}}
onError={(e) => console.error(e)}
/>
)}

The wrapper reads the tokenization key off the session, passes the element a Copy to clipboardpaymentMethods list of Copy to clipboard["card", "ach", "google-pay", "apple-pay"], 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 Copy to clipboard"card"; a bank payment reports as Copy to clipboard"ach".

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 Copy to clipboardappearance prop rather than from Collect.js CSS objects. See NMI's component documentation for the shape.

What the storefront writes onto the session

Everything the backend needs at authorize time has to be on Copy to clipboardsession.data. Each Copy to clipboardinitiatePaymentSession call merges into it.

Key Written by Required Notes Copy to clipboardtokenizationKey Copy to clipboardinitiatePayment Public key for the browser. Copy to clipboardsandbox Copy to clipboardinitiatePayment Tells the components which gateway host to load Collect.js from. Copy to clipboardamount, Copy to clipboardcurrency_code, Copy to clipboardsession_id Copy to clipboardinitiatePayment Copy to clipboardsession_id is sent to NMI as both Copy to clipboardorderid and Copy to clipboardmerchant_defined_field_1 so webhooks can be matched back to the session. Copy to clipboardpayment_token Storefront Yes The single-use token. Without it, Copy to clipboardauthorizePayment returns Copy to clipboardpending instead of charging. Copy to clipboardpayment_method Storefront Yes for Copy to clipboardpp_nmi Copy to clipboard"card" or Copy to clipboard"ach". The unified provider branches on it and defaults to Copy to clipboard"card". Copy to clipboardaccount_type Storefront ACH Copy to clipboard"checking" or Copy to clipboard"savings". Copy to clipboardaccount_holder_type Storefront ACH Copy to clipboard"personal" or Copy to clipboard"business". Copy to clipboardbilling Storefront, server side Recommended Cardholder address for AVS. See below. Copy to clipboardcard_type, Copy to clipboardcard_last4, Copy to clipboardcard_exp Storefront Optional Display metadata, passed through to Copy to clipboardpayment.data.

Billing address and AVS

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:

// storefront: in your submitPayment / placeOrder action
const cart = await retrieveCart()
const a = cart.billing_address
await sdk.store.payment.initiatePaymentSession(cart, {
provider_id: providerId,
data: {
payment_token: token,
payment_method: method,
...(a && {
billing: {
first_name: a.first_name,
last_name: a.last_name,
company: a.company,
address_1: a.address_1,
address_2: a.address_2,
city: a.city,
province: a.province,
postal_code: a.postal_code,
country_code: a.country_code,

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 Copy to clipboardpayment.data as Copy to clipboardavs_response and Copy to clipboardcvv_response, which makes them queryable later. On a decline the full gateway result is attached to the thrown Copy to clipboardNmiError as Copy to clipboarderror.raw, 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.

Showing the card on receipts

If the storefront puts Copy to clipboardcard_type, Copy to clipboardcard_last4, and Copy to clipboardcard_exp on the session, the provider copies them onto Copy to clipboardpayment.data after authorization so receipts and the admin can render something like "Visa 1111". None of these keys contain a real card number.

The shipped Copy to clipboardNmiCardFields does not set them. Collect.js returns a Copy to clipboardcard 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:

// NmiCardFields.tsx, inside the useCollectJs call
onToken: (response: CollectJsResponse) =>
onToken({
payment_token: response.token,
payment_method: "card",
card_type: response.card?.type, // e.g. "visa"
card_last4: response.card?.number?.slice(-4), // the number arrives masked
}),

Log the Copy to clipboardcard object once against your own account before relying on either field.

Webhooks

Medusa exposes one webhook route per registered provider, at Copy to clipboard/hooks/payment/<identifier>. Registering the package root creates all four:

Provider Route Configure it in the portal? Copy to clipboardnmi Copy to clipboardPOST https://<your-backend>/hooks/payment/nmi Yes, if you use the unified provider. Copy to clipboardnmi-ach Copy to clipboardPOST https://<your-backend>/hooks/payment/nmi-ach Yes. ACH cannot complete without it. Copy to clipboardnmi-card Copy to clipboardPOST https://<your-backend>/hooks/payment/nmi-card Optional. Copy to clipboardnmi-wallet Copy to clipboardPOST https://<your-backend>/hooks/payment/nmi-wallet 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 Copy to clipboardnmi-ach destination or payments sit in Copy to clipboardauthorized 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 Copy to clipboardid on the provider config appends it to the path, so Copy to clipboardid: "primary" gives Copy to clipboard/hooks/payment/nmi-ach_primary and so on.

In the NMI Merchant Portal, go to Settings then Webhooks and click Create. Enter your receiver URL and pick the event types from the list, which is grouped by category — the ACH events live under Check Status, not under Transactions. The signing key is generated by NMI and shown on that same Webhooks settings page; copy it into Copy to clipboardwebhookSecret. You do not choose it. Once the URL is saved, delivery starts with no further setup.

Subscribe to:

transaction.sale.success transaction.sale.failure
transaction.auth.success transaction.capture.success
transaction.refund.success transaction.void.success
settlement.batch.complete
transaction.check.status.settle (ACH only)
transaction.check.status.return (ACH only)
transaction.check.status.latereturn (ACH only)

The three Copy to clipboardcheck.status events are how an ACH payment finishes, and they are the only ones to rely on for it. Each carries Copy to clipboardorder_id, Copy to clipboardtransaction_id, Copy to clipboardmerchant_defined_fields, and the amount at Copy to clipboardaction.amount, so they always match back to a payment session.

Copy to clipboardsettlement.batch.complete is still handled, but treat it as inert. Its documented body is card-only — Copy to clipboardprocessor.type: "cc" with a Copy to clipboardby_card_type breakdown — and contains batch totals with no Copy to clipboardorder_id at any level, so Medusa drops it for want of a session to attach it to. Subscribing to it is harmless; depending on it for ACH is not.

How events are interpreted

The same event means different things for a card and for a bank debit, so the handler looks at Copy to clipboardevent_body.check to tell them apart.

NMI event Card ACH Copy to clipboardtransaction.auth.success authorized authorized Copy to clipboardtransaction.sale.success captured authorized (accepted, not settled) Copy to clipboardtransaction.capture.success captured captured Copy to clipboardtransaction.refund.success captured captured Copy to clipboardtransaction.void.success canceled canceled Copy to clipboardtransaction.sale.failure ignored failed (rejected at submission) Copy to clipboardtransaction.check.status.settle — captured Copy to clipboardtransaction.check.status.return — failed Copy to clipboardtransaction.check.status.latereturn — failed Copy to clipboardsettlement.batch.complete captured captured

Every request is verified before any of that happens. NMI signs with a Copy to clipboardWebhook-Signature: t=<nonce>,s=<signature> header, and the handler recomputes Copy to clipboardHMAC-SHA256(nonce + "." + rawBody) with your signing key and compares in constant time. A mismatch returns Copy to clipboardnot_supported, 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 with valid TLS, so for local development tunnel to your backend with Copy to clipboardcloudflared or Copy to clipboardngrok.

Delivery, retries, and why a 200 means less than you think

NMI treats an HTTP 200 as success. Anything else is retried up to 20 times over roughly three days — a few seconds apart at first, then minutes, then hourly, then twice daily — after which the event is dropped permanently. NMI cautions that the exact schedule may change, so do not encode it. Because the same event can arrive more than once, anything you build on these events should be idempotent.

The catch: Medusa's hook route answers 200 as soon as it hands the event to the event bus, before any signature check or mapping happens. So an event with a bad signature, or one this provider does not map, is still a 200 to NMI. Retries will never fire for a webhook your backend accepted and then ignored — if something is silently dropping events, NMI's delivery log will show success and tell you nothing. Debug from the Medusa side.

That route also delays processing by 5 seconds and retries internally 3 times. Both are tunable through the payment module's Copy to clipboardwebhook_delay and Copy to clipboardwebhook_retries options if you need different behaviour.

On asynchronous outcomes. Medusa's built-in payment webhook subscriber acts on the Copy to clipboardauthorized and Copy to clipboardcaptured outcomes. ACH settlement therefore works out of the box. Returns and voids are detected and mapped correctly by this provider, but Copy to clipboardfailed and Copy to clipboardcanceled webhook outcomes do not auto-transition the payment in current Medusa core. If you need automated reconciliation for returns, subscribe to the Copy to clipboardpayment.webhook_received event and handle it yourself. See ACH reconciliation.

ACH reconciliation

Medusa's payment status is a card state machine. Copy to clipboardauthorized means funds are held and Copy to clipboardcaptured means the money moved and the matter is closed. Neither is true of a bank debit, so this provider maps ACH onto the closest available states and you have to supply the rest:

Reality What the plugin reports What it actually means Debit submitted Copy to clipboardauthorized Money requested. Nothing is held and nothing has moved. Settled Copy to clipboardcaptured Money moved, and can still be clawed back for up to 60 days. Returned Copy to clipboardfaileddropped by core Money came back. Nothing in Medusa changes on its own.

Two consequences worth designing around.

Nothing stops you shipping an unsettled order. Medusa does not gate fulfillment on payment status — Copy to clipboardcreate-fulfillment contains no Copy to clipboardpayment_status check. An ACH order is fulfillable the moment it is placed, days before anyone knows whether the money arrives.

Clicking Capture on an ACH payment lies. The provider sends nothing, but Medusa still stamps Copy to clipboardcaptured_at, so the order reads as paid while the debit is in flight. The provider cannot refuse the click, because the settlement webhook captures through the same method and Medusa passes no way to distinguish the callers. Do not press Capture on ACH; let the webhook do it.

So gate on the ACH lifecycle rather than on payment status. Subscribe to Copy to clipboardpayment.webhook_received, classify with the exported helpers, and record the outcome somewhere your fulfillment path can read:

// src/subscribers/ach-reconciliation.ts
import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"
import { Modules, ContainerRegistrationKeys } from "@medusajs/framework/utils"
import { verifySignature, classifyAchEvent, extractSessionId } from "medusa-payment-nmi"
export default async function achReconciliation({ event, container }: SubscriberArgs<any>) {
const { payload } = event.data
const raw = Buffer.isBuffer(payload.rawData)
? payload.rawData.toString("utf8")
: String(payload.rawData)
// Re-verify: this subscriber sees every webhook, not just ours.
const header = payload.headers?.["webhook-signature"]
if (!verifySignature(process.env.NMI_WEBHOOK_SECRET!, raw, header)) return
const body = JSON.parse(raw)
const state = classifyAchEvent(body.event_type)
if (!state) return
const sessionId = extractSessionId(body.event_body ?? {})

The order lookup from a payment collection differs across Medusa 2.x minors, so verify that traversal against your version rather than copying it blind.

On a return, cancelling an unfulfilled order is usually the right move: Copy to clipboardcancel-order runs Copy to clipboarddeleteReservationsByLineItemsStep, which frees the inventory the order was holding. It also runs Copy to clipboardcancelPaymentStep against uncaptured payments, which would try to void a debit that has already come back — this provider tolerates that failure for ACH and records Copy to clipboardvoid_failed on the payment data rather than blocking the cancellation. If the order was already fulfilled there is no reservation to release and cancelling is not appropriate; that case needs a claim and a human.

Captures, refunds, and voids

Capture sends an NMI Copy to clipboardcapture against the stored Copy to clipboardtransactionid. For ACH it is a no-op, since settlement is what captures those — and pressing it anyway records a misleading capture. See ACH reconciliation.

Refund sends an NMI Copy to clipboardrefund. 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 Copy to clipboardvoided: true on Copy to clipboardpayment.data 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 Copy to clipboardNmiError carrying the response code and the full gateway result.

Testing against the sandbox

Set Copy to clipboardsandbox: true and both sides switch hosts together. The backend talks to Copy to clipboardsandbox.nmi.com/api/transact.php, and because Copy to clipboardinitiatePayment puts the flag on the session, the storefront components load Collect.js from Copy to clipboardsandbox.nmi.com 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 Copy to clipboardtransaction.check.status.settle event at your webhook endpoint with a valid signature and the Copy to clipboardorder_id set to the payment session id. Replay a Copy to clipboardtransaction.check.status.return to exercise the return path.

Troubleshooting

Symptom Cause Fix Copy to clipboardPayment Token does not exist on a bank payment The token was looked up in the card token space. Copy to clipboardtransact.php defaults Copy to clipboardpayment to Copy to clipboardcreditcard. Make sure Copy to clipboardpayment_method is Copy to clipboard"ach" on the session, and that you are using Copy to clipboardpp_nmi-ach or Copy to clipboardpp_nmi, not Copy to clipboardpp_nmi-card. Copy to clipboardInvalid amount An amount arrived as something other than a plain number. Medusa's Copy to clipboardBigNumber stringifies to Copy to clipboardNaN. 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 Copy to clipboardPaymentRequestAbstraction Collect.js probing for wallet support that the account does not have. Harmless. Copy to clipboarduse-collect-js.ts filters it in development. Typed text invisible inside the fields The iframe document's own background is white. Set Copy to clipboardbackground-color and Copy to clipboardcolor in Copy to clipboardcustomCss.base. Your font does not apply to the inputs Fonts do not cross the iframe boundary. Pass Copy to clipboardgoogleFont and reference the family in Copy to clipboardcustomCss.base. Webhook returns 200 but nothing happens Signature verification failed, which returns Copy to clipboardnot_supported. Confirm Copy to clipboardwebhookSecret matches the portal, and that no proxy is rewriting the raw body. ACH payments stay Copy to clipboardauthorized forever No settlement webhook reaching the route, or the event arriving carries no Copy to clipboardorder_id — Medusa ignores any event it cannot tie to a session. Subscribe to Copy to clipboardtransaction.check.status.settle and point it at Copy to clipboard/hooks/payment/nmi-ach. Copy to clipboardauthorizePayment returns Copy to clipboardpending No Copy to clipboardpayment_token on the session. The storefront never wrote the token back, or wrote it to a different provider's session.

Not supported yet

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 Copy to clipboardcurrency field to Copy to clipboardtransact.php, 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 Copy to clipboardcountry: "US" and Copy to clipboardcurrency: "USD" 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 Copy to clipboardpaymentType: "cc" or Copy to clipboard"ck" 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.

Local development

npm install # runs medusa plugin:build via prepare
npm run dev # medusa plugin:develop, watches and publishes to the local registry
npm test # vitest
npm run typecheck

To try local changes inside a real Medusa app, use the local plugin workflow:

# in this repo
npx medusa plugin:publish
# in your Medusa app
npx medusa plugin:add medusa-payment-nmi

Disclaimer

This 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.

License

MIT

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?