[GH-ISSUE #3595] [Bug] Route conflicts prevent multiple payment provider plugins from coexisting #26981

Closed
opened 2026-04-17 17:45:09 -05:00 by GiteaMirror · 11 comments
Owner

Originally created by @zaidmukaddam on GitHub (Jul 24, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/3595

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

Steps To Reproduce

  1. Install multiple payment provider plugins:

    pnpm add @polar-sh/sdk @dodopayments/better-auth
    
  2. Configure better-auth with both Polar and DodoPayments plugins:

    // lib/auth.ts
    import { betterAuth } from "better-auth"
    import { polarPlugin } from "@polar-sh/sdk/auth"
    import { dodoPayments } from "@dodopayments/better-auth"
    
    export const auth = betterAuth({
      database: // ... database config
      plugins: [
        polarPlugin({
          // Polar config - uses default paths:
          // /api/auth/checkout 
          // /api/auth/customer/portal
        }),
        dodoPayments({
          // DodoPayments config - attempts to use same paths:
          // /api/auth/checkout 
          // /api/auth/customer/portal  
        })
      ]
    })
    
  3. Try to use both providers in application:

    // Component code
    const handlePolarCheckout = () => authClient.checkout(options)
    const handleDodoCheckout = () => betterauthClient.checkout(options)
    
    const handlePolarPortal = () => authClient.customer.portal()
    const handleDodoPortal = () => betterauthClient.customer.portal()
    
  4. Observe route conflicts and runtime errors:

    • Both plugins try to register the same endpoint paths
    • Second plugin's routes are not accessible
    • API calls fail with errors like Cannot read properties of undefined (reading 'customer_id')

Current vs. Expected behavior

Current Behavior

  • Route conflicts: Multiple payment provider plugins cannot coexist because they register identical endpoint paths (/checkout, /customer/portal)
  • Plugin prioritization: Better-auth appears to prioritize the first plugin's route configuration, making subsequent plugins with conflicting paths inaccessible
  • Runtime errors: Attempting to use the second payment provider results in undefined properties and API failures
  • No path customization: Payment provider plugins don't expose options to customize their endpoint paths

Example error when using DodoPayments after Polar:
Error: Cannot read properties of undefined (reading 'customer_id')

Expected Behavior

  • Multiple payment providers: Should be able to use multiple payment provider plugins simultaneously in the same better-auth instance
  • Route namespace isolation: Each plugin should be able to register routes under its own namespace
  • Independent operation: Each payment provider should function independently without interfering with others

What version of Better Auth are you using?

1.3.3

Provide environment information

## Environment Information

**Framework & Runtime:**
- Node.js: `v22.15.1`
- Package Manager: `pnpm v10.12.4`
- Framework: `Next.js 15.4.3` with App Router

**Better-Auth Setup:**
- better-auth: `^1.3.3`
- Database: PostgreSQL with Drizzle ORM (`drizzle-orm: ^0.44.3`)
- Authentication providers: GitHub, Google (working fine)

**Payment Provider Plugins:**
- @polar-sh/sdk: `^0.34.6` 
- @polar-sh/better-auth: `^1.0.7`
- @dodopayments/better-auth: `^0.1.1`
- dodopayments: `^1.43.1`

**Key Dependencies:**

{
  "dependencies": {
    "better-auth": "^1.3.3",
    "@polar-sh/sdk": "^0.34.6", 
    "@polar-sh/better-auth": "^1.0.7",
    "@dodopayments/better-auth": "^0.1.1",
    "dodopayments": "^1.43.1",
    "next": "15.4.3",
    "drizzle-orm": "^0.44.3",
    "@ai-sdk/react": "^1.2.12",
    "react": "^19",
    "react-dom": "^19"
  },
  "devDependencies": {
    "patch-package": "^8.0.0",
    "drizzle-kit": "^0.31.4",
    "typescript": "^5"
  }
}


**Configuration:**
- Both plugins use default configuration
- Database connection working properly (PostgreSQL via @neondatabase/serverless)
- Other auth features (social login, sessions) working correctly
- Issue only occurs when both payment providers are enabled

Which area(s) are affected? (Select all that apply)

Client, Types, Backend, Package

Auth config (if applicable)

export const auth = betterAuth({
  cookieCache: {
    enabled: true,
    maxAge: 5 * 60,
  },
  database: drizzleAdapter(db, {
    provider: 'pg',
    schema: {
      user,
      session,
      verification,
      account,
      chat,
      message,
      extremeSearchUsage,
      messageUsage,
      subscription,
      payment,
      customInstructions,
      stream,
    },
  }),
  socialProviders: {
    github: {
      clientId: serverEnv.GITHUB_CLIENT_ID,
      clientSecret: serverEnv.GITHUB_CLIENT_SECRET,
    },
    google: {
      clientId: serverEnv.GOOGLE_CLIENT_ID,
      clientSecret: serverEnv.GOOGLE_CLIENT_SECRET,
    },
    twitter: {
      clientId: serverEnv.TWITTER_CLIENT_ID,
      clientSecret: serverEnv.TWITTER_CLIENT_SECRET,
    },
  },
  plugins: [
    dodopayments({
      client: dodoPayments,
      createCustomerOnSignUp: true,
      use: [
        dodocheckout({
          products: [
            {
              productId:
                process.env.NEXT_PUBLIC_PREMIUM_TIER ||
                (() => {
                  throw new Error('NEXT_PUBLIC_PREMIUM_TIER environment variable is required');
                })(),
              slug:
                process.env.NEXT_PUBLIC_PREMIUM_SLUG ||
                (() => {
                  throw new Error('NEXT_PUBLIC_PREMIUM_SLUG environment variable is required');
                })(),
            },
          ],
          successUrl: '/success',
          authenticatedUsersOnly: true,
        }),
        dodoportal(),
        dodowebhooks({
          webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_SECRET!,
          onPayload: async (payload) => {
            console.log('🔔 Received Dodo Payments webhook:', payload.type);
            console.log('📦 Payload data:', JSON.stringify(payload.data, null, 2));

            if (
              payload.type === 'payment.succeeded' ||
              payload.type === 'payment.failed' ||
              payload.type === 'payment.cancelled' ||
              payload.type === 'payment.processing'
            ) {
              console.log('🎯 Processing payment webhook:', payload.type);

              try {
                const data = payload.data;

                // Extract user ID from customer data if available
                let validUserId = null;
                if (data.customer?.email) {
                  try {
                    const userExists = await db.query.user.findFirst({
                      where: eq(user.email, data.customer.email),
                      columns: { id: true },
                    });
                    validUserId = userExists ? userExists.id : null;

                    if (!userExists) {
                      console.warn(
                        `⚠️ User with email ${data.customer.email} not found, creating payment without user link`,
                      );
                    }
                  } catch (error) {
                    console.error('Error checking user existence:', error);
                  }
                }

                // Build payment data
                const paymentData = {
                  id: data.payment_id,
                  createdAt: new Date(data.created_at),
                  updatedAt: data.updated_at ? new Date(data.updated_at) : null,
                  brandId: data.brand_id || null,
                  businessId: data.business_id || null,
                  cardIssuingCountry: data.card_issuing_country || null,
                  cardLastFour: data.card_last_four || null,
                  cardNetwork: data.card_network || null,
                  cardType: data.card_type || null,
                  currency: data.currency,
                  digitalProductsDelivered: data.digital_products_delivered || false,
                  discountId: data.discount_id || null,
                  errorCode: data.error_code || null,
                  errorMessage: data.error_message || null,
                  paymentLink: data.payment_link || null,
                  paymentMethod: data.payment_method || null,
                  paymentMethodType: data.payment_method_type || null,
                  settlementAmount: data.settlement_amount || null,
                  settlementCurrency: data.settlement_currency || null,
                  settlementTax: data.settlement_tax || null,
                  status: data.status || null,
                  subscriptionId: data.subscription_id || null,
                  tax: data.tax || null,
                  totalAmount: data.total_amount,
                  // JSON fields
                  billing: data.billing || null,
                  customer: data.customer || null,
                  disputes: data.disputes || null,
                  metadata: data.metadata || null,
                  productCart: data.product_cart || null,
                  refunds: data.refunds || null,
                  userId: validUserId,
                };

                console.log('💾 Final payment data:', {
                  id: paymentData.id,
                  status: paymentData.status,
                  userId: paymentData.userId,
                  totalAmount: paymentData.totalAmount,
                  currency: paymentData.currency,
                });

                // Use Drizzle's onConflictDoUpdate for proper upsert
                await db
                  .insert(payment)
                  .values(paymentData)
                  .onConflictDoUpdate({
                    target: payment.id,
                    set: {
                      updatedAt: paymentData.updatedAt || new Date(),
                      status: paymentData.status,
                      errorCode: paymentData.errorCode,
                      errorMessage: paymentData.errorMessage,
                      digitalProductsDelivered: paymentData.digitalProductsDelivered,
                      disputes: paymentData.disputes,
                      refunds: paymentData.refunds,
                      metadata: paymentData.metadata,
                      userId: paymentData.userId,
                    },
                  });

                console.log('✅ Upserted payment:', data.payment_id);

                // Invalidate user caches when payment status changes
                if (validUserId) {
                  invalidateUserCaches(validUserId);
                  console.log('🗑️ Invalidated caches for user:', validUserId);
                }
              } catch (error) {
                console.error('💥 Error processing payment webhook:', error);
                // Don't throw - let webhook succeed to avoid retries
              }
            }
          },
        }),
      ],
    }),
    polar({
      client: polarClient,
      createCustomerOnSignUp: true,
      enableCustomerPortal: true,
      getCustomerCreateParams: async ({ user: newUser }) => {
        console.log('🚀 getCustomerCreateParams called for user:', newUser.id);

        try {
          // Look for existing customer by email
          const { result: existingCustomers } = await polarClient.customers.list({
            email: newUser.email,
          });

          const existingCustomer = existingCustomers.items[0];

          if (existingCustomer && existingCustomer.externalId && existingCustomer.externalId !== newUser.id) {
            console.log(
              `🔗 Found existing customer ${existingCustomer.id} with external ID ${existingCustomer.externalId}`,
            );
            console.log(`🔄 Updating user ID from ${newUser.id} to ${existingCustomer.externalId}`);

            // Update the user's ID in database to match the existing external ID
            await db.update(user).set({ id: existingCustomer.externalId }).where(eq(user.id, newUser.id));

            console.log(`✅ Updated user ID to match existing external ID: ${existingCustomer.externalId}`);
          }

          return {};
        } catch (error) {
          console.error('💥 Error in getCustomerCreateParams:', error);
          return {};
        }
      },
      use: [
        checkout({
          products: [
            {
              productId:
                process.env.NEXT_PUBLIC_STARTER_TIER ||
                (() => {
                  throw new Error('NEXT_PUBLIC_STARTER_TIER environment variable is required');
                })(),
              slug:
                process.env.NEXT_PUBLIC_STARTER_SLUG ||
                (() => {
                  throw new Error('NEXT_PUBLIC_STARTER_SLUG environment variable is required');
                })(),
            },
          ],
          successUrl: `/success`,
          authenticatedUsersOnly: true,
        }),
        portal(),
        usage(),
        webhooks({
          secret:
            process.env.POLAR_WEBHOOK_SECRET ||
            (() => {
              throw new Error('POLAR_WEBHOOK_SECRET environment variable is required');
            })(),
          onPayload: async ({ data, type }) => {
            if (
              type === 'subscription.created' ||
              type === 'subscription.active' ||
              type === 'subscription.canceled' ||
              type === 'subscription.revoked' ||
              type === 'subscription.uncanceled' ||
              type === 'subscription.updated'
            ) {
              console.log('🎯 Processing subscription webhook:', type);
              console.log('📦 Payload data:', JSON.stringify(data, null, 2));

              try {
                // STEP 1: Extract user ID from customer data
                const userId = data.customer?.externalId;

                // STEP 1.5: Check if user exists to prevent foreign key violations
                let validUserId = null;
                if (userId) {
                  try {
                    const userExists = await db.query.user.findFirst({
                      where: eq(user.id, userId),
                      columns: { id: true },
                    });
                    validUserId = userExists ? userId : null;

                    if (!userExists) {
                      console.warn(
                        `⚠️ User ${userId} not found, creating subscription without user link - will auto-link when user signs up`,
                      );
                    }
                  } catch (error) {
                    console.error('Error checking user existence:', error);
                  }
                } else {
                  console.error('🚨 No external ID found for subscription', {
                    subscriptionId: data.id,
                    customerId: data.customerId,
                  });
                }
                // STEP 2: Build subscription data
                const subscriptionData = {
                  id: data.id,
                  createdAt: new Date(data.createdAt),
                  modifiedAt: safeParseDate(data.modifiedAt),
                  amount: data.amount,
                  currency: data.currency,
                  recurringInterval: data.recurringInterval,
                  status: data.status,
                  currentPeriodStart: safeParseDate(data.currentPeriodStart) || new Date(),
                  currentPeriodEnd: safeParseDate(data.currentPeriodEnd) || new Date(),
                  cancelAtPeriodEnd: data.cancelAtPeriodEnd || false,
                  canceledAt: safeParseDate(data.canceledAt),
                  startedAt: safeParseDate(data.startedAt) || new Date(),
                  endsAt: safeParseDate(data.endsAt),
                  endedAt: safeParseDate(data.endedAt),
                  customerId: data.customerId,
                  productId: data.productId,
                  discountId: data.discountId || null,
                  checkoutId: data.checkoutId || '',
                  customerCancellationReason: data.customerCancellationReason || null,
                  customerCancellationComment: data.customerCancellationComment || null,
                  metadata: data.metadata ? JSON.stringify(data.metadata) : null,
                  customFieldData: data.customFieldData ? JSON.stringify(data.customFieldData) : null,
                  userId: validUserId,
                };

                console.log('💾 Final subscription data:', {
                  id: subscriptionData.id,
                  status: subscriptionData.status,
                  userId: subscriptionData.userId,
                  amount: subscriptionData.amount,
                });

                // STEP 3: Use Drizzle's onConflictDoUpdate for proper upsert
                await db
                  .insert(subscription)
                  .values(subscriptionData)
                  .onConflictDoUpdate({
                    target: subscription.id,
                    set: {
                      modifiedAt: subscriptionData.modifiedAt || new Date(),
                      amount: subscriptionData.amount,
                      currency: subscriptionData.currency,
                      recurringInterval: subscriptionData.recurringInterval,
                      status: subscriptionData.status,
                      currentPeriodStart: subscriptionData.currentPeriodStart,
                      currentPeriodEnd: subscriptionData.currentPeriodEnd,
                      cancelAtPeriodEnd: subscriptionData.cancelAtPeriodEnd,
                      canceledAt: subscriptionData.canceledAt,
                      startedAt: subscriptionData.startedAt,
                      endsAt: subscriptionData.endsAt,
                      endedAt: subscriptionData.endedAt,
                      customerId: subscriptionData.customerId,
                      productId: subscriptionData.productId,
                      discountId: subscriptionData.discountId,
                      checkoutId: subscriptionData.checkoutId,
                      customerCancellationReason: subscriptionData.customerCancellationReason,
                      customerCancellationComment: subscriptionData.customerCancellationComment,
                      metadata: subscriptionData.metadata,
                      customFieldData: subscriptionData.customFieldData,
                      userId: subscriptionData.userId,
                    },
                  });

                console.log('✅ Upserted subscription:', data.id);
              } catch (error) {
                console.error('💥 Error processing subscription webhook:', error);
                // Don't throw - let webhook succeed to avoid retries
              }
            }
          },
        }),
      ],
    }),
    
    nextCookies(),
  ],
  trustedOrigins: ['https://localhost:3000', 'https://scira.ai', 'https://www.scira.ai'],
  allowedOrigins: ['https://localhost:3000', 'https://scira.ai', 'https://www.scira.ai'],
});

Additional context

Additional Context

Use Case:
We need to support multiple payment providers to offer users different payment options:

  • Polar: For subscription-based payments (recurring)
  • DodoPayments: For one-time payments (especially for Indian users with UPI/local payment methods)

Related Issues:

  • This affects any setup where multiple payment provider plugins are needed
  • Could impact future plugin ecosystem growth if plugins can't coexist
  • Currently forces developers to choose only one payment provider per better-auth instance

Root Cause:
Better-auth doesn't handle route conflicts between plugins - when multiple plugins register the same endpoint paths, the framework should either:

  1. Detect the conflict and throw a helpful error
  2. Automatically resolve the conflict with namespacing
  3. Provide a built-in mechanism for plugins to coexist

Suggested Solution (for better-auth core):

  1. Route conflict detection: Detect when multiple plugins try to register the same route and provide clear error messaging
  2. Plugin namespacing system: Allow better-auth to automatically namespace plugin routes (e.g., /api/auth/polar/checkout, /api/auth/dodo/checkout)
  3. Plugin isolation: Ensure the plugin registration system can handle multiple plugins with overlapping functionality
  4. Configuration options: Provide better-auth-level configuration to resolve plugin conflicts:
    betterAuth({
      plugins: [
        polarPlugin(),
        dodoPayments()
      ],
      pluginRoutes: {
        // Better-auth handles route conflicts
        autoNamespace: true, // or manual mapping
      }
    })
    

Impact:

  • Currently blocks multi-provider payment setups
  • Limits better-auth's extensibility with payment plugins
  • Forces hacky workarounds (manual patches, separate auth instances)

Repository:

Originally created by @zaidmukaddam on GitHub (Jul 24, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/3595 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce ## Steps To Reproduce 1. **Install multiple payment provider plugins**: ```bash pnpm add @polar-sh/sdk @dodopayments/better-auth ``` 2. **Configure better-auth with both Polar and DodoPayments plugins**: ```typescript // lib/auth.ts import { betterAuth } from "better-auth" import { polarPlugin } from "@polar-sh/sdk/auth" import { dodoPayments } from "@dodopayments/better-auth" export const auth = betterAuth({ database: // ... database config plugins: [ polarPlugin({ // Polar config - uses default paths: // /api/auth/checkout // /api/auth/customer/portal }), dodoPayments({ // DodoPayments config - attempts to use same paths: // /api/auth/checkout // /api/auth/customer/portal }) ] }) ``` 3. **Try to use both providers in application**: ```typescript // Component code const handlePolarCheckout = () => authClient.checkout(options) const handleDodoCheckout = () => betterauthClient.checkout(options) const handlePolarPortal = () => authClient.customer.portal() const handleDodoPortal = () => betterauthClient.customer.portal() ``` 4. **Observe route conflicts and runtime errors**: - Both plugins try to register the same endpoint paths - Second plugin's routes are not accessible - API calls fail with errors like `Cannot read properties of undefined (reading 'customer_id')` ### Current vs. Expected behavior ## Current Behavior - ❌ **Route conflicts**: Multiple payment provider plugins cannot coexist because they register identical endpoint paths (`/checkout`, `/customer/portal`) - ❌ **Plugin prioritization**: Better-auth appears to prioritize the first plugin's route configuration, making subsequent plugins with conflicting paths inaccessible - ❌ **Runtime errors**: Attempting to use the second payment provider results in undefined properties and API failures - ❌ **No path customization**: Payment provider plugins don't expose options to customize their endpoint paths Example error when using DodoPayments after Polar: Error: Cannot read properties of undefined (reading 'customer_id') ## Expected Behavior - ✅ **Multiple payment providers**: Should be able to use multiple payment provider plugins simultaneously in the same better-auth instance - ✅ **Route namespace isolation**: Each plugin should be able to register routes under its own namespace - ✅ **Independent operation**: Each payment provider should function independently without interfering with others ### What version of Better Auth are you using? 1.3.3 ### Provide environment information ```bash ## Environment Information **Framework & Runtime:** - Node.js: `v22.15.1` - Package Manager: `pnpm v10.12.4` - Framework: `Next.js 15.4.3` with App Router **Better-Auth Setup:** - better-auth: `^1.3.3` - Database: PostgreSQL with Drizzle ORM (`drizzle-orm: ^0.44.3`) - Authentication providers: GitHub, Google (working fine) **Payment Provider Plugins:** - @polar-sh/sdk: `^0.34.6` - @polar-sh/better-auth: `^1.0.7` - @dodopayments/better-auth: `^0.1.1` - dodopayments: `^1.43.1` **Key Dependencies:** { "dependencies": { "better-auth": "^1.3.3", "@polar-sh/sdk": "^0.34.6", "@polar-sh/better-auth": "^1.0.7", "@dodopayments/better-auth": "^0.1.1", "dodopayments": "^1.43.1", "next": "15.4.3", "drizzle-orm": "^0.44.3", "@ai-sdk/react": "^1.2.12", "react": "^19", "react-dom": "^19" }, "devDependencies": { "patch-package": "^8.0.0", "drizzle-kit": "^0.31.4", "typescript": "^5" } } **Configuration:** - Both plugins use default configuration - Database connection working properly (PostgreSQL via @neondatabase/serverless) - Other auth features (social login, sessions) working correctly - Issue only occurs when both payment providers are enabled ``` ### Which area(s) are affected? (Select all that apply) Client, Types, Backend, Package ### Auth config (if applicable) ```typescript export const auth = betterAuth({ cookieCache: { enabled: true, maxAge: 5 * 60, }, database: drizzleAdapter(db, { provider: 'pg', schema: { user, session, verification, account, chat, message, extremeSearchUsage, messageUsage, subscription, payment, customInstructions, stream, }, }), socialProviders: { github: { clientId: serverEnv.GITHUB_CLIENT_ID, clientSecret: serverEnv.GITHUB_CLIENT_SECRET, }, google: { clientId: serverEnv.GOOGLE_CLIENT_ID, clientSecret: serverEnv.GOOGLE_CLIENT_SECRET, }, twitter: { clientId: serverEnv.TWITTER_CLIENT_ID, clientSecret: serverEnv.TWITTER_CLIENT_SECRET, }, }, plugins: [ dodopayments({ client: dodoPayments, createCustomerOnSignUp: true, use: [ dodocheckout({ products: [ { productId: process.env.NEXT_PUBLIC_PREMIUM_TIER || (() => { throw new Error('NEXT_PUBLIC_PREMIUM_TIER environment variable is required'); })(), slug: process.env.NEXT_PUBLIC_PREMIUM_SLUG || (() => { throw new Error('NEXT_PUBLIC_PREMIUM_SLUG environment variable is required'); })(), }, ], successUrl: '/success', authenticatedUsersOnly: true, }), dodoportal(), dodowebhooks({ webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_SECRET!, onPayload: async (payload) => { console.log('🔔 Received Dodo Payments webhook:', payload.type); console.log('📦 Payload data:', JSON.stringify(payload.data, null, 2)); if ( payload.type === 'payment.succeeded' || payload.type === 'payment.failed' || payload.type === 'payment.cancelled' || payload.type === 'payment.processing' ) { console.log('🎯 Processing payment webhook:', payload.type); try { const data = payload.data; // Extract user ID from customer data if available let validUserId = null; if (data.customer?.email) { try { const userExists = await db.query.user.findFirst({ where: eq(user.email, data.customer.email), columns: { id: true }, }); validUserId = userExists ? userExists.id : null; if (!userExists) { console.warn( `⚠️ User with email ${data.customer.email} not found, creating payment without user link`, ); } } catch (error) { console.error('Error checking user existence:', error); } } // Build payment data const paymentData = { id: data.payment_id, createdAt: new Date(data.created_at), updatedAt: data.updated_at ? new Date(data.updated_at) : null, brandId: data.brand_id || null, businessId: data.business_id || null, cardIssuingCountry: data.card_issuing_country || null, cardLastFour: data.card_last_four || null, cardNetwork: data.card_network || null, cardType: data.card_type || null, currency: data.currency, digitalProductsDelivered: data.digital_products_delivered || false, discountId: data.discount_id || null, errorCode: data.error_code || null, errorMessage: data.error_message || null, paymentLink: data.payment_link || null, paymentMethod: data.payment_method || null, paymentMethodType: data.payment_method_type || null, settlementAmount: data.settlement_amount || null, settlementCurrency: data.settlement_currency || null, settlementTax: data.settlement_tax || null, status: data.status || null, subscriptionId: data.subscription_id || null, tax: data.tax || null, totalAmount: data.total_amount, // JSON fields billing: data.billing || null, customer: data.customer || null, disputes: data.disputes || null, metadata: data.metadata || null, productCart: data.product_cart || null, refunds: data.refunds || null, userId: validUserId, }; console.log('💾 Final payment data:', { id: paymentData.id, status: paymentData.status, userId: paymentData.userId, totalAmount: paymentData.totalAmount, currency: paymentData.currency, }); // Use Drizzle's onConflictDoUpdate for proper upsert await db .insert(payment) .values(paymentData) .onConflictDoUpdate({ target: payment.id, set: { updatedAt: paymentData.updatedAt || new Date(), status: paymentData.status, errorCode: paymentData.errorCode, errorMessage: paymentData.errorMessage, digitalProductsDelivered: paymentData.digitalProductsDelivered, disputes: paymentData.disputes, refunds: paymentData.refunds, metadata: paymentData.metadata, userId: paymentData.userId, }, }); console.log('✅ Upserted payment:', data.payment_id); // Invalidate user caches when payment status changes if (validUserId) { invalidateUserCaches(validUserId); console.log('🗑️ Invalidated caches for user:', validUserId); } } catch (error) { console.error('💥 Error processing payment webhook:', error); // Don't throw - let webhook succeed to avoid retries } } }, }), ], }), polar({ client: polarClient, createCustomerOnSignUp: true, enableCustomerPortal: true, getCustomerCreateParams: async ({ user: newUser }) => { console.log('🚀 getCustomerCreateParams called for user:', newUser.id); try { // Look for existing customer by email const { result: existingCustomers } = await polarClient.customers.list({ email: newUser.email, }); const existingCustomer = existingCustomers.items[0]; if (existingCustomer && existingCustomer.externalId && existingCustomer.externalId !== newUser.id) { console.log( `🔗 Found existing customer ${existingCustomer.id} with external ID ${existingCustomer.externalId}`, ); console.log(`🔄 Updating user ID from ${newUser.id} to ${existingCustomer.externalId}`); // Update the user's ID in database to match the existing external ID await db.update(user).set({ id: existingCustomer.externalId }).where(eq(user.id, newUser.id)); console.log(`✅ Updated user ID to match existing external ID: ${existingCustomer.externalId}`); } return {}; } catch (error) { console.error('💥 Error in getCustomerCreateParams:', error); return {}; } }, use: [ checkout({ products: [ { productId: process.env.NEXT_PUBLIC_STARTER_TIER || (() => { throw new Error('NEXT_PUBLIC_STARTER_TIER environment variable is required'); })(), slug: process.env.NEXT_PUBLIC_STARTER_SLUG || (() => { throw new Error('NEXT_PUBLIC_STARTER_SLUG environment variable is required'); })(), }, ], successUrl: `/success`, authenticatedUsersOnly: true, }), portal(), usage(), webhooks({ secret: process.env.POLAR_WEBHOOK_SECRET || (() => { throw new Error('POLAR_WEBHOOK_SECRET environment variable is required'); })(), onPayload: async ({ data, type }) => { if ( type === 'subscription.created' || type === 'subscription.active' || type === 'subscription.canceled' || type === 'subscription.revoked' || type === 'subscription.uncanceled' || type === 'subscription.updated' ) { console.log('🎯 Processing subscription webhook:', type); console.log('📦 Payload data:', JSON.stringify(data, null, 2)); try { // STEP 1: Extract user ID from customer data const userId = data.customer?.externalId; // STEP 1.5: Check if user exists to prevent foreign key violations let validUserId = null; if (userId) { try { const userExists = await db.query.user.findFirst({ where: eq(user.id, userId), columns: { id: true }, }); validUserId = userExists ? userId : null; if (!userExists) { console.warn( `⚠️ User ${userId} not found, creating subscription without user link - will auto-link when user signs up`, ); } } catch (error) { console.error('Error checking user existence:', error); } } else { console.error('🚨 No external ID found for subscription', { subscriptionId: data.id, customerId: data.customerId, }); } // STEP 2: Build subscription data const subscriptionData = { id: data.id, createdAt: new Date(data.createdAt), modifiedAt: safeParseDate(data.modifiedAt), amount: data.amount, currency: data.currency, recurringInterval: data.recurringInterval, status: data.status, currentPeriodStart: safeParseDate(data.currentPeriodStart) || new Date(), currentPeriodEnd: safeParseDate(data.currentPeriodEnd) || new Date(), cancelAtPeriodEnd: data.cancelAtPeriodEnd || false, canceledAt: safeParseDate(data.canceledAt), startedAt: safeParseDate(data.startedAt) || new Date(), endsAt: safeParseDate(data.endsAt), endedAt: safeParseDate(data.endedAt), customerId: data.customerId, productId: data.productId, discountId: data.discountId || null, checkoutId: data.checkoutId || '', customerCancellationReason: data.customerCancellationReason || null, customerCancellationComment: data.customerCancellationComment || null, metadata: data.metadata ? JSON.stringify(data.metadata) : null, customFieldData: data.customFieldData ? JSON.stringify(data.customFieldData) : null, userId: validUserId, }; console.log('💾 Final subscription data:', { id: subscriptionData.id, status: subscriptionData.status, userId: subscriptionData.userId, amount: subscriptionData.amount, }); // STEP 3: Use Drizzle's onConflictDoUpdate for proper upsert await db .insert(subscription) .values(subscriptionData) .onConflictDoUpdate({ target: subscription.id, set: { modifiedAt: subscriptionData.modifiedAt || new Date(), amount: subscriptionData.amount, currency: subscriptionData.currency, recurringInterval: subscriptionData.recurringInterval, status: subscriptionData.status, currentPeriodStart: subscriptionData.currentPeriodStart, currentPeriodEnd: subscriptionData.currentPeriodEnd, cancelAtPeriodEnd: subscriptionData.cancelAtPeriodEnd, canceledAt: subscriptionData.canceledAt, startedAt: subscriptionData.startedAt, endsAt: subscriptionData.endsAt, endedAt: subscriptionData.endedAt, customerId: subscriptionData.customerId, productId: subscriptionData.productId, discountId: subscriptionData.discountId, checkoutId: subscriptionData.checkoutId, customerCancellationReason: subscriptionData.customerCancellationReason, customerCancellationComment: subscriptionData.customerCancellationComment, metadata: subscriptionData.metadata, customFieldData: subscriptionData.customFieldData, userId: subscriptionData.userId, }, }); console.log('✅ Upserted subscription:', data.id); } catch (error) { console.error('💥 Error processing subscription webhook:', error); // Don't throw - let webhook succeed to avoid retries } } }, }), ], }), nextCookies(), ], trustedOrigins: ['https://localhost:3000', 'https://scira.ai', 'https://www.scira.ai'], allowedOrigins: ['https://localhost:3000', 'https://scira.ai', 'https://www.scira.ai'], }); ``` ### Additional context ## Additional Context **Use Case:** We need to support multiple payment providers to offer users different payment options: - **Polar**: For subscription-based payments (recurring) - **DodoPayments**: For one-time payments (especially for Indian users with UPI/local payment methods) **Related Issues:** - This affects any setup where multiple payment provider plugins are needed - Could impact future plugin ecosystem growth if plugins can't coexist - Currently forces developers to choose only one payment provider per better-auth instance **Root Cause:** Better-auth doesn't handle route conflicts between plugins - when multiple plugins register the same endpoint paths, the framework should either: 1. Detect the conflict and throw a helpful error 2. Automatically resolve the conflict with namespacing 3. Provide a built-in mechanism for plugins to coexist **Suggested Solution (for better-auth core):** 1. **Route conflict detection**: Detect when multiple plugins try to register the same route and provide clear error messaging 2. **Plugin namespacing system**: Allow better-auth to automatically namespace plugin routes (e.g., `/api/auth/polar/checkout`, `/api/auth/dodo/checkout`) 3. **Plugin isolation**: Ensure the plugin registration system can handle multiple plugins with overlapping functionality 4. **Configuration options**: Provide better-auth-level configuration to resolve plugin conflicts: ```typescript betterAuth({ plugins: [ polarPlugin(), dodoPayments() ], pluginRoutes: { // Better-auth handles route conflicts autoNamespace: true, // or manual mapping } }) ``` **Impact:** - Currently blocks multi-provider payment setups - Limits better-auth's extensibility with payment plugins - Forces hacky workarounds (manual patches, separate auth instances) **Repository:** - Link to production repo: https://github.com/zaidmukaddam/scira - Minimal reproduction case can be provided if needed
GiteaMirror added the lockedbug labels 2026-04-17 17:45:09 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Jul 24, 2025):

Hi @zaidmukaddam! I'm Dosu and I’m helping the better-auth team.

This is a confirmed limitation in better-auth: when multiple payment provider plugins register the same endpoint paths, only one set of routes is accessible, and the framework does not detect or resolve these conflicts. The core merges plugin endpoints without any conflict resolution or namespacing, so the last-registered plugin can overwrite routes from previous ones, leading to runtime errors and inaccessible endpoints. There is currently no built-in way to customize or namespace plugin routes, and no official workaround or roadmap item for this feature has been published yet. For now, supporting multiple payment providers in a single better-auth instance is not possible without forking or patching the plugins to use unique paths, or running separate auth instances for each provider, which is not ideal. This limitation blocks multi-provider setups and impacts extensibility for payment plugins.

Reference: better-auth plugin route registration and conflict issue

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Chat with Dosu Join Discord Share on X

<!-- gh-comment-id:3114562073 --> @dosubot[bot] commented on GitHub (Jul 24, 2025): <!-- Greeting --> Hi @zaidmukaddam! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> This is a confirmed limitation in better-auth: when multiple payment provider plugins register the same endpoint paths, only one set of routes is accessible, and the framework does not detect or resolve these conflicts. The core merges plugin endpoints without any conflict resolution or namespacing, so the last-registered plugin can overwrite routes from previous ones, leading to runtime errors and inaccessible endpoints. There is currently no built-in way to customize or namespace plugin routes, and no official workaround or roadmap item for this feature has been published yet. For now, supporting multiple payment providers in a single better-auth instance is not possible without forking or patching the plugins to use unique paths, or running separate auth instances for each provider, which is not ideal. This limitation blocks multi-provider setups and impacts extensibility for payment plugins. Reference: [better-auth plugin route registration and conflict issue](https://github.com/better-auth/better-auth/blob/01814c55e0392ab09f990444fd5e9d5ab81d9e56/packages/better-auth/src/api/index.ts) <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/44119cfc-cc88-4ba5-bb68-abfc57f0baff?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/44119cfc-cc88-4ba5-bb68-abfc57f0baff?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/44119cfc-cc88-4ba5-bb68-abfc57f0baff?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/44119cfc-cc88-4ba5-bb68-abfc57f0baff?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/44119cfc-cc88-4ba5-bb68-abfc57f0baff?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/44119cfc-cc88-4ba5-bb68-abfc57f0baff?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/44119cfc-cc88-4ba5-bb68-abfc57f0baff?feedback_type=other)</sup>&nbsp;&nbsp;[![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/3595)
Author
Owner

@zaidmukaddam commented on GitHub (Jul 26, 2025):

hey @Bekacru can you look into this, I need a patch for this since it's business critical

<!-- gh-comment-id:3122176172 --> @zaidmukaddam commented on GitHub (Jul 26, 2025): hey @Bekacru can you look into this, I need a patch for this since it's business critical
Author
Owner

@that-ambuj commented on GitHub (Jul 26, 2025):

Hey @zaidmukaddam, I've added a dodopayments prefix for the routes as suggested in the PR for our plugin. Could you please try version 1.0.1 to see if it works?

<!-- gh-comment-id:3122180219 --> @that-ambuj commented on GitHub (Jul 26, 2025): Hey @zaidmukaddam, I've added a `dodopayments` prefix for the routes as suggested in the PR for our plugin. Could you please try version `1.0.1` to see if it works?
Author
Owner

@elvenking commented on GitHub (Jul 27, 2025):

I am really glad somebody is fixing this 🙏

<!-- gh-comment-id:3124353522 --> @elvenking commented on GitHub (Jul 27, 2025): I am really glad somebody is fixing this 🙏
Author
Owner

@zaidmukaddam commented on GitHub (Aug 8, 2025):

hey @Bekacru can we get a fix for this?

<!-- gh-comment-id:3169019782 --> @zaidmukaddam commented on GitHub (Aug 8, 2025): hey @Bekacru can we get a fix for this?
Author
Owner

@that-ambuj commented on GitHub (Aug 11, 2025):

@Bekacru To add from my conversation with @zaidmukaddam and me reproducing the following issue:

Even after the adding the /dodopayments prefix to the endpoints, there's still conflict between paths that have the same ending like /payments/list. Not only does it cause type issues, at runtime it results in 404 error for all endpoints exposed by one of the plugins when they are both mounted simultaneously.

To reproduce: install and use both the polar's and dodo payments' adapters and try to call similar actions from both of them from client side, it'll result in both a type error and a runtime error of 404.

<!-- gh-comment-id:3176244921 --> @that-ambuj commented on GitHub (Aug 11, 2025): @Bekacru To add from my conversation with @zaidmukaddam and me reproducing the following issue: Even after the adding the `/dodopayments` prefix to the endpoints, there's still conflict between paths that have the same ending like `/payments/list`. Not only does it cause type issues, at runtime it results in 404 error for all endpoints exposed by one of the plugins when they are both mounted simultaneously. To reproduce: install and use both the polar's and dodo payments' adapters and try to call similar actions from both of them from client side, it'll result in both a type error and a runtime error of 404.
Author
Owner

@devxoshakya commented on GitHub (Aug 26, 2025):

off the topic, but how are you verifying payments are from the same user? if i logged in with say email1 and fill the form of dodopayements with email2 then there is no way to verify payments. Can you tell me if i am wrong.

<!-- gh-comment-id:3223246069 --> @devxoshakya commented on GitHub (Aug 26, 2025): off the topic, but how are you verifying payments are from the same user? if i logged in with say email1 and fill the form of dodopayements with email2 then there is no way to verify payments. Can you tell me if i am wrong.
Author
Owner

@that-ambuj commented on GitHub (Aug 26, 2025):

Hey @devxoshakya, I would appreciate if you ask questions through our support channels such as intercom or discord rather than on github issues. We would be very happy to help you personally over there.

<!-- gh-comment-id:3223329816 --> @that-ambuj commented on GitHub (Aug 26, 2025): Hey @devxoshakya, I would appreciate if you ask questions through our support channels such as intercom or discord rather than on github issues. We would be very happy to help you personally over there.
Author
Owner

@devxoshakya commented on GitHub (Aug 26, 2025):

Hey @devxoshakya, I would appreciate if you ask questions through our support channels such as intercom or discord rather than on github issues. We would be very happy to help you personally over there.

contacting over there !!

<!-- gh-comment-id:3223336844 --> @devxoshakya commented on GitHub (Aug 26, 2025): > Hey [@devxoshakya](https://github.com/devxoshakya), I would appreciate if you ask questions through our support channels such as intercom or discord rather than on github issues. We would be very happy to help you personally over there. contacting over there !!
Author
Owner

@himself65 commented on GitHub (Aug 27, 2025):

Thanks for the feedback. Right now, we don't check if two endpoints have a conflict.

We will bring the runtime warning/error for the upcoming fix if two endpoints have a path conflict. Then we will provide a better way to alias the plugin into a sub-path

<!-- gh-comment-id:3229164983 --> @himself65 commented on GitHub (Aug 27, 2025): Thanks for the feedback. Right now, we don't check if two endpoints have a conflict. We will bring the runtime warning/error for the upcoming fix if two endpoints have a path conflict. Then we will provide a better way to alias the plugin into a sub-path
Author
Owner

@dosubot[bot] commented on GitHub (Nov 26, 2025):

Hi, @zaidmukaddam. I'm Dosu, and I'm helping the better-auth team manage their backlog and am marking this issue as stale.

Issue Summary:

  • You reported conflicts between multiple payment provider plugins due to identical API endpoint paths like /checkout.
  • This causes runtime errors and inaccessible routes when using multiple providers simultaneously.
  • The core limitation is that better-auth currently lacks route conflict detection or namespacing.
  • A partial fix was attempted by prefixing routes (e.g., /dodopayments), but conflicts still occur for endpoints with similar suffixes.
  • Maintainers plan to add runtime warnings for conflicting endpoints and support aliasing plugins under sub-paths in a future update.

Next Steps:

  • Please let me know if this issue is still relevant with the latest version of better-auth by commenting here to keep the discussion open.
  • Otherwise, this issue will be automatically closed in 7 days.

Thank you for your understanding and contribution!

<!-- gh-comment-id:3582088873 --> @dosubot[bot] commented on GitHub (Nov 26, 2025): Hi, @zaidmukaddam. I'm [Dosu](https://dosu.dev), and I'm helping the better-auth team manage their backlog and am marking this issue as stale. **Issue Summary:** - You reported conflicts between multiple payment provider plugins due to identical API endpoint paths like `/checkout`. - This causes runtime errors and inaccessible routes when using multiple providers simultaneously. - The core limitation is that better-auth currently lacks route conflict detection or namespacing. - A partial fix was attempted by prefixing routes (e.g., `/dodopayments`), but conflicts still occur for endpoints with similar suffixes. - Maintainers plan to add runtime warnings for conflicting endpoints and support aliasing plugins under sub-paths in a future update. **Next Steps:** - Please let me know if this issue is still relevant with the latest version of better-auth by commenting here to keep the discussion open. - Otherwise, this issue will be automatically closed in 7 days. Thank you for your understanding and contribution!
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#26981