---
title: "Complete Payload CMS Ecommerce Plugin Guide — Stripe"
slug: "payloadcms-plugin-ecommerce-stripe-cart-orders-guide"
published: "2026-03-27"
updated: "2026-04-06"
validated: "2026-03-26"
categories:
  - "Payload"
tags:
  - "Payload CMS ecommerce plugin"
  - "Stripe adapter"
  - "Payload ecommerce setup"
  - "useCart hook"
  - "initiatePayment confirmOrder"
  - "Products Variants Carts"
  - "stripeAdapterClient"
  - "collectionsOverrides"
  - "shipping and taxes"
  - "guest cart localStorage"
  - "Stripe SDK 18.3.0"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "payload cms"
  - "@payloadcms/plugin-ecommerce"
  - "stripe"
  - "stripe.js"
  - "react"
status: "stable"
llm-purpose: "Payload CMS ecommerce plugin helps you set up Products, Carts, Stripe payments, and Orders quickly — configure stripeAdapter and React hooks. Start now."
llm-prereqs:
  - "Access to Payload CMS"
  - "Access to @payloadcms/plugin-ecommerce"
  - "Access to Stripe"
  - "Access to Stripe.js"
  - "Access to React"
llm-outputs:
  - "Completed outcome: Payload CMS ecommerce plugin helps you set up Products, Carts, Stripe payments, and Orders quickly — configure stripeAdapter and React hooks. Start now."
---

**Summary Triples**
- (@payloadcms/plugin-ecommerce, version, 3.80.0 (Beta))
- (@payloadcms/plugin-ecommerce, provides, Products, Variants, Carts, Transactions, Orders, Addresses data models)
- (Setup, requires, pass access control functions to plugin config)
- (stripeAdapter, used_for, initiating Stripe payments (client + server wiring))
- (React frontend, use, provided React provider and hooks (useCart, initiatePayment, confirmOrder, etc.))
- (Plugin, handles, data model and payment initiation — shipping/taxes/email remain custom)
- (Recommendation, pin_version, pin plugin version (3.80.0) while in beta)
- (Stripe SDK, compatibility, Stripe SDK 18.3.0 recommended)
- (Carts, support, guest cart persisted in localStorage (frontend responsibility))

### {GOAL}
Payload CMS ecommerce plugin helps you set up Products, Carts, Stripe payments, and Orders quickly — configure stripeAdapter and React hooks. Start now.

### {PREREQS}
- Access to Payload CMS
- Access to @payloadcms/plugin-ecommerce
- Access to Stripe
- Access to Stripe.js
- Access to React

### {STEPS}
1. Choose template or add plugin
2. Register plugin and access functions
3. Run database migrations
4. Review collections and relationships
5. Install and configure Stripe adapter
6. Wrap app with EcommerceProvider
7. Build cart UI with useCart hook
8. Implement checkout with Stripe
9. Add shipping, taxes, and emails

<!-- llm:goal="Payload CMS ecommerce plugin helps you set up Products, Carts, Stripe payments, and Orders quickly — configure stripeAdapter and React hooks. Start now." -->
<!-- llm:prereq="Access to Payload CMS" -->
<!-- llm:prereq="Access to @payloadcms/plugin-ecommerce" -->
<!-- llm:prereq="Access to Stripe" -->
<!-- llm:prereq="Access to Stripe.js" -->
<!-- llm:prereq="Access to React" -->
<!-- llm:output="Completed outcome: Payload CMS ecommerce plugin helps you set up Products, Carts, Stripe payments, and Orders quickly — configure stripeAdapter and React hooks. Start now." -->

# Complete Payload CMS Ecommerce Plugin Guide — Stripe
> Payload CMS ecommerce plugin helps you set up Products, Carts, Stripe payments, and Orders quickly — configure stripeAdapter and React hooks. Start now.
Matija Žiberna · 2026-03-27

`@payloadcms/plugin-ecommerce` is Payload's official ecommerce plugin. Install it, pass your access control functions, configure the `stripeAdapter`, and you get Products, Variants, Carts, Transactions, Orders, and Addresses wired together out of the box. The plugin handles the data model and payment initiation — you wire the frontend using the provided React hooks and add your own logic for shipping, taxes, and email on top. It is currently in Beta at version 3.80.0, so pin your version and read the changelog before upgrading.

