Stripe - cancel at the period end not working #2189

Closed
opened 2026-03-13 09:32:45 -05:00 by GiteaMirror · 5 comments
Owner

Originally created by @k1eu on GitHub (Oct 24, 2025).

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

NestJS Backedn - React Router 7 FE

  const stripeClient = new Stripe(process.env.STRIPE_SECRET_KEY!, {
    apiVersion: "2025-09-30.clover", // Latest API version as of Stripe SDK v19
  });

  1. Have a better auth stripe setup on both FE and Backend
  2. when user is on trial or subscribed - cancel subscription with client const response = await authClient.subscription.cancel(data);
  3. When user cancels through stripe center - stripe sends event with cancel_at instead of cancel_at_period_end so the db is not updated (on dashboard it is configured)
  4. User is not marked as cancel_at_period_end - so he can request again the cancelation which fails and then it is updated via this line: 4edec22a55/packages/stripe/src/index.ts (L841)
Image

Current vs. Expected behavior

  • Currently user is not marked as cancel_at_period_end when user cancells the ongoing sub.

  • expected: user is marked as cancel_at_period_end when user cancells the ongoing sub.

What version of Better Auth are you using?

1.3.9

System info

{
  "system": {
    "platform": "darwin",
    "arch": "arm64",
    "version": "Darwin Kernel Version 25.0.0: Wed Sep 17 21:41:45 PDT 2025; root:xnu-12377.1.9~141/RELEASE_ARM64_T6000",
    "release": "25.0.0",
    "cpuCount": 10,
    "cpuModel": "Apple M1 Max",
    "totalMemory": "32.00 GB",
    "freeMemory": "0.63 GB"
  },
  "node": {
    "version": "v24.8.0",
    "env": "development"
  },
  "packageManager": {
    "name": "pnpm",
    "version": "10.15.1"
  },
  "frameworks": [
    {
      "name": "express",
      "version": "^5.1.0"
    }
  ],
  "databases": [
    {
      "name": "postgres",
      "version": "^3.4.7"
    },
    {
      "name": "drizzle",
      "version": "^0.44.5"
    }
  ],
  "betterAuth": {
    "version": "^1.3.9",
    "config": {
      "databaseHooks": {
        "user": {
          "create": {}
        }
      },
      "hooks": {},
      "emailAndPassword": {
        "enabled": true,
        "requireEmailVerification": true
      },
      "emailVerification": {
        "sendOnSignUp": true,
        "autoSignInAfterVerification": true
      },
      "socialProviders": {
        "google": {
          "clientId": "[REDACTED]",
          "clientSecret": "[REDACTED]"
        }
      },
      "advanced": {
        "crossSubDomainCookies": {
          "enabled": true,
          "domain": "primehouse.localhost"
        }
      },
      "trustedOrigins": [
        "http://localhost:5173",
        "http://localhost:5174",
        "http://localhost:3000",
        "https://app.primehouse.localhost",
        "https://app.primehouse.pl",
        "https://api.primehouse.pl"
      ],
      "logger": {
        "level": "debug"
      },
      "plugins": [
        {
          "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
                  }
                }
              }
            }
          }
        },
        {
          "name": "open-api",
          "config": {
            "id": "open-api",
            "endpoints": {}
          }
        },
        {
          "name": "admin",
          "config": {
            "id": "admin",
            "hooks": {
              "after": [
                {}
              ]
            },
            "endpoints": {},
            "$ERROR_CODES": {
              "FAILED_TO_CREATE_USER": "Failed to create user",
              "USER_ALREADY_EXISTS": "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"
            },
            "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": {
              "ac": {
                "statements": {
                  "user": [
                    "create",
                    "list",
                    "set-role",
                    "ban",
                    "impersonate",
                    "delete",
                    "set-password",
                    "get",
                    "update"
                  ],
                  "session": [
                    "list",
                    "revoke",
                    "delete"
                  ]
                }
              },
              "roles": {
                "admin": {
                  "statements": {
                    "user": [
                      "create",
                      "list",
                      "set-role",
                      "ban",
                      "impersonate",
                      "delete",
                      "set-password",
                      "get",
                      "update"
                    ],
                    "session": [
                      "list",
                      "revoke",
                      "delete"
                    ]
                  }
                },
                "user": {
                  "statements": {
                    "user": [],
                    "session": []
                  }
                },
                "agent": {
                  "statements": {
                    "user": [],
                    "session": []
                  }
                }
              },
              "defaultRole": "agent"
            }
          }
        }
      ],
      "telemetry": {
        "enabled": false
      }
    }
  }
}

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

Package

Auth config (if applicable)

import { betterAuth } from "better-auth"
export const auth = betterAuth({
  emailAndPassword: {  
    enabled: true
  },
});

Additional context

That's how I fixed it for now

