Back to Payload

Payment Adapters

docs/ecommerce/payments.mdx

3.84.122.0 KB
Original Source

A deeper look into the payment adapter pattern used by the Ecommerce Plugin, and how to create your own.

The current list of supported payment adapters are:

REST API

The plugin will create REST API endpoints for each payment adapter you add to your configuration. The endpoints will be available at /api/payments/{provider_name}/{action} where provider_name is the name of the payment adapter and action is one of the following:

ActionMethodDescription
initiatePOSTInitiate a payment for an order. See initiatePayment for more details.
confirm-orderPOSTConfirm an order after a payment has been made. See confirmOrder for more details.

Stripe

Out of the box we integrate with Stripe to handle one-off purchases. To use Stripe, you will need to install the Stripe package:

bash
  pnpm add stripe

We recommend at least 18.5.0 to ensure compatibility with the plugin.

Then, in your plugins array of your Payload Config, call the plugin with:

ts
import { ecommercePlugin } from '@payloadcms/plugin-ecommerce'
import { stripeAdapter } from '@payloadcms/plugin-ecommerce/payments/stripe'
import { buildConfig } from 'payload'

export default buildConfig({
  // Payload config...
  plugins: [
    ecommercePlugin({
      // rest of config...
      payments: {
        paymentMethods: [
          stripeAdapter({
            secretKey: process.env.STRIPE_SECRET_KEY,
            publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
            // Optional - only required if you want to use webhooks
            webhookSecret: process.env.STRIPE_WEBHOOKS_SIGNING_SECRET,
          }),
        ],
      },
    }),
  ],
})

Configuration

The Stripe payment adapter takes the following configuration options:

OptionTypeDescription
secretKeystringYour Stripe Secret Key, found in the Stripe Dashboard.
publishableKeystringYour Stripe Publishable Key, found in the Stripe Dashboard.
webhookSecretstring(Optional) Your Stripe Webhooks Signing Secret, found in the Stripe Dashboard. Required if you want to use webhooks.
appInfoobject(Optional) An object containing name and version properties to identify your application to Stripe.
webhooksobject(Optional) An object where the keys are Stripe event types and the values are functions that will be called when that event is received. See Webhooks for more details.
groupOverridesobject(Optional) An object to override the default fields of the payment group. See Payment Fields for more details.

Stripe Webhooks

You can also add your own webhooks to handle events from Stripe. This is optional and the plugin internally does not use webhooks for any core functionality. It receives the following arguments:

ArgumentTypeDescription
eventStripe.EventThe Stripe event object
reqPayloadRequestThe Payload request object
stripeStripeThe initialized Stripe instance

You can add a webhook like so:

ts
import { ecommercePlugin } from '@payloadcms/plugin-ecommerce'
import { stripeAdapter } from '@payloadcms/plugin-ecommerce/payments/stripe'
import { buildConfig } from 'payload'

export default buildConfig({
  // Payload config...
  plugins: [
    ecommercePlugin({
      // rest of config...
      payments: {
        paymentMethods: [
          stripeAdapter({
            secretKey: process.env.STRIPE_SECRET_KEY,
            publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
            // Required
            webhookSecret: process.env.STRIPE_WEBHOOKS_SIGNING_SECRET,
            webhooks: {
              'payment_intent.succeeded': ({ event, req }) => {
                console.log({ event, data: event.data.object })
                req.payload.logger.info('Payment succeeded')
              },
            },
          }),
        ],
      },
    }),
  ],
})

To use webhooks you also need to have them configured in your Stripe Dashboard or use the Stripe CLI for local development.

Local Development with Stripe CLI

You can use the Stripe CLI to forward webhooks to your local development environment:

  1. Install and authenticate the Stripe CLI:
bash
stripe login
  1. Start listening for webhooks (note the correct endpoint path):
bash
stripe listen --forward-to localhost:3000/api/payments/stripe/webhooks
  1. Copy the webhook signing secret that is displayed when you start listening. It will look like:
Ready! Your webhook signing secret is whsec_abc123xyz...
  1. Add the secret to your .env file - copy the secret exactly as shown (do not add an extra whsec_ prefix):
bash
STRIPE_WEBHOOKS_SIGNING_SECRET=whsec_abc123xyz...
  1. Restart your Next.js dev server to pick up the new environment variable.

  2. Trigger a test webhook to verify your setup:

bash
stripe trigger payment_intent.succeeded
<Banner type="warning"> The webhook signing secret from `stripe listen` is ephemeral and can change each time you restart the CLI. Make sure to update your `.env` file and restart your dev server when you restart `stripe listen`. </Banner>

Frontend usage

