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.

Step 1: Install a Medusa Application#
Start by installing the Medusa application on your machine with the following command:
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.
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.
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.
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:
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, eithersandboxorproduction.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.
validateOptions Method
The validateOptions method validates that the module has received the required options.
Add the following method to the PayPalPaymentProviderService class:
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:
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:
- Determine the payment intent based on the
autoCaptureoption. By default, payments are authorized for later capture. - Create a PayPal order request with the payment details.
- You can customize the
applicationContextas needed. For example, you can set your store's name and the PayPal landing page type.
- You can customize the
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.
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:
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:
- Extract the PayPal order ID from the
datafield. This is the samedata.order_idyou returned in theinitiatePaymentmethod. - If the
autoCaptureoption is enabled, you capture the PayPal order. - 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.
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:
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:
- Extract the PayPal authorization ID from the
datafield. This is the samedata.authorization_idyou returned in theauthorizePaymentmethod. - 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.
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:
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:
- Extract the PayPal capture ID from the
datafield. This is the samedata.capture_idyou returned in thecapturePaymentmethod. - Extract the amount and currency code from the input.
- 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.
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:
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:
- Extract the PayPal order ID from the
datafield. This is the samedata.order_idyou returned in theinitiatePaymentmethod. - 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.
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:
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.
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:
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:
- Extract the PayPal order ID from the
datafield. This is the samedata.order_idyou returned in theinitiatePaymentmethod. - Retrieve the PayPal order details.
You return an object with a data field containing the retrieved payment information.
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:
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:
- Extract the PayPal authorization ID from the
datafield. This is the samedata.authorization_idyou returned in theauthorizePaymentmethod. - 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:
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:
- Extract the PayPal order ID from the
datafield. This is the samedata.order_idyou returned in theinitiatePaymentmethod. - Retrieve the PayPal order details.
- Map the PayPal order status to Medusa's
PaymentSessionStatus.
You return an object with the mapped payment status.
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:
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:
headers: The HTTP headers from the webhook request.body: The parsed JSON body from the webhook request.rawBody: The raw body from the webhook request as a string or buffer.
In the method, you:
- Extract the required PayPal webhook headers.
- Get an access token for the PayPal webhook verification API.
- Construct the verification payload with the extracted headers and webhook event data.
- 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:
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:
- Verify the webhook signature using the
verifyWebhookSignaturemethod. - Extract the
event_typefrom the webhook payload to determine the type of event. - Extract the session ID and amount from the webhook resource.
- 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:
You use ModuleProvider from the Modules SDK to create the module provider's definition. It accepts two parameters:
- The name of the module that this provider belongs to, which is
Modules.PAYMENTin this case. - An object with a required property
servicesindicating 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:
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 thesrc/modules/paypaldirectory.id: The ID of the module provider. The Payment Module Provider is then registered with the IDpp_{identifier}_{id}, where:{identifier}: The identifier static property defined in the Module Provider's service, which ispaypalin this case.{id}: The ID set in this configuration, which is alsopaypalin this case.
options: The options to pass to the module provider. These are the options you defined in theOptionsinterface 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:
- Log in to the PayPal Developer Dashboard.
- 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.

- Go to Apps & Credentials.
- 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.
- Click on your app to view its details.
- Copy the Client ID and Secret values.

Then, set these values as environment variables in your .env file:
PayPal Environment and Auto-Capture Option
Next, you can set the following optional environment variables in your .env file:
Where:
PAYPAL_ENVIRONMENT: The PayPal environment to use, eithersandboxfor testing orproductionfor live transactions. Default issandbox.PAYPAL_AUTO_CAPTURE: Whether to capture payments immediately (true) or authorize only (false). Default isfalse.
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.
To set up ngrok and create a public URL, run the following command in your terminal:
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:
- Go to Apps & Credentials.
- Click on your app to view its details.
- Scroll down to the Webhooks (or Sandbox Webhooks) section and click Add Webhook.
- 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}, whereprovider_idispaypal_paypal(the combination of theidentifierandidfrom your configuration).
- The URL format is
- In the Event Types section, select the following events:
- Payment authorization created
- Payment authorization voided
- Payment capture completed
- Payment capture denied
- 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:
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:
- Run the following command to start your Medusa application:
- Open the Medusa Admin dashboard in your browser at
http://localhost:9000/appand log in. - Go to Settings -> Regions.
- Click on the icon next to the region you want to enable PayPal for, then click Edit.
- In the Payment Providers dropdown, select PayPal (PAYPAL) to add it to the region.
- Click Save to update the region.
Repeat these steps for every region where you want to enable the PayPal Payment Module 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.
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:
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:
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:
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:
- Retrieve the PayPal Client ID from the environment variable
NEXT_PUBLIC_PAYPAL_CLIENT_ID. - 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.intentfield in the payment session. You set this field in theinitiatePaymentmethod of the PayPal Payment Module Provider's service. Its value depends on theautoCaptureoption.
- You set the intent based on the
- Wrap the children components with the
PayPalScriptProvidercomponent 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:
Then, in the PaymentWrapper component, add the following before the last return statement:
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:
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:
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 theplaceOrderfunction. This function is called after the PayPal payment is approved.getPayPalOrderId: Retrieves the PayPal order ID from the payment session'sdatafield.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 theonPaymentCompletedfunction 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:
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
PayPalButtonscomponent 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:
Then, in the PaymentButton component, add to the switch block a case for PayPal:
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:
Then, replace the setPaymentMethod function defined in the Payment component with the following:
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:
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:
- Go to your PayPal Developer Dashboard.
- Make sure you're in the Sandbox environment using the environment toggle at the top left.
- Go to Testing Tools -> Sandbox Accounts.
- Click on the email ending with
@personal.example.comto view the account details.

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

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

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

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:
- Visit the Medusa GitHub repository to report issues or ask questions.
- Join the Medusa Discord community for real-time support from community members.