4 MIN READ

Stripe with Medusa: Setup, Elements and Webhooks That Work

Configuring the Stripe module, wiring Payment Elements into a Next.js checkout, and the webhook and 3D Secure details that decide whether payments reconcile.

BY RAHUL MEHTAUPDATED
Illustration for “Stripe with Medusa: Setup, Elements and Webhooks That Work” — Stripe

Stripe is the default for good reason and the integration is well-trodden. What catches teams out is not the happy path — it is 3D Secure, webhook signature verification, and the fact that the client secret becomes stale the moment the cart total changes.

Backend configuration

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,
            automatic_payment_methods: true,
          },
        },
      ],
    },
  },
]

automatic_payment_methods lets Stripe decide which methods to show based on the customer's country and the currency — cards everywhere, iDEAL in the Netherlands, Bancontact in Belgium — without you enumerating them.

Then enable Stripe for each region in the admin under Settings → Regions. Registration alone does not make it appear at checkout; this is the most common "Stripe is configured but not showing" cause.

Creating the session

lib/data/payment.tsts
"use server"

export async function initStripeSession(cart: StoreCart) {
  const collection = await sdk.store.payment.initiatePaymentSession(cart, {
    provider_id: "pp_stripe_stripe",
  })

  const session = collection.payment_collection.payment_sessions?.find(
    (s) => s.provider_id === "pp_stripe_stripe",
  )

  return session?.data?.client_secret as string | undefined
}

The provider id is pp_stripe_stripe — the pp_ prefix plus the module id plus the provider name. Passing plain stripe returns a provider-not-found error that does not explain itself.

Create the session after shipping and promotions are applied. A session created against a £40 total and confirmed against a £47 one will fail, and the failure surfaces at the last possible moment.

The client

components/StripeCheckout.tsxtsx
"use client"

import { loadStripe } from "@stripe/stripe-js"
import {
  Elements,
  PaymentElement,
  useElements,
  useStripe,
} from "@stripe/react-stripe-js"
import { useState } from "react"

const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_KEY!)

function PaymentForm({ onAuthorized }: { onAuthorized: () => Promise<void> }) {
  const stripe = useStripe()
  const elements = useElements()
  const [error, setError] = useState<string>()
  const [submitting, setSubmitting] = useState(false)

  const submit = async (e: React.FormEvent) => {
    e.preventDefault()
    if (!stripe || !elements) return

    setSubmitting(true)
    setError(undefined)

    // `redirect: "if_required"` keeps card payments inline and still supports
    // methods that must redirect (iDEAL, some 3DS flows).
    const { error: stripeError } = await stripe.confirmPayment({
      elements,
      redirect: "if_required",
    })

    if (stripeError) {
      setError(stripeError.message)
      setSubmitting(false)
      return
    }

    await onAuthorized()   // completes the cart server-side
  }

  return (
    <form onSubmit={submit}>
      <PaymentElement />
      {error && <p role="alert">{error}</p>}
      <button type="submit" disabled={!stripe || submitting}>
        {submitting ? "Processing…" : "Place order"}
      </button>
    </form>
  )
}

export function StripeCheckout({ clientSecret, onAuthorized }: Props) {
  return (
    <Elements stripe={stripePromise} options={{ clientSecret }}>
      <PaymentForm onAuthorized={onAuthorized} />
    </Elements>
  )
}

PaymentElement renders whatever methods Stripe has enabled for the currency and country, so adding a wallet is a Stripe dashboard change rather than a code change.

Only complete the cart after confirmPayment returns without an error. Completing first produces orders with unauthorised payments, which is a reconciliation problem you will find in a month. The checkout sequence.

Webhooks

bash
# Endpoint
https://api.yourdomain.com/hooks/payment/stripe_stripe

# Events
payment_intent.succeeded
payment_intent.payment_failed
payment_intent.amount_capturable_updated

Take the signing secret into STRIPE_WEBHOOK_SECRET. The module verifies signatures against the raw body; if your platform or a proxy rewrites the body, verification fails with a signature error that looks like a wrong secret.

Locally:

bash
stripe listen --forward-to localhost:9000/hooks/payment/stripe_stripe

Without webhooks, 3D Secure and redirect-based methods never confirm server-side. Orders sit unpaid while Stripe shows them as succeeded, and nobody notices until the daily reconciliation.

3D Secure

