---
tags: [payloadcms, tech-patterns]
topic: payloadcms
sources: [ecommerce__overview.md, ecommerce__plugin.md, ecommerce__payments.md, ecommerce__frontend.md, ecommerce__advanced.md]
created: 2026-05-15
updated: 2026-05-15
---

# PayloadCMS — Ecommerce

## Overview

`@payloadcms/plugin-ecommerce` adds full-featured store functionality on top of Payload. Currently **Beta** — expect breaking changes.

**Collections created automatically:**
- `products` + `variants`, `variantTypes`, `variantOptions`
- `carts` (guest and authenticated)
- `orders`, `transactions`
- `addresses`

**Not covered natively:** shipping, taxes, subscriptions — implement yourself via hooks.

## Setup

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

Minimal config — you must provide all access-control functions yourself:

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

export default buildConfig({
  plugins: [
    ecommercePlugin({
      access: {
        adminOnlyFieldAccess: ({ req: { user } }) => Boolean(user?.roles?.includes('admin')),
        adminOrPublishedStatus: ({ req: { user } }) =>
          user?.roles?.includes('admin') ? true : { _status: { equals: 'published' } },
        isAdmin: ({ req: { user } }) => Boolean(user?.roles?.includes('admin')),
        isAuthenticated: ({ req: { user } }) => Boolean(user),
        isCustomer: ({ req: { user } }) => Boolean(user && !user?.roles?.includes('admin')),
        isDocumentOwner: ({ req: { user } }) =>
          user?.roles?.includes('admin') ? true : user?.id ? { customer: { equals: user.id } } : false,
      },
      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!,
          }),
        ],
      },
    }),
  ],
})
```

## Key Patterns

### Stripe Payment Flow

1. **Frontend calls `initiatePayment`** → server creates a Stripe PaymentIntent + Transaction (status: "Processing"), returns `client_secret`
2. **Frontend confirms payment** via Stripe.js using `client_secret`
3. **Frontend calls `confirmOrder`** → server verifies intent, creates Order, marks Transaction as "succeeded", sets `cart.purchasedAt`

REST endpoints added automatically:
- `POST /api/payments/stripe/initiate`
- `POST /api/payments/stripe/confirm-order`
- `POST /api/payments/stripe/webhooks`

### Stripe Webhooks (local dev)

```bash
stripe login
stripe listen --forward-to localhost:3000/api/payments/stripe/webhooks
# Copy the printed whsec_... secret → STRIPE_WEBHOOKS_SIGNING_SECRET in .env
stripe trigger payment_intent.succeeded  # test
```

The CLI webhook secret is **ephemeral** — changes on every `stripe listen` restart. Update `.env` and restart dev server.

### Cart API Endpoints

| Endpoint | Description |
|----------|-------------|
| `POST /api/carts/:id/add-item` | Add product/variant, auto-increments if match |
| `POST /api/carts/:id/update-item` | Set qty or `{ $inc: n }` operator |
| `POST /api/carts/:id/remove-item` | Remove by `itemID` |
| `POST /api/carts/:id/clear` | Empty cart |

Server-side equivalents: `addItem`, `updateItem`, `removeItem`, `clearCart` from `@payloadcms/plugin-ecommerce`.

### Multi-currency

Prices stored as **integers** (avoids float issues). USD `1000` = $10.00.

```ts
currencies: {
  supportedCurrencies: [USD, { code: 'JPY', decimals: 0, label: 'Japanese Yen', symbol: '¥' }],
  defaultCurrency: 'USD',
}
```

Adding a currency triggers a schema migration (new price fields on products/variants).

## Config / Code Examples

### Frontend Provider

```tsx
import { EcommerceProvider } from '@payloadcms/plugin-ecommerce/client/react'
import { stripeAdapterClient } from '@payloadcms/plugin-ecommerce/payments/stripe'
import { USD, EUR } from '@payloadcms/plugin-ecommerce'

<EcommerceProvider
  currenciesConfig={{ supportedCurrencies: [USD, EUR], defaultCurrency: 'USD' }}
  paymentMethods={[stripeAdapterClient({ publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY! })]}
>
  {children}
</EcommerceProvider>
```

**Available React hooks:**
- `useCart` — add/remove/update items, `isLoading` state
- `usePayments` — `initiatePayment`, `confirmOrder`
- `useAddresses` — create/update addresses
- `useCurrency` — `formatPrice(amount)`, `setCurrency`
- `useEcommerce` — all of the above unified + `onLogin`, `onLogout`, `clearSession`, `mergeCart`, `refreshCart`

### Session management

```ts
// After login — merges guest cart into user cart automatically
await onLogin()

// After logout — clears cart, addresses, localStorage
onLogout() // or clearSession()
```

### Custom CartItemMatcher (e.g. fulfillment options)

```ts
const fulfillmentMatcher: CartItemMatcher = ({ existingItem, newItem }) => {
  const productMatches = existingItem.product === newItem.product
  const variantMatches = newItem.variant ? existingItem.variant === newItem.variant : !existingItem.variant
  return productMatches && variantMatches && existingItem.fulfillment === newItem.fulfillment
}
```

### Piecemeal usage (collections only)

```ts
import { createProductsCollection, createCartsCollection } from 'payload-plugin-ecommerce'

// Add individual collections without the full plugin
```

Available factory functions: `createAddressesCollection`, `createCartsCollection`, `createOrdersCollection`, `createTransactionsCollection`, `createProductsCollection`, `createVariantsCollection`, `createVariantTypesCollection`, `createVariantOptionsCollection`.

### Custom ProductsValidation

Override validation called before transaction creation and payment confirmation:

```ts
products: {
  validation: ({ product, variant, quantity, currency }) => {
    if (!currency) throw new Error('Currency required')
    const priceField = `priceIn${currency.toUpperCase()}`
    if (!(variant ?? product)?.[priceField]) throw new Error('No price in currency')
    // add custom checks: max qty, region lock, etc.
  }
}
```

## Translations

The plugin ships admin UI translations under the `plugin-ecommerce` namespace. Import and register them explicitly:

```ts
import { en } from '@payloadcms/translations/languages/en'
import { enTranslations as ecommerceEn } from '@payloadcms/plugin-ecommerce/translations/languages/en'

buildConfig({
  i18n: {
    supportedLanguages: { en },
    translations: { en: ecommerceEn },
  },
})
```

Override specific strings by spreading and patching the namespace:

```ts
translations: {
  en: {
    ...ecommerceEn,
    'plugin-ecommerce': {
      ...ecommerceEn['plugin-ecommerce'],
      cart: 'Shopping Cart',
      orders: 'My Orders',
    },
  },
}
```

## Gotchas

- **`publicAccess` is the only default** — all other access functions must be implemented by you
- **Adding a currency = schema migration** — new price columns/fields on products and variants
- **Guest cart secret is ephemeral** — stored in localStorage; lost on browser clear
- **`stripe` SDK not auto-installed** — run `pnpm add stripe` separately (min `18.5.0`)
- **Never trust client-side prices** — always compute total server-side from DB records
- **Webhook secret from `stripe listen` changes on restart** — update `.env` each time
- **`customerOnlyFieldAccess` is deprecated** — use `isCustomer` instead (removed in v4)
- **`confirmOrder` creates the Order** — not `initiatePayment`; abandoned checkouts leave Transaction in "Processing"

## Related

- [[wiki/payloadcms/plugins|Plugins]] — plugin system overview and how to build your own
- [[wiki/payloadcms/database|Database]] — migrations, transactions, Postgres vs MongoDB
- [[wiki/payloadcms/access-control|Access Control]] — access function patterns
