[Stripe Plugin] Best way to auto-subscribe new users (plus a meta question about non-authed actions) #2005

Closed
opened 2026-03-13 09:20:24 -05:00 by GiteaMirror · 24 comments
Owner

Originally created by @MickeyKay on GitHub (Sep 23, 2025).

TL;DR I'm trying to understand the recommended method for auto-subscribing new users to a Stripe subscription (with a meta-question about handling auth-gated actions in non-authed contexts).

I have the following auth.ts config I was hoping might work to auto-subscribe new users to our free subscription plan:

...
createCustomerOnSignUp: true,
onCustomerCreate: async ({ stripeCustomer, user }) => {
  await auth.api.upgradeSubscription({
    body: {
        plan: 'free',
        referenceId: user.id,
        successUrl: '/dashboard',
        cancelUrl: '/pricing',
        disableRedirect: true,
    },
    // This endpoint requires session cookies.
    headers: await headers(),
  });
},
subscription: {
  enabled: true,
  plans,
}
...

. . . however the auth.api.upgradeSubscription call yields the following error:

ERROR [Better Auth]: Failed to create user [Error [APIError]: ] {
  status: 'UNAUTHORIZED',
  body: undefined,
  headers: {},
  statusCode: 401
}

. . . which makes total sense - the new user's session hasn't been created yet in the onCustomerCreate hook, and the current headers thus don't include the authenticated state required by the upgradeSubscription endpoint.

And to add onto that, I've run into this same issue for other "new user" functionality (e.g. creating a default org for new users) - in general they fall into the bucket of: many Better Auth endpoints require auth headers that aren't present in the database (or plugin) hooks in which I would naturally want to call them.

So. . . my question is: what is the recommended way to execute auth-gated Better Auth actions when a new user signs up?

  • Is there a way to do this natively using the database/plugin hooks that are available?
  • Is there some way to execute these actions from a non-authenticated context? My use case might seem a bit obscure, but to paint a really clear picture, what if I wanted to call auth.api.upgradeSubscription() from a background job that has no current user context? Surely there must be a way to programmatically do this on the server without headers being passed, no?
  • Should I be doing all this client-side instead, on some interstitial page that new users are redirected to (that seems prone to all sorts of issues)?
  • Should I run some logic in my middleware to check for the presence of default new-user properties (e.g. subscription, organization, etc), and then create them if they don't exist (seems operationally expensive and unnecessary)?
  • Something else I'm not thinking of?

Thanks so much for any input you can provide! Much appreciated!

Originally created by @MickeyKay on GitHub (Sep 23, 2025). __TL;DR I'm trying to understand the recommended method for auto-subscribing new users to a Stripe subscription (with a meta-question about handling auth-gated actions in non-authed contexts).__ I have the following `auth.ts` config I was hoping might work to auto-subscribe new users to our free subscription plan: ``` ... createCustomerOnSignUp: true, onCustomerCreate: async ({ stripeCustomer, user }) => { await auth.api.upgradeSubscription({ body: { plan: 'free', referenceId: user.id, successUrl: '/dashboard', cancelUrl: '/pricing', disableRedirect: true, }, // This endpoint requires session cookies. headers: await headers(), }); }, subscription: { enabled: true, plans, } ... ``` . . . however the `auth.api.upgradeSubscription` call yields the following error: ``` ERROR [Better Auth]: Failed to create user [Error [APIError]: ] { status: 'UNAUTHORIZED', body: undefined, headers: {}, statusCode: 401 } ``` . . . which makes total sense - the new user's session hasn't been created yet in the `onCustomerCreate` hook, and the current headers thus don't include the authenticated state required by the `upgradeSubscription` endpoint. And to add onto that, I've run into this same issue for other "new user" functionality (e.g. creating a default org for new users) - in general they fall into the bucket of: __many Better Auth endpoints require auth headers that aren't present in the database (or plugin) hooks in which I would naturally want to call them.__ So. . . my question is: __what is the recommended way to execute auth-gated Better Auth actions when a new user signs up?__ - Is there a way to do this natively using the database/plugin hooks that are available? - Is there some way to execute these actions from a non-authenticated context? My use case might seem a bit obscure, but to paint a really clear picture, what if I wanted to call `auth.api.upgradeSubscription()` from a background job that has no current user context? Surely there must be a way to programmatically do this on the server without `headers` being passed, no? - Should I be doing all this client-side instead, on some interstitial page that new users are redirected to (that seems prone to all sorts of issues)? - Should I run some logic in my middleware to check for the presence of default new-user properties (e.g. subscription, organization, etc), and then create them if they don't exist (seems operationally expensive and unnecessary)? - Something else I'm not thinking of? Thanks so much for any input you can provide! Much appreciated!
GiteaMirror added the plugin label 2026-03-13 09:20:24 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Sep 23, 2025):

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

The recommended way to perform auth-gated Better Auth actions (like auto-subscribing new users to a Stripe plan or creating default organizations) from server-side hooks or background jobs—where session/auth headers aren't available—is to use the API Key plugin. API keys let you authenticate requests programmatically and create a mock session for the user, bypassing the need for interactive session cookies.

You can generate API keys server-side and associate them with a user by passing userId when creating the key. When you call privileged endpoints (like auth.api.upgradeSubscription), include the API key in the request headers (default: x-api-key). Better Auth will treat the request as authenticated and apply permissions and rate limits as configured for the key.

This approach is secure and operationally efficient compared to client-side workarounds or middleware checks, since you can scope permissions, set expiration, and rate-limit keys. There are no super-admin tokens or undocumented bypasses; all privileged actions require either a session or API-key-based authentication. OIDC trusted clients only affect OAuth flows, not core API actions.

For more details and code examples, see the API Key plugin docs and Stripe plugin docs.

To reply, just mention @dosu.


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

