4 MIN READ

Payment Providers in Medusa: Architecture, Choice and Failover

How Medusa's payment abstraction works, why running two providers is easier than you think, and the authorise-versus-capture decision that determines your refund story.

BY SHUBHAM VERMAUPDATED
Illustration for “Payment Providers in Medusa: Architecture, Choice and Failover” — Payments

Medusa treats payment providers as interchangeable modules. That is a bigger deal than it sounds: on a hosted platform, changing processor is a project. Here it is a configuration change and a webhook endpoint.

For most merchants that is convenience. For anyone in a category where account closures happen, it is the reason to be on this platform at all.

The model

ConceptWhat it is
Payment providerA module implementing the provider interface
Payment collectionThe payment container for a cart or order
Payment sessionOne provider's attempt within a collection
PaymentAn authorised or captured amount
RefundMoney returned against a payment

A cart gets a payment collection; each provider the customer might use gets a session. The customer picks one, it authorises, and the rest are discarded. This is why offering card, PayPal and a local method on the same checkout requires no special handling.

Registration

medusa-config.tsts
modules: [
  {
    resolve: "@medusajs/medusa/payment",
    options: {
      providers: [
        {
          resolve: "@medusajs/medusa/payment-stripe",
          id: "stripe",
          options: {
            apiKey: process.env.STRIPE_API_KEY,
            webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
            capture: false,          // authorise now, capture at fulfilment
          },
        },
        {
          resolve: "./src/modules/payment-highrisk",
          id: "highrisk",
          options: {
            apiKey: process.env.HIGHRISK_API_KEY,
            merchantId: process.env.HIGHRISK_MERCHANT_ID,
          },
        },
      ],
    },
  },
]

Registration makes a provider available; enabling it per region in the admin decides where it appears. That is how you run Stripe in Europe and a local processor in India without a single conditional in the storefront.

Authorise versus capture

The decision that shapes your operational life.

Authorise at checkout, capture at fulfilment (capture: false) is the right default for physical goods:

  • Cancelling before shipping voids the authorisation rather than issuing a refund — cleaner books, no refund fees, faster for the customer.
  • You do not hold money for goods you have not shipped, which matters in some jurisdictions.
  • Authorisations expire, typically around seven days. Ship or capture before then.

Capture immediately (capture: true) suits digital goods and instant fulfilment, where there is no window in which cancelling is meaningful.

Physical goods with a fulfilment delay and immediate capture is the combination that generates refund volume you did not need.

Offering multiple providers

lib/data/checkout.tsts
const { payment_providers } = await sdk.store.payment.listPaymentProviders({
  region_id: cart.region_id!,
})

const collection = await sdk.store.payment.initiatePaymentSession(cart, {
  provider_id: selectedProviderId,
})

The storefront lists what the region allows and initiates a session for the customer's choice. Adding a provider later requires no storefront change — it appears in the list.

Failover

For high-risk merchants this is the whole argument. Two providers configured, one primary; if the primary account is closed or an outage starts, you switch the region's enabled provider and keep selling.

Practical requirements:

  1. Both underwritten in advance. Applications take weeks; a standby you have not opened is not a standby.
  2. Both exercised periodically. Route a small share of traffic through the secondary monthly, or you will discover the integration rotted at the worst moment.
  3. Refunds must work against the original provider. Refunding an order paid on the closed account is a manual process; document it before you need it.

This is the concrete mechanism behind migrating a regulated brand off Shopify.

Webhooks

Authorisation is not always synchronous — 3D Secure, bank redirects and delayed methods all resolve later. Webhooks are the source of truth.

src/api/hooks/payment/[provider]/route.tsts
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"

export async function POST(req: MedusaRequest, res: MedusaResponse) {
  const logger = req.scope.resolve("logger")

  try {
    // Verify the signature against the raw body before trusting anything.
    await verifyAndProcess(req)
    res.sendStatus(200)
  } catch (error) {
    logger.error(`[payment-webhook] ${(error as Error).message}`)
    // Non-2xx asks the provider to retry.
    res.sendStatus(400)
  }
}