Every few weeks someone lands in my DMs asking whether Payload has an official ecommerce plugin. For a long time the answer was "not really — you build it yourself." That changed in Q3 2025 when the Payload team shipped `@payloadcms/plugin-ecommerce`. I've been watching it mature, and it's now at a point where I'd reach for it on a standard Stripe-powered store without hesitation.

This guide covers everything from installation through a working checkout flow: the plugin config, the `access` parameter in detail, the Stripe adapter, the React provider and hooks, and an honest look at what remains on your plate. If you need a fully custom data model — non-Stripe gateway, complex multi-warehouse inventory, role-based pricing — the bespoke approach in [How to Build E-commerce with Payload CMS](/blog/how-to-build-ecommerce-with-payload-cms) is where to start instead.

---

## Should You Use the Plugin or Build Custom?

Before installing anything, confirm you're on the right path. The plugin covers the 80% case well.

| Situation | Recommendation |
|---|---|
| Standard store, Stripe payments, no unusual requirements | Use the plugin |
| Non-Stripe gateway, role-based pricing, complex variant rules | Build custom |
| Mostly standard but need extra fields or custom hooks | Use plugin as base, extend with overrides |

If you're building a straightforward shop — products, a cart, Stripe checkout, order history — the plugin gets you there without wiring every collection by hand. The moment you need a payment gateway Stripe doesn't cover, or pricing logic that can't live in a per-currency price field, the custom path gives you more control.

---

## Step 1: Installation and Project Setup

Two paths in depending on whether you're starting fresh or adding to an existing project.

### Path A — Start Fresh with the Official Template

The fastest way to a working ecommerce Payload project is the official template. It ships with Stripe, auth, draft preview, search, and automated E2E tests — everything wired together. It's also in Beta, same as the plugin.

```bash
pnpx create-payload-app my-project -t ecommerce
cd my-project && cp .env.example .env
pnpm install && pnpm dev
```

Open `http://localhost:3000`, create your first admin user, and you'll land in an admin panel with all the ecommerce collections already configured. Even if you're adding the plugin to an existing project, it's worth cloning the template separately just to read through `src/access/` and the frontend cart pages — they show the intended patterns for access control and the checkout flow.

### Path B — Add to an Existing Payload Project

```bash
pnpm add @payloadcms/plugin-ecommerce
```

Then register it in your config:

```ts
// File: src/payload.config.ts
import { buildConfig } from 'payload'
import { ecommercePlugin } from '@payloadcms/plugin-ecommerce'
import {
  adminOnlyFieldAccess,
  adminOrPublishedStatus,
  isAdmin,
  isAuthenticated,
  isCustomer,
  isDocumentOwner,
} from './access'

export default buildConfig({
  collections: [
    // your existing collections
  ],
  plugins: [
    ecommercePlugin({
      access: {
        adminOnlyFieldAccess,
        adminOrPublishedStatus,
        isAdmin,
        isAuthenticated,
        isCustomer,       // optional
        isDocumentOwner,
      },
      customers: { slug: 'users' },
    }),
  ],
})
```

The `access` parameter is the first thing that trips people up. The plugin does not ship default access functions — it requires you to provide your own. The required ones are `adminOnlyFieldAccess`, `adminOrPublishedStatus`, `isAdmin`, `isAuthenticated`, and `isDocumentOwner`. `isCustomer` is optional and used internally for address creation. Here's a minimal starting point for each:

```ts
// File: src/access/isAdmin.ts
import type { Access } from 'payload'

export const isAdmin: Access = ({ req }) => {
  return req.user?.roles?.includes('admin') ?? false
}
```

```ts
// File: src/access/isAuthenticated.ts
import type { Access } from 'payload'

export const isAuthenticated: Access = ({ req }) => {
  return Boolean(req.user)
}
```

```ts
// File: src/access/isDocumentOwner.ts
import type { Access } from 'payload'

export const isDocumentOwner: Access = ({ req }) => {
  if (!req.user) return false
  if (req.user.roles?.includes('admin')) return true
  return {
    customer: { equals: req.user.id },
  }
}
```