@dosubot[bot] commented on GitHub (Sep 23, 2025): <!-- Greeting --> Hi @MickeyKay! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> The recommended way to perform auth-gated Better Auth actions (like auto-subscribing new users to a Stripe plan or creating default organizations) from server-side hooks or background jobs—where session/auth headers aren't available—is to use the [API Key plugin](https://github.com/better-auth/better-auth/blob/f6cbdcc84ee5d2971fdcc8b23ff7c174f88cf45b/docs/content/docs/plugins/api-key.mdx). API keys let you authenticate requests programmatically and create a mock session for the user, bypassing the need for interactive session cookies. You can generate API keys server-side and associate them with a user by passing `userId` when creating the key. When you call privileged endpoints (like `auth.api.upgradeSubscription`), include the API key in the request headers (default: `x-api-key`). Better Auth will treat the request as authenticated and apply permissions and rate limits as configured for the key. This approach is secure and operationally efficient compared to client-side workarounds or middleware checks, since you can scope permissions, set expiration, and rate-limit keys. There are no super-admin tokens or undocumented bypasses; all privileged actions require either a session or API-key-based authentication. OIDC trusted clients only affect OAuth flows, not core API actions. For more details and code examples, see the [API Key plugin docs](https://github.com/better-auth/better-auth/blob/f6cbdcc84ee5d2971fdcc8b23ff7c174f88cf45b/docs/content/docs/plugins/api-key.mdx) and [Stripe plugin docs](https://github.com/better-auth/better-auth/blob/f6cbdcc84ee5d2971fdcc8b23ff7c174f88cf45b/docs/content/docs/plugins/stripe.mdx). <!-- 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/d9a2ce37-7e05-4a29-a55e-7cb116fa89e0?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/d9a2ce37-7e05-4a29-a55e-7cb116fa89e0?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/d9a2ce37-7e05-4a29-a55e-7cb116fa89e0?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/d9a2ce37-7e05-4a29-a55e-7cb116fa89e0?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/d9a2ce37-7e05-4a29-a55e-7cb116fa89e0?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/d9a2ce37-7e05-4a29-a55e-7cb116fa89e0?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/d9a2ce37-7e05-4a29-a55e-7cb116fa89e0?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/4861)
Author
Owner

@MickeyKay commented on GitHub (Sep 23, 2025):

Ah, good to know. To be clear @dosu, do you recommend creating a single API key for an "admin"(aka programmatic/job-based) user API key, or a unique API key for each new user that registers?

@MickeyKay commented on GitHub (Sep 23, 2025): Ah, good to know. To be clear @dosu, do you recommend creating a single API key for an "admin"(aka programmatic/job-based) user API key, or a unique API key for each new user that registers?
Author
Owner

@dosubot[bot] commented on GitHub (Sep 23, 2025):

Better Auth API keys are always tied to a specific user—there’s no concept of a global or singleton key. For most automation and background jobs, the recommended pattern is to create an API key for an admin user with the necessary permissions and use that for programmatic actions. This lets you centrally manage permissions, rate limits, expiration, and rotation for your automation logic, while keeping things secure and operationally simple.

You don’t need to create a unique API key for every new user unless you want each user to have their own programmatic access (for example, if you’re exposing API access to end users). For onboarding flows like auto-subscribing new users, using an admin API key is the standard approach—actions performed with the key will run in the context of the admin user, not the new user, but you can pass the new user's ID as needed.

All API key capabilities—permissions, rate limits, metadata, expiration—are managed per key and per user. If you ever need to scope actions to a specific user, you can create a key for that user, but for most system-wide automation, a single admin key is the way to go. See the API Key plugin docs for details on key creation and management.

To reply, just mention @dosu.


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

@dosubot[bot] commented on GitHub (Sep 23, 2025): <!-- Answer --> Better Auth API keys are always tied to a specific user—there’s no concept of a global or singleton key. For most automation and background jobs, the recommended pattern is to create an API key for an admin user with the necessary permissions and use that for programmatic actions. This lets you centrally manage permissions, rate limits, expiration, and rotation for your automation logic, while keeping things secure and operationally simple. You don’t need to create a unique API key for every new user unless you want each user to have their own programmatic access (for example, if you’re exposing API access to end users). For onboarding flows like auto-subscribing new users, using an admin API key is the standard approach—actions performed with the key will run in the context of the admin user, not the new user, but you can pass the new user's ID as needed. All API key capabilities—permissions, rate limits, metadata, expiration—are managed per key and per user. If you ever need to scope actions to a specific user, you can create a key for that user, but for most system-wide automation, a single admin key is the way to go. See the [API Key plugin docs](https://github.com/better-auth/better-auth/blob/f6cbdcc84ee5d2971fdcc8b23ff7c174f88cf45b/docs/content/docs/plugins/api-key.mdx) for details on key creation and management. <!-- 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/bdb3d9a9-7996-4f9d-9bf3-c2325d056a2d?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/bdb3d9a9-7996-4f9d-9bf3-c2325d056a2d?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/bdb3d9a9-7996-4f9d-9bf3-c2325d056a2d?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/bdb3d9a9-7996-4f9d-9bf3-c2325d056a2d?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/bdb3d9a9-7996-4f9d-9bf3-c2325d056a2d?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/bdb3d9a9-7996-4f9d-9bf3-c2325d056a2d?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/bdb3d9a9-7996-4f9d-9bf3-c2325d056a2d?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/4861)
Author
Owner

@MickeyKay commented on GitHub (Sep 25, 2025):

@dosu Perfect, this is very helpful! One more question, in general where do you recommend housing this sort of admin API key initialization logic? In some kind of setup script? Just populate it manually in the DB? Something else?

@MickeyKay commented on GitHub (Sep 25, 2025): @dosu Perfect, this is very helpful! One more question, in general where do you recommend housing this sort of admin API key initialization logic? In some kind of setup script? Just populate it manually in the DB? Something else?
Author
Owner

@dosubot[bot] commented on GitHub (Sep 25, 2025):

The best practice is to handle admin API key initialization in a setup script or migration that runs during deployment or provisioning—similar to how you’d automate admin user creation. This approach gives you automation, auditability, and secure handling of sensitive data (like storing the key in environment variables or a secrets manager). Manual DB population is possible but not recommended, since it lacks an audit trail and is harder to automate or rotate securely.

A typical pattern is: enable the API key plugin, create the key server-side using auth.api.createApiKey for your admin user, and store the resulting key securely (the full key is only returned at creation) example & details. You can adapt the same setup script or seeding logic you’d use for admin users example, and run it as part of your deployment pipeline or initial database migration.

This keeps your process automated, secure, and easy to audit or rotate as needed.

To reply, just mention @dosu.


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

