[GH-ISSUE #9130] stripe: duplicate local subscription rows when using payment_method_collection: "if_required" #28604

Open
opened 2026-04-17 20:02:36 -05:00 by GiteaMirror · 4 comments
Owner

Originally created by @mattiacerutti on GitHub (Apr 12, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/9130

Is this suited for github?

  • Yes, this is suited for github

Reproduction

  1. Configure Better Auth Stripe plugin with a plan and free trial:
subscription: {
  enabled: true,
  plans: [
    {
      name: "pro",
      priceId: "price_xxx",
      freeTrial: {
        days: 14,
      },
    },
  ],
}
  1. Add Checkout customization:
getCheckoutSessionParams: async ({ plan }) => {
  if (plan.name !== "pro") return {};

  return {
    params: {
      payment_method_collection: "if_required",
      subscription_data: {
        trial_period_days: 14,
        trial_settings: {
          end_behavior: {
            missing_payment_method: "cancel",
          },
        },
      },
    },
  };
};
  1. Trigger upgrade (which should not prompt for payment collection since it's a free trial):
await authClient.subscription.upgrade({
  plan: "pro",
  successUrl: "/dashboard",
  cancelUrl: "/pricing",
});
  1. Observe database after checkout completes.

Current vs. Expected behavior

Current:

  • Two subscription rows are created in the local database
  • Both rows have the same stripeSubscriptionId
  • Rows differ only by local primary key (id)

Expected:

  • Exactly one local subscription row should exist per stripeSubscriptionId
  • Subscription persistence should be idempotent across all Stripe events
  • Multiple webhook or lifecycle events should update the same record, not create new ones

What version of Better Auth are you using?

1.6.2

System info

{
  "system": {
    "platform": "darwin",
    "arch": "arm64",
    "version": "Darwin Kernel Version 25.3.0: Wed Jan 28 20:55:08 PST 2026; root:xnu-12377.91.3~2/RELEASE_ARM64_T6020",
    "release": "25.3.0",
    "cpuCount": 12,
    "cpuModel": "Apple M2 Pro",
    "totalMemory": "16.00 GB",
    "freeMemory": "0.20 GB"
  },
  "node": {
    "version": "v24.13.0",
    "env": "development"
  },
  "packageManager": {
    "name": "bun",
    "version": "1.3.6"
  },
  "frameworks": [
    {
      "name": "next",
      "version": "16.2.1"
    },
    {
      "name": "react",
      "version": "19.2.4"
    }
  ],
  "databases": [
    {
      "name": "@prisma/client",
      "version": "^7.4.2"
    }
  ],
  "betterAuth": {
    "version": "Unknown",
    "config": null,
    "error": "Converting circular structure to JSON\n    --> starting at object with constructor 'Stripe'\n    |     property 'account' -> object with constructor 'Constructor'\n    --- property '_stripe' closes the circle"
  }
}

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

Backend

Auth config (if applicable)


Additional context

This issue seems to be the same, but the bug is still present on my end.

Maybe the payment_method_collection is some other edge case.

Originally created by @mattiacerutti on GitHub (Apr 12, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/9130 ### Is this suited for github? - [x] Yes, this is suited for github ### Reproduction 1. Configure Better Auth Stripe plugin with a plan and free trial: ``` subscription: { enabled: true, plans: [ { name: "pro", priceId: "price_xxx", freeTrial: { days: 14, }, }, ], } ``` 2. Add Checkout customization: ``` getCheckoutSessionParams: async ({ plan }) => { if (plan.name !== "pro") return {}; return { params: { payment_method_collection: "if_required", subscription_data: { trial_period_days: 14, trial_settings: { end_behavior: { missing_payment_method: "cancel", }, }, }, }, }; }; ``` 3. Trigger upgrade (which should not prompt for payment collection since it's a free trial): ``` await authClient.subscription.upgrade({ plan: "pro", successUrl: "/dashboard", cancelUrl: "/pricing", }); ``` 4. Observe database after checkout completes. ### Current vs. Expected behavior ### Current: - Two subscription rows are created in the local database - Both rows have the same `stripeSubscriptionId` - Rows differ only by local primary key (id) ### Expected: - Exactly one local subscription row should exist per `stripeSubscriptionId` - Subscription persistence should be idempotent across all Stripe events - Multiple webhook or lifecycle events should update the same record, not create new ones ### What version of Better Auth are you using? 1.6.2 ### System info ```bash { "system": { "platform": "darwin", "arch": "arm64", "version": "Darwin Kernel Version 25.3.0: Wed Jan 28 20:55:08 PST 2026; root:xnu-12377.91.3~2/RELEASE_ARM64_T6020", "release": "25.3.0", "cpuCount": 12, "cpuModel": "Apple M2 Pro", "totalMemory": "16.00 GB", "freeMemory": "0.20 GB" }, "node": { "version": "v24.13.0", "env": "development" }, "packageManager": { "name": "bun", "version": "1.3.6" }, "frameworks": [ { "name": "next", "version": "16.2.1" }, { "name": "react", "version": "19.2.4" } ], "databases": [ { "name": "@prisma/client", "version": "^7.4.2" } ], "betterAuth": { "version": "Unknown", "config": null, "error": "Converting circular structure to JSON\n --> starting at object with constructor 'Stripe'\n | property 'account' -> object with constructor 'Constructor'\n --- property '_stripe' closes the circle" } } ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript ``` ### Additional context [This](https://github.com/better-auth/better-auth/issues/7099) issue seems to be the same, but the bug is still present on my end. Maybe the `payment_method_collection` is some other edge case.
GiteaMirror added the payments label 2026-04-17 20:02:36 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Apr 12, 2026):

This is the same race condition reported in #7099. It was fixed in PR #7104 by adding a metadata-based lookup in the customer.subscription.created webhook handler — the handler now checks metadata.subscriptionId to find the existing incomplete local record before falling back to a stripeSubscriptionId lookup [1].

That fix is present in the current codebase, so if you're on v1.6.2 it should already be included. However, there's a subtlety worth checking: the fix relies on the local subscription's database ID being stored in the Stripe checkout session metadata as subscriptionId. If your getCheckoutSessionParams customization or the payment_method_collection: "if_required" flow somehow causes the metadata to not propagate to the customer.subscription.created event, the handler falls back to searching by stripeSubscriptionId — which won't match the incomplete record (since stripeSubscriptionId hasn't been set on it yet), resulting in a duplicate [2].

Debugging steps:

  1. Check the customer.subscription.created webhook event in your Stripe dashboard — does metadata.subscriptionId exist on the subscription object? If it's missing, that's the problem.
  2. Check if your getCheckoutSessionParams is overriding or clearing the subscription_data.metadata that Better Auth sets. Since you're passing subscription_data: { trial_period_days: 14, ... }, this could be shallow-merging over the metadata Better Auth injects.

That second point is likely your issue. Better Auth stores its internal subscriptionId in subscription_data.metadata, but your getCheckoutSessionParams returns its own subscription_data object, which may be replacing the metadata entirely during the merge.

A quick workaround would be to avoid passing subscription_data in getCheckoutSessionParams and instead configure the trial via the plugin's built-in freeTrial option (which you already have). The payment_method_collection: "if_required" part should be fine on its own — it's the subscription_data override that's likely clobbering the metadata.

Also worth noting: the stripeSubscriptionId field has no unique database constraint [3], so there's no DB-level protection against duplicates. You may want to manually deduplicate the existing rows and add a unique index on stripeSubscriptionId in your schema as an extra safety net.

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:4231307274 --> @dosubot[bot] commented on GitHub (Apr 12, 2026): <!-- Answer --> This is the same race condition reported in #7099. It was fixed in [PR #7104](https://github.com/better-auth/better-auth/pull/7104) by adding a metadata-based lookup in the `customer.subscription.created` webhook handler — the handler now checks `metadata.subscriptionId` to find the existing incomplete local record before falling back to a `stripeSubscriptionId` lookup [[1]](https://github.com/better-auth/better-auth/blob/ed2c18df3150ab3a024f7d14a5b5fa58719332f2/packages/stripe/src/hooks.ts#L165-L181). That fix is present in the current codebase, so if you're on **v1.6.2** it should already be included. However, there's a subtlety worth checking: the fix relies on the local subscription's database ID being stored in the Stripe checkout session metadata as `subscriptionId`. If your `getCheckoutSessionParams` customization or the `payment_method_collection: "if_required"` flow somehow causes the metadata to not propagate to the `customer.subscription.created` event, the handler falls back to searching by `stripeSubscriptionId` — which won't match the incomplete record (since `stripeSubscriptionId` hasn't been set on it yet), resulting in a duplicate [[2]](https://github.com/better-auth/better-auth/blob/ed2c18df3150ab3a024f7d14a5b5fa58719332f2/packages/stripe/src/hooks.ts#L172-L174). **Debugging steps:** 1. Check the `customer.subscription.created` webhook event in your Stripe dashboard — does `metadata.subscriptionId` exist on the subscription object? If it's missing, that's the problem. 2. Check if your `getCheckoutSessionParams` is overriding or clearing the `subscription_data.metadata` that Better Auth sets. Since you're passing `subscription_data: { trial_period_days: 14, ... }`, this could be **shallow-merging over** the metadata Better Auth injects. That second point is likely your issue. Better Auth stores its internal `subscriptionId` in `subscription_data.metadata`, but your `getCheckoutSessionParams` returns its own `subscription_data` object, which may be replacing the metadata entirely during the merge. A quick workaround would be to avoid passing `subscription_data` in `getCheckoutSessionParams` and instead configure the trial via the plugin's built-in `freeTrial` option (which you already have). The `payment_method_collection: "if_required"` part should be fine on its own — it's the `subscription_data` override that's likely clobbering the metadata. Also worth noting: the `stripeSubscriptionId` field has no unique database constraint [[3]](https://github.com/better-auth/better-auth/blob/ed2c18df3150ab3a024f7d14a5b5fa58719332f2/packages/stripe/src/schema.ts#L20-L23), so there's no DB-level protection against duplicates. You may want to manually deduplicate the existing rows and add a unique index on `stripeSubscriptionId` in your schema as an extra safety net. <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=brand-link).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-great_response#message_id=00a5715c-c84e-49d2-a467-efa69c60f1f6&feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-irrelevant_answer#message_id=00a5715c-c84e-49d2-a467-efa69c60f1f6&feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-incorrect_sources#message_id=00a5715c-c84e-49d2-a467-efa69c60f1f6&feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-too_verbose#message_id=00a5715c-c84e-49d2-a467-efa69c60f1f6&feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-hallucination#message_id=00a5715c-c84e-49d2-a467-efa69c60f1f6&feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-bug_report#message_id=00a5715c-c84e-49d2-a467-efa69c60f1f6&feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-other#message_id=00a5715c-c84e-49d2-a467-efa69c60f1f6&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&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=chat-badge)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=This%20is%20the%20same%20race%20condition%20reported%20in%20%237099.%20It%20was%20fixed%20in%20%5BPR%20%237104%5D%28https%3A//github.com/better-auth/better-auth/pull/7104%29%20by%20adding%20a%20metadata-based%20lookup%20in%20the%20%60customer.subscription.created%60%20webhook%20handler%20%E2%80%94%20the%20handler%20now%20checks%20%60metadata.subscriptionId%60%20to%20find%20the%20existing%20incomplete%20local%20record%20before%20falling%20back%20to%20a%20%60stripeSubscriptionId%60%20lookup%20%5B%5B1%5D%5D%28https%3A//github.com/better-auth/better-auth/blob/ed2c18df3150ab3a024f7d14a5b5fa58719332f2/packages/stripe/src/hooks.ts%23L165-L181%29.%0A%0AThat%20fix%20is%20present%20in%20the%20current%20codebase%2C%20so%20if%20you%27re%20on%20%2A%2Av1.6.2%2A%2A%20it%20should%20already%20be%20included.%20However%2C%20there%27s%20a%20subtlety%20worth%20checking%3A%20the%20fix%20relies%20on%20the%20local%20subscription%27s%20database%20ID%20being%20stored%20in%20the%20Stripe%20checkout%20session%20metadata%20as%20%60subscriptionId%60.%20If%20your%20%60getCheckoutSessionParams%60%20customization%20or%20the%20%60payment_method_collection%3A%20%22if_required%22%60%20flow%20somehow%20causes%20the%20metadata%20to%20not%20propagate%20to%20the%20%60customer.subscription.created%60%20event%2C%20the%20handler%20falls%20back%20to%20searching%20by%20%60stripeSubscriptionId%60%20%E2%80%94%20which%20won%27t%20match%20the%20incomplete%20record%20%28since%20%60stripeSubscriptionId%60%20hasn%27t%20been%20set%20on%20it%20yet%29%2C%20resulting%20in%20a%20duplicate%20%5B%5B2%5D%5D%28https%3A//github.com/better-auth/better-auth/blob/ed2c18df3150ab3a024f7d14a5b5fa58719332f2/packages/stripe/src/hooks.ts%23L172-L174%29.%0A%0A%2A%2ADebugging%20steps%3A%2A%2A%0A%0A1.%20Check%20the%20%60customer.subscription.created%60%20webhook%20event%20in%20your%20Stripe%20dashboard%20%E2%80%94%20does%20%60metadata.subscriptionId%60%20exist%20on%20the%20subscription%20object%3F%20If%20it%27s%20missing%2C%20that%27s%20the%20problem.%0A2.%20Check%20if%20your%20%60getCheckoutSessionParams%60%20is%20overriding%20or%20clearing%20the%20%60subscription_data.metadata%60%20that%20Better%20Auth%20sets.%20Since%20you%27re%20passing%20%60subscription_data%3A%20%7B%20trial_period_days%3A%2014%2C%20...%20%7D%60%2C%20this%20could%20be%20%2A%2Ashallow-merging%20over%2A%2A%20the%20metadata%20Better%20Auth%20injects.%0A%0AThat%20second%20point%20is%20likely%20your%20issue.%20Better%20Auth%20stores%20its%20internal%20%60subscriptionId%60%20in%20%60subscription_data.metadata%60%2C%20but%20your%20%60getCheckoutSessionParams%60%20returns%20its%20own%20%60subscription_data%60%20object%2C%20which%20may%20be%20replacing%20the%20metadata%20entirely%20during%20the%20merge.%0A%0AA%20quick%20workaround%20would%20be%20to%20avoid%20passing%20%60subscription_data%60%20in%20%60getCheckoutSessionParams%60%20and%20instead%20configure%20the%20trial%20via%20the%20plugin%27s%20built-in%20%60freeTrial%60%20option%20%28which%20you%20already%20have%29.%20The%20%60payment_method_collection%3A%20%22if_required%22%60%20part%20should%20be%20fine%20on%20its%20own%20%E2%80%94%20it%27s%20the%20%60subscription_data%60%20override%20that%27s%20likely%20clobbering%20the%20metadata.%0A%0AAlso%20worth%20noting%3A%20the%20%60stripeSubscriptionId%60%20field%20has%20no%20unique%20database%20constraint%20%5B%5B3%5D%5D%28https%3A//github.com/better-auth/better-auth/blob/ed2c18df3150ab3a024f7d14a5b5fa58719332f2/packages/stripe/src/schema.ts%23L20-L23%29%2C%20so%20there%27s%20no%20DB-level%20protection%20against%20duplicates.%20You%20may%20want%20to%20manually%20deduplicate%20the%20existing%20rows%20and%20add%20a%20unique%20index%20on%20%60stripeSubscriptionId%60%20in%20your%20schema%20as%20an%20extra%20safety%20net.)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=join-discord)&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/9130)
Author
Owner

@mattiacerutti commented on GitHub (Apr 12, 2026):

@dosu Looked at the webhook event in the stripe dashboard and customer.subscription.created does not have metadata.subscriptionId set.
The whole metadata object is empty.

<!-- gh-comment-id:4231323016 --> @mattiacerutti commented on GitHub (Apr 12, 2026): @dosu Looked at the webhook event in the stripe dashboard and `customer.subscription.created` does not have `metadata.subscriptionId` set. The whole `metadata` object is empty.
Author
Owner

@dosubot[bot] commented on GitHub (Apr 12, 2026):

That confirms the root cause. Here's what's happening:

Better Auth sets subscription_data.metadata (with subscriptionId) at lines 1127–1138, but then spreads your getCheckoutSessionParams result after it at line 1141:

subscription_data: {
    ...freeTrial,
    metadata: subscriptionMetadata.set({
        userId: user.id,
        subscriptionId: subscription.id,
        referenceId,
    }, ...),
},
// ...
...params?.params,  // ← your subscription_data replaces the one above

Since you return subscription_data: { trial_period_days: 14, trial_settings: { ... } } in getCheckoutSessionParams, JavaScript's shallow spread completely replaces the internal subscription_data object — including its metadata. That's why metadata is empty on the Stripe subscription.

The top-level checkout session metadata is protected (re-applied after the spread at lines 1143–1151), but subscription_data.metadata is not. So the customer.subscription.created webhook handler can't find the existing local record via metadata.subscriptionId, falls back to a stripeSubscriptionId lookup (which also misses the incomplete record), and creates a duplicate [1].

This is a bug in the plugin — subscription_data should use the same defensive re-application pattern as top-level metadata. This also overlaps with #9129 where freeTrial settings are similarly clobbered [2].

Workaround: Don't pass subscription_data in getCheckoutSessionParams. Instead, use only payment_method_collection (which is a top-level checkout session param and won't interfere):

getCheckoutSessionParams: async ({ plan }) => {
  if (plan.name !== "pro") return {};
  return {
    params: {
      payment_method_collection: "if_required",
    },
  };
};

And rely on the plugin's built-in freeTrial: { days: 14 } to handle the trial. If you specifically need trial_settings.end_behavior.missing_payment_method: "cancel", you'd need to set that via the Stripe dashboard on the price/product level until this bug is fixed, since there's no way to pass it without clobbering subscription_data.metadata.

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:4231335874 --> @dosubot[bot] commented on GitHub (Apr 12, 2026): <!-- Answer --> That confirms the root cause. Here's what's happening: Better Auth sets `subscription_data.metadata` (with `subscriptionId`) at [lines 1127–1138](https://github.com/better-auth/better-auth/blob/ed2c18df3150ab3a024f7d14a5b5fa58719332f2/packages/stripe/src/routes.ts#L1127-L1138), but then spreads your `getCheckoutSessionParams` result **after** it at [line 1141](https://github.com/better-auth/better-auth/blob/ed2c18df3150ab3a024f7d14a5b5fa58719332f2/packages/stripe/src/routes.ts#L1141): ```typescript subscription_data: { ...freeTrial, metadata: subscriptionMetadata.set({ userId: user.id, subscriptionId: subscription.id, referenceId, }, ...), }, // ... ...params?.params, // ← your subscription_data replaces the one above ``` Since you return `subscription_data: { trial_period_days: 14, trial_settings: { ... } }` in `getCheckoutSessionParams`, JavaScript's shallow spread **completely replaces** the internal `subscription_data` object — including its `metadata`. That's why `metadata` is empty on the Stripe subscription. The top-level checkout session `metadata` is protected (re-applied after the spread at [lines 1143–1151](https://github.com/better-auth/better-auth/blob/ed2c18df3150ab3a024f7d14a5b5fa58719332f2/packages/stripe/src/routes.ts#L1143-L1151)), but `subscription_data.metadata` is not. So the `customer.subscription.created` webhook handler can't find the existing local record via `metadata.subscriptionId`, falls back to a `stripeSubscriptionId` lookup (which also misses the incomplete record), and creates a duplicate [[1]](https://github.com/better-auth/better-auth/blob/ed2c18df3150ab3a024f7d14a5b5fa58719332f2/packages/stripe/src/hooks.ts#L165-L181). This is a bug in the plugin — `subscription_data` should use the same defensive re-application pattern as top-level `metadata`. This also overlaps with [#9129](https://github.com/better-auth/better-auth/issues/9129) where `freeTrial` settings are similarly clobbered [[2]](https://github.com/better-auth/better-auth/issues/9129). **Workaround**: Don't pass `subscription_data` in `getCheckoutSessionParams`. Instead, use only `payment_method_collection` (which is a top-level checkout session param and won't interfere): ```typescript getCheckoutSessionParams: async ({ plan }) => { if (plan.name !== "pro") return {}; return { params: { payment_method_collection: "if_required", }, }; }; ``` And rely on the plugin's built-in `freeTrial: { days: 14 }` to handle the trial. If you specifically need `trial_settings.end_behavior.missing_payment_method: "cancel"`, you'd need to set that via the Stripe dashboard on the price/product level until this bug is fixed, since there's no way to pass it without clobbering `subscription_data.metadata`. <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=brand-link).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-great_response#message_id=3b790160-f345-4b8c-b7df-874b378c51f1&feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-irrelevant_answer#message_id=3b790160-f345-4b8c-b7df-874b378c51f1&feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-incorrect_sources#message_id=3b790160-f345-4b8c-b7df-874b378c51f1&feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-too_verbose#message_id=3b790160-f345-4b8c-b7df-874b378c51f1&feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-hallucination#message_id=3b790160-f345-4b8c-b7df-874b378c51f1&feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-bug_report#message_id=3b790160-f345-4b8c-b7df-874b378c51f1&feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-other#message_id=3b790160-f345-4b8c-b7df-874b378c51f1&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&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=chat-badge)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=That%20confirms%20the%20root%20cause.%20Here%27s%20what%27s%20happening%3A%0A%0ABetter%20Auth%20sets%20%60subscription_data.metadata%60%20%28with%20%60subscriptionId%60%29%20at%20%5Blines%201127%E2%80%931138%5D%28https%3A//github.com/better-auth/better-auth/blob/ed2c18df3150ab3a024f7d14a5b5fa58719332f2/packages/stripe/src/routes.ts%23L1127-L1138%29%2C%20but%20then%20spreads%20your%20%60getCheckoutSessionParams%60%20result%20%2A%2Aafter%2A%2A%20it%20at%20%5Bline%201141%5D%28https%3A//github.com/better-auth/better-auth/blob/ed2c18df3150ab3a024f7d14a5b5fa58719332f2/packages/stripe/src/routes.ts%23L1141%29%3A%0A%0A%60%60%60typescript%0Asubscription_data%3A%20%7B%0A%20%20%20%20...freeTrial%2C%0A%20%20%20%20metadata%3A%20subscriptionMetadata.set%28%7B%0A%20%20%20%20%20%20%20%20userId%3A%20user.id%2C%0A%20%20%20%20%20%20%20%20subscriptionId%3A%20subscription.id%2C%0A%20%20%20%20%20%20%20%20referenceId%2C%0A%20%20%20%20%7D%2C%20...%29%2C%0A%7D%2C%0A//%20...%0A...params%3F.params%2C%20%20//%20%E2%86%90%20your%20subscription_data%20replaces%20the%20one%20above%0A%60%60%60%0A%0ASince%20you%20return%20%60subscription_data%3A%20%7B%20trial_period_days%3A%2014%2C%20trial_settings%3A%20%7B%20...%20%7D%20%7D%60%20in%20%60getCheckoutSessionParams%60%2C%20JavaScript%27s%20shallow%20spread%20%2A%2Acompletely%20replaces%2A%2A%20the%20internal%20%60subscription_data%60%20object%20%E2%80%94%20including%20its%20%60metadata%60.%20That%27s%20why%20%60metadata%60%20is%20empty%20on%20the%20Stripe%20subscription.%0A%0AThe%20top-level%20checkout%20session%20%60metadata%60%20is%20protected%20%28re-applied%20after%20the%20spread%20at%20%5Blines%201143%E2%80%931151%5D%28https%3A//github.com/better-auth/better-auth/blob/ed2c18df3150ab3a024f7d14a5b5fa58719332f2/packages/stripe/src/routes.ts%23L1143-L1151%29%29%2C%20but%20%60subscription_data.metadata%60%20is%20not.%20So%20the%20%60customer.subscription.created%60%20webhook%20handler%20can%27t%20find%20the%20existing%20local%20record%20via%20%60metadata.subscriptionId%60%2C%20falls%20back%20to%20a%20%60stripeSubscriptionId%60%20lookup%20%28which%20also%20misses%20the%20incomplete%20record%29%2C%20and%20creates%20a%20duplicate%20%5B%5B1%5D%5D%28https%3A//github.com/better-auth/better-auth/blob/ed2c18df3150ab3a024f7d14a5b5fa58719332f2/packages/stripe/src/hooks.ts%23L165-L181%29.%0A%0AThis%20is%20a%20bug%20in%20the%20plugin%20%E2%80%94%20%60subscription_data%60%20should%20use%20the%20same%20defensive%20re-application%20pattern%20as%20top-level%20%60metadata%60.%20This%20also%20overlaps%20with%20%5B%239129%5D%28https%3A//github.com/better-auth/better-auth/issues/9129%29%20where%20%60freeTrial%60%20settings%20are%20similarly%20clobbered%20%5B%5B2%5D%5D%28https%3A//github.com/better-auth/better-auth/issues/9129%29.%0A%0A%2A%2AWorkaround%2A%2A%3A%20Don%27t%20pass%20%60subscription_data%60%20in%20%60getCheckoutSessionParams%60.%20Instead%2C%20use%20only%20%60payment_method_collection%60%20%28which%20is%20a%20top-level%20checkout%20session%20param%20and%20won%27t%20interfere%29%3A%0A%0A%60%60%60typescript%0AgetCheckoutSessionParams%3A%20async%20%28%7B%20plan%20%7D%29%20%3D%3E%20%7B%0A%20%20if%20%28plan.name%20%21%3D%3D%20%22pro%22%29%20return%20%7B%7D%3B%0A%20%20return%20%7B%0A%20%20%20%20params%3A%20%7B%0A%20%20%20%20%20%20payment_method_collection%3A%20%22if_required%22%2C%0A%20%20%20%20%7D%2C%0A%20%20%7D%3B%0A%7D%3B%0A%60%60%60%0A%0AAnd%20rely%20on%20the%20plugin%27s%20built-in%20%60freeTrial%3A%20%7B%20days%3A%2014%20%7D%60%20to%20handle%20the%20trial.%20If%20you%20specifically%20need%20%60trial_settings.end_behavior.missing_payment_method%3A%20%22cancel%22%60%2C%20you%27d%20need%20to%20set%20that%20via%20the%20Stripe%20dashboard%20on%20the%20price/product%20level%20until%20this%20bug%20is%20fixed%2C%20since%20there%27s%20no%20way%20to%20pass%20it%20without%20clobbering%20%60subscription_data.metadata%60.)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=join-discord)&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/9130)
Author
Owner

@mattiacerutti commented on GitHub (Apr 12, 2026):

That's exactly what's happening, removing the subscription_data object fixes it.

This makes the issue related to my other one #9129 (though this one is more severe since it causes inconsistent subscription state to be created)

<!-- gh-comment-id:4231645866 --> @mattiacerutti commented on GitHub (Apr 12, 2026): That's exactly what's happening, removing the `subscription_data` object fixes it. This makes the issue related to my other one #9129 (though this one is more severe since it causes inconsistent subscription state to be created)
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#28604