stripe({
      subscription: {
        enabled: true,
        plans: [
          {
            name: "name",
            priceId: "someprice",
            freeTrial: {
              days: 60,
            },
          },
          {
            name: "nameplus",
            priceId: "someprice",
            freeTrial: {
              days: 60,
            },
          },
          {
            name: "namepro",
            priceId: "someid",
            freeTrial: {
              days: 60,
            },
          },
        ],
      },
      stripeClient: stripeClient,
      stripeWebhookSecret: stripeWebhookSecret,
      createCustomerOnSignUp: true,
      async onEvent(event) {
        if (event.type !== "customer.subscription.updated") {
          return;
        }

        const subscription = event.data.object;

        if (event.data.previous_attributes?.cancel_at !== null) {
          return;
        }

        const cancelAt = subscription?.cancel_at;
        const curerntPeriodEnd =
          subscription?.items?.data[0]?.current_period_end;

        if (cancelAt && curerntPeriodEnd && cancelAt === curerntPeriodEnd) {
          await onSubscriptionCancel?.(subscription.id);
        }
      },
    }),
Originally created by @k1eu on GitHub (Oct 24, 2025). ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce NestJS Backedn - React Router 7 FE ``` const stripeClient = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2025-09-30.clover", // Latest API version as of Stripe SDK v19 }); ``` 1. Have a better auth stripe setup on both FE and Backend 2. when user is on trial or subscribed - cancel subscription with client `const response = await authClient.subscription.cancel(data);` 3. When user cancels through stripe center - stripe sends event with cancel_at instead of cancel_at_period_end so the db is not updated (on dashboard it is configured) 4. User is not marked as cancel_at_period_end - so he can request again the cancelation which fails and then it is updated via this line: https://github.com/better-auth/better-auth/blob/4edec22a5581b3a18762698555a594e32943a61d/packages/stripe/src/index.ts#L841 <img width="657" height="362" alt="Image" src="https://github.com/user-attachments/assets/30f5f739-1355-421e-8b96-6f8ddaa0bc41" /> ### Current vs. Expected behavior - Currently user is not marked as cancel_at_period_end when user cancells the ongoing sub. - expected: user is marked as cancel_at_period_end when user cancells the ongoing sub. ### What version of Better Auth are you using? 1.3.9 ### System info ```bash { "system": { "platform": "darwin", "arch": "arm64", "version": "Darwin Kernel Version 25.0.0: Wed Sep 17 21:41:45 PDT 2025; root:xnu-12377.1.9~141/RELEASE_ARM64_T6000", "release": "25.0.0", "cpuCount": 10, "cpuModel": "Apple M1 Max", "totalMemory": "32.00 GB", "freeMemory": "0.63 GB" }, "node": { "version": "v24.8.0", "env": "development" }, "packageManager": { "name": "pnpm", "version": "10.15.1" }, "frameworks": [ { "name": "express", "version": "^5.1.0" } ], "databases": [ { "name": "postgres", "version": "^3.4.7" }, { "name": "drizzle", "version": "^0.44.5" } ], "betterAuth": { "version": "^1.3.9", "config": { "databaseHooks": { "user": { "create": {} } }, "hooks": {}, "emailAndPassword": { "enabled": true, "requireEmailVerification": true }, "emailVerification": { "sendOnSignUp": true, "autoSignInAfterVerification": true }, "socialProviders": { "google": { "clientId": "[REDACTED]", "clientSecret": "[REDACTED]" } }, "advanced": { "crossSubDomainCookies": { "enabled": true, "domain": "primehouse.localhost" } }, "trustedOrigins": [ "http://localhost:5173", "http://localhost:5174", "http://localhost:3000", "https://app.primehouse.localhost", "https://app.primehouse.pl", "https://api.primehouse.pl" ], "logger": { "level": "debug" }, "plugins": [ { "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 } } } } } }, { "name": "open-api", "config": { "id": "open-api", "endpoints": {} } }, { "name": "admin", "config": { "id": "admin", "hooks": { "after": [ {} ] }, "endpoints": {}, "$ERROR_CODES": { "FAILED_TO_CREATE_USER": "Failed to create user", "USER_ALREADY_EXISTS": "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" }, "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": { "ac": { "statements": { "user": [ "create", "list", "set-role", "ban", "impersonate", "delete", "set-password", "get", "update" ], "session": [ "list", "revoke", "delete" ] } }, "roles": { "admin": { "statements": { "user": [ "create", "list", "set-role", "ban", "impersonate", "delete", "set-password", "get", "update" ], "session": [ "list", "revoke", "delete" ] } }, "user": { "statements": { "user": [], "session": [] } }, "agent": { "statements": { "user": [], "session": [] } } }, "defaultRole": "agent" } } } ], "telemetry": { "enabled": false } } } } ``` ### Which area(s) are affected? (Select all that apply) Package ### Auth config (if applicable) ```typescript import { betterAuth } from "better-auth" export const auth = betterAuth({ emailAndPassword: { enabled: true }, }); ``` ### Additional context That's how I fixed it for now ```ts stripe({ subscription: { enabled: true, plans: [ { name: "name", priceId: "someprice", freeTrial: { days: 60, }, }, { name: "nameplus", priceId: "someprice", freeTrial: { days: 60, }, }, { name: "namepro", priceId: "someid", freeTrial: { days: 60, }, }, ], }, stripeClient: stripeClient, stripeWebhookSecret: stripeWebhookSecret, createCustomerOnSignUp: true, async onEvent(event) { if (event.type !== "customer.subscription.updated") { return; } const subscription = event.data.object; if (event.data.previous_attributes?.cancel_at !== null) { return; } const cancelAt = subscription?.cancel_at; const curerntPeriodEnd = subscription?.items?.data[0]?.current_period_end; if (cancelAt && curerntPeriodEnd && cancelAt === curerntPeriodEnd) { await onSubscriptionCancel?.(subscription.id); } }, }),
GiteaMirror added the express label 2026-03-13 09:32:45 -05:00
Author
Owner

