Skip to content

bug: ecommerce plugin treats non-customer (API key) auth users as customers in initiatePayment + Stripe adapter #16377

@jhb-dev

Description

@jhb-dev

Describe the Bug

The plugin's initiatePayment endpoint and the built-in Stripe payment adapter both read req.user directly and assume it is a customer doc. When a request authenticates to the CMS with an API key from a separate auth collection (e.g. a machine-to-machine api-keys collection distinct from the customers collection), req.user is set to that API-key doc — which has no email field and is not a valid customers-collection reference.

Symptom 1 — initiatePayment endpoint

Source: packages/plugin-ecommerce/src/endpoints/initiatePayment.ts

const user = req.user
let customerEmail = user?.email ?? ''
if (user) {
  // ...cart handling...
} else {
  if (data?.customerEmail && typeof data.customerEmail === 'string') {
    customerEmail = data.customerEmail
  } else {
    return Response.json({ message: 'A customer email is required to make a purchase.' }, { status: 400 })
  }
}
  • When req.user is an API-key doc, user?.email is undefined, so customerEmail stays ''.
  • user is truthy → the else branch that reads data.customerEmail is skipped.
  • The (empty) customerEmail is forwarded to the payment adapter, where it blows up validation.

Symptom 2 — Stripe adapter

Source: packages/plugin-ecommerce/src/payments/adapters/stripe/initiatePayment.ts

if (!customerEmail || typeof customerEmail !== 'string') {
  throw new Error('A valid customer email is required to make a purchase.')
}

// ...later, creating the transaction doc:
const transaction = await payload.create({
  collection: transactionsSlug,
  data: {
    ...(req.user ? { customer: req.user.id } : { customerEmail }),
    // ...
  },
  req,
})

Two problems, same root cause:

  1. The customerEmail pre-check throws because the endpoint forwarded an empty string (symptom 1).
  2. Even if customerEmail were non-empty and Stripe succeeded, the transaction write would set customer: req.user.id — but req.user.id is an api-keys doc id, not a valid customers-collection id. The transactions.customer relationship validation then fails, and customerEmail is never set on the transaction either.

Suggested root-cause fix

Both call sites should treat a non-customer req.user as undefined / guest:

const isCustomer = req.user?.collection === customersSlug
const user = isCustomer ? req.user : undefined

And in the Stripe adapter transaction write:

  data: {
-   ...(req.user ? { customer: req.user.id } : { customerEmail }),
+   ...(req.user?.collection === customersSlug
+     ? { customer: req.user.id }
+     : { customerEmail }),
    ...
  },

Link to the code that reproduces this issue

https://github.com/jhb-dev/payload-ecommerce-initiate-payment-api-key-auth

Reproduction Steps

  1. Clone the reproduction repository and run the development server.

  2. On first boot, onInit seeds:

    • an api-keys auth doc with a known key 00000000-0000-0000-0000-000000000001
    • a published product with priceInUSD: 1000
    • a cart containing that product
  3. Find the seeded cart ID via GET /api/carts.

  4. POST to the plugin's Stripe-initiate endpoint, authenticating with the api-keys key and passing a customerEmail in the body (as a web app would for guest checkout):

    curl -X POST http://localhost:3000/api/payments/stripe/initiate \
      -H "Content-Type: application/json" \
      -H "Authorization: api-keys API-Key 00000000-0000-0000-0000-000000000001" \
      -d '{"cartID":"<seeded-cart-id>","customerEmail":"guest@example.com"}'
  5. Expected: the endpoint treats a non-customer user as a guest, uses customerEmail from the body, initiates payment, and writes a transaction with customerEmail (not customer).

  6. Actual: HTTP 500 {"message":"Error initiating payment."}, with server log:

    [ERROR]: Error initiating payment.
        err: {
          "type": "Error",
          "message": "A valid customer email is required to make a purchase.",
          "stack":
              Error: A valid customer email is required to make a purchase.
                  at Object.eval [as initiatePayment]
                    (@payloadcms/plugin-ecommerce/dist/payments/adapters/stripe/initiatePayment.js:26:19)
                  at eval
                    (@payloadcms/plugin-ecommerce/dist/endpoints/initiatePayment.js:209:57)
    

    The endpoint silently dropped the caller-supplied customerEmail because it read req.user.email (empty) instead.

Which area(s) are affected?

plugin: ecommerce

Environment Info

Binaries:
  Node: 24.3.0
  npm: 11.4.2
  pnpm: 10.33.0
Relevant Packages:
  payload: 3.84.0
  @payloadcms/plugin-ecommerce: 3.84.0
  next: 15.4.8
  @payloadcms/db-mongodb: 3.84.0
  @payloadcms/graphql: 3.84.0
  @payloadcms/next/utilities: 3.84.0
  @payloadcms/richtext-lexical: 3.84.0
  @payloadcms/translations: 3.84.0
  @payloadcms/ui/shared: 3.84.0
  react: 19.2.1
  react-dom: 19.2.1
Operating System:
  Platform: darwin
  Arch: arm64

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions