[GH-ISSUE #5780] Stripe recurring subscription cancel doest make DB set cancelAtPeriodEnd = true #10349

Closed
opened 2026-04-13 06:25:21 -05:00 by GiteaMirror · 11 comments
Owner

Originally created by @rayyan-buildin2 on GitHub (Nov 5, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/5780

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. User subscribes to a recurring plan (e.g., "Starter").
  2. User clicks Cancel button in our app.
  3. Better Auth redirects user to Stripe Billing Portal.
  4. User cancels subscription in Stripe and returns to our app.
  5. User expects to see Restore button, but still sees Cancel button.
  6. User clicks Cancel button again. Server responds that subscription is already canceled, updates DB status.
  7. Now, the Restore button appears.

Current vs. Expected behavior

After canceling in Stripe and returning to the app, the Restore button should immediately appear if the subscription is eligible for restore (cancelAtPeriodEnd = true).

What version of Better Auth are you using?

1.3.34

System info

{
  "system": {
    "platform": "darwin",
    "arch": "arm64",
    "version": "Darwin Kernel Version 24.6.0: Mon Jul 14 11:30:34 PDT 2025; root:xnu-11417.140.69~1/RELEASE_ARM64_T8103",
    "release": "24.6.0",
    "cpuCount": 8,
    "cpuModel": "Apple M1",
    "totalMemory": "8.00 GB",
    "freeMemory": "0.16 GB"
  },
  "node": {
    "version": "v22.17.1",
    "env": "development"
  },
  "packageManager": {
    "name": "pnpm",
    "version": "10.16.1"
  },
  "frameworks": [
    {
      "name": "express",
      "version": "^5.1.0"
    }
  ],
  "databases": null,
  "betterAuth": {
    "version": "^1.3.34",
    "config": {
      "secret": "[REDACTED]",
      "baseURL": "http://localhost:3000",
      "trustedOrigins": [
        "http://localhost:5173"
      ],
      "errorURL": "http://localhost:5173",
      "user": {
        "additionalFields": {
          "isOnboardingComplete": {
            "type": "boolean",
            "defaultValue": false,
            "required": false
          }
        }
      },
      "account": {
        "accountLinking": {
          "enabled": true,
          "allowDifferentEmails": false,
          "updateUserInfoOnLink": true
        }
      },
      "plugins": [
        {
          "name": "admin",
          "config": {
            "id": "admin",
            "hooks": {
              "after": [
                {}
              ]
            },
            "endpoints": {},
            "$ERROR_CODES": {
              "FAILED_TO_CREATE_USER": "Failed to create user",
              "USER_ALREADY_EXISTS": "User already exists.",
              "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL": "User already exists. Use another email.",
              "YOU_CANNOT_BAN_YOURSELF": "You cannot ban yourself",
              "YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE": "You are not allowed to change users role",
              "YOU_ARE_NOT_ALLOWED_TO_CREATE_USERS": "You are not allowed to create users",
              "YOU_ARE_NOT_ALLOWED_TO_LIST_USERS": "You are not allowed to list users",
              "YOU_ARE_NOT_ALLOWED_TO_LIST_USERS_SESSIONS": "You are not allowed to list users sessions",
              "YOU_ARE_NOT_ALLOWED_TO_BAN_USERS": "You are not allowed to ban users",
              "YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS": "You are not allowed to impersonate users",
              "YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS": "You are not allowed to revoke users sessions",
              "YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS": "You are not allowed to delete users",
              "YOU_ARE_NOT_ALLOWED_TO_SET_USERS_PASSWORD": "[REDACTED]",
              "BANNED_USER": "You have been banned from this application",
              "YOU_ARE_NOT_ALLOWED_TO_GET_USER": "You are not allowed to get user",
              "NO_DATA_TO_UPDATE": "No data to update",
              "YOU_ARE_NOT_ALLOWED_TO_UPDATE_USERS": "You are not allowed to update users",
              "YOU_CANNOT_REMOVE_YOURSELF": "You cannot remove yourself",
              "YOU_ARE_NOT_ALLOWED_TO_SET_NON_EXISTENT_VALUE": "You are not allowed to set a non-existent role value"
            },
            "schema": {
              "user": {
                "fields": {
                  "role": {
                    "type": "string",
                    "required": false,
                    "input": false
                  },
                  "banned": {
                    "type": "boolean",
                    "defaultValue": false,
                    "required": false,
                    "input": false
                  },
                  "banReason": {
                    "type": "string",
                    "required": false,
                    "input": false
                  },
                  "banExpires": {
                    "type": "date",
                    "required": false,
                    "input": false
                  }
                }
              },
              "session": {
                "fields": {
                  "impersonatedBy": {
                    "type": "string",
                    "required": false
                  }
                }
              }
            },
            "options": {
              "defaultRole": "user",
              "adminRoles": [
                "admin"
              ]
            }
          }
        },
        {
          "name": "stripe",
          "config": {
            "id": "stripe",
            "endpoints": {},
            "schema": {
              "subscription": {
                "fields": {
                  "plan": {
                    "type": "string",
                    "required": true
                  },
                  "referenceId": {
                    "type": "string",
                    "required": true
                  },
                  "stripeCustomerId": {
                    "type": "string",
                    "required": false
                  },
                  "stripeSubscriptionId": {
                    "type": "string",
                    "required": false
                  },
                  "status": {
                    "type": "string",
                    "defaultValue": "incomplete"
                  },
                  "periodStart": {
                    "type": "date",
                    "required": false
                  },
                  "periodEnd": {
                    "type": "date",
                    "required": false
                  },
                  "trialStart": {
                    "type": "date",
                    "required": false
                  },
                  "trialEnd": {
                    "type": "date",
                    "required": false
                  },
                  "cancelAtPeriodEnd": {
                    "type": "boolean",
                    "required": false,
                    "defaultValue": false
                  },
                  "seats": {
                    "type": "number",
                    "required": false
                  }
                }
              },
              "user": {
                "fields": {
                  "stripeCustomerId": {
                    "type": "string",
                    "required": false
                  }
                }
              }
            }
          }
        }
      ],
      "emailAndPassword": {
        "enabled": true,
        "requireEmailVerification": true
      },
      "emailVerification": {
        "sendOnSignUp": true,
        "autoSignInAfterVerification": true
      },
      "session": {
        "expiresIn": 604800,
        "updateAge": 86400
      },
      "advanced": {
        "defaultCookieAttributes": {
          "sameSite": "lax",
          "secure": false,
          "httpOnly": true
        }
      },
      "socialProviders": {
        "google": {
          "prompt": "select_account consent",
          "clientId": "[REDACTED]",
          "clientSecret": "[REDACTED]",
          "scope": [
            "openid",
            "email",
            "profile",
            "https://www.googleapis.com/auth/gmail.send"
          ],
          "accessType": "offline"
        }
      },
      "databaseHooks": {
        "account": {
          "create": {}
        }
      },
      "hooks": {}
    }
  }
}

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

Backend

Auth config (if applicable)

import {
  BETTER_AUTH_SECRET,
  BETTER_AUTH_URL,
  CORS_ORIGIN,
  GOOGLE_CLIENT_ID,
  GOOGLE_CLIENT_SECRET,
  IS_PROD,
  REQUIRE_EMAIL_VERIFICATION,
  STARTER_PRICE_ID,
  STRIPE_SECRET_KEY,
  STRIPE_WEBHOOK_SECRET,
} from "@/config/constants";
import {
  addProviderEmail,
  ProviderId,
} from "@/features/user/services/addProviderEmail.service";
import { MailClient } from "@/lib/mail-client";
import { client } from "@/lib/mongo";
import { stripe } from "@better-auth/stripe";
import { userRoleEnum } from "@packages/shared/schemas/user";
import { logger } from "@packages/shared/utils";
import { betterAuth } from "better-auth";
import { mongodbAdapter } from "better-auth/adapters/mongodb";
import { admin, createAuthMiddleware } from "better-auth/plugins";
import Stripe from "stripe";

const stripeClient = new Stripe(STRIPE_SECRET_KEY, {
  apiVersion: "2025-10-29.clover", // Latest API version as of Stripe SDK v19
});

export const auth = betterAuth({
  database: mongodbAdapter(client),
  secret: BETTER_AUTH_SECRET,
  baseURL: BETTER_AUTH_URL,
  trustedOrigins: [CORS_ORIGIN],
  errorURL: CORS_ORIGIN,
  user: {
    additionalFields: {
      isOnboardingComplete: {
        type: "boolean",
        defaultValue: false,
        required: false,
      },
    },
  },
  account: {
    accountLinking: {
      enabled: true,
      allowDifferentEmails: false,
      updateUserInfoOnLink: true,
    },
  },
  plugins: [
    admin({
      defaultRole: userRoleEnum.USER,
      adminRoles: [userRoleEnum.ADMIN],
    }),
    stripe({
      stripeClient,
      stripeWebhookSecret: STRIPE_WEBHOOK_SECRET,
      createCustomerOnSignUp: true,
      subscription: {
        enabled: true,
        plans: [
          {
            name: "starter",
            priceId: STARTER_PRICE_ID,
          },
        ],
      },
    }),
  ],
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: REQUIRE_EMAIL_VERIFICATION,
    sendResetPassword: async ({ user, url, token }, request) => {
      await MailClient.sendPasswordResetEmail(user.email, user.name, url);
    },
  },
  emailVerification: {
    sendOnSignUp: true,
    autoSignInAfterVerification: true,
    sendVerificationEmail: async ({ user, url, token }, request) => {
      await MailClient.sendVerificationEmail(user.email, user.name, url);
    },
  },
  session: {
    expiresIn: 60 * 60 * 24 * 7, // 7 days
    updateAge: 60 * 60 * 24, // 1 day
  },
  advanced: {
    defaultCookieAttributes: {
      sameSite: "lax",
      secure: IS_PROD,
      httpOnly: true,
    },
  },
  socialProviders: {
    google: {
      prompt: "select_account consent",
      clientId: GOOGLE_CLIENT_ID,
      clientSecret: GOOGLE_CLIENT_SECRET,
      scope: [
        "openid",
        "email",
        "profile",
        "https://www.googleapis.com/auth/gmail.send",
      ],
      accessType: "offline", // Required to get refresh tokens
    },
    // microsoft: {
    //   clientId: MICROSOFT_CLIENT_ID,
    //   clientSecret: MICROSOFT_CLIENT_SECRET,
    //   tenantId: "common",
    //   prompt: "select_account",
    // },
  },
  databaseHooks: {
    account: {
      create: {
        after: async (account) => {
          // skip if providerId is credentials
          if (account.providerId === "credentials") {
            return;
          }

          // Use service to add provider email
          await addProviderEmail(
            account.id,
            account.providerId as ProviderId,
            account.accessToken || undefined
          );
        },
      },
    },
  },
  hooks: {
    before: createAuthMiddleware(async (ctx) => {
      if (ctx.path === "/error") {
        const { error } = ctx.query as { error?: string };
        logger.error(new Error("Auth error"), "AUTH");

        let redirectUrl = `${CORS_ORIGIN}/?error=${encodeURIComponent(
          "Authentication Error"
        )}&error_description=${encodeURIComponent("Failed to authenticate")}`;

        if (error === "banned") {
          redirectUrl = `${CORS_ORIGIN}/?error=${encodeURIComponent(
            "You have been banned"
          )}&error_description=${encodeURIComponent("Contact support team")}`;
        }

        throw ctx.redirect(redirectUrl);
      }
    }),
  },
});

// Use the shared types for consistency
export type Session = typeof auth.$Infer.Session.session;
export type User = typeof auth.$Infer.Session.user;

Additional context

{
"name": "server",
"main": "src/index.ts",
"type": "module",
"scripts": {
"build": "tsdown",
"check-types": "tsc -b",
"compile": "bun build --compile --minify --sourcemap --bytecode ./src/index.ts --outfile server",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"docker:start": "docker compose up -d",
"docker:watch": "docker compose up",
"docker:stop": "docker compose stop",
"docker:down": "docker compose down"
},
"dependencies": {
"@better-auth/stripe": "^1.3.34",
"@google/generative-ai": "^0.24.1",
"@orpc/client": "^1.10.4",
"@orpc/server": "^1.10.4",
"@packages/shared": "workspace:*",
"better-auth": "^1.3.34",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"googleapis": "^160.0.0",
"minio": "^8.0.6",
"mongoose": "^8.19.3",
"stripe": "^19.2.1",
"zod": "^4.1.12"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.5",
"@types/node": "^22.19.0",
"knip": "^5.67.1",
"tsdown": "^0.15.12",
"tsx": "^4.20.6",
"typescript": "^5.9.3"
}
}

Originally created by @rayyan-buildin2 on GitHub (Nov 5, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/5780 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. User subscribes to a recurring plan (e.g., "Starter"). 2. User clicks Cancel button in our app. 3. Better Auth redirects user to Stripe Billing Portal. 4. User cancels subscription in Stripe and returns to our app. 5. User expects to see Restore button, but still sees Cancel button. 6. User clicks Cancel button again. Server responds that subscription is already canceled, updates DB status. 7. Now, the Restore button appears. ### Current vs. Expected behavior After canceling in Stripe and returning to the app, the Restore button should immediately appear if the subscription is eligible for restore (cancelAtPeriodEnd = true). ### What version of Better Auth are you using? 1.3.34 ### System info ```bash { "system": { "platform": "darwin", "arch": "arm64", "version": "Darwin Kernel Version 24.6.0: Mon Jul 14 11:30:34 PDT 2025; root:xnu-11417.140.69~1/RELEASE_ARM64_T8103", "release": "24.6.0", "cpuCount": 8, "cpuModel": "Apple M1", "totalMemory": "8.00 GB", "freeMemory": "0.16 GB" }, "node": { "version": "v22.17.1", "env": "development" }, "packageManager": { "name": "pnpm", "version": "10.16.1" }, "frameworks": [ { "name": "express", "version": "^5.1.0" } ], "databases": null, "betterAuth": { "version": "^1.3.34", "config": { "secret": "[REDACTED]", "baseURL": "http://localhost:3000", "trustedOrigins": [ "http://localhost:5173" ], "errorURL": "http://localhost:5173", "user": { "additionalFields": { "isOnboardingComplete": { "type": "boolean", "defaultValue": false, "required": false } } }, "account": { "accountLinking": { "enabled": true, "allowDifferentEmails": false, "updateUserInfoOnLink": true } }, "plugins": [ { "name": "admin", "config": { "id": "admin", "hooks": { "after": [ {} ] }, "endpoints": {}, "$ERROR_CODES": { "FAILED_TO_CREATE_USER": "Failed to create user", "USER_ALREADY_EXISTS": "User already exists.", "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL": "User already exists. Use another email.", "YOU_CANNOT_BAN_YOURSELF": "You cannot ban yourself", "YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE": "You are not allowed to change users role", "YOU_ARE_NOT_ALLOWED_TO_CREATE_USERS": "You are not allowed to create users", "YOU_ARE_NOT_ALLOWED_TO_LIST_USERS": "You are not allowed to list users", "YOU_ARE_NOT_ALLOWED_TO_LIST_USERS_SESSIONS": "You are not allowed to list users sessions", "YOU_ARE_NOT_ALLOWED_TO_BAN_USERS": "You are not allowed to ban users", "YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS": "You are not allowed to impersonate users", "YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS": "You are not allowed to revoke users sessions", "YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS": "You are not allowed to delete users", "YOU_ARE_NOT_ALLOWED_TO_SET_USERS_PASSWORD": "[REDACTED]", "BANNED_USER": "You have been banned from this application", "YOU_ARE_NOT_ALLOWED_TO_GET_USER": "You are not allowed to get user", "NO_DATA_TO_UPDATE": "No data to update", "YOU_ARE_NOT_ALLOWED_TO_UPDATE_USERS": "You are not allowed to update users", "YOU_CANNOT_REMOVE_YOURSELF": "You cannot remove yourself", "YOU_ARE_NOT_ALLOWED_TO_SET_NON_EXISTENT_VALUE": "You are not allowed to set a non-existent role value" }, "schema": { "user": { "fields": { "role": { "type": "string", "required": false, "input": false }, "banned": { "type": "boolean", "defaultValue": false, "required": false, "input": false }, "banReason": { "type": "string", "required": false, "input": false }, "banExpires": { "type": "date", "required": false, "input": false } } }, "session": { "fields": { "impersonatedBy": { "type": "string", "required": false } } } }, "options": { "defaultRole": "user", "adminRoles": [ "admin" ] } } }, { "name": "stripe", "config": { "id": "stripe", "endpoints": {}, "schema": { "subscription": { "fields": { "plan": { "type": "string", "required": true }, "referenceId": { "type": "string", "required": true }, "stripeCustomerId": { "type": "string", "required": false }, "stripeSubscriptionId": { "type": "string", "required": false }, "status": { "type": "string", "defaultValue": "incomplete" }, "periodStart": { "type": "date", "required": false }, "periodEnd": { "type": "date", "required": false }, "trialStart": { "type": "date", "required": false }, "trialEnd": { "type": "date", "required": false }, "cancelAtPeriodEnd": { "type": "boolean", "required": false, "defaultValue": false }, "seats": { "type": "number", "required": false } } }, "user": { "fields": { "stripeCustomerId": { "type": "string", "required": false } } } } } } ], "emailAndPassword": { "enabled": true, "requireEmailVerification": true }, "emailVerification": { "sendOnSignUp": true, "autoSignInAfterVerification": true }, "session": { "expiresIn": 604800, "updateAge": 86400 }, "advanced": { "defaultCookieAttributes": { "sameSite": "lax", "secure": false, "httpOnly": true } }, "socialProviders": { "google": { "prompt": "select_account consent", "clientId": "[REDACTED]", "clientSecret": "[REDACTED]", "scope": [ "openid", "email", "profile", "https://www.googleapis.com/auth/gmail.send" ], "accessType": "offline" } }, "databaseHooks": { "account": { "create": {} } }, "hooks": {} } } } ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript import { BETTER_AUTH_SECRET, BETTER_AUTH_URL, CORS_ORIGIN, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, IS_PROD, REQUIRE_EMAIL_VERIFICATION, STARTER_PRICE_ID, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, } from "@/config/constants"; import { addProviderEmail, ProviderId, } from "@/features/user/services/addProviderEmail.service"; import { MailClient } from "@/lib/mail-client"; import { client } from "@/lib/mongo"; import { stripe } from "@better-auth/stripe"; import { userRoleEnum } from "@packages/shared/schemas/user"; import { logger } from "@packages/shared/utils"; import { betterAuth } from "better-auth"; import { mongodbAdapter } from "better-auth/adapters/mongodb"; import { admin, createAuthMiddleware } from "better-auth/plugins"; import Stripe from "stripe"; const stripeClient = new Stripe(STRIPE_SECRET_KEY, { apiVersion: "2025-10-29.clover", // Latest API version as of Stripe SDK v19 }); export const auth = betterAuth({ database: mongodbAdapter(client), secret: BETTER_AUTH_SECRET, baseURL: BETTER_AUTH_URL, trustedOrigins: [CORS_ORIGIN], errorURL: CORS_ORIGIN, user: { additionalFields: { isOnboardingComplete: { type: "boolean", defaultValue: false, required: false, }, }, }, account: { accountLinking: { enabled: true, allowDifferentEmails: false, updateUserInfoOnLink: true, }, }, plugins: [ admin({ defaultRole: userRoleEnum.USER, adminRoles: [userRoleEnum.ADMIN], }), stripe({ stripeClient, stripeWebhookSecret: STRIPE_WEBHOOK_SECRET, createCustomerOnSignUp: true, subscription: { enabled: true, plans: [ { name: "starter", priceId: STARTER_PRICE_ID, }, ], }, }), ], emailAndPassword: { enabled: true, requireEmailVerification: REQUIRE_EMAIL_VERIFICATION, sendResetPassword: async ({ user, url, token }, request) => { await MailClient.sendPasswordResetEmail(user.email, user.name, url); }, }, emailVerification: { sendOnSignUp: true, autoSignInAfterVerification: true, sendVerificationEmail: async ({ user, url, token }, request) => { await MailClient.sendVerificationEmail(user.email, user.name, url); }, }, session: { expiresIn: 60 * 60 * 24 * 7, // 7 days updateAge: 60 * 60 * 24, // 1 day }, advanced: { defaultCookieAttributes: { sameSite: "lax", secure: IS_PROD, httpOnly: true, }, }, socialProviders: { google: { prompt: "select_account consent", clientId: GOOGLE_CLIENT_ID, clientSecret: GOOGLE_CLIENT_SECRET, scope: [ "openid", "email", "profile", "https://www.googleapis.com/auth/gmail.send", ], accessType: "offline", // Required to get refresh tokens }, // microsoft: { // clientId: MICROSOFT_CLIENT_ID, // clientSecret: MICROSOFT_CLIENT_SECRET, // tenantId: "common", // prompt: "select_account", // }, }, databaseHooks: { account: { create: { after: async (account) => { // skip if providerId is credentials if (account.providerId === "credentials") { return; } // Use service to add provider email await addProviderEmail( account.id, account.providerId as ProviderId, account.accessToken || undefined ); }, }, }, }, hooks: { before: createAuthMiddleware(async (ctx) => { if (ctx.path === "/error") { const { error } = ctx.query as { error?: string }; logger.error(new Error("Auth error"), "AUTH"); let redirectUrl = `${CORS_ORIGIN}/?error=${encodeURIComponent( "Authentication Error" )}&error_description=${encodeURIComponent("Failed to authenticate")}`; if (error === "banned") { redirectUrl = `${CORS_ORIGIN}/?error=${encodeURIComponent( "You have been banned" )}&error_description=${encodeURIComponent("Contact support team")}`; } throw ctx.redirect(redirectUrl); } }), }, }); // Use the shared types for consistency export type Session = typeof auth.$Infer.Session.session; export type User = typeof auth.$Infer.Session.user; ``` ### Additional context { "name": "server", "main": "src/index.ts", "type": "module", "scripts": { "build": "tsdown", "check-types": "tsc -b", "compile": "bun build --compile --minify --sourcemap --bytecode ./src/index.ts --outfile server", "dev": "tsx watch src/index.ts", "start": "node dist/index.js", "docker:start": "docker compose up -d", "docker:watch": "docker compose up", "docker:stop": "docker compose stop", "docker:down": "docker compose down" }, "dependencies": { "@better-auth/stripe": "^1.3.34", "@google/generative-ai": "^0.24.1", "@orpc/client": "^1.10.4", "@orpc/server": "^1.10.4", "@packages/shared": "workspace:*", "better-auth": "^1.3.34", "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.1.0", "googleapis": "^160.0.0", "minio": "^8.0.6", "mongoose": "^8.19.3", "stripe": "^19.2.1", "zod": "^4.1.12" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.5", "@types/node": "^22.19.0", "knip": "^5.67.1", "tsdown": "^0.15.12", "tsx": "^4.20.6", "typescript": "^5.9.3" } }
GiteaMirror added the locked label 2026-04-13 06:25:21 -05:00
Author
Owner

@better-auth-agent[bot] commented on GitHub (Nov 5, 2025):

It looks like the cancellation event from Stripe isn’t immediately triggering an update to set the cancelAtPeriodEnd flag in your database. This behavior can occur if the webhook processing isn’t applying the change right away or if there’s a delay in your subscription update hook. You might want to:

• Verify that your Stripe webhook for subscription updates is correctly configured and that the cancellation event is handled as expected (i.e., the webhook should update the cancelAtPeriodEnd field immediately).

• Check your subscription update logic or database hooks to see if there’s any condition that might defer updating the flag until a subsequent cancellation attempt.

• Add logging around your webhook handler to confirm that the cancellation event is received and the DB update is executed, ensuring no errors occur silently.

If you’ve already ruled these out and the issue still persists, it might be helpful to get insight directly from our team. Tag @bekacru and @ping__ for further assistance.

tag @better-auth-agent if you would like an answer from the Agent.

What additional details about your webhook setup or subscription update flow might help us further troubleshoot this issue?

DiagramDiscordGitHub

Diagram Join Star

<!-- gh-comment-id:3492294783 --> @better-auth-agent[bot] commented on GitHub (Nov 5, 2025): It looks like the cancellation event from Stripe isn’t immediately triggering an update to set the cancelAtPeriodEnd flag in your database. This behavior can occur if the webhook processing isn’t applying the change right away or if there’s a delay in your subscription update hook. You might want to: • Verify that your Stripe webhook for subscription updates is correctly configured and that the cancellation event is handled as expected (i.e., the webhook should update the cancelAtPeriodEnd field immediately). • Check your subscription update logic or database hooks to see if there’s any condition that might defer updating the flag until a subsequent cancellation attempt. • Add logging around your webhook handler to confirm that the cancellation event is received and the DB update is executed, ensuring no errors occur silently. If you’ve already ruled these out and the issue still persists, it might be helpful to get insight directly from our team. Tag @bekacru and @ping__ for further assistance. tag @better-auth-agent if you would like an answer from the Agent. What additional details about your webhook setup or subscription update flow might help us further troubleshoot this issue? <!-- bot:webhook reply v1 --> [Diagram](https://repodiagrams.s3.eu-north-1.amazonaws.com/better-auth_ultra_detailed_interactive.html) • [Discord](https://discord.gg/better-auth) • [GitHub](https://github.com/better-auth/better-auth) [![Diagram](https://img.shields.io/badge/Diagram-2b3137?style=flat-square)](https://repodiagrams.s3.eu-north-1.amazonaws.com/better-auth_ultra_detailed_interactive.html) [![Join](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/better-auth) [![Star](https://img.shields.io/badge/star-181717?logo=github&logoColor=white&style=flat-square)](https://github.com/better-auth/better-auth)
Author
Owner

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

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

This is a known issue with Better Auth v1.3.34: when a user cancels a Stripe subscription via the Billing Portal, Stripe sends a webhook event with a cancel_at field (not cancel_at_period_end). The Better Auth Stripe plugin does not automatically update the cancelAtPeriodEnd field in your database, so your UI doesn't reflect the cancellation until another action triggers a DB update. This causes the Restore button not to appear immediately [source].

Workaround:
You can add an onEvent handler to your Stripe plugin config to listen for the customer.subscription.updated event and manually update your DB when Stripe schedules a cancellation at period end. Here’s an example:

stripe({
  // ...other config
  async onEvent(event) {
    if (event.type !== "customer.subscription.updated") return;
    const subscription = event.data.object;
    const cancelAt = subscription?.cancel_at;
    const currentPeriodEnd = subscription?.items?.data[0]?.current_period_end;
    if (cancelAt && currentPeriodEnd && cancelAt === currentPeriodEnd) {
      // Call your handler to update cancelAtPeriodEnd in DB
      await onSubscriptionCancel?.(subscription.id);
    }
  },
})

[reference]

Permanent fix:
There’s an open PR to add a cancelAt field to the subscription schema and update the webhook handler to persist Stripe’s cancel_at value directly, which will resolve this issue natively in the package [PR #5487].

Until that PR is merged and released, use the workaround above to keep your DB in sync with Stripe cancellations made via the Billing Portal.

To reply, just mention @dosu.


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

<!-- gh-comment-id:3492312523 --> @dosubot[bot] commented on GitHub (Nov 5, 2025): <!-- Greeting --> Hi @rayyan-buildin2! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> This is a known issue with Better Auth v1.3.34: when a user cancels a Stripe subscription via the Billing Portal, Stripe sends a webhook event with a `cancel_at` field (not `cancel_at_period_end`). The Better Auth Stripe plugin does not automatically update the `cancelAtPeriodEnd` field in your database, so your UI doesn't reflect the cancellation until another action triggers a DB update. This causes the Restore button not to appear immediately [[source]](https://github.com/better-auth/better-auth/issues/5535). **Workaround:** You can add an `onEvent` handler to your Stripe plugin config to listen for the `customer.subscription.updated` event and manually update your DB when Stripe schedules a cancellation at period end. Here’s an example: ```typescript stripe({ // ...other config async onEvent(event) { if (event.type !== "customer.subscription.updated") return; const subscription = event.data.object; const cancelAt = subscription?.cancel_at; const currentPeriodEnd = subscription?.items?.data[0]?.current_period_end; if (cancelAt && currentPeriodEnd && cancelAt === currentPeriodEnd) { // Call your handler to update cancelAtPeriodEnd in DB await onSubscriptionCancel?.(subscription.id); } }, }) ``` [[reference]](https://github.com/better-auth/better-auth/issues/5535#issuecomment-3441862845) **Permanent fix:** There’s an open PR to add a `cancelAt` field to the subscription schema and update the webhook handler to persist Stripe’s `cancel_at` value directly, which will resolve this issue natively in the package [[PR #5487]](https://github.com/better-auth/better-auth/pull/5487). Until that PR is merged and released, use the workaround above to keep your DB in sync with Stripe cancellations made via the Billing Portal. <!-- 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/c7d58b63-c493-498c-b7c3-af376d1d7949?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/c7d58b63-c493-498c-b7c3-af376d1d7949?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/c7d58b63-c493-498c-b7c3-af376d1d7949?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/c7d58b63-c493-498c-b7c3-af376d1d7949?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/c7d58b63-c493-498c-b7c3-af376d1d7949?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/c7d58b63-c493-498c-b7c3-af376d1d7949?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/c7d58b63-c493-498c-b7c3-af376d1d7949?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/5780)
Author
Owner

@dragos-cojocaru commented on GitHub (Nov 14, 2025):

We are having the same problem

<!-- gh-comment-id:3533132488 --> @dragos-cojocaru commented on GitHub (Nov 14, 2025): We are having the same problem
Author
Owner

@Bekacru commented on GitHub (Nov 14, 2025):

will be fixed in the upcoming release

<!-- gh-comment-id:3533276612 --> @Bekacru commented on GitHub (Nov 14, 2025): will be fixed in the upcoming release
Author
Owner

@Rayyan-Balami commented on GitHub (Nov 14, 2025):

stripe({
  stripeClient,
  stripeWebhookSecret: STRIPE_WEBHOOK_SECRET,
  createCustomerOnSignUp: true,
  subscription: {
    enabled: true,
    plans: [
      {
        name: "starter",
        priceId: STARTER_PRICE_ID,
      },
    ],
    getCheckoutSessionParams: async ({ user, session, plan, subscription }, request) => {
      return {
        params: {
          allow_promotion_codes: true,
        },
      };
    },
  },
  onEvent: async (event) => {
    if (event.type !== "customer.subscription.updated") return;

    const sub = event.data.object as Stripe.Subscription;

    try {
      await SubscriptionModel.findOneAndUpdate(
        { stripeSubscriptionId: sub.id },
        { cancelAtPeriodEnd: !!sub.cancel_at },
        { new: true }
      );
    } catch (error) {
      logger.error(error as Error, `Failed to update cancelAtPeriodEnd for subscription ${sub.id}`);
    }
  },
});

for workaround try above onEvent

<!-- gh-comment-id:3533709845 --> @Rayyan-Balami commented on GitHub (Nov 14, 2025): ```ts stripe({ stripeClient, stripeWebhookSecret: STRIPE_WEBHOOK_SECRET, createCustomerOnSignUp: true, subscription: { enabled: true, plans: [ { name: "starter", priceId: STARTER_PRICE_ID, }, ], getCheckoutSessionParams: async ({ user, session, plan, subscription }, request) => { return { params: { allow_promotion_codes: true, }, }; }, }, onEvent: async (event) => { if (event.type !== "customer.subscription.updated") return; const sub = event.data.object as Stripe.Subscription; try { await SubscriptionModel.findOneAndUpdate( { stripeSubscriptionId: sub.id }, { cancelAtPeriodEnd: !!sub.cancel_at }, { new: true } ); } catch (error) { logger.error(error as Error, `Failed to update cancelAtPeriodEnd for subscription ${sub.id}`); } }, }); ``` for workaround try above onEvent
Author
Owner

@ixartz commented on GitHub (Nov 18, 2025):

Reverting to Stripe v18 works for me

<!-- gh-comment-id:3549462484 --> @ixartz commented on GitHub (Nov 18, 2025): Reverting to Stripe v18 works for me
Author
Owner

@EphraimElgrabli commented on GitHub (Dec 9, 2025):

From what I understand, the fix for Billing Portal cancellations (handling both cancel_at and cancel_at_period_end) was implemented in PR #5818 / f42f5a0, and the logic now correctly sets cancelAtPeriodEnd and triggers onSubscriptionCancel in all relevant cases.
Before applying a workaround on my side, I wanted to confirm:
Has this fix already been released in a stable version, or is it currently only available on the canary branch?
The issue still reproduces for me on v1.4.4, so I’m unsure whether I need to update to a newer release, wait for the next publish, or temporarily patch it with an onEvent handler.

<!-- gh-comment-id:3631999489 --> @EphraimElgrabli commented on GitHub (Dec 9, 2025): From what I understand, the fix for Billing Portal cancellations (handling both cancel_at and cancel_at_period_end) was implemented in PR #5818 / f42f5a0, and the logic now correctly sets cancelAtPeriodEnd and triggers onSubscriptionCancel in all relevant cases. Before applying a workaround on my side, I wanted to confirm: Has this fix already been released in a stable version, or is it currently only available on the canary branch? The issue still reproduces for me on v1.4.4, so I’m unsure whether I need to update to a newer release, wait for the next publish, or temporarily patch it with an onEvent handler.
Author
Owner

@KEBABSELLER6 commented on GitHub (Dec 14, 2025):

I'm still having the same issue with 1.4.5, only getting the updated event and not the cancellation event. The webhook also doens't work, since the event never appears I assume.

I see reverting way back to Stripe v18 worked for @ixartz

Should we just do the workaround with the event handler?

<!-- gh-comment-id:3651626330 --> @KEBABSELLER6 commented on GitHub (Dec 14, 2025): I'm still having the same issue with 1.4.5, only getting the updated event and not the cancellation event. The webhook also doens't work, since the event never appears I assume. I see reverting way back to Stripe v18 worked for @ixartz Should we just do the workaround with the event handler?
Author
Owner

@joshsedl commented on GitHub (Mar 4, 2026):

Any reason, this was set to completed? This is still a problem using:

  • @better-auth/stripe@1.5.2
  • stripe@20.4.0
  • Stripe API Version: "2026-02-25.clover"

User cancels subscription on stripe's side (cancelling on period end), the "cancelAt" and "periodEnd" are correctly set and equal, but "cancelAtPeriodEnd" is still set to false for whatever reason. Example:
Image
(Also unsure why "canceledAt" is also not set)

<!-- gh-comment-id:3996809743 --> @joshsedl commented on GitHub (Mar 4, 2026): Any reason, this was set to completed? This is still a problem using: - @better-auth/stripe@1.5.2 - stripe@20.4.0 - Stripe API Version: "2026-02-25.clover" User cancels subscription on stripe's side (cancelling on period end), the "cancelAt" and "periodEnd" are correctly set and equal, but "cancelAtPeriodEnd" is still set to false for whatever reason. Example: <img width="1219" height="138" alt="Image" src="https://github.com/user-attachments/assets/7ce91db0-2f62-4f4d-95bd-071b99fc452b" /> (Also unsure why "canceledAt" is also not set)
Author
Owner

@JPustkuchen commented on GitHub (Mar 6, 2026):

@himself65 Please see the comment above, guess this needs to be re-opened?

<!-- gh-comment-id:4012720331 --> @JPustkuchen commented on GitHub (Mar 6, 2026): @himself65 Please see the comment above, guess this needs to be re-opened?
Author
Owner

@bytaesu commented on GitHub (Mar 6, 2026):

@JPustkuchen @joshsedl

Stripe plugin pass through the webhook payload we receive from Stripe. You can check how it was sent in the Stripe dashboard webhook event logs. For more details, you need to check Stripe docs. We also briefly explain this in our documentation.

In short, cancellation should be detected using both cancelAt and cancelAtPeriodEnd, which follows the behavior of the Stripe SDK.

https://better-auth.com/docs/plugins/stripe#canceling-a-subscription

Image
<!-- gh-comment-id:4013493638 --> @bytaesu commented on GitHub (Mar 6, 2026): @JPustkuchen @joshsedl Stripe plugin pass through the webhook payload we receive from Stripe. You can check how it was sent in the Stripe dashboard webhook event logs. For more details, you need to check Stripe docs. We also briefly explain this in our documentation. In short, cancellation should be detected using both `cancelAt` and `cancelAtPeriodEnd`, which follows the behavior of the Stripe SDK. https://better-auth.com/docs/plugins/stripe#canceling-a-subscription <img width="885" height="428" alt="Image" src="https://github.com/user-attachments/assets/8c42dc84-68a2-4895-8228-ba76e1d0c5c9" />
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#10349