@k1eu commented on GitHub (Oct 24, 2025):

I can prepare the fixes just let me know if the fix I have is good enough so I can move it into the pacakge

@k1eu commented on GitHub (Oct 24, 2025): I can prepare the fixes just let me know if the fix I have is good enough so I can move it into the pacakge
Author
Owner

@dosubot[bot] commented on GitHub (Oct 24, 2025):

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

Your fix is on the right track and matches what others have done as a workaround for this Stripe webhook issue. There’s an open PR that proposes adding a cancelAt field to the subscription schema and updating the webhook handler to persist Stripe’s cancel_at value, which will make the package handle this natively and keep the DB in sync with Stripe’s scheduled cancellations. Your approach—checking if cancel_at equals current_period_end and updating the cancellation status—is consistent with what’s needed until that PR lands. If you want to move your fix into the package, consider following the pattern in that PR: add a cancelAt field, update the webhook handler to persist it, and ensure both cancelAt and cancelAtPeriodEnd are set correctly for all cancellation scenarios. This will make the fix robust and future-proof for Stripe API changes.
See the related PR and discussion

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 (Oct 24, 2025): <!-- Greeting --> Hi @k1eu! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> Your fix is on the right track and matches what others have done as a workaround for this Stripe webhook issue. There’s an open PR that proposes adding a cancelAt field to the subscription schema and updating the webhook handler to persist Stripe’s cancel_at value, which will make the package handle this natively and keep the DB in sync with Stripe’s scheduled cancellations. Your approach—checking if cancel_at equals current_period_end and updating the cancellation status—is consistent with what’s needed until that PR lands. If you want to move your fix into the package, consider following the pattern in that PR: add a cancelAt field, update the webhook handler to persist it, and ensure both cancelAt and cancelAtPeriodEnd are set correctly for all cancellation scenarios. This will make the fix robust and future-proof for Stripe API changes. [See the related PR and discussion](https://github.com/better-auth/better-auth/pull/5487) <!-- 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/25ad6d5a-79d2-4e13-91ad-258f07a6eb2a?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/25ad6d5a-79d2-4e13-91ad-258f07a6eb2a?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/25ad6d5a-79d2-4e13-91ad-258f07a6eb2a?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/25ad6d5a-79d2-4e13-91ad-258f07a6eb2a?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/25ad6d5a-79d2-4e13-91ad-258f07a6eb2a?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/25ad6d5a-79d2-4e13-91ad-258f07a6eb2a?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/25ad6d5a-79d2-4e13-91ad-258f07a6eb2a?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/5535)
Author
Owner

@Arputikos commented on GitHub (Nov 19, 2025):

It seems like it's a stripe bug (?) I have same issue, I don't use better auth at all. Next.js + stripe. Stripe sends customer.subscription.updated event but with cancel_at_period_end = false.

@Arputikos commented on GitHub (Nov 19, 2025): It seems like it's a stripe bug (?) I have same issue, I don't use better auth at all. Next.js + stripe. Stripe sends customer.subscription.updated event but with cancel_at_period_end = false.
Author
Owner

@agentpietrucha commented on GitHub (Nov 25, 2025):

I experience similar issue to @Arputikos. Have you made any progress on this?

@agentpietrucha commented on GitHub (Nov 25, 2025): I experience similar issue to @Arputikos. Have you made any progress on this?
Author
Owner

@Masstronaut commented on GitHub (Dec 18, 2025):

in #5388 @MaxLikesCode pointed out that stripe transitioned from sending cancel_at_period_end to cancel_at in the clover API release.

So the issue isn't a bug so much as a breaking change introduced in the clover API which @better-auth/stripe doesn't support yet.

So for those of us using the clover API version, better-auth custom hooks as @k1eu has done here seems like the best interim solution.

@Masstronaut commented on GitHub (Dec 18, 2025): in #5388 @MaxLikesCode pointed out that stripe transitioned from sending `cancel_at_period_end` to `cancel_at` in the clover API release. So the issue isn't a bug so much as a breaking change introduced in the clover API which @better-auth/stripe doesn't support yet. So for those of us using the clover API version, better-auth custom hooks as @k1eu has done here seems like the best interim solution.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#2189