```ts
// File: src/access/adminOnlyFieldAccess.ts
import type { FieldAccess } from 'payload'

export const adminOnlyFieldAccess: FieldAccess = ({ req }) => {
  return req.user?.roles?.includes('admin') ?? false
}
```

```ts
// File: src/access/adminOrPublishedStatus.ts
import type { Access } from 'payload'

export const adminOrPublishedStatus: Access = ({ req }) => {
  if (req.user?.roles?.includes('admin')) return true
  return { _status: { equals: 'published' } }
}
```

After registering the plugin, run your migrations to create the new tables. The [Payload CMS migration workflow for PostgreSQL](/blog/payloadcms-postgres-push-to-migrations) covers this in detail if you're not already on a migration-first setup.

```bash
pnpm payload migrate:create --name ecommerce_plugin
pnpm payload migrate
```

---

## Step 2: Understanding the Collections the Plugin Creates

The plugin adds six collections. The relationships matter more than the field list, so here's the entity flow:

```
Customer → Cart → Transaction → Order
Cart → CartItem → Product / Variant
Customer → Address (reusable across Orders)
```

**Products** are your catalog. Each product has a price field per configured currency — the plugin creates separate price fields, one per currency, not a single multi-value field. Products also hold a join to their Variants and a set of allowed Variant Types. If you're building anything beyond a simple flat product list, the [Shopify-style variant architecture for Payload CMS](/blog/shopify-variant-system-payload-cms) is worth reading before you design your product structure.

**Variants** are optional and attach to Products via the join field. Each variant carries its own price and can be assigned Variant Options from the parent product's Variant Types. A T-shirt with Size (S, M, L) and Color (Black, White) would have one Variant Type per attribute, each with the relevant options, and separate Variant documents for each combination.

**Carts** are created automatically the first time a customer adds a product. The plugin supports both authenticated users (cart linked to a `customer` field) and guests (cart stored by ID in localStorage, `customer` is null). The plugin registers these custom cart endpoints automatically:

- `POST /api/carts/:cartID/add-item`
- `POST /api/carts/:cartID/update-item`
- `POST /api/carts/:cartID/remove-item`
- `POST /api/carts/:cartID/clear`

You generally won't call these directly — the `useCart` hook manages them — but they're there if you need server-side cart manipulation.

**Transactions** are created when `initiatePayment` runs. They start at status `"Processing"` and stay there if the customer abandons checkout. When payment completes and `confirmOrder` succeeds, the Transaction moves to `"succeeded"`. Only admins can access Transactions — they're an internal payment audit trail, not something customers query directly.

**Orders** are created only after a Transaction reaches `"succeeded"`. An Order is the permanent record of what was purchased, by whom, and at what price at the time of purchase. Customers and admins can both read Orders. Guest users get secure access via an `accessToken` generated at checkout and sent in their confirmation email.

**Addresses** belong to Customers and are reusable across Orders. On checkout, the customer picks a saved address or creates a new one, which the plugin stores for next time.

---

## Step 3: Wiring Up Stripe

The plugin uses an adapter pattern. `stripeAdapter` handles the server-side payment logic — creating PaymentIntents, registering webhook handlers, managing Transactions — and `stripeAdapterClient` handles the client-side Stripe.js integration. You configure both in their respective places and the plugin registers the REST endpoints automatically.

### Install the Stripe SDK

```bash
pnpm add stripe
```

The plugin is developed and tested against Stripe SDK 18.3.0. Use at least that version.

### Environment Variables

```bash
# File: .env
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOKS_SIGNING_SECRET=whsec_...
```

### Configure the Stripe Adapter

```ts
// File: src/payload.config.ts
import { buildConfig } from 'payload'
import { ecommercePlugin } from '@payloadcms/plugin-ecommerce'
import { stripeAdapter } from '@payloadcms/plugin-ecommerce/payments/stripe'

export default buildConfig({
  plugins: [
    ecommercePlugin({
      access: { /* your access functions */ },
      customers: { slug: 'users' },
      payments: {
        paymentMethods: [
          stripeAdapter({
            secretKey: process.env.STRIPE_SECRET_KEY!,
            publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
            webhookSecret: process.env.STRIPE_WEBHOOKS_SIGNING_SECRET!, // optional but recommended
          }),
        ],
      },
    }),
  ],
})
```