Mandatory in Europe under SCA and increasingly common elsewhere. PaymentElement plus confirmPayment handles the challenge automatically, but two consequences follow:

  • Authorisation may complete after the customer returns. Your confirmation page must tolerate a pending payment and update when the webhook lands.
  • Test it deliberately. Stripe's 4000 0027 6000 3184 card forces the challenge. A checkout only tested with 4242… has never exercised the path most European customers will take.

Testing

CardBehaviour
`4242 4242 4242 4242`Succeeds
`4000 0027 6000 3184`Requires 3D Secure
`4000 0000 0000 9995`Declined, insufficient funds
`4000 0000 0000 0341`Attaches, then fails on charge

Test the whole path in test mode, then run one real transaction in production before launch. Test mode does not exercise your live account's configuration.

Common failures

"Provider not found." Wrong provider id — it is pp_stripe_stripe.

Stripe missing at checkout. Registered but not enabled for the region.

Signature verification fails. Raw body altered by a proxy, or the wrong secret.

Amount mismatch. Session created before the total was final. Refresh on cart change.

Orders without payments. Cart completed before confirmation returned.

Saving cards for later

For subscriptions or one-click reorder you need a stored payment method, which means a Stripe Customer rather than a bare payment intent:

ts
const { paymentIntent } = await stripe.confirmPayment({
  elements,
  confirmParams: { setup_future_usage: "off_session" },
  redirect: "if_required",
})

setup_future_usage: "off_session" tells Stripe the customer consents to future charges without them present. Later charges reference the saved method against the Stripe Customer id, which you store on the Medusa customer.

Two consequences worth planning for. Off-session charges can still trigger authentication for European cards, so handle the "requires action" outcome by emailing the customer a link rather than failing silently. And consent must be explicit at the time of saving — a checkbox, not an assumption.

Stripe Tax

If you sell into the US or cross-border in the EU, Stripe Tax computes destination-based rates without a separate vendor:

ts
options: {
  apiKey: process.env.STRIPE_API_KEY,
  automatic_tax: true,
}

It handles US economic nexus tracking and EU OSS thresholds, which is the part that makes manual rates unworkable. It also means tax is calculated at Stripe rather than in Medusa, so your order records must store the returned breakdown — you need line-level tax for returns and reporting, and reconstructing it later from rates that have since changed is unpleasant.

Compare it against a dedicated engine on filing support: Stripe Tax calculates and reports, while some competitors also file returns. Tax configuration.

Disputes and Radar

Stripe's dispute flow is worth wiring into your own systems rather than living in the dashboard.

Subscribe to charge.dispute.created and create an internal record with the deadline, so a dispute appears in your operational tooling rather than in an email someone misses. Evidence submission is largely mechanical — order details, tracking number, delivery confirmation, customer communications — and most of it is data you already hold, which makes automating the submission worthwhile at any volume.

Radar rules are worth revisiting once you have a few months of orders. The defaults are tuned for a generic merchant; your category, average order value and geography are not generic, and a rule tuned to your actual fraud pattern beats the default in both directions — fewer false declines and fewer disputes.


We have wired this integration many times and would rather you skipped the 3DS surprises. Ask.

Frequently asked questions

How do I set up Stripe with Medusa v2?

Register `@medusajs/medusa/payment-stripe` in the payment module's providers with your API key and webhook secret, enable Stripe for each region in the admin, then initiate a payment session at checkout and confirm it client-side with Stripe Elements.

What is the Stripe provider id in Medusa?

`pp_stripe_stripe` — the payment provider prefix plus the module id plus the provider name. Using `stripe` alone produces a provider-not-found error.

Why is Stripe not showing at my checkout?

Registration makes a provider available; it still has to be enabled for the cart's region under Settings → Regions in the admin. That is the usual cause when the configuration looks correct.

Do I need Stripe webhooks with Medusa?

Yes. Asynchronous authorisations — 3D Secure, redirect-based methods — only confirm server-side through the webhook. Without it, orders remain unpaid in Medusa while Stripe reports success.

How does 3D Secure work with Medusa and Stripe?

Stripe's `PaymentElement` and `confirmPayment` handle the challenge automatically. Authorisation may complete after the customer returns, so the confirmation page must tolerate a pending state and reconcile when the webhook arrives.

Should Stripe capture immediately or at fulfilment?

Set `capture: false` for physical goods so payments are authorised at checkout and captured when you ship. Cancelling then voids an authorisation rather than issuing a refund. Digital goods can capture immediately.

[ Keep reading ]