[GH-ISSUE #3337] Stripe plugin fails to validate subscription when using lookupKey in price #9574

Closed
opened 2026-04-13 05:05:54 -05:00 by GiteaMirror · 3 comments
Owner

Originally created by @ow-ph on GitHub (Jul 10, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/3337

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

//auth.ts

export const auth = betterAuth({
  // some config
  
  plugins: [
    stripe({
       subscription: {
         plans: [
         {
            name: "basic", // the name of the plan, it'll be automatically lower cased when stored in the database
            // priceId: "price_1234567890", // the price ID from stripe
            lookupKey: "default_price",
            // annualDiscountPriceId: "price_1234567890", // (optional) the price ID for annual billing with a discount
            annualDiscountLookupKey: "annual_price",
            limits: {
                projects: 5,
                storage: 10
            }
        }
         ]
       }
    })
  ]
})

// checkout page

await client.subscription.upgrade({
    plan: "basic",
    successUrl: "/dashboard",
    cancelUrl: "/pricing",
    annual: true, // Optional: upgrade to an annual plan
    referenceId: "org_123" // Optional: defaults to the current logged in user ID
    seats: 5 // Optional: for team plans
});

Current vs. Expected behavior

💥 Current Behavior

The user is redirected to Stripe, where the product and price are displayed correctly. After accepting the payment, the user is redirected back to the application. However, in the database, the subscription status remains "incomplete".

Expected Behavior

After completing the payment, the user should start the trial period as intended. This behavior works correctly when using a priceId directly instead of a lookup_key.

What version of Better Auth are you using?

1.2.10

Provide environment information

macos with chrome, stripe in sandbox

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

Backend

Auth config (if applicable)

export const auth = betterAuth({
  database: new Pool({
    connectionString: process.env.BETTER_AUTH_DATABASE_URL,
  }),
  advanced: {
    database: {
      generateId: false,
    },
  },

  emailAndPassword: {
    enabled: true,
    requireEmailVerification: true,
  },

  emailVerification: {
    autoSignInAfterVerification: true, // Automatically signIn the user after verification
  },

  plugins: [
    emailOTP({
      sendVerificationOnSignUp: true,
      disableSignUp: true,

      async sendVerificationOTP({ email, otp, type }) {
        if (type === 'email-verification') {
          sendOtpConfirmEmail({
            Email: email,
            Code: otp,
          })
        }
        // Implement the sendVerificationOTP method to send the OTP to the user's email address
        //console.log(`Sending ${type} OTP to ${email}: ${otp}`);
      },
    }),

    organization({
      ac, // Adicionar access controller customizado
      roles: { // Adicionar roles customizadas
        owner,
        admin,
        member,
        viewer
      },
      async sendInvitationEmail(data) {
        
      },
    }),

    stripe({
      stripeClient,
      stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
      createCustomerOnSignUp: true,
      subscription: {
        organization: { enabled: true }, // Enable organization subscriptions
        enabled: true,
        plans: async () => (getAllPlans()),
        authorizeReference: async ({ user, referenceId, action }) => {
         
          return true // test proposes
        },
        onSubscriptionComplete: async ({ subscription, plan }) => {
          console.log(`Subscription completed: ${subscription.id} for plan ${plan.name}`)
        },
        onSubscriptionCancel: async ({ subscription }) => {
          console.log(`Subscription canceled: ${subscription.id}`)
        },

        // Configurar parâmetros customizados de checkout para não exigir cartão durante trial
        getCheckoutSessionParams: async ({ user, session: _session, plan, subscription }, _request) => {
          return {
            params: {
              tax_id_collection: {
                enabled: true
              },
              // Não coleta método de pagamento durante trial - só coletará quando necessário
              payment_method_collection: 'if_required',
              // Permite códigos promocionais
              allow_promotion_codes: true,
              // Configurações de trial
              subscription_data: {
                trial_period_days: plan.freeTrial?.days || 0,
                trial_settings: {
                  end_behavior: {
                    missing_payment_method: 'pause'
                  }
                }
              },
              // Mensagem customizada
              custom_text: {
                submit: {
                  message: 'Não é necessário informar um cartão de crédito agora. Você pode adicionar um mais tarde.',
                }
              },
              metadata: {
                planType: plan.name,
                userId: user.id,
                organizationId: subscription.id
              }
            }

            , options: {
              idempotencyKey: `trial_${user.id}_${plan.name}_${Date.now()}`
            }
          }
        }
      },

    }),

    supabaseJWT({
      jwtSecret: process.env.BETTER_AUTH_SECRET!,
      expiresIn: "1h", // Optional, defaults to 1 hour
      additionalClaims: {
        // Add any additional claims you want in the JWT
      },
    }),

    mixpanelPlugin({
      provider: process.env.MIXPANEL_PROVIDER!,
      projectToken: process.env.MIXPANEL_TOKEN,
    }),

    nextCookies() // make sure this is the last plugin in the array
  ],
})

Additional context

No response

Originally created by @ow-ph on GitHub (Jul 10, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/3337 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce ```typescript //auth.ts export const auth = betterAuth({ // some config plugins: [ stripe({ subscription: { plans: [ { name: "basic", // the name of the plan, it'll be automatically lower cased when stored in the database // priceId: "price_1234567890", // the price ID from stripe lookupKey: "default_price", // annualDiscountPriceId: "price_1234567890", // (optional) the price ID for annual billing with a discount annualDiscountLookupKey: "annual_price", limits: { projects: 5, storage: 10 } } ] } }) ] }) ``` ```typescript // checkout page await client.subscription.upgrade({ plan: "basic", successUrl: "/dashboard", cancelUrl: "/pricing", annual: true, // Optional: upgrade to an annual plan referenceId: "org_123" // Optional: defaults to the current logged in user ID seats: 5 // Optional: for team plans }); ``` ### Current vs. Expected behavior 💥 Current Behavior The user is redirected to Stripe, where the product and price are displayed correctly. After accepting the payment, the user is redirected back to the application. However, in the database, the subscription status remains "incomplete". ✅ Expected Behavior After completing the payment, the user should start the trial period as intended. This behavior works correctly when using a priceId directly instead of a lookup_key. ### What version of Better Auth are you using? 1.2.10 ### Provide environment information ```bash macos with chrome, stripe in sandbox ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript export const auth = betterAuth({ database: new Pool({ connectionString: process.env.BETTER_AUTH_DATABASE_URL, }), advanced: { database: { generateId: false, }, }, emailAndPassword: { enabled: true, requireEmailVerification: true, }, emailVerification: { autoSignInAfterVerification: true, // Automatically signIn the user after verification }, plugins: [ emailOTP({ sendVerificationOnSignUp: true, disableSignUp: true, async sendVerificationOTP({ email, otp, type }) { if (type === 'email-verification') { sendOtpConfirmEmail({ Email: email, Code: otp, }) } // Implement the sendVerificationOTP method to send the OTP to the user's email address //console.log(`Sending ${type} OTP to ${email}: ${otp}`); }, }), organization({ ac, // Adicionar access controller customizado roles: { // Adicionar roles customizadas owner, admin, member, viewer }, async sendInvitationEmail(data) { }, }), stripe({ stripeClient, stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, createCustomerOnSignUp: true, subscription: { organization: { enabled: true }, // Enable organization subscriptions enabled: true, plans: async () => (getAllPlans()), authorizeReference: async ({ user, referenceId, action }) => { return true // test proposes }, onSubscriptionComplete: async ({ subscription, plan }) => { console.log(`Subscription completed: ${subscription.id} for plan ${plan.name}`) }, onSubscriptionCancel: async ({ subscription }) => { console.log(`Subscription canceled: ${subscription.id}`) }, // Configurar parâmetros customizados de checkout para não exigir cartão durante trial getCheckoutSessionParams: async ({ user, session: _session, plan, subscription }, _request) => { return { params: { tax_id_collection: { enabled: true }, // Não coleta método de pagamento durante trial - só coletará quando necessário payment_method_collection: 'if_required', // Permite códigos promocionais allow_promotion_codes: true, // Configurações de trial subscription_data: { trial_period_days: plan.freeTrial?.days || 0, trial_settings: { end_behavior: { missing_payment_method: 'pause' } } }, // Mensagem customizada custom_text: { submit: { message: 'Não é necessário informar um cartão de crédito agora. Você pode adicionar um mais tarde.', } }, metadata: { planType: plan.name, userId: user.id, organizationId: subscription.id } } , options: { idempotencyKey: `trial_${user.id}_${plan.name}_${Date.now()}` } } } }, }), supabaseJWT({ jwtSecret: process.env.BETTER_AUTH_SECRET!, expiresIn: "1h", // Optional, defaults to 1 hour additionalClaims: { // Add any additional claims you want in the JWT }, }), mixpanelPlugin({ provider: process.env.MIXPANEL_PROVIDER!, projectToken: process.env.MIXPANEL_TOKEN, }), nextCookies() // make sure this is the last plugin in the array ], }) ``` ### Additional context _No response_
GiteaMirror added the locked label 2026-04-13 05:05:54 -05:00
Author
Owner

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

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

This looks like a real issue with how the Stripe plugin handles lookupKey. The plugin does resolve a priceId from lookupKey when creating a subscription, but there may be a mismatch in how the webhook (like checkout.session.completed) validates and updates the subscription status—potentially only matching on priceId and not lookupKey, which can leave the subscription stuck as "incomplete" even after payment succeeds. This would explain why it works with priceId but not lookupKey in your config. The code paths for both are mostly unified, but there are subtle differences in webhook validation that could cause this gap (source).

There was a related issue with incomplete subscriptions that was fixed by a PR version of the Stripe plugin, but it didn't specifically mention lookupKey (source). There’s no documentation or support thread that directly addresses lookupKey caveats.

As a workaround, if possible, try specifying both priceId and lookupKey in your plan config, or stick to priceId until this is fixed upstream. If you need lookupKey support, you may want to monitor for updates or patches to the Stripe plugin that address webhook validation for lookupKey-based plans.

To reply, just mention @dosu.


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

<!-- gh-comment-id:3058734201 --> @dosubot[bot] commented on GitHub (Jul 10, 2025): <!-- Greeting --> Hi @ow-ph! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> This looks like a real issue with how the Stripe plugin handles lookupKey. The plugin does resolve a priceId from lookupKey when creating a subscription, but there may be a mismatch in how the webhook (like checkout.session.completed) validates and updates the subscription status—potentially only matching on priceId and not lookupKey, which can leave the subscription stuck as "incomplete" even after payment succeeds. This would explain why it works with priceId but not lookupKey in your config. The code paths for both are mostly unified, but there are subtle differences in webhook validation that could cause this gap [(source)](https://github.com/better-auth/better-auth/blob/79f5f73291a1e64436225804a6f3e1eeca0db53b/packages/stripe/src/index.ts). There was a related issue with incomplete subscriptions that was fixed by a PR version of the Stripe plugin, but it didn't specifically mention lookupKey [(source)](https://github.com/better-auth/better-auth/issues/2929). There’s no documentation or support thread that directly addresses lookupKey caveats. As a workaround, if possible, try specifying both priceId and lookupKey in your plan config, or stick to priceId until this is fixed upstream. If you need lookupKey support, you may want to monitor for updates or patches to the Stripe plugin that address webhook validation for lookupKey-based plans. <!-- 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/5220fd8b-5e74-4886-a7fd-2a6a6faff2f3?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/5220fd8b-5e74-4886-a7fd-2a6a6faff2f3?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/5220fd8b-5e74-4886-a7fd-2a6a6faff2f3?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/5220fd8b-5e74-4886-a7fd-2a6a6faff2f3?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/5220fd8b-5e74-4886-a7fd-2a6a6faff2f3?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/5220fd8b-5e74-4886-a7fd-2a6a6faff2f3?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/5220fd8b-5e74-4886-a7fd-2a6a6faff2f3?feedback_type=other)</sup>&nbsp;&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/3337)
Author
Owner

@dani-z commented on GitHub (Aug 5, 2025):

@ow-ph is there a reason why you are not using priceId instead? I am currently using that with no problems.

PS. What is that mixpanelPlugin you have there? 🤔

<!-- gh-comment-id:3153400999 --> @dani-z commented on GitHub (Aug 5, 2025): @ow-ph is there a reason why you are not using `priceId` instead? I am currently using that with no problems. PS. What is that `mixpanelPlugin` you have there? 🤔
Author
Owner

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

Hi, @ow-ph. 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 that Better Auth v1.2.10's Stripe plugin fails to validate subscriptions when using lookupKey for price configuration.
  • This causes subscriptions to remain "incomplete" after payment, while using priceId directly works correctly.
  • The likely cause is webhook validation mismatches that handle priceId but not lookupKey properly.
  • A suggested workaround is to use priceId or both priceId and lookupKey until an official fix is released.
  • The maintainer asked why priceId isn't used and inquired about an unrelated mixpanelPlugin you mentioned.

Next Steps:

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

Thanks for your understanding and contribution!

<!-- gh-comment-id:3486876887 --> @dosubot[bot] commented on GitHub (Nov 4, 2025): Hi, @ow-ph. 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 that Better Auth v1.2.10's Stripe plugin fails to validate subscriptions when using `lookupKey` for price configuration. - This causes subscriptions to remain "incomplete" after payment, while using `priceId` directly works correctly. - The likely cause is webhook validation mismatches that handle `priceId` but not `lookupKey` properly. - A suggested workaround is to use `priceId` or both `priceId` and `lookupKey` until an official fix is released. - The maintainer asked why `priceId` isn't used and inquired about an unrelated `mixpanelPlugin` you mentioned. **Next Steps:** - Please let me know if this issue is still relevant with the latest version of better-auth, and if so, you can keep the discussion open by commenting here. - Otherwise, this issue will be automatically closed in 7 days. Thanks 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#9574