The import path is `@payloadcms/plugin-ecommerce/payments/stripe` for the server-side adapter. The `webhookSecret` is optional for the core payment flow — `initiatePayment` and `confirmOrder` work without it — but you'll want it as soon as you need custom event handling.

### Handling Stripe Events

If you need to react to specific Stripe events, register handlers inside `stripeAdapter` rather than building a separate webhook route. The plugin registers them within Payload's REST API automatically:

```ts
// File: src/payload.config.ts
stripeAdapter({
  secretKey: process.env.STRIPE_SECRET_KEY!,
  publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
  webhookSecret: process.env.STRIPE_WEBHOOKS_SIGNING_SECRET!,
  webhooks: {
    'payment_intent.payment_failed': async ({ event, req }) => {
      const paymentIntent = event.data.object
      await req.payload.update({
        collection: 'transactions',
        where: { stripePaymentIntentID: { equals: paymentIntent.id } },
        data: { status: 'failed' },
        req,
      })
    },
  },
})
```

The `req` object inside webhook handlers is the full Payload request, so you have access to `req.payload` for database operations.

> [!WARNING]
> **Active Beta bug — `confirmOrder` does not verify PaymentIntent status before creating an Order.** If a payment fails or is cancelled (for example via a Stripe-hosted PayPal payment the user declines), `confirmOrder` still creates an Order and marks the cart as purchased. PR #15902 is in progress. Until it lands, register a `payment_intent.payment_failed` webhook handler like the one above, and check Transaction status on your order confirmation page before showing the success UI to the customer.

> [!WARNING]
> **Active Beta bug — `initiatePayment` hardcodes `cart.subtotal` as the PaymentIntent amount.** Shipping costs and discounts are not factored in. PR #15954 adds a `resolveAmount` override. Until it lands, compute the full order total yourself before calling `initiatePayment` and handle any delta (shipping, discount) upstream in your checkout logic.

---

## Step 4: The Frontend — Provider, Cart, and Checkout

The plugin ships a React provider and hooks. You wrap your app with `EcommerceProvider`, then use `useCart` for cart operations and `usePayments` to initiate and finalize checkout.

### Wrap Your App with `EcommerceProvider`

```tsx
// File: src/app/layout.tsx
import { EcommerceProvider } from '@payloadcms/plugin-ecommerce/client/react'
import { stripeAdapterClient } from '@payloadcms/plugin-ecommerce/payments/stripe'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <EcommerceProvider
          api="/api"
          cartsSlug="carts"
          customersSlug="users"
          addressesSlug="addresses"
          paymentMethods={[
            stripeAdapterClient({
              publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
            }),
          ]}
          currenciesConfig={{
            defaultCurrency: 'USD',
            supportedCurrencies: [
              { code: 'USD', decimals: 2, label: 'US Dollar', symbol: '$' },
            ],
          }}
        >
          {children}
        </EcommerceProvider>
      </body>
    </html>
  )
}
```

The client-side adapter comes from `@payloadcms/plugin-ecommerce/payments/stripe` as well — it's a separate named export, `stripeAdapterClient`, not the same as the server-side `stripeAdapter`.

### Adding Products to the Cart

```tsx
// File: src/components/AddToCartButton.tsx
'use client'

import { useCart } from '@payloadcms/plugin-ecommerce/client/react'

type Props = {
  productId: string
  variantId?: string
}

export function AddToCartButton({ productId, variantId }: Props) {
  const { addItem, isLoading } = useCart()

  return (
    <button
      disabled={isLoading}
      onClick={() =>
        addItem({
          product: productId,
          ...(variantId && { variant: variantId }),
        })
      }
    >
      {isLoading ? 'Adding...' : 'Add to Cart'}
    </button>
  )
}
```

`useCart` also exposes `removeItem`, `incrementItem`, `decrementItem`, `clearCart`, and `refreshCart`. One behaviour to know upfront: adding the same product and variant combination increments quantity rather than creating a separate line item. If you need multiple distinct line items for the same SKU, you'll need to manage cart state manually using `refreshCart` after direct API calls — this was a confirmed pain point in the GitHub issues.

### Displaying Cart Items

```tsx
// File: src/components/Cart.tsx
'use client'

import { useCart } from '@payloadcms/plugin-ecommerce/client/react'

export function Cart() {
  const { cart, removeItem, isLoading } = useCart()

  if (!cart?.items?.length) return <p>Your cart is empty.</p>

  return (
    <ul>
      {cart.items.map((item: any) => (
        <li key={item.id}>
          <span>{item.product?.title ?? 'Product'}</span>
          <span> × {item.quantity}</span>
          <button onClick={() => removeItem({ itemID: item.id })}>
            Remove
          </button>
        </li>
      ))}
    </ul>
  )
}
```

### Guest Cart Behaviour

For unauthenticated users, the plugin stores the cart ID in localStorage and sends it on each cart request. There is no built-in "claim cart" endpoint. When the user logs in, you associate the guest cart with their account by updating `cart.customer` via the standard Payload REST API, then clearing the localStorage reference:

```ts
// File: src/lib/claimCart.ts
export async function claimCartAfterLogin(cartId: string, userId: string) {
  await fetch(`/api/carts/${cartId}`, {
    method: 'PATCH',
    credentials: 'include',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ customer: userId }),
  })

  localStorage.removeItem('cartId')
}
```

Call this in your post-login callback before redirecting the user.

### Checkout — `initiatePayment` and `confirmOrder`

```tsx
// File: src/components/CheckoutFlow.tsx
'use client'

import { usePayments, useCart } from '@payloadcms/plugin-ecommerce/client/react'
import { useStripe, useElements, PaymentElement } from '@stripe/react-stripe-js'

export function CheckoutFlow() {
  const { cart } = useCart()
  const { initiatePayment, confirmOrder, isLoading } = usePayments()
  const stripe = useStripe()
  const elements = useElements()

  async function handleCheckout() {
    if (!cart?.id || !stripe || !elements) return

    // Step 1 — plugin calls /api/payments/stripe/initiate-payment,
    // creates a Transaction (status: "Processing"), returns clientSecret
    const { clientSecret } = await initiatePayment({
      cartID: cart.id,
      email: 'customer@example.com', // collect from your checkout form
    })

    // Step 2 — confirm payment on the client with Stripe.js
    const { error, paymentIntent } = await stripe.confirmPayment({
      elements,
      clientSecret,
      confirmParams: {
        return_url: `${window.location.origin}/order-confirmation`,
      },
      redirect: 'if_required',
    })

    if (error) {
      console.error(error.message)
      return
    }

    // Step 3 — plugin calls /api/payments/stripe/confirm-order,
    // retrieves the PaymentIntent from Stripe, creates the Order
    await confirmOrder({ paymentIntentID: paymentIntent?.id })
  }

  return (
    <div>
      <PaymentElement />
      <button disabled={isLoading} onClick={handleCheckout}>
        {isLoading ? 'Processing...' : 'Pay Now'}
      </button>
    </div>
  )
}
```

Both `/api/payments/stripe/initiate-payment` and `/api/payments/stripe/confirm-order` are registered automatically by the plugin — you don't build these endpoints yourself. `initiatePayment` creates the Transaction in Payload and returns the Stripe `clientSecret`. `confirmOrder` retrieves the PaymentIntent from Stripe and creates the Order document. Your job is calling them at the right point in the UI flow.

---

## Step 5: The Gaps — What You Still Need to Build

The plugin handles the data model and the payment adapter. These areas are out of scope and need manual implementation:

**Shipping.** There is no shipping concept in the plugin. The practical approach is to add a `shippingMethod` relationship field to the Orders collection via `collectionsOverrides`, calculate shipping cost in a hook on the cart or order, and factor it into the PaymentIntent amount yourself before calling `initiatePayment`. Until the `resolveAmount` override lands (PR #15954), the total is `cart.subtotal` only. The [safe hook patterns guide for Payload and PostgreSQL](/blog/payload-cms-hooks-safe-data-manipulation-postgresql) covers transactional consistency if you're writing to multiple collections in one operation.

**Taxes.** Not included. The standard approach is to calculate tax based on the shipping address before `initiatePayment` runs and include it in the total. Stripe Tax can automate the calculation if you're already in the Stripe ecosystem.

**Subscriptions.** Stripe Billing is not wired to the plugin. Subscriptions need a separate `subscriptions` collection, their own webhook handlers for `customer.subscription.created` and `invoice.payment_succeeded`, and linkage back to your Users collection. Treat this as a parallel implementation on top of the plugin's infrastructure rather than an extension of it.

**Order confirmation emails.** The plugin creates the Order document but sends no emails. Add an `afterChange` hook to the Orders collection that fires when `status` transitions to `"succeeded"` and dispatches a transactional email from there. The [Payload CMS email notifications guide](/blog/payload-cms-email-notifications-server-actions) covers the full setup including the native email plugin and the common `afterChange` hook pitfalls to avoid.

---

## FAQ

**Does `@payloadcms/plugin-ecommerce` work with MongoDB or only PostgreSQL?**
It works with any database adapter Payload supports. The plugin uses Payload's standard collection API — no raw SQL. The migration commands in Step 1 are specific to `@payloadcms/db-postgres`. For MongoDB you skip the migration step entirely.

**Can I override the collections the plugin creates?**
Yes. The plugin accepts a `collectionsOverrides` option where you can add fields, change access control, or attach hooks to any generated collection. The plugin merges your overrides with its defaults, so you're extending rather than replacing the entire collection definition.

**The plugin is at version 3.80.0 and Payload core is also 3.x — are they the same version?**
Yes. The plugin lives in the Payload monorepo and versions in lockstep with the core package. If you're on `payload@3.80.0`, use `@payloadcms/plugin-ecommerce@3.80.0`. Mismatched versions are a common source of type errors.

**How do I add multiple currency support?**
Configure `currencies.supportedCurrencies` in `ecommercePlugin()` and ensure the same currencies are enabled in your Stripe account. The plugin creates a separate price field per currency on Products and Variants. There is an active Beta bug where `useCurrency()` may only return USD regardless of config (GitHub issue #14541) — verify it's resolved in your installed version before building multi-currency UI.

**What Stripe SDK version should I install?**
The plugin targets Stripe SDK 18.3.0. Use at least that version: `pnpm add stripe@^18.3.0`.

---

## Conclusion

`@payloadcms/plugin-ecommerce` gives you a complete ecommerce data model — Products, Variants, Carts, Transactions, Orders, Addresses — plus a Stripe adapter, auto-registered payment endpoints, and a React provider with `useCart` and `usePayments` hooks. The setup covered here goes from zero to a working checkout flow: plugin config with the correct access functions, Stripe adapter with the right import paths and config shape, `EcommerceProvider` wrapping your app, and `initiatePayment`/`confirmOrder` handling the full payment lifecycle.

The active Beta bugs around `confirmOrder` status verification and the hardcoded subtotal amount are real — both have PRs in progress and should land soon. Pin `@payloadcms/plugin-ecommerce` to an exact version in `package.json` and review the changelog before every upgrade.

When your requirements outgrow what the plugin covers — custom payment gateway, complex pricing rules, inventory management — the next step is building the data model yourself. The full walkthrough is in [How to Build E-commerce with Payload CMS](/blog/how-to-build-ecommerce-with-payload-cms).

Let me know in the comments if you have questions, and subscribe for more practical development guides.

Thanks, Matija

## LLM Response Snippet
```json
{
  "goal": "Payload CMS ecommerce plugin helps you set up Products, Carts, Stripe payments, and Orders quickly — configure stripeAdapter and React hooks. Start now.",
  "responses": [
    {
      "question": "What does the article \"Complete Payload CMS Ecommerce Plugin Guide — Stripe\" cover?",
      "answer": "Payload CMS ecommerce plugin helps you set up Products, Carts, Stripe payments, and Orders quickly — configure stripeAdapter and React hooks. Start now."
    }
  ]
}
```