Integrate PayPal (Payment) with Medusa

In this tutorial, you'll learn how to integrate PayPal with Medusa for payment processing.

When you install a Medusa application, you get a fully-fledged commerce platform with a Framework for customization. Medusa's architecture facilitates integrating third-party services to handle various functionalities, including payment processing.

PayPal is a widely used payment gateway that allows businesses to accept payments online securely. By integrating PayPal with Medusa, you can offer your customers a convenient and trusted payment option.

Summary#

By following this tutorial, you'll learn how to:

  • Install and set up Medusa.
  • Integrate PayPal as a Payment Module Provider in Medusa.
  • Customize the Next.js Starter Storefront to include PayPal as a payment option.

You can follow this tutorial whether you're new to Medusa or an advanced Medusa developer.

Diagram showing the checkout flow between customer, Medusa, and PayPal

Full Code
Find the complete code for this integration on GitHub.

Step 1: Install a Medusa Application#

Start by installing the Medusa application on your machine with the following command:

Terminal
npx create-medusa-app@latest

You'll first be asked for the project's name. Then, when asked whether you want to install the Next.js Starter Storefront, choose "Yes."

Afterwards, the installation process will start, which will install the Medusa application in a directory with your project's name and the Next.js Starter Storefront in a separate directory named {project-name}-storefront.

Why is the storefront installed separately: The Medusa application is composed of a headless Node.js server and an admin dashboard. The storefront is installed or custom-built separately and connects to the Medusa application through its REST API endpoints, called API routes. Learn more in Medusa's Architecture documentation.

Once the installation finishes successfully, the Medusa Admin dashboard will open with a form to create a new user. Enter the user's credentials and submit the form. Afterwards, you can log in with the new user and explore the dashboard.

Ran into Errors: Check out the troubleshooting guides for help.

Step 2: Create PayPal Module Provider#

To integrate third-party services into Medusa, you create a custom module. A module is a reusable package with functionalities related to a single feature or domain.

Medusa's Payment Module provides an interface to process payments in your Medusa application. It delegates the actual payment processing to the underlying providers.

In this step, you'll integrate PayPal as a Payment Module Provider and configure it in your Medusa application. Later, you'll use it to process payments.

Note: Refer to the Modules documentation to learn more about modules in Medusa.

a. Install PayPal SDK#

To interact with PayPal's APIs, run the following command in your Medusa application to install the PayPal server SDK:

You'll use the SDK in the Payment Module Provider's service.

b. Create Module Directory#

A module is created under the src/modules directory of your Medusa application. So, create the directory src/modules/paypal.

c. Create PayPal Module's Service#

A module has a service that contains its logic. For Payment Module Providers, the service implements the logic to process payments with third-party services.

To create the service of the PayPal Payment Module Provider, create the file src/modules/paypal/service.ts with the following content:

src/modules/paypal/service.ts
1import { AbstractPaymentProvider } from "@medusajs/framework/utils"2import { Logger } from "@medusajs/framework/types"3import {4  Client,5  Environment,6  OrdersController,7  PaymentsController,8} from "@paypal/paypal-server-sdk"9
10type Options = {11  client_id: string12  client_secret: string13  environment?: "sandbox" | "production"14  autoCapture?: boolean15  webhook_id?: string16}17
18type InjectedDependencies = {19  logger: Logger20}21
22class PayPalPaymentProviderService extends AbstractPaymentProvider<Options> {23  static identifier = "paypal"24
25  protected logger_: Logger26  protected options_: Options27  protected client_: Client28  protected ordersController_: OrdersController29  protected paymentsController_: PaymentsController30
31  constructor(container: InjectedDependencies, options: Options) {32    super(container, options)33
34    this.logger_ = container.logger35    this.options_ = {36      environment: "sandbox",37      autoCapture: false,38      ...options,39    }40
41    // Initialize PayPal client42    this.client_ = new Client({43      environment:44        this.options_.environment === "production"45          ? Environment.Production46          : Environment.Sandbox,47      clientCredentialsAuthCredentials: {48        oAuthClientId: this.options_.client_id,49        oAuthClientSecret: this.options_.client_secret,50      },51    })52
53    this.ordersController_ = new OrdersController(this.client_)54    this.paymentsController_ = new PaymentsController(this.client_)55  }56
57  // TODO: Add methods58}59
60export default PayPalPaymentProviderService

A Payment Module Provider service must extend the AbstractPaymentProvider class. It must also have a static identifier property that uniquely identifies the provider.

The module provider's constructor receives two parameters:

  • container: The module's container that contains Framework resources available to the module.
  • options: Options that are passed to the module provider when it's registered in Medusa's configurations. You define the following option:
    • client_id: The PayPal Client ID.
    • client_secret: The PayPal Client Secret.
    • environment: The PayPal environment to use, either sandbox or production.
    • autoCapture: Whether to capture payments immediately or authorize them for later capture.
    • webhook_id: The PayPal Webhook ID for validating webhooks.

In the constructor, you initialize the PayPal SDK client and controllers using the provided credentials.

In the next sections, you'll implement the methods required by the AbstractPaymentProvider class to process payments with PayPal.

Note: Refer to the Create Payment Module Provider guide for detailed information about the methods.

validateOptions Method

The validateOptions method validates that the module has received the required options.

Add the following method to the PayPalPaymentProviderService class:

src/modules/paypal/service.ts
1import { 2  MedusaError3} from "@medusajs/framework/utils"4
5class PayPalPaymentProviderService extends AbstractPaymentProvider<Options> {6  // ...7  static validateOptions(options: Record<any, any>): void | never {8    if (!options.client_id) {9      throw new MedusaError(10        MedusaError.Types.INVALID_DATA, 11        "Client ID is required"12      )13    }14    if (!options.client_secret) {15      throw new MedusaError(16        MedusaError.Types.INVALID_DATA, 17        "Client secret is required"18      )19    }20  }21}

The validateOptions method receives the options passed to the module provider as a parameter.

In the method, you throw an error if the client_id or client_secret options are missing. This will stop the application from starting.

initiatePayment Method

The initiatePayment method initializes a payment session with the third-party service. It's called when the customer selects a payment method during checkout.

You'll create a PayPal order in this method.

Add the following method to the PayPalPaymentProviderService class:

src/modules/paypal/service.ts
1import type {2  InitiatePaymentInput,3  InitiatePaymentOutput,4} from "@medusajs/framework/types"5import {6  CheckoutPaymentIntent,7  OrderApplicationContextLandingPage,8  OrderApplicationContextUserAction,9  OrderRequest,10} from "@paypal/paypal-server-sdk"11
12class PayPalPaymentProviderService extends AbstractPaymentProvider<Options> {13  // ...14  async initiatePayment(15    input: InitiatePaymentInput16  ): Promise<InitiatePaymentOutput> {17    try {18      const { amount, currency_code } = input19
20      // Determine intent based on autoCapture option21      const intent = this.options_.autoCapture22        ? CheckoutPaymentIntent.Capture23        : CheckoutPaymentIntent.Authorize24
25      // Create PayPal order request26      const orderRequest: OrderRequest = {27        intent: intent,28        purchaseUnits: [29          {30            amount: {31              currencyCode: currency_code.toUpperCase(),32              value: amount.toString(),33            },34            description: "Order payment",35            customId: input.data?.session_id as string | undefined,36          },37        ],38        applicationContext: {39          // TODO: Customize as needed40          brandName: "Store",41          landingPage: OrderApplicationContextLandingPage.NoPreference,42          userAction: OrderApplicationContextUserAction.PayNow,43        },44      }45
46      const response = await this.ordersController_.createOrder({47        body: orderRequest,48        prefer: "return=representation",49      })50
51      const order = response.result52
53      if (!order?.id) {54        throw new MedusaError(55          MedusaError.Types.UNEXPECTED_STATE,56          "Failed to create PayPal order"57        )58      }59
60      // Extract approval URL from links61      const approvalUrl = order.links?.find(62        (link) => link.rel === "approve"63      )?.href64
65      return {66        id: order.id,67        data: {68          order_id: order.id,69          intent: intent,70          status: order.status,71          approval_url: approvalUrl,72          session_id: input.data?.session_id,73          currency_code74        },75      }76    } catch (error: any) {77      throw new MedusaError(78        MedusaError.Types.UNEXPECTED_STATE,79        `Failed to initiate PayPal payment: ${error.result?.message || error}`80      )81    }82  }83}

The initiatePayment method receives an object with the payment details, such as the amount and currency code.

In the method, you:

  1. Determine the payment intent based on the autoCapture option. By default, payments are authorized for later capture.
  2. Create a PayPal order request with the payment details.
    • You can customize the applicationContext as needed. For example, you can set your store's name and the PayPal landing page type.

You return an object with the PayPal order ID and a data object with additional information, such as the intent and approval URL. Medusa stores the data object in the payment session's data field, allowing you to access it for later processing.

Note: Refer to the Create Payment Module Provider guide for detailed information about this method.

authorizePayment Method

The authorizePayment method authorizes a payment with the third-party service. It's called when the customer places their order to authorize the payment with the selected payment method.

You'll authorize or capture the PayPal order in this method, based on the autoCapture option.

Add the following method to the PayPalPaymentProviderService class:

src/modules/paypal/service.ts
1import type {2  AuthorizePaymentInput,3  AuthorizePaymentOutput,4  PaymentSessionStatus,5} from "@medusajs/framework/types"6
7class PayPalPaymentProviderService extends AbstractPaymentProvider<Options> {8  // ...9  async authorizePayment(10    input: AuthorizePaymentInput11  ): Promise<AuthorizePaymentOutput> {12    try {13      const orderId = input.data?.order_id as string | undefined14
15      if (!orderId || typeof orderId !== "string") {16        throw new MedusaError(17          MedusaError.Types.INVALID_DATA,18          "PayPal order ID is required"19        )20      }21
22      // If autoCapture is enabled, authorize and capture in one step23      if (this.options_.autoCapture) {24        const response = await this.ordersController_.captureOrder({25          id: orderId,26          prefer: "return=representation",27        })28
29        const capture = response.result30
31        if (!capture?.id) {32          throw new MedusaError(33            MedusaError.Types.UNEXPECTED_STATE,34            "Failed to capture PayPal payment"35          )36        }37
38        // Extract capture ID from purchase units39        const captureId =40          capture.purchaseUnits?.[0]?.payments?.captures?.[0]?.id41
42        return {43          data: {44            ...input.data,45            capture_id: captureId,46            intent: "CAPTURE",47          },48          status: "captured" as PaymentSessionStatus,49        }50      }51
52      // Otherwise, just authorize53      const response = await this.ordersController_.authorizeOrder({54        id: orderId,55        prefer: "return=representation",56      })57
58      const authorization = response.result59
60      if (!authorization?.id) {61        throw new MedusaError(62          MedusaError.Types.UNEXPECTED_STATE,63          "Failed to authorize PayPal payment"64        )65      }66
67      // Extract authorization ID from purchase units68      const authId =69        authorization.purchaseUnits?.[0]?.payments?.authorizations?.[0]?.id70
71      return {72        data: {73          order_id: orderId,74          authorization_id: authId,75          intent: "AUTHORIZE",76          currency_code: input.data?.currency_code,77        },78        status: "authorized" as PaymentSessionStatus,79      }80    } catch (error: any) {81      throw new MedusaError(82        MedusaError.Types.UNEXPECTED_STATE,83        `Failed to authorize PayPal payment: ${error.message || error}`84      )85    }86  }87}

The authorizePayment method receives an object with the payment session's data field.

In the method, you:

  1. Extract the PayPal order ID from the data field. This is the same data.order_id you returned in the initiatePayment method.
  2. If the autoCapture option is enabled, you capture the PayPal order.
  3. Otherwise, you authorize the PayPal order.

You return an object with a data field containing additional information, such as the authorization or capture ID. Medusa will store the data object in the newly created payment record for later processing.

Note: Refer to the Create Payment Module Provider guide for detailed information about this method.

capturePayment Method

The capturePayment method captures a previously authorized payment with the third-party service. It's called either when:

  • An admin user captures the payment from the Medusa Admin dashboard.
  • PayPal webhook notifies Medusa that the payment has been captured.

You'll captured the authorized PayPal order in this method.

Add the following method to the PayPalPaymentProviderService class:

src/modules/paypal/service.ts
1import type {2  CapturePaymentInput,3  CapturePaymentOutput,4} from "@medusajs/framework/types"5
6class PayPalPaymentProviderService extends AbstractPaymentProvider<Options> {7  // ...8  async capturePayment(9    input: CapturePaymentInput10  ): Promise<CapturePaymentOutput> {11    try {12      const authorizationId = input.data?.authorization_id as string | undefined13
14      if (!authorizationId || typeof authorizationId !== "string") {15        throw new MedusaError(16          MedusaError.Types.INVALID_DATA,17          "PayPal authorization ID is required for capture"18        )19      }20
21      const response = await this.paymentsController_.captureAuthorizedPayment({22        authorizationId: authorizationId,23        prefer: "return=representation",24      })25
26      const capture = response.result27
28      if (!capture?.id) {29        throw new MedusaError(30          MedusaError.Types.UNEXPECTED_STATE,31          "Failed to capture PayPal payment"32        )33      }34
35      return {36        data: {37          ...input.data,38          capture_id: capture.id,39        },40      }41    } catch (error: any) {42      throw new MedusaError(43        MedusaError.Types.UNEXPECTED_STATE,44        `Failed to capture PayPal payment: ${error.result?.message || error}`45      )46    }47  }48}

The capturePayment method receives an object with the payment record's data field.

In the method, you:

  1. Extract the PayPal authorization ID from the data field. This is the same data.authorization_id you returned in the authorizePayment method.
  2. Capture the authorized PayPal payment.

You return an object with a data field containing additional information, such as the capture ID. Medusa updates the payment record's data field with the returned data object.

Note: Refer to the Create Payment Module Provider guide for detailed information about this method.