Three rules. Verify the signature — an unverified payment webhook is an authorisation bypass. Be idempotent; providers redeliver, and a duplicate capture is a real incident. Respond quickly and do slow work in a workflow, because providers time out and retry.

Choosing a provider

SituationTypical choice
Standard DTC, US and EUStripe
Strong PayPal audiencePayPal alongside cards
IndiaRazorpay
Restricted or high-risk categoryA specialist high-risk acquirer
B2B invoicingA manual provider plus net terms

Provider guides: Stripe, PayPal, Razorpay. For anything without an existing module, building a custom provider.

Reconciliation

The operational practice that catches what monitoring misses: compare what the processor says happened against what Medusa recorded, daily.

src/jobs/reconcile-payments.tsts
export default async function reconcilePayments(container: MedusaContainer) {
  const logger = container.resolve("logger")
  const query = container.resolve("query")

  const since = new Date(Date.now() - 24 * 60 * 60 * 1000)
  const remote = await fetchProviderTransactions(since)

  const { data: payments } = await query.graph({
    entity: "payment",
    fields: ["id", "amount", "data"],
    filters: { created_at: { $gte: since } },
  })

  const local = new Map(payments.map((p) => [p.data?.intent_id as string, p]))

  for (const tx of remote) {
    const match = local.get(tx.id)
    if (!match) logger.error(`[reconcile] captured at provider, missing locally: ${tx.id}`)
    else if (Number(match.amount) !== tx.amount) {
      logger.error(`[reconcile] amount mismatch on ${tx.id}`)
    }
  }
}

export const config = { name: "reconcile-payments", schedule: "0 6 * * *" }

Every discrepancy is money — either taken and not recorded, or recorded and not taken. Both matter, and neither shows up in an error rate.

Refunds and partial refunds

Rules that avoid the common mistakes:

  • Refund through the provider that took the payment. Obvious until you have switched providers and someone tries to refund a nine-month-old order.
  • Refund tax proportionally. A partial refund of two items out of five returns two-fifths of the tax, not the whole amount. See tax configuration.
  • Decide about shipping. Full return usually refunds shipping; partial usually does not. Encode the rule rather than leaving it to whoever processes it.
  • Void rather than refund before capture. Cheaper, faster for the customer, and cleaner in the books.

PCI scope

Self-hosting raises the question, and the answer is reassuring provided you keep one rule: card details must never touch your server.

Using a provider's hosted fields or elements — Stripe Elements, PayPal's buttons, Razorpay's modal — means card data goes from the customer's browser to the processor directly. Your application only ever sees tokens and identifiers, which keeps you in the lightest self-assessment category rather than the full audit.

What breaks that: proxying card details through your API, logging a request body containing a card number, or building your own card form against a raw charge API. Each moves you into a materially more expensive compliance position.

If a provider offers only a raw API, consider whether that is a reason to choose a different one.


Payments are the part of a build where quiet mistakes are most expensive. We are happy to review an integration.

Frequently asked questions

How do payment providers work in Medusa v2?

Providers are modules registered in the payment module's options and enabled per region. At checkout, Medusa creates a payment collection for the cart and a session for the chosen provider; that session authorises, and the payment is captured either immediately or at fulfilment.

Can Medusa use multiple payment providers at once?

Yes. Register as many as you need and enable them per region. A single checkout can offer several, and the customer's choice determines which session is used, which also makes provider failover a configuration change.

Should I capture payment immediately or at fulfilment?

Authorise at checkout and capture at fulfilment for physical goods, so cancellations void an authorisation instead of creating a refund. Capture immediately for digital goods. Watch authorisation expiry, typically around seven days.

How do I handle payment webhooks in Medusa?

Expose a route per provider, verify the signature against the raw request body, respond quickly, and do any slow processing in a workflow. Handlers must be idempotent because providers redeliver events.

What payment providers does Medusa support?

Stripe has an official module, and PayPal, Razorpay and others are available as community or custom modules. Because the interface is public, any processor with an API can be integrated — which is the point for merchants in categories mainstream processors avoid.

How do refunds work in Medusa?

Refunds are created against a captured payment through the admin or the API, and the provider module forwards them to the processor. Refunds must go back through the provider that took the payment, which matters when you have switched providers.

[ Keep reading ]