The most straightforward way to use Stripe on the frontend is with the EcommerceProvider component and the stripeAdapterClient function. Wrap your application in the provider and pass in the Stripe adapter with your publishable key:

ts
import { EcommerceProvider } from '@payloadcms/plugin-ecommerce/client/react'
import { stripeAdapterClient } from '@payloadcms/plugin-ecommerce/payments/stripe'

<EcommerceProvider
  paymentMethods={[
    stripeAdapterClient({
      publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY || '',
    }),
  ]}
>
  {children}
</EcommerceProvider>

Then you can use the usePayments hook to access the initiatePayment and confirmOrder functions, see the Frontend docs for more details.

Making your own Payment Adapter

You can make your own payment adapter by implementing the PaymentAdapter interface. This interface requires you to implement the following methods:

PropertyTypeDescription
namestringThe name of the payment method. This will be used to identify the payment method in the API and on the frontend.
labelstring(Optional) A human-readable label for the payment method. This will be used in the admin panel and on the frontend.
initiatePayment(args: InitiatePaymentArgs) => Promise<InitiatePaymentResult>The function that is called via the /api/payments/{provider_name}/initiate endpoint to initiate a payment for an order. More
confirmOrder(args: ConfirmOrderArgs) => Promise<void>The function that is called via the /api/payments/{provider_name}/confirm-order endpoint to confirm an order after a payment has been made. More
endpointsEndpoint[](Optional) An array of endpoints to be bootstrapped to Payload's API in order to support the payment method. All API paths are relative to /api/payments/{provider_name}
groupGroupFieldA group field config to be used in transactions to track the necessary data for the payment processor, eg. PaymentIntentID for Stripe. See Payment Fields for more details.

The arguments can be extended but should always include the PaymentAdapterArgs type which has the following types:

PropertyTypeDescription
labelstring(Optional) Allow overriding the default UI label for this adapter.
groupOverridesFieldsOverride(Optional) Allow overriding the default fields of the payment group. See Payment Fields for more details.

initiatePayment

The initiatePayment function is called when a payment is initiated. At this step the transaction is created with a status "Processing", an abandoned purchase will leave this transaction in this state. It receives an object with the following properties:

PropertyTypeDescription
transactionsSlugTransactionThe transaction being processed.
dataobjectThe cart associated with the transaction.
customersSlugstringThe customer associated with the transaction.
reqPayloadRequestThe Payload request object.

The data object will contain the following properties:

PropertyTypeDescription
billingAddressAddressThe billing address associated with the transaction.
shippingAddressAddress(Optional) The shipping address associated with the transaction. If this is missing then use the billing address.
cartCartThe cart collection item.
customerEmailstringIn the case that req.user is missing, customerEmail should be required in order to process guest checkouts.
currencystringThe currency for the cart associated with the transaction.

The return type then only needs to contain the following properties though the type supports any additional data returned as needed for the frontend:

PropertyTypeDescription
messagestringA success message to be returned to the client.

At any point in the function you can throw an error to return a 4xx or 5xx response to the client.

A heavily simplified example of implementing initiatePayment could look like:

ts
import {
  PaymentAdapter,
  PaymentAdapterArgs,
} from '@payloadcms/plugin-ecommerce'
import Stripe from 'stripe'

export const initiatePayment: NonNullable<PaymentAdapter>['initiatePayment'] =
  async ({ data, req, transactionsSlug }) => {
    const payload = req.payload

    // Check for any required data
    const currency = data.currency
    const cart = data.cart

    if (!currency) {
      throw new Error('Currency is required.')
    }

    const stripe = new Stripe(secretKey)

    try {
      let customer = (
        await stripe.customers.list({
          email: customerEmail,
        })
      ).data[0]

      // Ensure stripe has a customer for this email
      if (!customer?.id) {
        customer = await stripe.customers.create({
          email: customerEmail,
        })
      }

      const shippingAddressAsString = JSON.stringify(shippingAddressFromData)

      const paymentIntent = await stripe.paymentIntents.create()

      // Create a transaction for the payment intent in the database
      const transaction = await payload.create({
        collection: transactionsSlug,
        data: {},
      })

      // Return the client_secret so that the client can complete the payment
      const returnData: InitiatePaymentReturnType = {
        clientSecret: paymentIntent.client_secret || '',
        message: 'Payment initiated successfully',
        paymentIntentID: paymentIntent.id,
      }

      return returnData
    } catch (error) {
      payload.logger.error(error, 'Error initiating payment with Stripe')

      throw new Error(
        error instanceof Error
          ? error.message
          : 'Unknown error initiating payment',
      )
    }
  }

confirmOrder

The confirmOrder function is called after a payment is completed on the frontend and at this step the order is created in Payload. It receives the following properties:

PropertyTypeDescription
ordersSlugstringThe orders collection slug.
transactionsSlugstringThe transactions collection slug.
cartsSlugstringThe carts collection slug.
customersSlugstringThe customers collection slug.
dataobjectThe cart associated with the transaction.
reqPayloadRequestThe Payload request object.

The data object will contain any data the frontend chooses to send through and at a minimum the following:

PropertyTypeDescription
customerEmailstringIn the case that req.user is missing, customerEmail should be required in order to process guest checkouts.

The return type can also contain any additional data with a minimum of the following:

PropertyTypeDescription
messagestringA success message to be returned to the client.
orderIDstringThe ID of the created order.
transactionIDstringThe ID of the associated transaction.

A heavily simplified example of implementing confirmOrder could look like:

ts
import {
  PaymentAdapter,
  PaymentAdapterArgs,
} from '@payloadcms/plugin-ecommerce'
import Stripe from 'stripe'

export const confirmOrder: NonNullable<PaymentAdapter>['confirmOrder'] =
  async ({
    data,
    ordersSlug = 'orders',
    req,
    transactionsSlug = 'transactions',
  }) => {
    const payload = req.payload

    const customerEmail = data.customerEmail
    const paymentIntentID = data.paymentIntentID as string

    const stripe = new Stripe(secretKey)

    try {
      // Find our existing transaction by the payment intent ID
      const transactionsResults = await payload.find({
        collection: transactionsSlug,
        where: {
          'stripe.paymentIntentID': {
            equals: paymentIntentID,
          },
        },
      })

      const transaction = transactionsResults.docs[0]

      // Verify the payment intent exists and retrieve it
      const paymentIntent =
        await stripe.paymentIntents.retrieve(paymentIntentID)

      // Create the order in the database
      const order = await payload.create({
        collection: ordersSlug,
        data: {},
      })

      const timestamp = new Date().toISOString()

      // Update the cart to mark it as purchased, this will prevent further updates to the cart
      await payload.update({
        id: cartID,
        collection: 'carts',
        data: {
          purchasedAt: timestamp,
        },
      })

      // Update the transaction with the order ID and mark as succeeded
      await payload.update({
        id: transaction.id,
        collection: transactionsSlug,
        data: {
          order: order.id,
          status: 'succeeded',
        },
      })

      return {
        message: 'Payment initiated successfully',
        orderID: order.id,
        transactionID: transaction.id,
      }
    } catch (error) {
      payload.logger.error(error, 'Error initiating payment with Stripe')
    }
  }

Payment Fields

Payment fields are used primarily on the transactions collection to store information about the payment method used. Each payment adapter must provide a group field which will be used to store this information.

For example, the Stripe adapter provides the following group field:

ts
const groupField: GroupField = {
  name: 'stripe',
  type: 'group',
  admin: {
    condition: (data) => {
      const path = 'paymentMethod'

      return data?.[path] === 'stripe'
    },
  },
  fields: [
    {
      name: 'customerID',
      type: 'text',
      label: 'Stripe Customer ID',
    },
    {
      name: 'paymentIntentID',
      type: 'text',
      label: 'Stripe PaymentIntent ID',
    },
  ],
}

Client side Payment Adapter

The client side adapter should implement the PaymentAdapterClient interface:

PropertyTypeDescription
namestringThe name of the payment method. This will be used to identify the payment method in the API and on the frontend.
labelstring(Optional) A human-readable label for the payment method. This can be used as a human readable format.
initiatePaymentbooleanFlag to toggle on the EcommerceProvider's ability to call the /api/payments/{provider_name}/initiate endpoint. If your payment method does not require this step, set this to false.
confirmOrderbooleanFlag to toggle on the EcommerceProvider's ability to call the /api/payments/{provider_name}/confirm-order endpoint. If your payment method does not require this step, set this to false.

And for the args use the PaymentAdapterClientArgs type:

PropertyTypeDescription
labelstring(Optional) Allow overriding the default UI label for this adapter.

Best Practices

Always handle sensitive operations like creating payment intents and confirming payments on the server side. Use webhooks to listen for events from Stripe and update your orders accordingly. Never expose your secret key on the frontend. By default Nextjs will only expose environment variables prefixed with NEXT_PUBLIC_ to the client.

While we validate the products and prices on the server side when creating a payment intent, you should override the validation function to add any additional checks you may need for your specific use case.

You are safe to pass the ID of a transaction to the frontend however you shouldn't pass any sensitive information or the transaction object itself.

When passing price information to your payment provider it should always come from the server and it should be verified against the products in your database. Never trust price information coming from the client.

When using webhooks, ensure that you verify the webhook signatures to confirm that the requests are genuinely from Stripe. This helps prevent unauthorized access and potential security vulnerabilities.