refundPayment Method

The refundPayment method refunds a previously captured payment with the third-party service. It's called when an admin user issues a refund from the Medusa Admin dashboard.

You'll refund the captured PayPal payment in this method.

Add the following method to the PayPalPaymentProviderService class:

src/modules/paypal/service.ts
1import type {2  RefundPaymentInput,3  RefundPaymentOutput,4} from "@medusajs/framework/types"5import { BigNumber } from "@medusajs/framework/utils"6
7class PayPalPaymentProviderService extends AbstractPaymentProvider<Options> {8  // ...9  async refundPayment(input: RefundPaymentInput): Promise<RefundPaymentOutput> {10    try {11      const captureId = input.data?.capture_id as string | undefined12
13      if (!captureId || typeof captureId !== "string") {14        throw new MedusaError(15          MedusaError.Types.INVALID_DATA,16          "PayPal capture ID is required for refund"17        )18      }19
20      const refundRequest = {21        amount: {22          currencyCode: (input.data?.currency_code as string | undefined)23            ?.toUpperCase() || "",24          value: new BigNumber(input.amount).numeric.toString(),25        }26      }27
28      const response = await this.paymentsController_.refundCapturedPayment({29        captureId: captureId,30        body: Object.keys(refundRequest).length > 0 ? refundRequest : undefined,31        prefer: "return=representation",32      })33
34      const refund = response.result35
36      if (!refund?.id) {37        throw new MedusaError(38          MedusaError.Types.UNEXPECTED_STATE,39          "Failed to refund PayPal payment"40        )41      }42
43      return {44        data: {45          ...input.data,46          refund_id: refund.id,47        },48      }49    } catch (error: any) {50      throw new MedusaError(51        MedusaError.Types.UNEXPECTED_STATE,52        `Failed to refund PayPal payment: ${error.result?.message || error}`53      )54    }55  }56}

The refundPayment method receives an object with the payment record's data field.

In the method, you:

  1. Extract the PayPal capture ID from the data field. This is the same data.capture_id you returned in the capturePayment method.
  2. Extract the amount and currency code from the input.
  3. Refund the captured PayPal payment.

You return an object with a data field containing additional information, such as the refund ID. Medusa updates the payment record's data field with the returned data object.

Note: Refer to the Create Payment Module Provider guide for detailed information about this method.

updatePayment Method

The updatePayment method updates the payment session in the third-party service. It's called when the payment session needs to be updated, such as when the order amount changes.

You'll update the PayPal order amount in this method.

Add the following method to the PayPalPaymentProviderService class:

src/modules/paypal/service.ts
1import type {2  UpdatePaymentInput,3  UpdatePaymentOutput,4} from "@medusajs/framework/types"5import {6  PatchOp,7} from "@paypal/paypal-server-sdk"8
9class PayPalPaymentProviderService extends AbstractPaymentProvider<Options> {10  // ...11  async updatePayment(12    input: UpdatePaymentInput13  ): Promise<UpdatePaymentOutput> {14    try {15      const orderId = input.data?.order_id as string | undefined16
17      if (!orderId) {18        throw new MedusaError(19          MedusaError.Types.INVALID_DATA,20          "PayPal order ID is required"21        )22      }23      24      await this.ordersController_.patchOrder({25        id: orderId as string,26        body: [27          {28            op: PatchOp.Replace,29            path: "/purchase_units/@reference_id=='default'/amount/value",30            value: new BigNumber(input.amount).numeric.toString(),31          }32        ]33      })34
35      return {36        data: {37          ...input.data,38          currency_code: input.currency_code,39        },40      }41    } catch (error: any) {42      throw new MedusaError(43        MedusaError.Types.UNEXPECTED_STATE,44        `Failed to update PayPal payment: ${error.result?.message || error}`45      )46    }47  }48}

The updatePayment method receives an object with the payment session's data field.

In the method, you:

  1. Extract the PayPal order ID from the data field. This is the same data.order_id you returned in the initiatePayment method.
  2. Update the PayPal order amount.

You return an object with a data field containing the updated payment information. Medusa updates the payment session's data field with the returned data object.

Note: Refer to the Create Payment Module Provider guide for detailed information about this method.

deletePayment Method

The deletePayment method deletes the payment session in the third-party service. It's called when the customer changes the payment method during checkout.

PayPal orders cannot be deleted, so you can leave this method empty. PayPal will automatically cancel orders that are not approved within a certain timeframe.

Add the following method to the PayPalPaymentProviderService class:

src/modules/paypal/service.ts
1import type {2  DeletePaymentInput,3  DeletePaymentOutput,4} from "@medusajs/framework/types"5
6class PayPalPaymentProviderService extends AbstractPaymentProvider<Options> {7  // ...8  async deletePayment(9    input: DeletePaymentInput10  ): Promise<DeletePaymentOutput> {11    // Note: PayPal doesn't have a cancelOrder API endpoint12    // Orders can only be voided if they're authorized, which is handled in cancelPayment13    // For orders that haven't been authorized yet, they will expire automatically14
15    return {16      data: input.data,17    }18  }19}

The deletePayment method receives an object with the payment session's data field.

In the method, you simply return the existing data object without making any changes.

Note: Refer to the Create Payment Module Provider guide for detailed information about this method.

retrievePayment Method

The retrievePayment method retrieves the payment details from the third-party service. You'll retrieve the order details from PayPal in this method.

Add the following method to the PayPalPaymentProviderService class:

src/modules/paypal/service.ts
1import type {2  RetrievePaymentInput,3  RetrievePaymentOutput,4} from "@medusajs/framework/types"5
6class PayPalPaymentProviderService extends AbstractPaymentProvider<Options> {7  // ...8  async retrievePayment(9    input: RetrievePaymentInput10  ): Promise<RetrievePaymentOutput> {11    try {12      const orderId = input.data?.order_id as string | undefined13
14      if (!orderId || typeof orderId !== "string") {15        throw new MedusaError(16          MedusaError.Types.INVALID_DATA,17          "PayPal order ID is required"18        )19      }20
21      const response = await this.ordersController_.getOrder({22        id: orderId,23      })24
25      const order = response.result26
27      if (!order?.id) {28        throw new MedusaError(29          MedusaError.Types.NOT_FOUND,30          "PayPal order not found"31        )32      }33
34      return {35        data: {36          order_id: order.id,37          status: order.status,38          intent: order.intent,39        },40      }41    } catch (error: any) {42      throw new MedusaError(43        MedusaError.Types.UNEXPECTED_STATE,44        `Failed to retrieve PayPal payment: ${error.result?.message || error}`45      )46    }47  }48}

The retrievePayment method receives an object with the payment record's data field.

In the method, you:

  1. Extract the PayPal order ID from the data field. This is the same data.order_id you returned in the initiatePayment method.
  2. Retrieve the PayPal order details.

You return an object with a data field containing the retrieved payment information.

Note: Refer to the Create Payment Module Provider guide for detailed information about this method.

cancelPayment Method

