4 MIN READ

PayPal with Medusa: Integration, Buttons and Dispute Handling

Adding PayPal alongside cards, wiring the JS SDK buttons into a Next.js checkout, and the dispute and webhook behaviour that differs from a card processor.

BY RAHUL MEHTAUPDATED
Illustration for “PayPal with Medusa: Integration, Buttons and Dispute Handling” — Payments

PayPal is worth adding for one reason: a meaningful share of customers will not complete a card checkout and will complete a PayPal one. In some categories it is a fifth of orders.

Mechanically it differs from a card processor in ways that matter — approval happens inside PayPal's own flow, and disputes are adjudicated by PayPal rather than a card network.

Configuration

PayPal does not ship as a first-party Medusa module, so you use a community provider or write one against PayPal's Orders API. Either way the registration looks the same:

medusa-config.tsts
{
  resolve: "@medusajs/medusa/payment",
  options: {
    providers: [
      {
        resolve: "./src/modules/payment-paypal",
        id: "paypal",
        options: {
          clientId: process.env.PAYPAL_CLIENT_ID,
          clientSecret: process.env.PAYPAL_CLIENT_SECRET,
          sandbox: process.env.NODE_ENV !== "production",
          webhookId: process.env.PAYPAL_WEBHOOK_ID,
        },
      },
    ],
  },
}

Then enable it per region in the admin, and note which currencies your PayPal account actually supports — the list is shorter than Stripe's, and an unsupported currency fails at order creation with a message about the merchant account rather than the currency.

The flow

PayPal inverts the usual sequence. Instead of collecting details on your page:

  1. Your backend creates a PayPal order and returns its id.
  2. The PayPal button opens a popup; the customer approves inside PayPal.
  3. The popup returns an approval token to your page.
  4. Your backend captures or authorises against the PayPal order.
  5. You complete the Medusa cart.
components/PayPalCheckout.tsxtsx
"use client"

import { PayPalScriptProvider, PayPalButtons } from "@paypal/react-paypal-js"

export function PayPalCheckout({
  cart,
  sessionData,
  onApproved,
}: {
  cart: StoreCart
  sessionData: { paypal_order_id: string }
  onApproved: () => Promise<void>
}) {
  return (
    <PayPalScriptProvider
      options={{
        clientId: process.env.NEXT_PUBLIC_PAYPAL_CLIENT_ID!,
        currency: cart.currency_code.toUpperCase(),
        intent: "authorize",
      }}
    >
      <PayPalButtons
        style={{ layout: "vertical", label: "pay" }}
        createOrder={async () => sessionData.paypal_order_id}
        onApprove={async () => {
          // The server authorises against PayPal, then completes the cart.
          await onApproved()
        }}
        onError={(err) => console.error("[paypal]", err)}
      />
    </PayPalScriptProvider>
  )
}

createOrder returns the id your payment session already created, rather than creating a second order. Creating one client-side is the classic mistake: PayPal ends up with an order your backend does not know about, and the amounts drift.

Authorise, then capture

Use intent: "authorize" and capture at fulfilment, matching the card default. Cancelling before shipping voids the authorisation, so no refund fee and no accounting cleanup. PayPal authorisations are honoured for a limited window — check current terms and capture within it. Authorise versus capture.

Webhooks

More important here than with cards, because approval happens entirely outside your request cycle. A customer can approve in the popup and close the tab before your completion call runs.

text
Endpoint: https://api.yourdomain.com/hooks/payment/paypal_paypal

Events:
  CHECKOUT.ORDER.APPROVED
  PAYMENT.AUTHORIZATION.CREATED
  PAYMENT.CAPTURE.COMPLETED
  PAYMENT.CAPTURE.DENIED
  CUSTOMER.DISPUTE.CREATED

Verify signatures using PayPal's verification endpoint with your webhookId, and make handlers idempotent — PayPal redelivers.

Subscribe to CUSTOMER.DISPUTE.CREATED specifically. Disputes have response deadlines, and finding out by email a week later is how you lose them.

Disputes

PayPal adjudicates its own disputes and the process differs from card chargebacks:

AspectPayPalCard network
AdjudicatorPayPalIssuing bank
WindowTypically 180 daysVaries, often 120
EvidenceTracking, delivery proofTracking, AVS, 3DS
FeeVaries by case typeUsually charged

The practical requirement is the same in both: upload tracking numbers to the transaction. Item-not-received disputes with delivery confirmation are usually resolved in the merchant's favour, and without it they are usually not. Wire this into your fulfilment flow as an automatic step rather than a manual one.

Testing

Use sandbox credentials with a sandbox buyer account. Cover:

  • Approval and capture — the happy path.
  • Cancel inside the popup. Your cart must survive.
  • Close the popup mid-flow, then retry.
  • Webhook redelivery, including duplicates.
  • Refund, partial and full.

The second and third are where implementations break, because they are the paths nobody demos.

Practical guidance

Put the PayPal button above the card form. Customers who want it should not scroll past a card form to find it.

Do not require an account first. PayPal supports guest card payments through the same button in most markets.

Reconcile daily. Compare PayPal captures against Medusa payments. Asynchronous approval means occasional drift, and daily reconciliation turns a mystery into a line item.

Reconciliation and reporting

PayPal settles on its own schedule and its own fee structure, which means its numbers will not line up with your card processor's without deliberate work.

Three practices that keep the books honest:

Store the PayPal capture id on the Medusa payment. It is the only reliable join between your order and the settlement report.

Reconcile daily rather than monthly. Asynchronous approval means occasional drift — a payment captured at PayPal with no matching Medusa order. Daily, it is one line to investigate; monthly, it is a spreadsheet exercise.

Record fees per transaction, not as a monthly lump. PayPal's rate varies by method, currency and cross-border status, so a blended assumption will misstate margin per order.

Currency and cross-border

PayPal converts currencies itself, and its rate is not your bank's. Two consequences:

  • Charge in the customer's currency where your account supports it, so the buyer sees a familiar amount and PayPal's conversion applies to your settlement rather than their charge.
  • Cross-border transactions carry a higher fee. If you sell internationally in volume, model it — the difference between domestic and cross-border rates is material at scale.

Check which currencies your PayPal account actually supports before configuring a region around one. The list is shorter than Stripe's, and an unsupported currency fails at order creation with a message about the merchant account rather than the currency.

Testing checklist

Sandbox credentials and a sandbox buyer account, then work through every path — including the ones nobody demos:

text
[ ] Approve and capture — the happy path
[ ] Cancel inside the popup; cart survives intact
[ ] Close the popup mid-flow, then retry successfully
[ ] Approve, then close the tab before completion — webhook completes the order
[ ] Webhook redelivery of an already-processed event
[ ] Partial refund, then full refund
[ ] Currency your account does not support — fails clearly

The fourth line is the one that finds real bugs. It is also the exact behaviour of a customer on a slow mobile connection, which makes it far more common in production than in testing.


We wire PayPal alongside cards on most consumer builds. Ask about it.

Frequently asked questions

Does Medusa support PayPal?

Not as a first-party module. You use a community provider or write one against PayPal's Orders API using the payment provider interface, then register and enable it per region like any other provider.

Should I offer PayPal instead of cards?

Alongside, not instead. PayPal is additive — it captures customers who abandon card forms — while removing cards would cost you more than it gains. Place the button above the card form.

How does the PayPal checkout flow differ from Stripe?

Approval happens inside PayPal's popup rather than on your page. Your backend creates a PayPal order, the customer approves it in PayPal, and your backend then authorises or captures against that order before completing the Medusa cart.

Do I need PayPal webhooks?

Yes. Approval and capture complete outside your request cycle, so a customer can approve and close the tab before your completion call runs. Webhooks are the only reliable way to reconcile, and dispute notifications arrive through them too.

How are PayPal disputes handled?

PayPal adjudicates them itself, on a longer window than most card networks. Tracking numbers uploaded to the transaction are the decisive evidence for item-not-received cases, so automate that as part of fulfilment.

Can I capture PayPal payments at fulfilment rather than checkout?

Yes — use `intent: "authorize"` and capture when you ship, matching the card default. Authorisations are valid for a limited window, so capture within it or re-authorise.

[ Keep reading ]