[GH-ISSUE #7099] Duplicate rows created in Subscription table on upgrade (Stripe) #19359

Closed
opened 2026-04-15 18:21:51 -05:00 by GiteaMirror · 8 comments
Owner

Originally created by @DrGains on GitHub (Jan 2, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/7099

Originally assigned to: @bytaesu on GitHub.

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Create a User with no subscription
  2. Upgrade to a paid subscription
  3. After successful upgrade, two database records in the Subscription table are created, both completety identical with only the "id" being different.

Current vs. Expected behavior

In 1.4.9 and below, when I upgraded a user from a free plan (no Subscription attached, no row in Subscription table) to a paid plan, only one row is created in the Subscription table for the user.

In 1.4.10, two identical rows are created after successful plan upgrade. Both are 100% identical, except the "id"

I am using Stripe local webhook listener:
stripe listen --forward-to localhost:3000/api/auth/stripe/webhook

This causes some issues, for example when I want to Cancel my subscription, only one of the rows will get its "cancelAt" date updated.

What version of Better Auth are you using?

1.4.10

System info

{
  "system": {
    "platform": "darwin",
    "arch": "arm64",
    "version": "Darwin Kernel Version 24.6.0: Mon Jul 14 11:30:40 PDT 2025; root:xnu-11417.140.69~1/RELEASE_ARM64_T6041",
    "release": "24.6.0",
    "cpuCount": 14,
    "cpuModel": "Apple M4 Pro",
    "totalMemory": "48.00 GB",
    "freeMemory": "0.09 GB"
  },
  "node": {
    "version": "v22.17.0",
    "env": "development"
  },
  "packageManager": {
    "name": "npm",
    "version": "11.6.4"
  },
  "frameworks": [
    {
      "name": "next",
      "version": "^16.1.1"
    },
    {
      "name": "react",
      "version": "^19.2.3"
    }
  ],
  "databases": [
    {
      "name": "@prisma/client",
      "version": "^7.2.0"
    }
  ],
  "betterAuth": {
    "version": "Unknown",
    "config": null,
    "error": "(0 , _smtp2goNodejs.default) is not a function"
  }
}

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

Backend

Auth config (if applicable)

import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { nextCookies } from "better-auth/next-js";
import { admin } from "better-auth/plugins";
import { stripe } from "@better-auth/stripe";
import Stripe from "stripe";
import {
  sendEmail,
  getVerificationEmailHtml,
  getPasswordResetEmailHtml,
  getEmailChangeVerificationHtml,
  getDeleteAccountVerificationHtml,
  getSubscriptionStartedEmailHtml,
  getSubscriptionRenewedEmailHtml,
  getPaymentFailedEmailHtml,
} from "@/lib/email";
import APP_CONFIG from "@/lib/config";
import prisma from "@/lib/db";
import { deleteObject } from "@/lib/server/s3";
import { env } from "@/lib/validations/env";
import { formatCurrency, formatDateLong } from "@/lib/utils";
import { isExternalUrl } from "@/lib/file-utils";

const stripeClient = new Stripe(env.STRIPE_SECRET_KEY, {
  apiVersion: "2025-11-17.clover",
});

export const auth = betterAuth({
  database: prismaAdapter(prisma, {
    provider: "postgresql",
  }),
  session: {
    cookieCache: {
      enabled: true,
      maxAge: 5 * 60,
    },
  },
  rateLimit: {
    enabled: true,
  },
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: true,
    sendResetPassword: async ({ user, url }) => {
      await sendEmail({
        to: user.email,
        subject: `Reset your ${APP_CONFIG.name} password`,
        html: getPasswordResetEmailHtml(user.name || "there", url),
      });
    },
  },
  emailVerification: {
    sendOnSignUp: true,
    autoSignInAfterVerification: false,
    sendVerificationEmail: async ({ user, url }) => {
      await sendEmail({
        to: user.email,
        subject: user.name
          ? `Verify your email, ${user.name}`
          : "Verify your email",
        html: getVerificationEmailHtml(user.name || "there", url),
      });
    },
  },
  user: {
    changeEmail: {
      enabled: true,
      sendChangeEmailVerification: async ({ user, newEmail, url }) => {
        await sendEmail({
          to: user.email,
          subject: "Approve your email change",
          html: getEmailChangeVerificationHtml(
            user.name || "there",
            newEmail,
            url,
          ),
        });
      },
      callbackURL: "/auth/sign-in?info=email-changed",
    },
    deleteUser: {
      enabled: true,
      sendDeleteAccountVerification: async ({ user, url }) => {
        await sendEmail({
          to: user.email,
          subject: "Confirm account deletion",
          html: getDeleteAccountVerificationHtml(user.name || "there", url),
        });
      },
      beforeDelete: async (user) => {
        await prisma.$transaction(async (tx) => {
          const activeSubscriptions = await tx.subscription.findMany({
            where: {
              referenceId: user.id,
              status: { in: ["active", "trialing", "past_due"] },
            },
          });

          if (activeSubscriptions.length > 0) {
            throw new Error(
              "Cannot delete user with active subscriptions. Please cancel all subscriptions first.",
            );
          }

          await tx.subscription.deleteMany({
            where: { referenceId: user.id },
          });

          await tx.verification.deleteMany({
            where: { value: user.id },
          });
        });

        if (user.image && !isExternalUrl(user.image)) {
          await Promise.all([
            deleteObject(
              `${env.NEXT_PUBLIC_APP_NAME}/avatars/full/${user.image}`,
            ),
            deleteObject(
              `${env.NEXT_PUBLIC_APP_NAME}/avatars/thumbnail/${user.image}`,
            ),
          ]).catch(() => {});
        }
      },
    },
  },
  socialProviders: {
    google: {
      clientId: env.GOOGLE_CLIENT_ID,
      clientSecret: env.GOOGLE_CLIENT_SECRET,
    },
  },
  plugins: [
    admin({
      bannedUserMessage:
        "Your account has been banned. Please contact support for assistance.",
    }),
    stripe({
      stripeClient,
      stripeWebhookSecret: env.STRIPE_WEBHOOK_SECRET,
      createCustomerOnSignUp: true,
      subscription: {
        enabled: true,
        plans: [
          {
            name: "plus",
            priceId: "price_xxx",
            annualDiscountPriceId: "price_xxx",
            limits: {
              projects: 10,
              storage: 25,
            },
          },
          {
            name: "pro",
            priceId: "price_xxx",
            annualDiscountPriceId: "price_xxx",
            limits: {
              projects: -1,
              storage: -1,
            },
          },
        ],
        getCheckoutSessionParams: async () => ({
          params: {
            tax_id_collection: {
              enabled: true,
            },
            billing_address_collection: "required",
            customer_update: {
              name: "auto",
              address: "auto",
            },
          },
        }),
        onSubscriptionComplete: async ({
          subscription,
          stripeSubscription,
          plan,
        }) => {
          try {
            const user = await prisma.user.findFirst({
              where: { stripeCustomerId: subscription.stripeCustomerId },
            });
            if (!user?.email) return;

            const amount = formatCurrency(
              stripeSubscription.items.data[0].price.unit_amount!,
            );
            const nextBillingDate = subscription.periodEnd
              ? formatDateLong(subscription.periodEnd)
              : "N/A";

            let invoiceUrl: string | undefined;
            if (stripeSubscription.latest_invoice) {
              const invoiceId =
                typeof stripeSubscription.latest_invoice === "string"
                  ? stripeSubscription.latest_invoice
                  : stripeSubscription.latest_invoice.id!;

              const invoice = await stripeClient.invoices.retrieve(invoiceId);
              invoiceUrl = invoice.hosted_invoice_url ?? undefined;
            }

            await sendEmail({
              to: user.email,
              subject: `Welcome! ${plan.name.charAt(0).toUpperCase() + plan.name.slice(1)} subscription active`,
              html: getSubscriptionStartedEmailHtml(
                user.name || "there",
                plan.name,
                amount,
                nextBillingDate,
                invoiceUrl,
              ),
            });
          } catch (error) {
            // Handle error
          }
        },
      },
      async onEvent(event) {
        try {
          if (!event.type.startsWith("invoice.")) return;

          const invoice = event.data.object as Stripe.Invoice;
          const customerId =
            typeof invoice.customer === "string"
              ? invoice.customer
              : invoice.customer?.id;
          if (!customerId) return;

          const user = await prisma.user.findFirst({
            where: { stripeCustomerId: customerId },
          });
          if (!user?.email) return;

          if (
            event.type === "invoice.payment_succeeded" &&
            invoice.billing_reason === "subscription_cycle"
          ) {
            const amount = formatCurrency(invoice.amount_paid ?? 0);
            const nextBillingDate = invoice.period_end
              ? formatDateLong(invoice.period_end)
              : "N/A";

            await sendEmail({
              to: user.email,
              subject: "Subscription renewed",
              html: getSubscriptionRenewedEmailHtml(
                user.name || "there",
                amount,
                nextBillingDate,
                invoice.hosted_invoice_url ?? undefined,
              ),
            });
          }

          if (event.type === "invoice.payment_failed") {
            const billingSession =
              await stripeClient.billingPortal.sessions.create({
                customer: customerId,
                return_url: `${env.BETTER_AUTH_URL}/dashboard/billing`,
              });

            await sendEmail({
              to: user.email,
              subject: "Payment failed - action required",
              html: getPaymentFailedEmailHtml(
                user.name || "there",
                formatCurrency(invoice.amount_due ?? 0),
                billingSession.url,
              ),
            });
          }
        } catch (error) {
          // Handle error
        }
      },
    }),
    nextCookies(),
  ],
  onAPIError: {
    errorURL: "/auth/error",
    onError: (error) => {
      // Handle error
    },
  },
  secret: env.BETTER_AUTH_SECRET,
  baseURL: env.BETTER_AUTH_URL,
});

export type Session = typeof auth.$Infer.Session;

Additional context

Using the latest release of Better-Auth and Stripe 20.0.0

Originally created by @DrGains on GitHub (Jan 2, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/7099 Originally assigned to: @bytaesu on GitHub. ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Create a User with no subscription 2. Upgrade to a paid subscription 3. After successful upgrade, two database records in the Subscription table are created, both completety identical with only the "id" being different. ### Current vs. Expected behavior In 1.4.9 and below, when I upgraded a user from a free plan (no Subscription attached, no row in Subscription table) to a paid plan, only one row is created in the Subscription table for the user. In 1.4.10, two identical rows are created after successful plan upgrade. Both are 100% identical, except the "id" I am using Stripe local webhook listener: stripe listen --forward-to localhost:3000/api/auth/stripe/webhook This causes some issues, for example when I want to Cancel my subscription, only one of the rows will get its "cancelAt" date updated. ### What version of Better Auth are you using? 1.4.10 ### System info ```bash { "system": { "platform": "darwin", "arch": "arm64", "version": "Darwin Kernel Version 24.6.0: Mon Jul 14 11:30:40 PDT 2025; root:xnu-11417.140.69~1/RELEASE_ARM64_T6041", "release": "24.6.0", "cpuCount": 14, "cpuModel": "Apple M4 Pro", "totalMemory": "48.00 GB", "freeMemory": "0.09 GB" }, "node": { "version": "v22.17.0", "env": "development" }, "packageManager": { "name": "npm", "version": "11.6.4" }, "frameworks": [ { "name": "next", "version": "^16.1.1" }, { "name": "react", "version": "^19.2.3" } ], "databases": [ { "name": "@prisma/client", "version": "^7.2.0" } ], "betterAuth": { "version": "Unknown", "config": null, "error": "(0 , _smtp2goNodejs.default) is not a function" } } ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript import { betterAuth } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; import { nextCookies } from "better-auth/next-js"; import { admin } from "better-auth/plugins"; import { stripe } from "@better-auth/stripe"; import Stripe from "stripe"; import { sendEmail, getVerificationEmailHtml, getPasswordResetEmailHtml, getEmailChangeVerificationHtml, getDeleteAccountVerificationHtml, getSubscriptionStartedEmailHtml, getSubscriptionRenewedEmailHtml, getPaymentFailedEmailHtml, } from "@/lib/email"; import APP_CONFIG from "@/lib/config"; import prisma from "@/lib/db"; import { deleteObject } from "@/lib/server/s3"; import { env } from "@/lib/validations/env"; import { formatCurrency, formatDateLong } from "@/lib/utils"; import { isExternalUrl } from "@/lib/file-utils"; const stripeClient = new Stripe(env.STRIPE_SECRET_KEY, { apiVersion: "2025-11-17.clover", }); export const auth = betterAuth({ database: prismaAdapter(prisma, { provider: "postgresql", }), session: { cookieCache: { enabled: true, maxAge: 5 * 60, }, }, rateLimit: { enabled: true, }, emailAndPassword: { enabled: true, requireEmailVerification: true, sendResetPassword: async ({ user, url }) => { await sendEmail({ to: user.email, subject: `Reset your ${APP_CONFIG.name} password`, html: getPasswordResetEmailHtml(user.name || "there", url), }); }, }, emailVerification: { sendOnSignUp: true, autoSignInAfterVerification: false, sendVerificationEmail: async ({ user, url }) => { await sendEmail({ to: user.email, subject: user.name ? `Verify your email, ${user.name}` : "Verify your email", html: getVerificationEmailHtml(user.name || "there", url), }); }, }, user: { changeEmail: { enabled: true, sendChangeEmailVerification: async ({ user, newEmail, url }) => { await sendEmail({ to: user.email, subject: "Approve your email change", html: getEmailChangeVerificationHtml( user.name || "there", newEmail, url, ), }); }, callbackURL: "/auth/sign-in?info=email-changed", }, deleteUser: { enabled: true, sendDeleteAccountVerification: async ({ user, url }) => { await sendEmail({ to: user.email, subject: "Confirm account deletion", html: getDeleteAccountVerificationHtml(user.name || "there", url), }); }, beforeDelete: async (user) => { await prisma.$transaction(async (tx) => { const activeSubscriptions = await tx.subscription.findMany({ where: { referenceId: user.id, status: { in: ["active", "trialing", "past_due"] }, }, }); if (activeSubscriptions.length > 0) { throw new Error( "Cannot delete user with active subscriptions. Please cancel all subscriptions first.", ); } await tx.subscription.deleteMany({ where: { referenceId: user.id }, }); await tx.verification.deleteMany({ where: { value: user.id }, }); }); if (user.image && !isExternalUrl(user.image)) { await Promise.all([ deleteObject( `${env.NEXT_PUBLIC_APP_NAME}/avatars/full/${user.image}`, ), deleteObject( `${env.NEXT_PUBLIC_APP_NAME}/avatars/thumbnail/${user.image}`, ), ]).catch(() => {}); } }, }, }, socialProviders: { google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET, }, }, plugins: [ admin({ bannedUserMessage: "Your account has been banned. Please contact support for assistance.", }), stripe({ stripeClient, stripeWebhookSecret: env.STRIPE_WEBHOOK_SECRET, createCustomerOnSignUp: true, subscription: { enabled: true, plans: [ { name: "plus", priceId: "price_xxx", annualDiscountPriceId: "price_xxx", limits: { projects: 10, storage: 25, }, }, { name: "pro", priceId: "price_xxx", annualDiscountPriceId: "price_xxx", limits: { projects: -1, storage: -1, }, }, ], getCheckoutSessionParams: async () => ({ params: { tax_id_collection: { enabled: true, }, billing_address_collection: "required", customer_update: { name: "auto", address: "auto", }, }, }), onSubscriptionComplete: async ({ subscription, stripeSubscription, plan, }) => { try { const user = await prisma.user.findFirst({ where: { stripeCustomerId: subscription.stripeCustomerId }, }); if (!user?.email) return; const amount = formatCurrency( stripeSubscription.items.data[0].price.unit_amount!, ); const nextBillingDate = subscription.periodEnd ? formatDateLong(subscription.periodEnd) : "N/A"; let invoiceUrl: string | undefined; if (stripeSubscription.latest_invoice) { const invoiceId = typeof stripeSubscription.latest_invoice === "string" ? stripeSubscription.latest_invoice : stripeSubscription.latest_invoice.id!; const invoice = await stripeClient.invoices.retrieve(invoiceId); invoiceUrl = invoice.hosted_invoice_url ?? undefined; } await sendEmail({ to: user.email, subject: `Welcome! ${plan.name.charAt(0).toUpperCase() + plan.name.slice(1)} subscription active`, html: getSubscriptionStartedEmailHtml( user.name || "there", plan.name, amount, nextBillingDate, invoiceUrl, ), }); } catch (error) { // Handle error } }, }, async onEvent(event) { try { if (!event.type.startsWith("invoice.")) return; const invoice = event.data.object as Stripe.Invoice; const customerId = typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id; if (!customerId) return; const user = await prisma.user.findFirst({ where: { stripeCustomerId: customerId }, }); if (!user?.email) return; if ( event.type === "invoice.payment_succeeded" && invoice.billing_reason === "subscription_cycle" ) { const amount = formatCurrency(invoice.amount_paid ?? 0); const nextBillingDate = invoice.period_end ? formatDateLong(invoice.period_end) : "N/A"; await sendEmail({ to: user.email, subject: "Subscription renewed", html: getSubscriptionRenewedEmailHtml( user.name || "there", amount, nextBillingDate, invoice.hosted_invoice_url ?? undefined, ), }); } if (event.type === "invoice.payment_failed") { const billingSession = await stripeClient.billingPortal.sessions.create({ customer: customerId, return_url: `${env.BETTER_AUTH_URL}/dashboard/billing`, }); await sendEmail({ to: user.email, subject: "Payment failed - action required", html: getPaymentFailedEmailHtml( user.name || "there", formatCurrency(invoice.amount_due ?? 0), billingSession.url, ), }); } } catch (error) { // Handle error } }, }), nextCookies(), ], onAPIError: { errorURL: "/auth/error", onError: (error) => { // Handle error }, }, secret: env.BETTER_AUTH_SECRET, baseURL: env.BETTER_AUTH_URL, }); export type Session = typeof auth.$Infer.Session; ``` ### Additional context Using the latest release of Better-Auth and Stripe 20.0.0
GiteaMirror added the lockedbug labels 2026-04-15 18:21:51 -05:00
Author
Owner

@better-auth-agent[bot] commented on GitHub (Jan 2, 2026):

It seems you're experiencing a problem with duplicate rows being created in the Subscription table after upgrading to version 1.4.10. This situation might be tied to issues identified in the Stripe subscription handling logic. There have been discussions regarding similar subscription problems, and changes have been made to improve subscription handling.

One possible reason for the duplicate records could be related to implicit referenceId lookups that previously caused upgrades to not function correctly when users had active subscriptions across multiple organizations. Recent changes have been made to refine the subscription upgrade flow, ensuring that it does not mistakenly trigger when it should not [2].

To troubleshoot this issue, consider the following steps:

  1. Check Subscription Logic: Ensure that your implementation of the subscription logic aligns with the latest updates and fixes made to the Better Auth framework.
  2. Examine Webhook Handling: Since you're using a local webhook listener for Stripe, ensure that each event is being handled correctly without duplication.
  3. Update to Latest Version: If you haven't done so already, make sure you're on the latest version of Better Auth, as other fixes might have been introduced since your current version.

It's always a good idea to review the migration and integration documentation when changes like these occur. If you're still facing issues, consider reaching out to the Better Auth team members, Beka and Max (@bekacru and @ping__), for more specific help.

What specific part of the subscription upgrade process would you like more insights on?

If you need more help, tag @better-auth-agent in a comment so I can respond.

DiagramDiscordGitHub

Diagram Join Star

<!-- gh-comment-id:3705163824 --> @better-auth-agent[bot] commented on GitHub (Jan 2, 2026): It seems you're experiencing a problem with duplicate rows being created in the Subscription table after upgrading to version 1.4.10. This situation might be tied to issues identified in the Stripe subscription handling logic. There have been discussions regarding similar subscription problems, and changes have been made to improve subscription handling. One possible reason for the duplicate records could be related to implicit referenceId lookups that previously caused upgrades to not function correctly when users had active subscriptions across multiple organizations. Recent changes have been made to refine the subscription upgrade flow, ensuring that it does not mistakenly trigger when it should not [[2]](https://github.com/better-auth/better-auth/pull/4607). To troubleshoot this issue, consider the following steps: 1. **Check Subscription Logic**: Ensure that your implementation of the subscription logic aligns with the latest updates and fixes made to the Better Auth framework. 2. **Examine Webhook Handling**: Since you're using a local webhook listener for Stripe, ensure that each event is being handled correctly without duplication. 3. **Update to Latest Version**: If you haven't done so already, make sure you're on the latest version of Better Auth, as other fixes might have been introduced since your current version. It's always a good idea to review the migration and integration documentation when changes like these occur. If you're still facing issues, consider reaching out to the Better Auth team members, Beka and Max (@bekacru and @ping__), for more specific help. What specific part of the subscription upgrade process would you like more insights on? _If you need more help, tag @better-auth-agent in a comment so I can respond._ <!-- bot:webhook reply v1 --> [Diagram](https://repodiagrams.s3.eu-north-1.amazonaws.com/skyvern_ultra_detailed_interactive.html) • [Discord](https://discord.gg/fG2XXEuQX3) • [GitHub](https://github.com/Skyvern-AI/Skyvern) [![Diagram](https://img.shields.io/badge/Diagram-2b3137?style=flat-square)](https://repodiagrams.s3.eu-north-1.amazonaws.com/skyvern_ultra_detailed_interactive.html) [![Join](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/fG2XXEuQX3) [![Star](https://img.shields.io/badge/star-181717?logo=github&logoColor=white&style=flat-square)](https://github.com/Skyvern-AI/Skyvern)
Author
Owner

@dosubot[bot] commented on GitHub (Jan 2, 2026):

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

This is a regression in Better Auth v1.4.10 caused by a race condition between the local subscription creation (when you start an upgrade) and the new Stripe webhook handler for customer.subscription.created. The upgrade flow creates a local "incomplete" subscription and stores its ID in the Checkout session metadata. If Stripe sends the customer.subscription.created event before the local record is updated with the Stripe subscription ID (which happens in the checkout.session.completed handler), the webhook can't find a matching row and creates a duplicate with the same data but a different primary key. This is why you see two identical rows, and only one gets updated on cancellation or further actions. This issue was introduced by recent changes to the Stripe plugin, especially the new webhook handler added in PR #6924 and related updates to subscription ID handling source.

As a workaround, you can manually deduplicate the Subscription table for now, or patch the webhook handler to reconcile/merge rows when a duplicate is detected (for example, by matching on referenceId and stripeCustomerId if stripeSubscriptionId is missing). You may also want to monitor for upstream fixes in the Stripe plugin, as this is a known side effect of the recent changes source.

If you report this as a bug, include logs showing the order of webhook events and the state of the Subscription table before and after the upgrade. This will help maintainers reproduce and address the race condition.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:3705168924 --> @dosubot[bot] commented on GitHub (Jan 2, 2026): <!-- Greeting --> Hi @DrGains! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> This is a regression in Better Auth v1.4.10 caused by a race condition between the local subscription creation (when you start an upgrade) and the new Stripe webhook handler for `customer.subscription.created`. The upgrade flow creates a local "incomplete" subscription and stores its ID in the Checkout session metadata. If Stripe sends the `customer.subscription.created` event before the local record is updated with the Stripe subscription ID (which happens in the `checkout.session.completed` handler), the webhook can't find a matching row and creates a duplicate with the same data but a different primary key. This is why you see two identical rows, and only one gets updated on cancellation or further actions. This issue was introduced by recent changes to the Stripe plugin, especially the new webhook handler added in PR #6924 and related updates to subscription ID handling [source](https://github.com/better-auth/better-auth/pull/6924). As a workaround, you can manually deduplicate the Subscription table for now, or patch the webhook handler to reconcile/merge rows when a duplicate is detected (for example, by matching on `referenceId` and `stripeCustomerId` if `stripeSubscriptionId` is missing). You may also want to monitor for upstream fixes in the Stripe plugin, as this is a known side effect of the recent changes [source](https://github.com/better-auth/better-auth/blob/a4df0d96b676d934caee6071a6b0df7a139b7071/packages/stripe/src/hooks.ts#L12-L408). If you report this as a bug, include logs showing the order of webhook events and the state of the Subscription table before and after the upgrade. This will help maintainers reproduce and address the race condition. <!-- 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/118580fa-cb56-4448-bf41-173d52c66088?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/118580fa-cb56-4448-bf41-173d52c66088?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/118580fa-cb56-4448-bf41-173d52c66088?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/118580fa-cb56-4448-bf41-173d52c66088?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/118580fa-cb56-4448-bf41-173d52c66088?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/118580fa-cb56-4448-bf41-173d52c66088?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/118580fa-cb56-4448-bf41-173d52c66088?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=This%20is%20a%20regression%20in%20Better%20Auth%20v1.4.10%20caused%20by%20a%20race%20condition%20between%20the%20local%20subscription%20creation%20%28when%20you%20start%20an%20upgrade%29%20and%20the%20new%20Stripe%20webhook%20handler%20for%20%60customer.subscription.created%60.%20The%20upgrade%20flow%20creates%20a%20local%20%22incomplete%22%20subscription%20and%20stores%20its%20ID%20in%20the%20Checkout%20session%20metadata.%20If%20Stripe%20sends%20the%20%60customer.subscription.created%60%20event%20before%20the%20local%20record%20is%20updated%20with%20the%20Stripe%20subscription%20ID%20%28which%20happens%20in%20the%20%60checkout.session.completed%60%20handler%29%2C%20the%20webhook%20can%27t%20find%20a%20matching%20row%20and%20creates%20a%20duplicate%20with%20the%20same%20data%20but%20a%20different%20primary%20key.%20This%20is%20why%20you%20see%20two%20identical%20rows%2C%20and%20only%20one%20gets%20updated%20on%20cancellation%20or%20further%20actions.%20This%20issue%20was%20introduced%20by%20recent%20changes%20to%20the%20Stripe%20plugin%2C%20especially%20the%20new%20webhook%20handler%20added%20in%20PR%20%236924%20and%20related%20updates%20to%20subscription%20ID%20handling%20%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/pull/6924%29.%0A%0AAs%20a%20workaround%2C%20you%20can%20manually%20deduplicate%20the%20Subscription%20table%20for%20now%2C%20or%20patch%20the%20webhook%20handler%20to%20reconcile/merge%20rows%20when%20a%20duplicate%20is%20detected%20%28for%20example%2C%20by%20matching%20on%20%60referenceId%60%20and%20%60stripeCustomerId%60%20if%20%60stripeSubscriptionId%60%20is%20missing%29.%20You%20may%20also%20want%20to%20monitor%20for%20upstream%20fixes%20in%20the%20Stripe%20plugin%2C%20as%20this%20is%20a%20known%20side%20effect%20of%20the%20recent%20changes%20%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/blob/a4df0d96b676d934caee6071a6b0df7a139b7071/packages/stripe/src/hooks.ts%23L12-L408%29.%0A%0AIf%20you%20report%20this%20as%20a%20bug%2C%20include%20logs%20showing%20the%20order%20of%20webhook%20events%20and%20the%20state%20of%20the%20Subscription%20table%20before%20and%20after%20the%20upgrade.%20This%20will%20help%20maintainers%20reproduce%20and%20address%20the%20race%20condition.)&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/7099)
Author
Owner

@bytaesu commented on GitHub (Jan 3, 2026):

HI @DrGains,

We recently added a customer.subscription.created event handler to handle subscription assignments from the Stripe dashboard, and I've confirmed that it's creating duplicate records.

This behavior exists in v1.4.10. I'll fix it today 🙏

<!-- gh-comment-id:3706813571 --> @bytaesu commented on GitHub (Jan 3, 2026): HI @DrGains, We recently added a `customer.subscription.created` event handler to handle subscription assignments from the Stripe dashboard, and I've confirmed that it's creating duplicate records. This behavior exists in v1.4.10. I'll fix it today 🙏
Author
Owner

@bytaesu commented on GitHub (Jan 3, 2026):

Hi @DrGains,

You could check that the issue is resolved in this version. If the issue persists, feel free to let me know.
I’ll try to get an official release out ASAP 🙏

npm i https://pkg.pr.new/better-auth/better-auth/@better-auth/stripe@7104
<!-- gh-comment-id:3706879622 --> @bytaesu commented on GitHub (Jan 3, 2026): Hi @DrGains, You could check that the issue is resolved in this version. If the issue persists, feel free to let me know. I’ll try to get an official release out ASAP 🙏 ``` npm i https://pkg.pr.new/better-auth/better-auth/@better-auth/stripe@7104 ```
Author
Owner

@Rohit-Singh-Rawat commented on GitHub (Jan 9, 2026):

Hi @bytaesu.
How can we determine whether a subscription is annual or monthly in listActiveSubscriptions, given that the priceId returned by the function is always for the monthly plan, regardless of the actual subscription type?

<!-- gh-comment-id:3728862946 --> @Rohit-Singh-Rawat commented on GitHub (Jan 9, 2026): Hi @bytaesu. How can we determine whether a subscription is annual or monthly in listActiveSubscriptions, given that the priceId returned by the function is always for the monthly plan, regardless of the actual subscription type?
Author
Owner

@bytaesu commented on GitHub (Jan 9, 2026):

Hi @bytaesu. How can we determine whether a subscription is annual or monthly in listActiveSubscriptions, given that the priceId returned by the function is always for the monthly plan, regardless of the actual subscription type?

Hi @Rohit-Singh-Rawat,

Thanks for letting me know! Could you make a issue about this? I'll handle that

<!-- gh-comment-id:3728918124 --> @bytaesu commented on GitHub (Jan 9, 2026): > Hi [@bytaesu](https://github.com/bytaesu). How can we determine whether a subscription is annual or monthly in listActiveSubscriptions, given that the priceId returned by the function is always for the monthly plan, regardless of the actual subscription type? Hi @Rohit-Singh-Rawat, Thanks for letting me know! Could you make a issue about this? I'll handle that
Author
Owner

@bytaesu commented on GitHub (Jan 9, 2026):

@Rohit-Singh-Rawat

I just opened the PR. This seems necessary, but since it involves a schema change, I'll need to review it.

<!-- gh-comment-id:3729114264 --> @bytaesu commented on GitHub (Jan 9, 2026): @Rohit-Singh-Rawat I just opened the PR. This seems necessary, but since it involves a schema change, I'll need to review it.
Author
Owner

@Rohit-Singh-Rawat commented on GitHub (Jan 9, 2026):

Thanks for taking quick action @bytaesu,
I think adding the billing interval to the schema would be a good decision. For now, as a workaround, I thought of checking the subscription’s priceId from the subscriptions object. However, instead of returning the annual priceId, it always returns the base price, or you can say the monthly plan’s priceId.

<!-- gh-comment-id:3729970118 --> @Rohit-Singh-Rawat commented on GitHub (Jan 9, 2026): Thanks for taking quick action @bytaesu, I think adding the billing interval to the schema would be a good decision. For now, as a workaround, I thought of checking the subscription’s priceId from the subscriptions object. However, instead of returning the annual priceId, it always returns the base price, or you can say the monthly plan’s priceId.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#19359