The cancelPayment method cancels a previously authorized payment with the third-party service. It's called when an admin user cancels an order from the Medusa Admin dashboard.

You'll void the authorized PayPal payment in this method.

Add the following method to the PayPalPaymentProviderService class:

src/modules/paypal/service.ts
1import type {2  CancelPaymentInput,3  CancelPaymentOutput,4} from "@medusajs/framework/types"5
6class PayPalPaymentProviderService extends AbstractPaymentProvider<Options> {7  // ...8  async cancelPayment(9    input: CancelPaymentInput10  ): Promise<CancelPaymentOutput> {11    try {12      const authorizationId = input.data?.authorization_id as string | undefined13
14      if (!authorizationId || typeof authorizationId !== "string") {15        throw new MedusaError(16          MedusaError.Types.INVALID_DATA,17          "PayPal authorization ID is required for cancellation"18        )19      }20
21      await this.paymentsController_.voidPayment({22        authorizationId: authorizationId,23      })24
25      return {26        data: input.data,27      }28    } catch (error: any) {29      throw new MedusaError(30        MedusaError.Types.UNEXPECTED_STATE,31        `Failed to cancel PayPal payment: ${error.result?.message || error}`32      )33    }34  }35}

The cancelPayment method receives an object with the payment record's data field.

In the method, you:

  1. Extract the PayPal authorization ID from the data field. This is the same data.authorization_id you returned in the authorizePayment method.
  2. Void the authorized PayPal payment.

You return an object with the existing data field without making any changes. Medusa updates the payment record's data field with the returned data object.

getPaymentStatus Method

The getPaymentStatus method retrieves the current status of the payment from the third-party service. You'll retrieve the order status from PayPal in this method.

Add the following method to the PayPalPaymentProviderService class:

src/modules/paypal/service.ts
1import type {2  GetPaymentStatusInput,3  GetPaymentStatusOutput,4} from "@medusajs/framework/types"5import { OrderStatus } from "@paypal/paypal-server-sdk"6
7class PayPalPaymentProviderService extends AbstractPaymentProvider<Options> {8  // ...9  async getPaymentStatus(10    input: GetPaymentStatusInput11  ): Promise<GetPaymentStatusOutput> {12    try {13      const orderId = input.data?.order_id as string | undefined14
15      if (!orderId || typeof orderId !== "string") {16        return { status: "pending" as PaymentSessionStatus }17      }18
19      const response = await this.ordersController_.getOrder({20        id: orderId,21      })22
23      const order = response.result24
25      if (!order) {26        return { status: "pending" as PaymentSessionStatus }27      }28
29      const status = order.status30
31      switch (status) {32        case OrderStatus.Created:33        case OrderStatus.Saved:34          return { status: "pending" as PaymentSessionStatus }35        case OrderStatus.Approved:36          return { status: "authorized" as PaymentSessionStatus }37        case OrderStatus.Completed:38          return { status: "authorized" as PaymentSessionStatus }39        case OrderStatus.Voided:40          return { status: "canceled" as PaymentSessionStatus }41        default:42          return { status: "pending" as PaymentSessionStatus }43      }44    } catch (error: any) {45      return { status: "pending" as PaymentSessionStatus }46    }47  }48}

The getPaymentStatus method receives an object with the payment record's data field.

In the method, you:

  1. Extract the PayPal order ID from the data field. This is the same data.order_id you returned in the initiatePayment method.
  2. Retrieve the PayPal order details.
  3. Map the PayPal order status to Medusa's PaymentSessionStatus.

You return an object with the mapped payment status.

Note: Refer to the Create Payment Module Provider guide for detailed information about this method.

verifyWebhookSignature Method

The verifyWebhookSignature method is not required by the AbstractPaymentProvider class. You'll create this method to verify PayPal webhook signatures, and use it in the next method that handles webhooks.

Add the following method to the PayPalPaymentProviderService class:

src/modules/paypal/service.ts
1class PayPalPaymentProviderService extends AbstractPaymentProvider<Options> {2  // ...3  private async verifyWebhookSignature(4    headers: Record<string, any>,5    body: any,6    rawBody: string | Buffer | undefined7  ): Promise<boolean> {8    try {9      if (!this.options_.webhook_id) {10        throw new MedusaError(11          MedusaError.Types.INVALID_DATA,12          "PayPal webhook ID is required for webhook signature verification"13        )14      }15  16      const transmissionId =17        headers["paypal-transmission-id"]18      const transmissionTime =19        headers["paypal-transmission-time"]20      const certUrl =21        headers["paypal-cert-url"]22      const authAlgo =23        headers["paypal-auth-algo"]24      const transmissionSig =25        headers["paypal-transmission-sig"]26  27      if (28        !transmissionId ||29        !transmissionTime ||30        !certUrl ||31        !authAlgo ||32        !transmissionSig33      ) {34        throw new MedusaError(35          MedusaError.Types.INVALID_DATA,36          "Missing required PayPal webhook headers"37        )38      }39  40      // PayPal's API endpoint for webhook verification41      const baseUrl =42        this.options_.environment === "production"43          ? "https://api.paypal.com"44          : "https://api.sandbox.paypal.com"45  46      const verifyUrl = `${baseUrl}/v1/notifications/verify-webhook-signature`47  48      // Get access token for verification API call49      const authResponse = await fetch(`${baseUrl}/v1/oauth2/token`, {50        method: "POST",51        headers: {52          "Content-Type": "application/x-www-form-urlencoded",53          Authorization: `Basic ${Buffer.from(54            `${this.options_.client_id}:${this.options_.client_secret}`55          ).toString("base64")}`,56        },57        body: "grant_type=client_credentials",58      })59  60      if (!authResponse.ok) {61        throw new MedusaError(62          MedusaError.Types.UNEXPECTED_STATE,63          "Failed to get access token for webhook verification"64        )65      }66  67      const authData = await authResponse.json()68      const accessToken = authData.access_token69  70      if (!accessToken) {71        throw new MedusaError(72          MedusaError.Types.UNEXPECTED_STATE,73          "Access token not received from PayPal"74        )75      }76  77      let webhookEvent: any78      if (rawBody) {79        const rawBodyString =80          typeof rawBody === "string" ? rawBody : rawBody.toString("utf8")81        try {82          webhookEvent = JSON.parse(rawBodyString)83        } catch (e) {84          this.logger_.warn("Raw body is not valid JSON, using parsed body")85          webhookEvent = body86        }87      } else {88        this.logger_.warn(89          "Raw body not available, using parsed body. Verification may fail if formatting differs."90        )91        webhookEvent = body92      }93  94      const verifyPayload = {95        transmission_id: transmissionId,96        transmission_time: transmissionTime,97        cert_url: certUrl,98        auth_algo: authAlgo,99        transmission_sig: transmissionSig,100        webhook_id: this.options_.webhook_id,101        webhook_event: webhookEvent,102      }103  104      const verifyResponse = await fetch(verifyUrl, {105        method: "POST",106        headers: {107          "Content-Type": "application/json",108          Authorization: `Bearer ${accessToken}`,109        },110        body: JSON.stringify(verifyPayload),111      })112  113      if (!verifyResponse.ok) {114        throw new MedusaError(115          MedusaError.Types.UNEXPECTED_STATE,116          "Webhook verification API call failed"117        )118      }119  120      const verifyData = await verifyResponse.json()121  122      // PayPal returns verification_status: "SUCCESS" if verification passes123      const isValid = verifyData.verification_status === "SUCCESS"124  125      if (!isValid) {126        throw new MedusaError(127          MedusaError.Types.UNEXPECTED_STATE,128          "Webhook signature verification failed"129        )130      }131  132      return isValid133    } catch (e) {134      this.logger_.error("PayPal verifyWebhookSignature error:", e)135      return false136    }137  }138}

The verifyWebhookSignature method receives the following parameters:

  1. headers: The HTTP headers from the webhook request.
  2. body: The parsed JSON body from the webhook request.
  3. rawBody: The raw body from the webhook request as a string or buffer.

In the method, you:

  1. Extract the required PayPal webhook headers.
  2. Get an access token for the PayPal webhook verification API.
  3. Construct the verification payload with the extracted headers and webhook event data.
  4. Call the PayPal webhook verification API to verify the webhook signature.

You return true if the webhook signature is valid (based on the API's response), or false otherwise.

getWebhookActionAndData Method

The getWebhookActionAndData method processes incoming webhook events from the third-party service. Medusa provides a webhook endpoint at /hooks/payment/{provider_id} that you can use to receive PayPal webhooks. This endpoint calls the getWebhookActionAndData method to process the webhook event.

Add the following method to the PayPalPaymentProviderService class:

src/modules/paypal/service.ts
1import { PaymentActions } from "@medusajs/framework/utils"2import type {3  ProviderWebhookPayload,4  WebhookActionResult,5} from "@medusajs/framework/types"6
7class PayPalPaymentProviderService extends AbstractPaymentProvider<Options> {8  // ...9  async getWebhookActionAndData(10    payload: ProviderWebhookPayload["payload"]11  ): Promise<WebhookActionResult> {12    try {13      const { data, rawData, headers } = payload14
15      // Verify webhook signature16      const isValid = await this.verifyWebhookSignature(17        headers || {},18        data,19        rawData || ""20      )21
22      if (!isValid) {23        this.logger_.error("Invalid PayPal webhook signature")24        return {25          action: "failed",26          data: {27            session_id: "",28            amount: new BigNumber(0),29          },30        }31      }32
33      // PayPal webhook events have event_type34      const eventType = (data as any)?.event_type35
36      if (!eventType) {37        this.logger_.warn("PayPal webhook event missing event_type")38        return {39          action: "not_supported",40          data: {41            session_id: "",42            amount: new BigNumber(0),43          },44        }45      }46
47      // Extract order ID and amount from webhook payload48      const resource = (data as any)?.resource49      let sessionId: string | undefined = (data as any)?.resource?.custom_id50
51      if (!sessionId) {52        this.logger_.warn("Session ID not found in PayPal webhook resource")53        return {54          action: "not_supported",55          data: {56            session_id: "",57            amount: new BigNumber(0),58          },59        }60      }61
62      const amountValue =63        resource?.amount?.value ||64        resource?.purchase_units?.[0]?.payments?.captures?.[0]?.amount65          ?.value ||66        resource?.purchase_units?.[0]?.payments?.authorizations?.[0]67          ?.amount?.value ||68        069
70      const amount = new BigNumber(amountValue)71      const payloadData = {72        session_id: sessionId,73        amount,74      }75
76      // Map PayPal webhook events to Medusa actions77      switch (eventType) {78        case "PAYMENT.AUTHORIZATION.CREATED":79          return {80            action: PaymentActions.AUTHORIZED,81            data: payloadData,82          }83
84        case "PAYMENT.CAPTURE.DENIED":85          return {86            action: PaymentActions.FAILED,87            data: payloadData,88          }89
90        case "PAYMENT.AUTHORIZATION.VOIDED":91          return {92            action: PaymentActions.CANCELED,93            data: payloadData,94          }95        96        case "PAYMENT.CAPTURE.COMPLETED":97          return {98            action: PaymentActions.SUCCESSFUL,99            data: payloadData,100          }101
102        default:103          this.logger_.info(`Unhandled PayPal webhook event: ${eventType}`)104          return {105            action: PaymentActions.NOT_SUPPORTED,106            data: payloadData,107          }108      }109    } catch (error: any) {110      this.logger_.error("PayPal getWebhookActionAndData error:", error.result?.message || error)111      return {112        action: "failed",113        data: {114          session_id: "",115          amount: new BigNumber(0),116        },117      }118    }119  }120}

The getWebhookActionAndData method receives a payload object containing the webhook request data.

In the method, you:

  1. Verify the webhook signature using the verifyWebhookSignature method.
  2. Extract the event_type from the webhook payload to determine the type of event.
  3. Extract the session ID and amount from the webhook resource.
  4. Map the PayPal webhook event types to Medusa actions.

You return an object containing the action Medusa should take (such as authorized), along with the payment session ID and amount. Based on the returned action, Medusa uses the methods you implemented earlier to perform the necessary operations.

d. Export Module Definition#

You've now finished implementing the necessary methods for the PayPal Payment Module Provider.

The final piece to a module is its definition, which you export in an index.ts file at the module's root directory. This definition tells Medusa the module's details, including its service.

To create the module's definition, create the file src/modules/paypal/index.ts with the following content:

src/modules/paypal/index.ts
1import PayPalPaymentProviderService from "./service"2import { ModuleProvider, Modules } from "@medusajs/framework/utils"3
4export default ModuleProvider(Modules.PAYMENT, {5  services: [PayPalPaymentProviderService],6})

You use ModuleProvider from the Modules SDK to create the module provider's definition. It accepts two parameters:

  1. The name of the module that this provider belongs to, which is Modules.PAYMENT in this case.
  2. An object with a required property services indicating the Module Provider's services.

e. Add Module Provider to Medusa's Configuration#

Once you finish building the module, add it to Medusa's configurations to start using it.

In medusa-config.ts, add a modules property:

medusa-config.ts
1module.exports = defineConfig({2  // ...3  modules: [4    // ...5    {6      resolve: "@medusajs/medusa/payment",7      options: {8        providers: [9          {10            resolve: "./src/modules/paypal",11            id: "paypal",12            options: {13              client_id: process.env.PAYPAL_CLIENT_ID!,14              client_secret: process.env.PAYPAL_CLIENT_SECRET!,15              environment: process.env.PAYPAL_ENVIRONMENT || "sandbox",16              autoCapture: process.env.PAYPAL_AUTO_CAPTURE === "true",17              webhook_id: process.env.PAYPAL_WEBHOOK_ID,18            },19          },20        ],21      },22    },23  ],24})

To pass Payment Module Providers to the Payment Module, add the modules property to the Medusa configuration and pass the Payment Module in its value.

The Payment Module accepts a providers option, which is an array of Payment Module Providers to register.

To register the PayPal Payment Module Provider, you add an object to the providers array with the following properties:

  • resolve: The NPM package or path to the module provider. In this case, it's the path to the src/modules/paypal directory.
  • id: The ID of the module provider. The Payment Module Provider is then registered with the ID pp_{identifier}_{id}, where:
    • {identifier}: The identifier static property defined in the Module Provider's service, which is paypal in this case.
    • {id}: The ID set in this configuration, which is also paypal in this case.
  • options: The options to pass to the module provider. These are the options you defined in the Options interface of the module provider's service.

f. Set Options as Environment Variables#

Next, you'll set the necessary options as environment variables. You'll retrieve their values from the PayPal Developer Dashboard.

PayPal Client ID and Secret

To get your PayPal Client ID and Secret:

  1. Log in to the PayPal Developer Dashboard.
  2. Make sure you're in the correct environment (Sandbox or Live) using the environment toggle at the top left. It's recommended to use Sandbox for development and testing.

PayPal environment toggle highlighted with Sandbox selected

  1. Go to Apps & Credentials.
  2. If you don't have a default app, create one by clicking Create App.
    • Enter app name, set type to "Merchant", and select the sandbox business account.
  3. Click on your app to view its details.
  4. Copy the Client ID and Secret values.

PayPal app details showing Client ID and Secret

Then, set these values as environment variables in your .env file:

.env
1PAYPAL_CLIENT_ID=your_paypal_client_id2PAYPAL_CLIENT_SECRET=your_paypal_client_secret

PayPal Environment and Auto-Capture Option

Next, you can set the following optional environment variables in your .env file:

.env
1PAYPAL_ENVIRONMENT=sandbox # or "production" for live2PAYPAL_AUTO_CAPTURE=true # or "false" to authorize only

Where:

  • PAYPAL_ENVIRONMENT: The PayPal environment to use, either sandbox for testing or production for live transactions. Default is sandbox.
  • PAYPAL_AUTO_CAPTURE: Whether to capture payments immediately (true) or authorize only (false). Default is false.

PayPal Webhook ID

Finally, you'll set up a webhook in the PayPal Developer Dashboard to receive payment events. Webhooks require a publicly accessible URL. In this section, you'll use ngrok to create a temporary public URL for testing webhooks locally.

Tip: Deploy your Medusa application with Cloud in minutes. Benefit from features like zero-configuration deployments, automatic scaling, GitHub integration, and more.

To set up ngrok and create a public URL, run the following command in your terminal:

Code
npx ngrok http 9000

This will create a public URL that tunnels to your local Medusa server running on port 9000. Copy the generated URL (for example, https://abcd1234.ngrok.io).

Then, on the PayPal Developer Dashboard:

  1. Go to Apps & Credentials.
  2. Click on your app to view its details.
  3. Scroll down to the Webhooks (or Sandbox Webhooks) section and click Add Webhook.
  4. In the Webhook URL field, enter your ngrok URL followed by /hooks/payment/paypal_paypal. For example: https://abcd1234.ngrok.io/hooks/payment/paypal_paypal.
    • The URL format is {base_url}/hooks/payment/{provider_id}, where provider_id is paypal_paypal (the combination of the identifier and id from your configuration).
  5. In the Event Types section, select the following events:
    • Payment authorization created
    • Payment authorization voided
    • Payment capture completed
    • Payment capture denied
  6. Click Save to create the webhook.

Then, copy the Webhook ID from the webhook details. Set it as an environment variable in your .env file:

.env
PAYPAL_WEBHOOK_ID=your_paypal_webhook_id

Make sure the ngrok command remains running while you test PayPal webhooks locally. If you restart ngrok, you'll get a new public URL, and you'll need to update the webhook URL in the PayPal Developer Dashboard accordingly.

In the next steps, you'll customize the Next.js Starter Storefront to support paying with PayPal, then test out the integration.


Step 3: Enable PayPal Module Provider#

In this step, you'll enable the PayPal Payment Module Provider in a region of your Medusa store. A region is a geographical area where you sell products, and each region has its own settings, such as currency and payment providers.

You must enable the PayPal Payment Module Provider in at least one region. To do this:

  1. Run the following command to start your Medusa application:
  1. Open the Medusa Admin dashboard in your browser at http://localhost:9000/app and log in.
  2. Go to Settings -> Regions.
  3. Click on the icon next to the region you want to enable PayPal for, then click Edit.
  4. In the Payment Providers dropdown, select PayPal (PAYPAL) to add it to the region.
  5. Click Save to update the region.

Repeat these steps for every region where you want to enable the PayPal Payment Module Provider.

Medusa Admin dashboard showing region edit screen with PayPal selected as payment provider


Step 4: Add PayPal to Storefront#

In this step, you'll customize the Next.js Starter Storefront that you set up with the Medusa application to support paying with PayPal. You'll add a PayPal button to the checkout page that allows customers to pay using PayPal.

Reminder: 

The Next.js Starter Storefront was installed in a separate directory from your Medusa application. The storefront directory's name follows the pattern {your-project}-storefront.

For example, if your Medusa application's directory is medusa-paypal, you can find the storefront by going to the parent directory and changing to medusa-paypal-storefront:

Terminal
cd ../medusa-paypal-storefront # change based on your project name

a. Install PayPal SDK#

To add the PayPal button, you'll use the PayPal React SDK. This SDK provides React components that make it easy to integrate PayPal into your React application.

In the storefront directory, run the following command to install the PayPal React SDK:

b. Add Client ID Environment Variable#

Next, add the PayPal Client ID as an environment variable in the storefront.

Copy the same PayPal Client ID you used in the Medusa application, then add it to the .env.local file in the storefront directory:

Storefront
.env.local
NEXT_PUBLIC_PAYPAL_CLIENT_ID=your_paypal_client_id

c. Add PayPal Wrapper Component#

Next, you'll add a PayPal wrapper component that initializes the PayPal SDK and provides the PayPal context to its child components.

Create the file src/modules/checkout/components/payment-wrapper/paypal-wrapper.tsx with the following content:

Storefront
src/modules/checkout/components/payment-wrapper/paypal-wrapper.tsx
1"use client"2
3import { PayPalScriptProvider } from "@paypal/react-paypal-js"4import { HttpTypes } from "@medusajs/types"5import { createContext } from "react"6
7type PayPalWrapperProps = {8  paymentSession: HttpTypes.StorePaymentSession9  children: React.ReactNode10}11
12export const PayPalContext = createContext(false)13
14const PayPalWrapper: React.FC<PayPalWrapperProps> = ({15  paymentSession,16  children,17}) => {18  const clientId = process.env.NEXT_PUBLIC_PAYPAL_CLIENT_ID19
20  if (!clientId) {21    throw new Error(22      "PayPal client ID is missing. Set NEXT_PUBLIC_PAYPAL_CLIENT_ID environment variable or ensure payment session has client_id."23    )24  }25
26  const initialOptions = {27    clientId,28    currency: paymentSession.currency_code.toUpperCase() || "USD",29    intent: paymentSession.data?.intent === "CAPTURE" ? "capture" : "authorize",30  }31
32  return (33    <PayPalContext.Provider value={true}>34      <PayPalScriptProvider options={initialOptions}>35        {children}36      </PayPalScriptProvider>37    </PayPalContext.Provider>38  )39}40
41export default PayPalWrapper

You create a PayPalWrapper component that accepts a Medusa payment session and children components as props.

In the component, you:

  1. Retrieve the PayPal Client ID from the environment variable NEXT_PUBLIC_PAYPAL_CLIENT_ID.
  2. Set the initial options for the PayPal SDK, including the client ID, currency, and intent (capture or authorize).
    • You set the intent based on the data.intent field in the payment session. You set this field in the initiatePayment method of the PayPal Payment Module Provider's service. Its value depends on the autoCapture option.
  3. Wrap the children components with the PayPalScriptProvider component from the PayPal React SDK, passing the initial options.

Next, you'll use this wrapper component in the checkout page to provide the PayPal context to the PayPal button component you'll add later.

In src/modules/checkout/components/payment-wrapper/index.tsx, add the following imports at the top of the file:

Storefront
src/modules/checkout/components/payment-wrapper/index.tsx
1import PayPalWrapper from "./paypal-wrapper"2import { isPaypal } from "@lib/constants"

Then, in the PaymentWrapper component, add the following before the last return statement:

Storefront
src/modules/checkout/components/payment-wrapper/index.tsx
1if (isPaypal(paymentSession?.provider_id) && paymentSession) {2  return (3    <PayPalWrapper4      paymentSession={paymentSession}5    >6      {children}7    </PayPalWrapper>8  )9}

If the customer has selected PayPal as the payment method, you wrap the children components with the PayPalWrapper component, passing the payment session as a prop.

d. Add PayPal Button Component#

Next, you'll add a PayPal button component that renders the PayPal button and handles the payment process.

Create the file src/modules/checkout/components/payment-button/paypal-payment-button.tsx with the following content:

Storefront
src/modules/checkout/components/payment-button/paypal-payment-button.tsx
1"use client"2
3import { PayPalButtons, usePayPalScriptReducer } from "@paypal/react-paypal-js"4import { placeOrder } from "@lib/data/cart"5import { HttpTypes } from "@medusajs/types"6import { Button } from "@medusajs/ui"7import React, { useState } from "react"8import ErrorMessage from "../error-message"9
10type PayPalPaymentButtonProps = {11  cart: HttpTypes.StoreCart12  notReady: boolean13  "data-testid"?: string14}15
16const PayPalPaymentButton: React.FC<PayPalPaymentButtonProps> = ({17  cart,18  notReady,19  "data-testid": dataTestId,20}) => {21  const [submitting, setSubmitting] = useState(false)22  const [errorMessage, setErrorMessage] = useState<string | null>(null)23  const [{ isResolved }] = usePayPalScriptReducer()24
25  const paymentSession = cart.payment_collection?.payment_sessions?.find(26    (s) => s.status === "pending"27  )28
29  // TODO: add function handlers30}31
32export default PayPalPaymentButton

You create a PayPalPaymentButton component that accepts the cart, a notReady flag, and an optional data-testid prop for testing.

In the component, you initialize the following variables:

  • submitting: A state variable to track if the payment is being submitted.
  • errorMessage: A state variable to store any error messages.
  • isResolved: A variable from the PayPal SDK that indicates whether the PayPal SDK script has loaded.
  • paymentSession: The pending PayPal payment session from the cart's payment collection.

Next, you'll add the function handlers for creating PayPal orders and placing the Medusa order.

Replace the // TODO: add function handlers comment with the following:

Storefront
src/modules/checkout/components/payment-button/paypal-payment-button.tsx
1const onPaymentCompleted = async () => {2  await placeOrder()3    .catch((err) => {4      setErrorMessage(err.message)5    })6    .finally(() => {7      setSubmitting(false)8    })9}10
11// Get PayPal order ID from payment session data12// The Medusa PayPal provider should create a PayPal order during initialization13// and store the order ID in the payment session data14const getPayPalOrderId = (): string | null => {15  if (!paymentSession?.data) {16    return null17  }18
19  // Try different possible keys where the order ID might be stored20  return (21    (paymentSession.data.order_id as string) ||22    (paymentSession.data.orderId as string) ||23    (paymentSession.data.id as string) ||24    null25  )26}27
28const createOrder = async () => {29  setSubmitting(true)30  setErrorMessage(null)31
32  try {33    if (!paymentSession) {34      throw new Error("Payment session not found")35    }36
37    // Check if Medusa server already created a PayPal order38    const existingOrderId = getPayPalOrderId()39
40    if (existingOrderId) {41      // Medusa already created the order, use that order ID42      return existingOrderId43    }44
45    // If no order ID exists, we need to create one46    // This might happen if the PayPal provider doesn't create orders during initialization47    // In this case, we'll need to create the order via PayPal API48    // For now, throw an error - the backend should handle order creation49    throw new Error(50      "PayPal order not found. Please ensure the payment session is properly initialized."51    )52  } catch (error: any) {53    setErrorMessage(error.message || "Failed to create PayPal order")54    setSubmitting(false)55    throw error56  }57}58
59const onApprove = async (data: { orderID: string }) => {60  try {61    setSubmitting(true)62    setErrorMessage(null)63
64    // After PayPal approval, place the order65    // The Medusa server will handle the payment authorization66    await onPaymentCompleted()67  } catch (error: any) {68    setErrorMessage(error.message || "Failed to process PayPal payment")69    setSubmitting(false)70  }71}72
73const onError = (err: Record<string, unknown>) => {74  setErrorMessage(75    (err.message as string) || "An error occurred with PayPal payment"76  )77  setSubmitting(false)78}79
80const onCancel = () => {81  setSubmitting(false)82  setErrorMessage("PayPal payment was cancelled")83}84
85// TODO: add a return statement

You add the following function handlers:

  • onPaymentCompleted: Places the Medusa order by calling the placeOrder function. This function is called after the PayPal payment is approved.
  • getPayPalOrderId: Retrieves the PayPal order ID from the payment session's data field.
  • createOrder: Returns the PayPal order ID that was created by the Medusa server during payment initialization. If no order ID exists, it throws an error.
  • onApprove: Called when the customer approves the PayPal payment. It calls the onPaymentCompleted function to place the Medusa order.
  • onError: Called when an error occurs during the PayPal payment process. It updates the error message state.
  • onCancel: Called when the customer cancels the PayPal payment. It updates the error message state.

Finally, you'll add the return statement to render the PayPal button.

Replace the // TODO: add a return statement comment with the following:

Storefront
src/modules/checkout/components/payment-button/paypal-payment-button.tsx
1// If PayPal SDK is not ready, show a loading button2if (!isResolved) {3  return (4    <>5      <Button6        disabled={true}7        size="large"8        isLoading={true}9        data-testid={dataTestId}10      >11        Loading PayPal...12      </Button>13      <ErrorMessage14        error={errorMessage}15        data-testid="paypal-payment-error-message"16      />17    </>18  )19}20
21return (22  <>23    <div className="mb-4">24      <PayPalButtons25        createOrder={createOrder}26        onApprove={onApprove}27        onError={onError}28        onCancel={onCancel}29        style={{30          layout: "horizontal",31          color: "black",32          shape: "rect",33          label: "buynow",34        }}35        disabled={notReady || submitting}36      />37    </div>38    <ErrorMessage39      error={errorMessage}40      data-testid="paypal-payment-error-message"41    />42  </>43)

You render two different states:

  • If the PayPal SDK is not ready, you render a loading button.
  • If the SDK is ready, you render the PayPalButtons component from the PayPal React SDK, passing the function handlers as props.

e. Use PayPal Button in Checkout Page#

Next, you'll use the PayPalPaymentButton component in the checkout page to allow customers to pay with PayPal.

In src/modules/checkout/components/payment-button/index.tsx, add the following imports at the top of the file:

Storefront
src/modules/checkout/components/payment-button/index.tsx
1import { isPaypal } from "@lib/constants"2import PayPalPaymentButton from "./paypal-payment-button"

Then, in the PaymentButton component, add to the switch block a case for PayPal:

Storefront
src/modules/checkout/components/payment-button/index.tsx
1const PaymentButton: React.FC<PaymentButtonProps> = ({2  cart,3  "data-testid": dataTestId,4}) => {5  // ...6  switch (true) {7    case isPaypal(paymentSession?.provider_id):8      return (9        <PayPalPaymentButton10          notReady={notReady}11          cart={cart}12          data-testid={dataTestId}13        />14      )15    // ...16  }17}

When the customer selects PayPal as the payment method, you render the PayPalPaymentButton component, passing the cart and notReady flag as props.

f. Handle Selecting PayPal in Checkout Page#

Finally, you'll handle selecting PayPal as the payment method in the checkout page. You'll ensure that when the customer selects PayPal, the payment session is created and initialized correctly.

In src/modules/checkout/components/payment/index.tsx, add the following import at the top of the file:

Storefront
src/modules/checkout/components/payment/index.tsx
import { isPaypal } from "@lib/constants"

Then, replace the setPaymentMethod function defined in the Payment component with the following:

Storefront
src/modules/checkout/components/payment/index.tsx
1const setPaymentMethod = async (method: string) => {2  setError(null)3  setSelectedPaymentMethod(method)4  if (isStripeLike(method) || isPaypal(method)) {5    await initiatePaymentSession(cart, {6      provider_id: method,7    })8  }9}

You change the if condition in the setPaymentMethod function to also initialize the payment session when PayPal is selected.

Finally, change the handleSubmit function defined in the Payment component to the following:

Storefront
src/modules/checkout/components/payment/index.tsx
1const handleSubmit = async () => {2  setIsLoading(true)3  try {4    const shouldInputCard =5      isStripeLike(selectedPaymentMethod) && !activeSession6
7    const checkActiveSession =8      activeSession?.provider_id === selectedPaymentMethod9
10    if (!checkActiveSession) {11      await initiatePaymentSession(cart, {12        provider_id: selectedPaymentMethod,13      })14    }15
16    // For PayPal, we don't need to input card details, so go to review17    if (!shouldInputCard || isPaypal(selectedPaymentMethod)) {18      return router.push(19        pathname + "?" + createQueryString("step", "review"),20        {21          scroll: false,22        }23      )24    }25  } catch (err: any) {26    setError(err.message)27  } finally {28    setIsLoading(false)29  }30}

You mainly change the condition that checks whether to navigate to the review step. If PayPal is selected, you navigate to the review step directly since no card details are needed.

Test the PayPal Integration#

You can now test the PayPal integration by placing an order from the Next.js Starter Storefront.

Get Sandbox PayPal Account Credentials

Before you test the integration, you'll need to get sandbox PayPal account credentials to use for testing payments.

To get sandbox PayPal account credentials:

  1. Go to your PayPal Developer Dashboard.
  2. Make sure you're in the Sandbox environment using the environment toggle at the top left.
  3. Go to Testing Tools -> Sandbox Accounts.
  4. Click on the email ending with @personal.example.com to view the account details.

PayPal Developer Dashboard showing sandbox accounts

  1. On the account details page, copy the Email and Password values. You'll use those to pay with PayPal during testing.

Test Checkout with PayPal

First, run the Medusa application with the following command:

Then, run the Next.js Starter Storefront with the following command in the storefront directory:

Open the storefront at http://localhost:8000 in your browser. Add an item to the cart and proceed to checkout.

On the payment step, select PayPal as the payment method, then click Continue to Review.

Next.js Starter Storefront checkout page showing PayPal selected as payment method

This navigates you to the review step, where a PayPal button appears for completing your order.

Next.js Starter Storefront checkout page showing PayPal button

Click the PayPal button to be redirected to PayPal's payment page. On the PayPal login page, use the sandbox account credentials you obtained earlier to log in and complete the payment.

Once you complete the payment, PayPal redirects you back to the storefront's order confirmation page.

Check Webhook Event Handling

If you've set up webhooks using ngrok or with your deployed Medusa instance, PayPal sends webhook events to your Medusa application after payment completion.

You'll see the following logged in your Medusa application's terminal:

Terminal
info:    Processing payment.webhook_received which has 1 subscribershttp:    POST /hooks/payment/paypal_paypal ← - (200) - 6.028 ms

Medusa uses the getWebhookActionAndData method you implemented earlier to process the webhook event and perform any necessary actions, such as authorizing the payment.

Capturing and Refunding Payments

In the Medusa Admin dashboard, you can go to Orders and view the order you just placed. You can see the payment status and details.

From the order's details page, you can capture the authorized payment, and refund the captured payment from the Payments section. Medusa will use your PayPal Payment Module Provider to perform these actions.

Medusa Admin dashboard showing order payment details with capture and refund options


Next Steps#

You've successfully integrated PayPal with Medusa. You can now receive payments using PayPal in your Medusa store.

Learn More About Medusa#

If you're new to Medusa, check out the main documentation, where you'll get a more in-depth understanding of all the concepts you've used in this guide and more.

To learn more about the commerce features that Medusa provides, check out Medusa's Commerce Modules.

Troubleshooting#

If you encounter issues during development, check out the troubleshooting guides.

Getting Help#

If you encounter issues not covered in the troubleshooting guides:

  1. Visit the Medusa GitHub repository to report issues or ask questions.
  2. Join the Medusa Discord community for real-time support from community members.
Was this page helpful?
Ask Anything
Ask any questions about Medusa. Get help with your development.
You can also use the Medusa MCP server in Cursor, VSCode, etc...
FAQ
What is Medusa?
How can I create a module?
How can I create a data model?
How do I create a workflow?
How can I extend a data model in the Product Module?
Recipes
How do I build a marketplace with Medusa?
How do I build digital products with Medusa?
How do I build subscription-based purchases with Medusa?
What other recipes are available in the Medusa documentation?
Chat is cleared on refresh
Line break