@dosubot[bot] commented on GitHub (Sep 25, 2025): <!-- Answer --> The best practice is to handle admin API key initialization in a setup script or migration that runs during deployment or provisioning—similar to how you’d automate admin user creation. This approach gives you automation, auditability, and secure handling of sensitive data (like storing the key in environment variables or a secrets manager). Manual DB population is possible but not recommended, since it lacks an audit trail and is harder to automate or rotate securely. A typical pattern is: enable the API key plugin, create the key server-side using `auth.api.createApiKey` for your admin user, and store the resulting key securely (the full key is only returned at creation) [example & details](https://github.com/better-auth/better-auth/issues/4257#issuecomment-3227221447). You can adapt the same setup script or seeding logic you’d use for admin users [example](https://github.com/better-auth/better-auth/issues/3323), and run it as part of your deployment pipeline or initial database migration. This keeps your process automated, secure, and easy to audit or rotate as needed. <!-- 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/b2bd86fe-9994-46f3-8d78-fca9307823ab?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/b2bd86fe-9994-46f3-8d78-fca9307823ab?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/b2bd86fe-9994-46f3-8d78-fca9307823ab?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/b2bd86fe-9994-46f3-8d78-fca9307823ab?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/b2bd86fe-9994-46f3-8d78-fca9307823ab?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/b2bd86fe-9994-46f3-8d78-fca9307823ab?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/b2bd86fe-9994-46f3-8d78-fca9307823ab?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/4861)
Author
Owner

@MickeyKay commented on GitHub (Sep 25, 2025):

@dosu Last question (hopefully) for now. At present, I'm using the organization plugin, which means that every user is associated with one or more roles that are specifically tied to one or more organizations. If I assign an API key to one of my super-admin users, are the associated key permissions somehow tied to a specific organization, or do they apply globally, irrespective of organization logic?

@MickeyKay commented on GitHub (Sep 25, 2025): @dosu Last question (hopefully) for now. At present, I'm using the organization plugin, which means that every user is associated with one or more roles that are specifically tied to one or more organizations. If I assign an API key to one of my super-admin users, are the associated key permissions somehow tied to a specific organization, or do they apply globally, irrespective of organization logic?
Author
Owner

@dosubot[bot] commented on GitHub (Sep 25, 2025):

API key permissions in Better Auth are always applied globally for the user they're assigned to, not scoped to a specific organization. When you use an API key for a super-admin user, all actions run in the context of that user, and any organization-based role checks are enforced by the organization plugin and endpoint logic—not by the API key itself. So, unless an endpoint specifically requires an organization context, the user's permissions (including organization roles) apply wherever relevant across all organizations they belong to. There’s no built-in way to restrict an API key to a single organization; the organization plugin enforces role logic globally for the user session created by the API key. Source

To reply, just mention @dosu.


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

@dosubot[bot] commented on GitHub (Sep 25, 2025): <!-- Answer --> API key permissions in Better Auth are always applied globally for the user they're assigned to, not scoped to a specific organization. When you use an API key for a super-admin user, all actions run in the context of that user, and any organization-based role checks are enforced by the organization plugin and endpoint logic—not by the API key itself. So, unless an endpoint specifically requires an organization context, the user's permissions (including organization roles) apply wherever relevant across all organizations they belong to. There’s no built-in way to restrict an API key to a single organization; the organization plugin enforces role logic globally for the user session created by the API key. [Source](https://github.com/better-auth/better-auth/blob/f6cbdcc84ee5d2971fdcc8b23ff7c174f88cf45b/packages/better-auth/src/plugins/api-key/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/dd867909-6c61-437a-a519-6285e8e7a961?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/dd867909-6c61-437a-a519-6285e8e7a961?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/dd867909-6c61-437a-a519-6285e8e7a961?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/dd867909-6c61-437a-a519-6285e8e7a961?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/dd867909-6c61-437a-a519-6285e8e7a961?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/dd867909-6c61-437a-a519-6285e8e7a961?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/dd867909-6c61-437a-a519-6285e8e7a961?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/4861)
Author
Owner

@MickeyKay commented on GitHub (Sep 25, 2025):

@dosu Okay, I've now got the following stripe plugin config in auth.ts, and I can confirm that the success console.log() is firing with expected data:

stripe({
  stripeClient,
  stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
  createCustomerOnSignUp: true,
  onCustomerCreate: async ({ user }) => {
    try {
      const data = await auth.api.upgradeSubscription({
        body: {
            plan: 'free',
            referenceId: user.id,
            successUrl: '/dashboard',
            cancelUrl: '/pricing',
            disableRedirect: true,
        },
        // This endpoint requires session cookies.
        headers: new Headers({
          'x-api-key': process.env.BETTER_AUTH_SUPER_ADMIN_API_KEY!,
        }),
      });

      console.log('[onCustomerCreate] Success', data);
    } catch(err) {
      console.error('[onCustomerCreate] Error', err)
    }
  },
  subscription: {
    enabled: true,
    plans,
    authorizeReference: async ({ user, action }) => {
      return true;
    }
  }
}),

. . . however when I view the new customer in my Stripe admin (they are indeed created), they have no subscription associated with them.

Here is my plans config, for reference:

export const plans = [
	{
    name: 'free',
    priceId: process.env.STRIPE_PRICE_ID_FREE,
    limits: {
    	maxMonitors: 5,
	    maxStatusPages: 3,
      maxOrganizations: 1, // Only allow personal organization.
	    minIntervalSeconds: 300,
	    maxTimeoutSeconds: 15,
	    maxRegions: 1,
	    retentionDays: 30,
    }
  },
  {
  	name: 'solo',
  	priceId: process.env.STRIPE_PRICE_ID_SOLO,
  	limits: {
  		maxMonitors: 50,
	    maxStatusPages: 10,
      maxOrganizations: 1,
	    minIntervalSeconds: 60,
	    maxTimeoutSeconds: 30,
	    maxRegions: 5,
	    retentionDays: 60,
  	},
  	freeTrial: {
  		days: 14,
  	}
  },
  {
  	name: 'team',
  	priceId: process.env.STRIPE_PRICE_ID_TEAM,
  	limits: {
  		maxMonitors: Infinity,
  		maxStatusPages: Infinity,
      maxOrganizations: 5,
  		minIntervalSeconds: 60,
  		maxTimeoutSeconds: 30,
  		maxRegions: 10,
  		retentionDays: 90,
  	},
  	freeTrial: {
  		days: 14,
  	}
  },
];

Any idea why this might be?

Additionally, while running stripe:listen in my local dev terminal, I can see that while my webhooks are being successfully triggered, I am getting the following error for one of them:

ERROR [Better Auth]: Stripe webhook failed. Error: TypeError: Cannot read properties of undefined (reading 'id')

. . . and I see that my subscriptions table in my database remains empty.

Any insights as to why this functionality might be failing? Anything I should check/verify?

@MickeyKay commented on GitHub (Sep 25, 2025): @dosu Okay, I've now got the following stripe plugin config in auth.ts, and I can confirm that the success `console.log()` is firing with expected `data`: ``` stripe({ stripeClient, stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, createCustomerOnSignUp: true, onCustomerCreate: async ({ user }) => { try { const data = await auth.api.upgradeSubscription({ body: { plan: 'free', referenceId: user.id, successUrl: '/dashboard', cancelUrl: '/pricing', disableRedirect: true, }, // This endpoint requires session cookies. headers: new Headers({ 'x-api-key': process.env.BETTER_AUTH_SUPER_ADMIN_API_KEY!, }), }); console.log('[onCustomerCreate] Success', data); } catch(err) { console.error('[onCustomerCreate] Error', err) } }, subscription: { enabled: true, plans, authorizeReference: async ({ user, action }) => { return true; } } }), ``` . . . however when I view the new customer in my Stripe admin (they are indeed created), they have no subscription associated with them. Here is my `plans` config, for reference: ``` export const plans = [ { name: 'free', priceId: process.env.STRIPE_PRICE_ID_FREE, limits: { maxMonitors: 5, maxStatusPages: 3, maxOrganizations: 1, // Only allow personal organization. minIntervalSeconds: 300, maxTimeoutSeconds: 15, maxRegions: 1, retentionDays: 30, } }, { name: 'solo', priceId: process.env.STRIPE_PRICE_ID_SOLO, limits: { maxMonitors: 50, maxStatusPages: 10, maxOrganizations: 1, minIntervalSeconds: 60, maxTimeoutSeconds: 30, maxRegions: 5, retentionDays: 60, }, freeTrial: { days: 14, } }, { name: 'team', priceId: process.env.STRIPE_PRICE_ID_TEAM, limits: { maxMonitors: Infinity, maxStatusPages: Infinity, maxOrganizations: 5, minIntervalSeconds: 60, maxTimeoutSeconds: 30, maxRegions: 10, retentionDays: 90, }, freeTrial: { days: 14, } }, ]; ``` Any idea why this might be? Additionally, while running `stripe:listen` in my local dev terminal, I can see that while my webhooks are being successfully triggered, I am getting the following error for one of them: ``` ERROR [Better Auth]: Stripe webhook failed. Error: TypeError: Cannot read properties of undefined (reading 'id') ``` . . . and I see that my `subscriptions` table in my database remains empty. Any insights as to why this functionality might be failing? Anything I should check/verify?
Author
Owner

@dosubot[bot] commented on GitHub (Sep 25, 2025):

The error you're seeing—TypeError: Cannot read properties of undefined (reading 'id')—is caused by a breaking change in the Stripe API: as of the 2025-03-01 version, fields like current_period_start and current_period_end are no longer top-level on the subscription object, but are now under subscription.items.data[0]. The Better Auth Stripe plugin's webhook handler hasn't been updated for this change, so it fails when processing the webhook, which prevents your subscription records from being created in the database. That's why your Stripe customers have no subscriptions and your subscriptions table is empty [source].

To fix this immediately, downgrade your Stripe API version in the Stripe dashboard and your project to 2024-07-01 or earlier, where those fields are still top-level. After downgrading, restart your server and retry the flow—this should restore webhook processing and subscription creation.

For a long-term fix, the webhook handler in Better Auth needs to be updated to read current_period_start and current_period_end from subscription.items.data[0] if they're not present at the top level.

Also, double-check that your STRIPE_PRICE_ID_FREE is set to a valid, active, recurring price in your Stripe dashboard, and make sure your better-auth and @better-auth/stripe package versions match exactly to avoid plugin compatibility issues.

Let me know if you need a code snippet for the webhook handler patch or if downgrading the API version resolves it for you!

To reply, just mention @dosu.


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

@dosubot[bot] commented on GitHub (Sep 25, 2025): <!-- Answer --> The error you're seeing—`TypeError: Cannot read properties of undefined (reading 'id')`—is caused by a breaking change in the Stripe API: as of the 2025-03-01 version, fields like `current_period_start` and `current_period_end` are no longer top-level on the subscription object, but are now under `subscription.items.data[0]`. The Better Auth Stripe plugin's webhook handler hasn't been updated for this change, so it fails when processing the webhook, which prevents your subscription records from being created in the database. That's why your Stripe customers have no subscriptions and your `subscriptions` table is empty [[source]](https://github.com/better-auth/better-auth/issues/2087). To fix this immediately, downgrade your Stripe API version in the Stripe dashboard and your project to 2024-07-01 or earlier, where those fields are still top-level. After downgrading, restart your server and retry the flow—this should restore webhook processing and subscription creation. For a long-term fix, the webhook handler in Better Auth needs to be updated to read `current_period_start` and `current_period_end` from `subscription.items.data[0]` if they're not present at the top level. Also, double-check that your `STRIPE_PRICE_ID_FREE` is set to a valid, active, recurring price in your Stripe dashboard, and make sure your `better-auth` and `@better-auth/stripe` package versions match exactly to avoid plugin compatibility issues. Let me know if you need a code snippet for the webhook handler patch or if downgrading the API version resolves it for you! <!-- 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/a71c805c-d8c4-4318-8995-7b7feb6f65b4?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/a71c805c-d8c4-4318-8995-7b7feb6f65b4?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/a71c805c-d8c4-4318-8995-7b7feb6f65b4?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/a71c805c-d8c4-4318-8995-7b7feb6f65b4?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/a71c805c-d8c4-4318-8995-7b7feb6f65b4?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/a71c805c-d8c4-4318-8995-7b7feb6f65b4?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/a71c805c-d8c4-4318-8995-7b7feb6f65b4?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/4861)
Author
Owner

@Zefty commented on GitHub (Sep 26, 2025):

Im running into a similar scenario, @MickeyKay did you managed to have a solution for this?

@Zefty commented on GitHub (Sep 26, 2025): Im running into a similar scenario, @MickeyKay did you managed to have a solution for this?
Author
Owner

@MickeyKay commented on GitHub (Sep 26, 2025):

@dosu despite my best searching, I cannot seem to find a solution for rolling back the API version in my stripe account. I only see the options to upgrade. Even when I set up a new account, the version options that are available are all later than the date you specified.

Do you have any suggestions for how to achieve the suggestion you made? Are there any indicators that the plug-in will be updated to support newer stripe versions in the near future? I’m curious why other users aren’t running into the same issue, given anyone who is installing the plug-in and spinning up a new stripe account would run into this, I imagine.

@MickeyKay commented on GitHub (Sep 26, 2025): @dosu despite my best searching, I cannot seem to find a solution for rolling back the API version in my stripe account. I only see the options to upgrade. Even when I set up a new account, the version options that are available are all later than the date you specified. Do you have any suggestions for how to achieve the suggestion you made? Are there any indicators that the plug-in will be updated to support newer stripe versions in the near future? I’m curious why other users aren’t running into the same issue, given anyone who is installing the plug-in and spinning up a new stripe account would run into this, I imagine.
Author
Owner

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

You can't roll back the Stripe API version for new accounts—Stripe only allows upgrades, not downgrades, and new accounts are locked to the latest API version. This is why the workaround I mentioned isn't possible if your account was created after Stripe's 2025-03-01 API change.

The root cause is that the Better Auth Stripe plugin's webhook handler expects fields like current_period_start and current_period_end at the top level of the subscription object, but Stripe moved them under subscription.items.data[0] in the new API. This breaks subscription creation and causes the webhook errors you're seeing (details).

Until the plugin is updated, the only workaround is to patch the webhook handler locally. In packages/stripe/src/routes/stripe-webhook.ts (or similar), update any code that accesses subscription.current_period_start and subscription.current_period_end to:

const periodStart = subscription.current_period_start ?? subscription.items?.data?.[0]?.current_period_start;
const periodEnd = subscription.current_period_end ?? subscription.items?.data?.[0]?.current_period_end;

And use periodStart and periodEnd in the rest of the handler. This will support both old and new Stripe API versions.

There's currently no official fix or PR in progress for this in the Better Auth repo, so all new Stripe accounts will hit this until the plugin is updated. Some users aren't affected because they have older Stripe accounts (with older API versions), aren't using subscriptions, or have patched their own forks.

Keep an eye on the repo for updates, but for now, a local patch is the only way forward for new Stripe accounts.

To reply, just mention @dosu.


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

@dosubot[bot] commented on GitHub (Sep 26, 2025): <!-- Answer --> You can't roll back the Stripe API version for new accounts—Stripe only allows upgrades, not downgrades, and new accounts are locked to the latest API version. This is why the workaround I mentioned isn't possible if your account was created after Stripe's 2025-03-01 API change. The root cause is that the Better Auth Stripe plugin's webhook handler expects fields like `current_period_start` and `current_period_end` at the top level of the subscription object, but Stripe moved them under `subscription.items.data[0]` in the new API. This breaks subscription creation and causes the webhook errors you're seeing [(details)](https://github.com/better-auth/better-auth/issues/2087). Until the plugin is updated, the only workaround is to patch the webhook handler locally. In `packages/stripe/src/routes/stripe-webhook.ts` (or similar), update any code that accesses `subscription.current_period_start` and `subscription.current_period_end` to: ```js const periodStart = subscription.current_period_start ?? subscription.items?.data?.[0]?.current_period_start; const periodEnd = subscription.current_period_end ?? subscription.items?.data?.[0]?.current_period_end; ``` And use `periodStart` and `periodEnd` in the rest of the handler. This will support both old and new Stripe API versions. There's currently no official fix or PR in progress for this in the Better Auth repo, so all new Stripe accounts will hit this until the plugin is updated. Some users aren't affected because they have older Stripe accounts (with older API versions), aren't using subscriptions, or have patched their own forks. Keep an eye on the repo for updates, but for now, a local patch is the only way forward for new Stripe accounts. <!-- 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/04a84e5e-b943-45a3-a410-dc071e9fd02a?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/04a84e5e-b943-45a3-a410-dc071e9fd02a?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/04a84e5e-b943-45a3-a410-dc071e9fd02a?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/04a84e5e-b943-45a3-a410-dc071e9fd02a?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/04a84e5e-b943-45a3-a410-dc071e9fd02a?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/04a84e5e-b943-45a3-a410-dc071e9fd02a?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/04a84e5e-b943-45a3-a410-dc071e9fd02a?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/4861)
Author
Owner

@MickeyKay commented on GitHub (Sep 26, 2025):

@dosu Ah, the real solution here was to just update both the @better-auth/stripe and better-auth packages to there latest versions, which solved this problem! Subscriptions are now populating in my table as expected! I do have two follow-up questions though, based on my current code which looks like this:

stripe({
  stripeClient,
  stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
  createCustomerOnSignUp: true,
  onCustomerCreate: async ({ user }) => {
    try {
      const data = await auth.api.upgradeSubscription({
        body: {
            plan: 'free',
            referenceId: user.id,
            successUrl: '/dashboard',
            cancelUrl: '/pricing',
            disableRedirect: true,
        },
        // This endpoint requires session cookies.
        headers: new Headers({
          'x-api-key': process.env.BETTER_AUTH_SUPER_ADMIN_API_KEY!,
        }),
      });

      console.log('[onCustomerCreate] Success', user);
    } catch(err) {
      console.error('[onCustomerCreate] Error', err)
    }
  },
  subscription: {
    enabled: true,
    plans,
    authorizeReference: async ({ user, action }) => {
      return true;
    }
  }
}),
  1. Am I supposed to redirect the user to the data.url value returned by auth.api.upgradeSubscription? If so, is there a recommended way to do this from the server so I don't prematurely return from the onCustomerCreate callback? In other words, I have no idea if there's still better-auth logic that needs to run after this callback, and so doing something like redirect(data.url) seems risky.

  2. All subscriptions are being associated with the admin user for whom I created (and am passing) the x-api-key value. Even though I specify referenceId: user.id (and can confirm user.id is indeed the id of the newly created user), it seems like the auth.api.upgradeSubscription() call is instead referencing the super-admin user associated with the x-api-key I'm passing, and therefor assigning all new subscriptions to them instead of the newly registered user. Is this expected? Am I doing something wrong in this code?

@MickeyKay commented on GitHub (Sep 26, 2025): @dosu Ah, the real solution here was to just update both the @better-auth/stripe and better-auth packages to there latest versions, which solved this problem! Subscriptions are now populating in my table as expected! I do have two follow-up questions though, based on my current code which looks like this: ``` stripe({ stripeClient, stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, createCustomerOnSignUp: true, onCustomerCreate: async ({ user }) => { try { const data = await auth.api.upgradeSubscription({ body: { plan: 'free', referenceId: user.id, successUrl: '/dashboard', cancelUrl: '/pricing', disableRedirect: true, }, // This endpoint requires session cookies. headers: new Headers({ 'x-api-key': process.env.BETTER_AUTH_SUPER_ADMIN_API_KEY!, }), }); console.log('[onCustomerCreate] Success', user); } catch(err) { console.error('[onCustomerCreate] Error', err) } }, subscription: { enabled: true, plans, authorizeReference: async ({ user, action }) => { return true; } } }), ``` 1. Am I supposed to redirect the user to the `data.url` value returned by `auth.api.upgradeSubscription`? If so, is there a recommended way to do this from the server so I don't prematurely return from the `onCustomerCreate` callback? In other words, I have no idea if there's still better-auth logic that needs to run after this callback, and so doing something like `redirect(data.url)` seems risky. 2. All subscriptions are being associated with the admin user for whom I created (and am passing) the `x-api-key` value. Even though I specify `referenceId: user.id` (and can confirm `user.id` is indeed the id of the newly created user), it seems like the `auth.api.upgradeSubscription()` call is instead referencing the super-admin user associated with the `x-api-key` I'm passing, and therefor assigning all new subscriptions to them instead of the newly registered user. Is this expected? Am I doing something wrong in this code?
Author
Owner

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

Great questions!

  1. You should not redirect the user to the data.url from the server or inside the onCustomerCreate hook. Server-side hooks like onCustomerCreate are meant for non-interactive side effects (like logging or automation), not for controlling browser navigation. The redirect logic for Stripe checkout should always be handled client-side, after the user is authenticated and their session is established. The server just returns the checkout session URL and a redirect flag; it's up to your frontend to use that URL for navigation when appropriate. If you try to redirect from the server or from a hook, you risk breaking the Better Auth flow or interfering with session logic. Source

  2. Subscriptions are being associated with the admin user (the API key owner) because API requests authenticated with an API key always run in the context of the user the key belongs to. Passing referenceId: user.id does not override this; it only sets a reference field for record association and authorization checks, but the subscription ownership is still tied to the authenticated user (the admin). This is expected behavior. To associate a subscription with the newly registered user, you need to perform the upgradeSubscription call from within an authenticated session for that user (e.g., after login), or use a user-specific API key. Using an admin API key will always associate the subscription with the admin user, regardless of the referenceId you pass. Source

For onboarding flows, the recommended pattern is: after signup and session creation, redirect the user client-side to the Stripe checkout URL returned by upgradeSubscription, so the subscription is created for the correct user. Server-side automation with an admin API key is best for system-wide tasks, not for onboarding individual users.

Let me know if you want a code example for the client-side redirect pattern!

To reply, just mention @dosu.


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

@dosubot[bot] commented on GitHub (Sep 26, 2025): <!-- Answer --> Great questions! 1. You should **not** redirect the user to the `data.url` from the server or inside the `onCustomerCreate` hook. Server-side hooks like `onCustomerCreate` are meant for non-interactive side effects (like logging or automation), not for controlling browser navigation. The redirect logic for Stripe checkout should always be handled client-side, after the user is authenticated and their session is established. The server just returns the checkout session URL and a redirect flag; it's up to your frontend to use that URL for navigation when appropriate. If you try to redirect from the server or from a hook, you risk breaking the Better Auth flow or interfering with session logic. [Source](https://github.com/better-auth/better-auth/blob/f6cbdcc84ee5d2971fdcc8b23ff7c174f88cf45b/docs/content/docs/plugins/stripe.mdx) 2. Subscriptions are being associated with the admin user (the API key owner) because API requests authenticated with an API key always run in the context of the user the key belongs to. Passing `referenceId: user.id` does not override this; it only sets a reference field for record association and authorization checks, but the subscription ownership is still tied to the authenticated user (the admin). This is expected behavior. To associate a subscription with the newly registered user, you need to perform the `upgradeSubscription` call from within an authenticated session for that user (e.g., after login), or use a user-specific API key. Using an admin API key will always associate the subscription with the admin user, regardless of the `referenceId` you pass. [Source](https://github.com/better-auth/better-auth/blob/f6cbdcc84ee5d2971fdcc8b23ff7c174f88cf45b/packages/stripe/src/index.ts) For onboarding flows, the recommended pattern is: after signup and session creation, redirect the user client-side to the Stripe checkout URL returned by `upgradeSubscription`, so the subscription is created for the correct user. Server-side automation with an admin API key is best for system-wide tasks, not for onboarding individual users. Let me know if you want a code example for the client-side redirect pattern! <!-- 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/37f50472-cb15-48d7-b415-5007df109ceb?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/37f50472-cb15-48d7-b415-5007df109ceb?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/37f50472-cb15-48d7-b415-5007df109ceb?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/37f50472-cb15-48d7-b415-5007df109ceb?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/37f50472-cb15-48d7-b415-5007df109ceb?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/37f50472-cb15-48d7-b415-5007df109ceb?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/37f50472-cb15-48d7-b415-5007df109ceb?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/4861)
Author
Owner

@MickeyKay commented on GitHub (Sep 26, 2025):

@dosu Well dang, this would have been nice to know from the start. I'm a bit confused here, since you were the one that suggested that using the API Key plugin was the way to perform user-specific actions in a non-authenticated context, but it sounds like it doesn't work in this case. To play back what you suggested:

The recommended way to perform auth-gated Better Auth actions (like auto-subscribing new users to a Stripe plan or creating default organizations) from server-side hooks or background jobs—where session/auth headers aren't available—is to use the API Key plugin. API keys let you authenticate requests programmatically and create a mock session for the user, bypassing the need for interactive session cookies.

I sounds like this doesn't actually work - is that right?

Also, per your suggestion of handling all of this on the client, this is exactly what I was asking about in my very first comment in this thread:

Should I be doing all this client-side instead, on some interstitial page that new users are redirected to (that seems prone to all sorts of issues)?

Your suggestions all seemed to convey that this could be accomplished on the server. If that's not the case, then what is the use-case for the server-side code auth.api.upgradeSubscription? How is this function intended to be used if the callback URL needs to somehow be passed to the client (or the user at least needs to otherwise be redirected)? The docs for this plugin really don't spell that out clearly.

@MickeyKay commented on GitHub (Sep 26, 2025): @dosu Well dang, this would have been nice to know from the start. I'm a bit confused here, since you were the one that suggested that using the API Key plugin was the way to perform user-specific actions in a non-authenticated context, but it sounds like it doesn't work in this case. To play back what you suggested: > The recommended way to perform auth-gated Better Auth actions (like auto-subscribing new users to a Stripe plan or creating default organizations) from server-side hooks or background jobs—where session/auth headers aren't available—is to use the [API Key plugin](https://github.com/better-auth/better-auth/blob/f6cbdcc84ee5d2971fdcc8b23ff7c174f88cf45b/docs/content/docs/plugins/api-key.mdx). API keys let you authenticate requests programmatically and create a mock session for the user, bypassing the need for interactive session cookies. I sounds like this doesn't actually work - is that right? Also, per your suggestion of handling all of this on the client, this is exactly what I was asking about in my very first comment in this thread: > Should I be doing all this client-side instead, on some interstitial page that new users are redirected to (that seems prone to all sorts of issues)? Your suggestions all seemed to convey that this could be accomplished on the server. If that's _not_ the case, then what is the use-case for the server-side code `auth.api.upgradeSubscription`? How is this function intended to be used if the callback URL needs to somehow be passed to the client (or the user at least needs to otherwise be redirected)? The docs for this plugin really don't spell that out clearly.
Author
Owner

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

You're right—using the API Key plugin does not let you perform user-specific actions (like upgrading a subscription for a new user) in the context of that user. API keys always act as the user they belong to; passing referenceId only sets a reference field, but the subscription is owned by the authenticated user (the API key owner), not the user you specify in referenceId docs.

For onboarding flows, the only way to associate a subscription with a new user is to call upgradeSubscription from an authenticated session for that user—typically, after signup and session creation, you redirect the user client-side to the Stripe checkout URL returned by the API docs. Server-side hooks like onCustomerCreate are for non-interactive side effects and can't control browser navigation or impersonate arbitrary users.

The intended use-case for server-side auth.api.upgradeSubscription is for automation or admin actions in the context of the API key owner (e.g., managing your own org's subscriptions), not for onboarding new users. The docs could definitely be clearer about this limitation and the need for client-side handling for user-specific onboarding flows.

If you want a code example for the recommended client-side onboarding pattern, let me know!

To reply, just mention @dosu.


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

@dosubot[bot] commented on GitHub (Sep 26, 2025): <!-- Answer --> You're right—using the API Key plugin does not let you perform user-specific actions (like upgrading a subscription for a new user) in the context of that user. API keys always act as the user they belong to; passing `referenceId` only sets a reference field, but the subscription is owned by the authenticated user (the API key owner), not the user you specify in `referenceId` [docs](https://github.com/better-auth/better-auth/blob/f6cbdcc84ee5d2971fdcc8b23ff7c174f88cf45b/packages/stripe/src/index.ts). For onboarding flows, the only way to associate a subscription with a new user is to call `upgradeSubscription` from an authenticated session for that user—typically, after signup and session creation, you redirect the user client-side to the Stripe checkout URL returned by the API [docs](https://github.com/better-auth/better-auth/blob/f6cbdcc84ee5d2971fdcc8b23ff7c174f88cf45b/docs/content/docs/plugins/stripe.mdx). Server-side hooks like `onCustomerCreate` are for non-interactive side effects and can't control browser navigation or impersonate arbitrary users. The intended use-case for server-side `auth.api.upgradeSubscription` is for automation or admin actions in the context of the API key owner (e.g., managing your own org's subscriptions), not for onboarding new users. The docs could definitely be clearer about this limitation and the need for client-side handling for user-specific onboarding flows. If you want a code example for the recommended client-side onboarding pattern, let me know! <!-- 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/9c58c477-e005-412e-a07f-83e6b4049d47?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/9c58c477-e005-412e-a07f-83e6b4049d47?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/9c58c477-e005-412e-a07f-83e6b4049d47?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/9c58c477-e005-412e-a07f-83e6b4049d47?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/9c58c477-e005-412e-a07f-83e6b4049d47?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/9c58c477-e005-412e-a07f-83e6b4049d47?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/9c58c477-e005-412e-a07f-83e6b4049d47?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/4861)
Author
Owner

@MickeyKay commented on GitHub (Sep 26, 2025):

@dosu I would love a client-side onboarding example please!

@MickeyKay commented on GitHub (Sep 26, 2025): @dosu I would love a client-side onboarding example please!
Author
Owner

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

Here’s a practical client-side onboarding example for upgrading a new user’s subscription with Better Auth and Stripe:

After signup and authentication (i.e., once the user has a valid session), call the subscription upgrade API from your frontend and redirect the user to the Stripe Checkout URL returned by the API. This ensures the subscription is associated with the correct user.

Here’s a React-style example:

import { useRouter } from "next/router";
import { useState } from "react";
// Import your Better Auth client instance
import { authClient } from "@/lib/auth-client";

export function OnboardingSubscribeButton() {
  const router = useRouter();
  const [loading, setLoading] = useState(false);

  const handleSubscribe = async () => {
    setLoading(true);
    try {
      // Must be called after user is authenticated (e.g., after signup/login)
      const result = await authClient.subscription.upgrade({
        plan: "free", // or your plan name
        successUrl: "/dashboard", // where to send the user after successful payment
        cancelUrl: "/pricing",    // where to send the user if they cancel
        // referenceId, seats, metadata, etc. can be added as needed
      });

      if (result?.url) {
        // Redirect the user to Stripe Checkout
        window.location.assign(result.url);
      } else {
        // Handle unexpected result
        alert("Failed to initiate checkout. Please try again.");
      }
    } catch (err) {
      // Handle API errors (e.g., show a toast or error message)
      alert("Subscription upgrade failed: " + (err?.message || err));
    } finally {
      setLoading(false);
    }
  };

  return (
    <button onClick={handleSubscribe} disabled={loading}>
      {loading ? "Redirecting..." : "Start Free Plan"}
    </button>
  );
}

Key points:

  • Only call authClient.subscription.upgrade after the user is authenticated; this ensures the subscription is tied to the correct user session.
  • The API returns a Stripe Checkout URL (result.url). Redirect the user to this URL to complete payment.
  • After payment, Stripe will redirect the user to your successUrl (via an intermediate Better Auth endpoint that ensures the subscription is processed before redirecting).
  • Handle errors gracefully and consider what to do if the user cancels or abandons checkout (e.g., show a status page or allow retry).
  • For team/org plans, pass referenceId and seats as needed. For custom metadata, use the getCheckoutSessionParams option in your Stripe plugin config.

References and more details:

Let me know if you want a Next.js-specific example or have questions about handling advanced flows!

To reply, just mention @dosu.


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

@dosubot[bot] commented on GitHub (Sep 26, 2025): <!-- Answer --> Here’s a practical client-side onboarding example for upgrading a new user’s subscription with Better Auth and Stripe: After signup and authentication (i.e., once the user has a valid session), call the subscription upgrade API from your frontend and redirect the user to the Stripe Checkout URL returned by the API. This ensures the subscription is associated with the correct user. Here’s a React-style example: ```tsx import { useRouter } from "next/router"; import { useState } from "react"; // Import your Better Auth client instance import { authClient } from "@/lib/auth-client"; export function OnboardingSubscribeButton() { const router = useRouter(); const [loading, setLoading] = useState(false); const handleSubscribe = async () => { setLoading(true); try { // Must be called after user is authenticated (e.g., after signup/login) const result = await authClient.subscription.upgrade({ plan: "free", // or your plan name successUrl: "/dashboard", // where to send the user after successful payment cancelUrl: "/pricing", // where to send the user if they cancel // referenceId, seats, metadata, etc. can be added as needed }); if (result?.url) { // Redirect the user to Stripe Checkout window.location.assign(result.url); } else { // Handle unexpected result alert("Failed to initiate checkout. Please try again."); } } catch (err) { // Handle API errors (e.g., show a toast or error message) alert("Subscription upgrade failed: " + (err?.message || err)); } finally { setLoading(false); } }; return ( <button onClick={handleSubscribe} disabled={loading}> {loading ? "Redirecting..." : "Start Free Plan"} </button> ); } ``` Key points: - Only call `authClient.subscription.upgrade` after the user is authenticated; this ensures the subscription is tied to the correct user session. - The API returns a Stripe Checkout URL (`result.url`). Redirect the user to this URL to complete payment. - After payment, Stripe will redirect the user to your `successUrl` (via an intermediate Better Auth endpoint that ensures the subscription is processed before redirecting). - Handle errors gracefully and consider what to do if the user cancels or abandons checkout (e.g., show a status page or allow retry). - For team/org plans, pass `referenceId` and `seats` as needed. For custom metadata, use the `getCheckoutSessionParams` option in your Stripe plugin config. References and more details: - [Better Auth Stripe plugin docs: Client-side usage & redirect pattern](https://github.com/better-auth/better-auth/blob/f6cbdcc84ee5d2971fdcc8b23ff7c174f88cf45b/docs/content/docs/plugins/stripe.mdx) - [API implementation details](https://github.com/better-auth/better-auth/blob/f6cbdcc84ee5d2971fdcc8b23ff7c174f88cf45b/packages/stripe/src/index.ts) Let me know if you want a Next.js-specific example or have questions about handling advanced flows! <!-- 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/01d02348-742f-48cf-a95b-67100ef6f95a?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/01d02348-742f-48cf-a95b-67100ef6f95a?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/01d02348-742f-48cf-a95b-67100ef6f95a?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/01d02348-742f-48cf-a95b-67100ef6f95a?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/01d02348-742f-48cf-a95b-67100ef6f95a?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/01d02348-742f-48cf-a95b-67100ef6f95a?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/01d02348-742f-48cf-a95b-67100ef6f95a?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/4861)
Author
Owner

@MickeyKay commented on GitHub (Sep 26, 2025):

@dosu out of curiosity, the API keys plugin clearly didn’t work, but would the admin plugin perhaps get the job done? Could I use its impersonation functionality to somehow pass valid authenticated headers? Or would we run into the same or similar issues?

@MickeyKay commented on GitHub (Sep 26, 2025): @dosu out of curiosity, the API keys plugin clearly didn’t work, but would the admin plugin perhaps get the job done? Could I use its impersonation functionality to somehow pass valid authenticated headers? Or would we run into the same or similar issues?
Author
Owner

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

The admin plugin’s impersonation feature is designed for interactive use (like admin dashboards or browser-based support), not for backend automation or onboarding flows. While you can use the impersonateUser API to generate a session token for another user, you’d have to manually propagate that token in the headers for every subsequent API call—and the server context doesn’t switch automatically. This pattern is not officially supported or recommended for server-to-server actions like onboarding or subscription upgrades, and it comes with security and operational caveats docs discussion.

All privileged admin plugin APIs (except createUser) require a valid session, even for server-side calls, and there’s no built-in way to bypass this for automation. The impersonation session is short-lived and must be explicitly used for each API call; it’s not intended for backend automation reference.

So, you’d run into the same limitations as with API keys: there’s no supported way to perform user-specific onboarding actions server-side. The recommended approach remains to initiate onboarding (like subscription upgrades) client-side, after the user is authenticated, to ensure the correct user context and security.

If you have a strong need for server-side onboarding automation, sharing your use case with the Better Auth team could help inform future improvements.

To reply, just mention @dosu.


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

@dosubot[bot] commented on GitHub (Sep 26, 2025): <!-- Answer --> The admin plugin’s impersonation feature is designed for interactive use (like admin dashboards or browser-based support), not for backend automation or onboarding flows. While you can use the impersonateUser API to generate a session token for another user, you’d have to manually propagate that token in the headers for every subsequent API call—and the server context doesn’t switch automatically. This pattern is not officially supported or recommended for server-to-server actions like onboarding or subscription upgrades, and it comes with security and operational caveats [docs](https://github.com/better-auth/better-auth/blob/f6cbdcc84ee5d2971fdcc8b23ff7c174f88cf45b/docs/content/docs/plugins/admin.mdx) [discussion](https://github.com/better-auth/better-auth/issues/3547). All privileged admin plugin APIs (except createUser) require a valid session, even for server-side calls, and there’s no built-in way to bypass this for automation. The impersonation session is short-lived and must be explicitly used for each API call; it’s not intended for backend automation [reference](https://github.com/better-auth/better-auth/issues/3717#issuecomment-3140014443). So, you’d run into the same limitations as with API keys: there’s no supported way to perform user-specific onboarding actions server-side. The recommended approach remains to initiate onboarding (like subscription upgrades) client-side, after the user is authenticated, to ensure the correct user context and security. If you have a strong need for server-side onboarding automation, sharing your use case with the Better Auth team could help inform future improvements. <!-- 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/023b8405-e9a1-4047-a468-0283441b3e0a?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/023b8405-e9a1-4047-a468-0283441b3e0a?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/023b8405-e9a1-4047-a468-0283441b3e0a?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/023b8405-e9a1-4047-a468-0283441b3e0a?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/023b8405-e9a1-4047-a468-0283441b3e0a?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/023b8405-e9a1-4047-a468-0283441b3e0a?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/023b8405-e9a1-4047-a468-0283441b3e0a?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/4861)
Author
Owner

@nfmelendez commented on GitHub (Oct 1, 2025):

@dosu I have somehow the same problem, i am in the backend and i want to know if a user has permissons within an organization (i am using organization plugin) to perform an action but i don't have the headers because is a background job, so is impossible for me in the backend to call betterAuth.api.hasPermission to know if the user can perform an action. how i solve this, seems that a pretty common case

@nfmelendez commented on GitHub (Oct 1, 2025): @dosu I have somehow the same problem, i am in the backend and i want to know if a user has permissons within an organization (i am using organization plugin) to perform an action but i don't have the headers because is a background job, so is impossible for me in the backend to call **betterAuth.api.hasPermission** to know if the user can perform an action. how i solve this, seems that a pretty common case
Author
Owner

@bytaesu commented on GitHub (Nov 7, 2025):

Hello @MickeyKay

Could you try setting it up like this?

I tested it in a real app, and the subscription was created successfully. While we could add an option for it, this case seems well-suited for implementation through a hook. Please leave a comment if you think this approach isn’t appropriate.

createCustomerOnSignUp: true,
onCustomerCreate: async ({ stripeCustomer, user }, ctx) => {
	const subscription = await stripeClient.subscriptions.create({
		customer: stripeCustomer.id,
		items: [{ price: YOUR_FREE_PRICE_ID }],
	});
	await ctx.context.adapter.create({
		model: "subscription",
		data: {
			referenceId: user.id,
			stripeCustomerId: stripeCustomer.id,
			stripeSubscriptionId: subscription.id,
			status: "active",
			plan: "free",
		},
	});
},
@bytaesu commented on GitHub (Nov 7, 2025): Hello @MickeyKay Could you try setting it up like this? I tested it in a real app, and the subscription was created successfully. While we could add an option for it, this case seems well-suited for implementation through a hook. Please leave a comment if you think this approach isn’t appropriate. ```ts createCustomerOnSignUp: true, onCustomerCreate: async ({ stripeCustomer, user }, ctx) => { const subscription = await stripeClient.subscriptions.create({ customer: stripeCustomer.id, items: [{ price: YOUR_FREE_PRICE_ID }], }); await ctx.context.adapter.create({ model: "subscription", data: { referenceId: user.id, stripeCustomerId: stripeCustomer.id, stripeSubscriptionId: subscription.id, status: "active", plan: "free", }, }); }, ```
Author
Owner

@bytaesu commented on GitHub (Nov 20, 2025):

Closing this issue. Please comment if the problem persists.

@bytaesu commented on GitHub (Nov 20, 2025): Closing this issue. Please comment if the problem persists.
Author
Owner

@mmikhan commented on GitHub (Jan 25, 2026):

@bytaesu thanks for the example code snippet that you provided. It works exactly as expected. Would have been very helpful to have it documented 🙏

@mmikhan commented on GitHub (Jan 25, 2026): @bytaesu thanks for the example code snippet that you provided. It works exactly as expected. Would have been very helpful to have it documented 🙏
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#2005