diff --git a/.changeset/kind-rice-poke.md b/.changeset/kind-rice-poke.md
new file mode 100644
index 0000000000..e2d7143259
--- /dev/null
+++ b/.changeset/kind-rice-poke.md
@@ -0,0 +1,5 @@
+---
+"@better-auth/stripe": patch
+---
+
+A new API endpoint to create Stripe billing portal sessions, allowing users to manage subscriptions, payment methods, and billing history
diff --git a/docs/content/docs/plugins/stripe.mdx b/docs/content/docs/plugins/stripe.mdx
index 48720580a0..8be9620fcc 100644
--- a/docs/content/docs/plugins/stripe.mdx
+++ b/docs/content/docs/plugins/stripe.mdx
@@ -384,6 +384,31 @@ This will reactivate a subscription that was previously set to cancel at the end
> **Note:** This only works for subscriptions that are still active but marked to cancel at the end of the period. It cannot restore subscriptions that have already ended.
+#### Creating Billing Portal Sessions
+
+To create a [Stripe billing portal session](https://docs.stripe.com/api/customer_portal/sessions/create) where customers can manage their subscriptions, update payment methods, and view billing history:
+
+
+```ts
+type createBillingPortal = {
+ /**
+ * Reference id of the subscription to upgrade.
+ */
+ referenceId?: string = "123"
+ /**
+ * Return URL to redirect back after successful subscription.
+ */
+ returnUrl?: string
+}
+```
+
+
+This endpoint creates a Stripe billing portal session and returns a URL in the response as `data.url`. You can redirect users to this URL to allow them to manage their subscription, payment methods, and billing history.
+
### Reference System
By default, subscriptions are associated with the user ID. However, you can use a custom reference ID to associate subscriptions with other entities, such as organizations:
@@ -822,4 +847,4 @@ For local development, you can use the Stripe CLI to forward webhooks to your lo
stripe listen --forward-to localhost:3000/api/auth/stripe/webhook
```
-This will provide you with a webhook signing secret that you can use in your local environment.
+This will provide you with a webhook signing secret that you can use in your local environment.
\ No newline at end of file
diff --git a/packages/stripe/src/index.ts b/packages/stripe/src/index.ts
index 54a0017f6e..283ffb35e5 100644
--- a/packages/stripe/src/index.ts
+++ b/packages/stripe/src/index.ts
@@ -69,7 +69,8 @@ export const stripe = (options: O) => {
| "upgrade-subscription"
| "list-subscription"
| "cancel-subscription"
- | "restore-subscription",
+ | "restore-subscription"
+ | "billing-portal",
) =>
createAuthMiddleware(async (ctx) => {
const session = ctx.context.session;
@@ -1033,6 +1034,73 @@ export const stripe = (options: O) => {
throw ctx.redirect(getUrl(ctx, callbackURL));
},
),
+ createBillingPortal: createAuthEndpoint(
+ "/subscription/billing-portal",
+ {
+ method: "POST",
+ body: z.object({
+ referenceId: z.string().optional(),
+ returnUrl: z.string().default("/"),
+ }),
+ use: [
+ sessionMiddleware,
+ originCheck((ctx) => ctx.body.returnUrl),
+ referenceMiddleware("billing-portal"),
+ ],
+ },
+ async (ctx) => {
+ const { user } = ctx.context.session;
+ const referenceId = ctx.body.referenceId || user.id;
+
+ let customerId = user.stripeCustomerId;
+
+ if (!customerId) {
+ const subscription = await ctx.context.adapter
+ .findMany({
+ model: "subscription",
+ where: [
+ {
+ field: "referenceId",
+ value: referenceId,
+ },
+ ],
+ })
+ .then((subs) =>
+ subs.find(
+ (sub) => sub.status === "active" || sub.status === "trialing",
+ ),
+ );
+
+ customerId = subscription?.stripeCustomerId;
+ }
+
+ if (!customerId) {
+ throw new APIError("BAD_REQUEST", {
+ message: "No Stripe customer found for this user",
+ });
+ }
+
+ try {
+ const { url } = await client.billingPortal.sessions.create({
+ customer: customerId,
+ return_url: getUrl(ctx, ctx.body.returnUrl),
+ });
+
+ return ctx.json({
+ url,
+ redirect: true,
+ });
+ } catch (error: any) {
+ ctx.context.logger.error(
+ "Error creating billing portal session",
+ error,
+ );
+ throw new APIError("BAD_REQUEST", {
+ message: error.message,
+ });
+ }
+ },
+ ),
} as const;
return {
id: "stripe",
diff --git a/packages/stripe/src/stripe.test.ts b/packages/stripe/src/stripe.test.ts
index 785bf0ba11..2af96b4d32 100644
--- a/packages/stripe/src/stripe.test.ts
+++ b/packages/stripe/src/stripe.test.ts
@@ -844,4 +844,40 @@ describe("stripe", async () => {
expect(mockStripe.customers.create).toHaveBeenCalledTimes(1);
});
+
+ it("should create billing portal session", async () => {
+ await authClient.signUp.email(
+ {
+ ...testUser,
+ email: "billing-portal@email.com",
+ },
+ {
+ throw: true,
+ },
+ );
+
+ const headers = new Headers();
+ await authClient.signIn.email(
+ {
+ ...testUser,
+ email: "billing-portal@email.com",
+ },
+ {
+ throw: true,
+ onSuccess: setCookieToHeader(headers),
+ },
+ );
+ const billingPortalRes = await authClient.subscription.billingPortal({
+ returnUrl: "/dashboard",
+ fetchOptions: {
+ headers,
+ },
+ });
+ expect(billingPortalRes.data?.url).toBe("https://billing.stripe.com/mock");
+ expect(billingPortalRes.data?.redirect).toBe(true);
+ expect(mockStripe.billingPortal.sessions.create).toHaveBeenCalledWith({
+ customer: expect.any(String),
+ return_url: "http://localhost:3000/dashboard",
+ });
+ });
});
diff --git a/packages/stripe/src/types.ts b/packages/stripe/src/types.ts
index 2130ae79b3..11df6eee5a 100644
--- a/packages/stripe/src/types.ts
+++ b/packages/stripe/src/types.ts
@@ -274,7 +274,8 @@ export interface StripeOptions {
| "upgrade-subscription"
| "list-subscription"
| "cancel-subscription"
- | "restore-subscription";
+ | "restore-subscription"
+ | "billing-portal";
},
ctx: GenericEndpointContext,
) => Promise;