[GH-ISSUE #5976] Stripe webhook fails with "subscription_exposed_id must be a string, but got: null" on checkout.session.completed #19023

Closed
opened 2026-04-15 17:47:45 -05:00 by GiteaMirror · 4 comments
Owner

Originally created by @bradmcnew on GitHub (Nov 14, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/5976

Originally assigned to: @bytaesu on GitHub.

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Set up Better Auth with Stripe plugin using API version 2025-09-30.clover or newer
  2. Configure the Stripe plugin in auth config:
import { betterAuth } from "better-auth"
import { stripe } from "@better-auth/stripe"
import Stripe from "stripe"
const stripeClient = new Stripe(process.env.STRIPE_SECRET_KEY!, {
 apiVersion: "2025-09-30.clover",
})
export const auth = betterAuth({
 plugins: [
 stripe({
 stripeClient,
 stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
 createCustomerOnSignUp: true,
 subscription: {
 enabled: true,
 plans: [/* your plans */]
 }
 })
 ]
})
  1. Create a subscription checkout session using:
const { data, error } = await authClient.subscription.upgrade({
 plan: "pro",
 successUrl: "/dashboard", 
 cancelUrl: "/pricing",
});

Complete the Stripe checkout process
Observe webhook logs showing the error

Current vs. Expected behavior

Current behavior:

Stripe webhooks are received with HTTP 200 response but fail internally
Error logs show: Stripe: Argument "subscription_exposed_id" must be a string, but got: null
Checkout session metadata shows customerId: null and subscriptionId: null
No subscription records are created in the database
Webhook handler fails when trying to fetch subscription data from Stripe API
Expected behavior:

checkout.session.completed webhooks should successfully process subscription data
Subscription records should be created in the database with proper stripeCustomerId and stripeSubscriptionId values
Webhook processing should handle subscription lifecycle events without errors
Root cause: This appears to be related to Stripe API version compatibility issues where the webhook handler expects subscription data that's not present in the checkout session, similar to the documented issue with payment mode checkout sessions.

Error logs:

ERROR [Better Auth]: Stripe webhook failed. Error: Stripe: Argument "subscription_exposed_id" must be a string, but got: null (on API request to `GET /v1/subscriptions/{subscription_exposed_id}`)
[Stripe Webhook] Checkout session metadata: {
  sessionId: 'cs_test_...',
  customerId: null,
  subscriptionId: null,
  metadata: {}
}
[Better Auth] POST request: { path: '/api/auth/stripe/webhook' }
[Redis] Connected successfully for rate limiting
2025-11-14T00:14:27.252Z ERROR [Better Auth]: Stripe webhook failed. Error: TypeError: Cannot read properties of undefined (reading 'id')
[Stripe Webhook] Received event: {
  type: 'customer.subscription.updated',
  id: 'evt_1STAUBE2RscS4ig0F0XwhOlB',
  created: '2025-11-14T00:03:31.000Z'
}
[Stripe Webhook] Subscription metadata: {
  subscriptionId: 'sub_1STAU7E2RscS4ig0McpPBNKu',
  customerId: 'cus_TQ0V3Ar5vuUg5Z',
  metadata: { foo: 'bar' },
  status: 'active'
}
 POST /api/auth/stripe/webhook 200 in 3595ms

What version of Better Auth are you using?

1.3.34

System info

{
  "system": {
    "platform": "darwin",
    "arch": "arm64",
    "version": "Darwin Kernel Version 25.1.0: Mon Oct 20 19:32:41 PDT 2025; root:xnu-12377.41.6~2/RELEASE_ARM64_T6000",
    "release": "25.1.0",
    "cpuCount": 8,
    "cpuModel": "Apple M1 Pro",
    "totalMemory": "16.00 GB",
    "freeMemory": "0.13 GB"
  },
  "node": {
    "version": "v22.16.0",
    "env": "development"
  },
  "packageManager": {
    "name": "npm",
    "version": "10.9.2"
  },
  "frameworks": [
    {
      "name": "next",
      "version": "15.5.4"
    },
    {
      "name": "react",
      "version": "19.1.0"
    }
  ],
  "databases": [
    {
      "name": "pg",
      "version": "^8.16.3"
    }
  ],
  "betterAuth": {
    "version": "^1.3.34",
    "config": null
  }
}

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

Backend

Auth config (if applicable)

/**
 * Better Auth Configuration for Vercel Deployment
 *
 * Provides OAuth 2.1 authentication for ChatGPT via the MCP plugin.
 * Handles user sessions, OAuth flows, and integrates with PostgreSQL.
 */

import { betterAuth } from "better-auth";
import { mcp, apiKey, jwt } from "better-auth/plugins";
import { stripe } from "@better-auth/stripe";
import { Pool } from "pg";
import { Redis } from "ioredis";
import Stripe from "stripe";
import type { Subscription, StripePlan } from "@better-auth/stripe";

// Create PostgreSQL connection pool
const pool = new Pool({
  host: process.env.POSTGRES_HOST || 'localhost',
  port: parseInt(process.env.POSTGRES_PORT || '5432'),
  database: process.env.POSTGRES_DATABASE || 'askmymoney',
  user: process.env.POSTGRES_USER || 'postgres',
  password: process.env.POSTGRES_PASSWORD || 'postgres',
  ssl: process.env.POSTGRES_SSL === 'true' ? { rejectUnauthorized: false } : false,
  max: 20,
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
});

// Add error handling for the pool
pool.on('error', (error) => {
  console.error('[Postgres] Unexpected error on idle client', error);
});

// Create Redis client for secondary storage (rate limiting, caching)
if (!process.env.REDIS_URL) {
  throw new Error('REDIS_URL is required for rate limiting. Please set it in your .env file.');
}

const redis = new Redis(process.env.REDIS_URL, {
  maxRetriesPerRequest: 3,
  enableReadyCheck: true,
});

redis.on('error', (error) => {
  console.error('[Redis] Connection error:', error);
});

redis.on('connect', () => {
  console.log('[Redis] Connected successfully for rate limiting');
});

// Create Stripe client for subscription management
const stripeClient = new Stripe(process.env.STRIPE_SECRET_KEY || '', {
  apiVersion: "2025-10-29.clover",
});

// Helper utilities for composing URLs without duplicate slashes
const stripTrailingSlash = (value: string) =>
  value.endsWith("/") ? value.slice(0, -1) : value;

const ensureLeadingSlash = (value: string) =>
  value.startsWith("/") ? value : `/${value}`;

// Determine the application origin (used for user-facing URLs)
const appOrigin =
  process.env.APP_URL ||
  (process.env.VERCEL_URL && `https://${process.env.VERCEL_URL}`) ||
  "https://dev.askmymoney.ai";

const authBasePath = ensureLeadingSlash(
  process.env.BETTER_AUTH_BASE_PATH || "/api/auth"
);

// Get base URL for Better Auth endpoints (should include `/api/auth`)
const baseURL =
  process.env.BETTER_AUTH_URL ||
  `${stripTrailingSlash(appOrigin)}${authBasePath}`;

// Resource URL advertised to OAuth clients (should remain the origin)
const resourceURL = process.env.MCP_RESOURCE_URL || appOrigin;

// Initialize Better Auth with MCP and Stripe plugins
export const auth = betterAuth({
  // Database configuration
  database: pool,

  // Secondary storage for caching and rate limiting (Redis)
  secondaryStorage: {
    get: async (key) => {
      const value = await redis.get(key);
      return value ? JSON.parse(value) : null;
    },
    set: async (key, value, ttl) => {
      const serialized = JSON.stringify(value);
      if (ttl && ttl > 0) {
        await redis.setex(key, Math.ceil(ttl / 1000), serialized);
      } else {
        const defaultTTL = 60 * 60 * 24 * 7; // 7 days
        await redis.setex(key, defaultTTL, serialized);
      }
    },
    delete: async (key) => {
      await redis.del(key);
    },
  },

  // Base URL for OAuth redirects
  baseURL,

  // Secret for signing tokens (MUST be set in production)
  secret:
    process.env.BETTER_AUTH_SECRET ||
    process.env.SESSION_SECRET ||
    "development-secret-change-in-production",

  // Session configuration
  session: {
    expiresIn: 60 * 60 * 24 * 30, // 30 days
    updateAge: 60 * 60 * 24,
    cookieCache: {
      enabled: true,
      maxAge: 5 * 60, // 5 minutes
    },
  },

  // Advanced cookie configuration for cross-site redirects
  advanced: {
    useSecureCookies: true, // Always use secure cookies for HTTPS (dev.askmymoney.ai uses HTTPS)
    cookies: {
      session_token: {
        attributes: {
          sameSite: 'lax', // Allow cookies on same-site navigations (like Stripe redirect)
        },
      },
    },
  },

  // User schema

  // Email and password authentication
  emailAndPassword: {
    enabled: true,
    minPasswordLength: 8,
    maxPasswordLength: 128,
  },
  telemetry: {
    debug: true
  },
  logger: {
    level: 'debug',
  },


  // Plugins
  plugins: [
    jwt({
      // JWT plugin provides JWKS endpoint for token verification
      disableSettingJwtHeader: true, 
      jwt: {
        issuer: baseURL,
        audience: resourceURL,
        expirationTime: "1h",
      },
      jwks: {
        keyPairConfig: {
          alg: "RS256", // Use RS256 for better compatibility with OAuth clients
        },
      },
    }),
    mcp({
      loginPage: "/login",
      resource: resourceURL,
      // OAuth configuration
      oidcConfig: {
        allowDynamicClientRegistration: true,
        loginPage: "/login",
        codeExpiresIn: 300,
        accessTokenExpiresIn: 3600,
        refreshTokenExpiresIn: 60 * 60 * 24 * 90,
        defaultScope: "openid profile email",
        scopes: [
          "openid",
          "profile",
          "email",
          "claudeai",
          "offline_access",
        ],
        trustedClients: [
          {
            clientId: "claude.ai",
            name: "Claude",
            type: "public",
            metadata: {},
            disabled: false,
            redirectURLs: [
              "https://claude.ai/api/mcp/auth_callback",
              "https://claude.com/api/mcp/auth_callback",
            ],
            skipConsent: true,
          },
          {
            clientId: "chatgpt.com",
            name: "ChatGPT",
            type: "public",
            metadata: {},
            disabled: false,
            redirectURLs: [
              "https://chatgpt.com/connector_platform_oauth_redirect",
              "https://chat.openai.com/connector_platform_oauth_redirect",
            ],
            skipConsent: true,
          },
        ],
      },
    }),
    stripe({
      stripeClient,
      stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
      createCustomerOnSignUp: true,
      onEvent: async (event) => {
        console.log("[Stripe Webhook] Received event:", {
          type: event.type,
          id: event.id,
          created: new Date(event.created * 1000).toISOString(),
        });

        if (event.type === "checkout.session.completed") {
          const session = event.data.object as any;
          console.log("[Stripe Webhook] Checkout session metadata:", {
            sessionId: session.id,
            customerId: session.customer,
            subscriptionId: session.subscription,
            metadata: session.metadata,
          });
        }
      },
      subscription: {
        enabled: true,
        plans: [
          {
            name: "basic",
            priceId: process.env.STRIPE_BASIC_PRICE_ID || "",
            limits: {
              maxAccounts: 3,
            },
          },
          {
            name: "pro",
            priceId: process.env.STRIPE_PRO_PRICE_ID || "",
            limits: {
              maxAccounts: 10,
            },
            freeTrial: {
              days: 14,
              onTrialStart: async (subscription: Subscription) => {
                console.log("[Stripe] Trial started", {
                  referenceId: subscription.referenceId,
                });
              },
              onTrialEnd: async (
                { subscription }: { subscription: Subscription }
              ) => {
                console.log("[Stripe] Trial ended", {
                  referenceId: subscription.referenceId,
                });
              },
              onTrialExpired: async (subscription: Subscription) => {
                console.warn("[Stripe] Trial expired without conversion", {
                  referenceId: subscription.referenceId,
                });
              },
            },
          },
          {
            name: "enterprise",
            priceId: process.env.STRIPE_ENTERPRISE_PRICE_ID || "",
            limits: {
              maxAccounts: Infinity,
            },
          },
        ],
        requireEmailVerification: false,
        onSubscriptionComplete: async ({
          subscription,
          plan,
        }: {
          subscription: Subscription;
          plan: StripePlan;
        }) => {
          console.log("[Stripe] Subscription complete - HOOK CALLED", {
            subscriptionId: subscription.id,
            referenceId: subscription.referenceId,
            plan: plan.name,
            status: subscription.status,
            stripeSubscriptionId: subscription.stripeSubscriptionId,
          });

          // Send subscription confirmation email
          try {
            const { EmailService } = await import("@/lib/services/email-service");

            // Get user details from database
            const pool = auth.options.database as Pool;
            const client = await pool.connect();

            try {
              const userResult = await client.query(
                'SELECT email, name FROM "user" WHERE id = $1',
                [subscription.referenceId]
              );

              const user = userResult.rows[0];
              if (user?.email) {
                const userName = user.name || "there";
                const planName = plan.name.charAt(0).toUpperCase() + plan.name.slice(1);

                await EmailService.sendSubscriptionConfirmation(
                  user.email,
                  userName,
                  planName
                );

                console.log("[Stripe] Subscription confirmation email sent to", user.email);
              }
            } finally {
              client.release();
            }
          } catch (error) {
            console.error("[Stripe] Failed to send subscription confirmation email:", error);
            // Don't throw - email failure shouldn't block subscription
          }
        },
        onSubscriptionUpdate: async ({
          subscription,
        }: {
          subscription: Subscription;
        }) => {
          console.log("[Stripe] Subscription updated", {
            subscriptionId: subscription.id,
          });
        },
        onSubscriptionCancel: async ({
          subscription,
        }: {
          subscription: Subscription;
        }) => {
          console.log("[Stripe] Subscription canceled", {
            subscriptionId: subscription.id,
          });
        },
        onSubscriptionDeleted: async ({
          subscription,
        }: {
          subscription: Subscription;
        }) => {
          console.warn("[Stripe] Subscription deleted", {
            subscriptionId: subscription.id,
          });
        },
      },
    }),
  ],

  // Rate limiting configuration (uses Redis)
  rateLimit: {
    enabled: true,
    window: 60,
    max: 100,
    storage: "secondary-storage",
    customRules: {
      "/api/auth/sign-in/*": {
        window: 60,
        max: 10,
      },
      "/api/auth/oauth/token": {
        window: 60,
        max: 20,
      },
      "/api/auth/oauth/authorize": {
        window: 60,
        max: 20,
      },
      "/api/auth/.well-known/*": false,
    },
  },

});

export type AuthInstance = typeof auth;

Additional context

No response

Originally created by @bradmcnew on GitHub (Nov 14, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/5976 Originally assigned to: @bytaesu on GitHub. ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Set up Better Auth with Stripe plugin using API version 2025-09-30.clover or newer 2. Configure the Stripe plugin in auth config: ``` import { betterAuth } from "better-auth" import { stripe } from "@better-auth/stripe" import Stripe from "stripe" const stripeClient = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2025-09-30.clover", }) export const auth = betterAuth({ plugins: [ stripe({ stripeClient, stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, createCustomerOnSignUp: true, subscription: { enabled: true, plans: [/* your plans */] } }) ] }) ``` 3. Create a subscription checkout session using: ``` const { data, error } = await authClient.subscription.upgrade({ plan: "pro", successUrl: "/dashboard", cancelUrl: "/pricing", }); ``` Complete the Stripe checkout process Observe webhook logs showing the error ### Current vs. Expected behavior Current behavior: Stripe webhooks are received with HTTP 200 response but fail internally Error logs show: Stripe: Argument "subscription_exposed_id" must be a string, but got: null Checkout session metadata shows customerId: null and subscriptionId: null No subscription records are created in the database Webhook handler fails when trying to fetch subscription data from Stripe API Expected behavior: checkout.session.completed webhooks should successfully process subscription data Subscription records should be created in the database with proper stripeCustomerId and stripeSubscriptionId values Webhook processing should handle subscription lifecycle events without errors Root cause: This appears to be related to Stripe API version compatibility issues where the webhook handler expects subscription data that's not present in the checkout session, similar to the documented issue with payment mode checkout sessions. Error logs: ``` ERROR [Better Auth]: Stripe webhook failed. Error: Stripe: Argument "subscription_exposed_id" must be a string, but got: null (on API request to `GET /v1/subscriptions/{subscription_exposed_id}`) [Stripe Webhook] Checkout session metadata: { sessionId: 'cs_test_...', customerId: null, subscriptionId: null, metadata: {} } ``` ``` [Better Auth] POST request: { path: '/api/auth/stripe/webhook' } [Redis] Connected successfully for rate limiting 2025-11-14T00:14:27.252Z ERROR [Better Auth]: Stripe webhook failed. Error: TypeError: Cannot read properties of undefined (reading 'id') [Stripe Webhook] Received event: { type: 'customer.subscription.updated', id: 'evt_1STAUBE2RscS4ig0F0XwhOlB', created: '2025-11-14T00:03:31.000Z' } [Stripe Webhook] Subscription metadata: { subscriptionId: 'sub_1STAU7E2RscS4ig0McpPBNKu', customerId: 'cus_TQ0V3Ar5vuUg5Z', metadata: { foo: 'bar' }, status: 'active' } POST /api/auth/stripe/webhook 200 in 3595ms ``` ### What version of Better Auth are you using? 1.3.34 ### System info ```bash { "system": { "platform": "darwin", "arch": "arm64", "version": "Darwin Kernel Version 25.1.0: Mon Oct 20 19:32:41 PDT 2025; root:xnu-12377.41.6~2/RELEASE_ARM64_T6000", "release": "25.1.0", "cpuCount": 8, "cpuModel": "Apple M1 Pro", "totalMemory": "16.00 GB", "freeMemory": "0.13 GB" }, "node": { "version": "v22.16.0", "env": "development" }, "packageManager": { "name": "npm", "version": "10.9.2" }, "frameworks": [ { "name": "next", "version": "15.5.4" }, { "name": "react", "version": "19.1.0" } ], "databases": [ { "name": "pg", "version": "^8.16.3" } ], "betterAuth": { "version": "^1.3.34", "config": null } } ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript /** * Better Auth Configuration for Vercel Deployment * * Provides OAuth 2.1 authentication for ChatGPT via the MCP plugin. * Handles user sessions, OAuth flows, and integrates with PostgreSQL. */ import { betterAuth } from "better-auth"; import { mcp, apiKey, jwt } from "better-auth/plugins"; import { stripe } from "@better-auth/stripe"; import { Pool } from "pg"; import { Redis } from "ioredis"; import Stripe from "stripe"; import type { Subscription, StripePlan } from "@better-auth/stripe"; // Create PostgreSQL connection pool const pool = new Pool({ host: process.env.POSTGRES_HOST || 'localhost', port: parseInt(process.env.POSTGRES_PORT || '5432'), database: process.env.POSTGRES_DATABASE || 'askmymoney', user: process.env.POSTGRES_USER || 'postgres', password: process.env.POSTGRES_PASSWORD || 'postgres', ssl: process.env.POSTGRES_SSL === 'true' ? { rejectUnauthorized: false } : false, max: 20, idleTimeoutMillis: 30000, connectionTimeoutMillis: 2000, }); // Add error handling for the pool pool.on('error', (error) => { console.error('[Postgres] Unexpected error on idle client', error); }); // Create Redis client for secondary storage (rate limiting, caching) if (!process.env.REDIS_URL) { throw new Error('REDIS_URL is required for rate limiting. Please set it in your .env file.'); } const redis = new Redis(process.env.REDIS_URL, { maxRetriesPerRequest: 3, enableReadyCheck: true, }); redis.on('error', (error) => { console.error('[Redis] Connection error:', error); }); redis.on('connect', () => { console.log('[Redis] Connected successfully for rate limiting'); }); // Create Stripe client for subscription management const stripeClient = new Stripe(process.env.STRIPE_SECRET_KEY || '', { apiVersion: "2025-10-29.clover", }); // Helper utilities for composing URLs without duplicate slashes const stripTrailingSlash = (value: string) => value.endsWith("/") ? value.slice(0, -1) : value; const ensureLeadingSlash = (value: string) => value.startsWith("/") ? value : `/${value}`; // Determine the application origin (used for user-facing URLs) const appOrigin = process.env.APP_URL || (process.env.VERCEL_URL && `https://${process.env.VERCEL_URL}`) || "https://dev.askmymoney.ai"; const authBasePath = ensureLeadingSlash( process.env.BETTER_AUTH_BASE_PATH || "/api/auth" ); // Get base URL for Better Auth endpoints (should include `/api/auth`) const baseURL = process.env.BETTER_AUTH_URL || `${stripTrailingSlash(appOrigin)}${authBasePath}`; // Resource URL advertised to OAuth clients (should remain the origin) const resourceURL = process.env.MCP_RESOURCE_URL || appOrigin; // Initialize Better Auth with MCP and Stripe plugins export const auth = betterAuth({ // Database configuration database: pool, // Secondary storage for caching and rate limiting (Redis) secondaryStorage: { get: async (key) => { const value = await redis.get(key); return value ? JSON.parse(value) : null; }, set: async (key, value, ttl) => { const serialized = JSON.stringify(value); if (ttl && ttl > 0) { await redis.setex(key, Math.ceil(ttl / 1000), serialized); } else { const defaultTTL = 60 * 60 * 24 * 7; // 7 days await redis.setex(key, defaultTTL, serialized); } }, delete: async (key) => { await redis.del(key); }, }, // Base URL for OAuth redirects baseURL, // Secret for signing tokens (MUST be set in production) secret: process.env.BETTER_AUTH_SECRET || process.env.SESSION_SECRET || "development-secret-change-in-production", // Session configuration session: { expiresIn: 60 * 60 * 24 * 30, // 30 days updateAge: 60 * 60 * 24, cookieCache: { enabled: true, maxAge: 5 * 60, // 5 minutes }, }, // Advanced cookie configuration for cross-site redirects advanced: { useSecureCookies: true, // Always use secure cookies for HTTPS (dev.askmymoney.ai uses HTTPS) cookies: { session_token: { attributes: { sameSite: 'lax', // Allow cookies on same-site navigations (like Stripe redirect) }, }, }, }, // User schema // Email and password authentication emailAndPassword: { enabled: true, minPasswordLength: 8, maxPasswordLength: 128, }, telemetry: { debug: true }, logger: { level: 'debug', }, // Plugins plugins: [ jwt({ // JWT plugin provides JWKS endpoint for token verification disableSettingJwtHeader: true, jwt: { issuer: baseURL, audience: resourceURL, expirationTime: "1h", }, jwks: { keyPairConfig: { alg: "RS256", // Use RS256 for better compatibility with OAuth clients }, }, }), mcp({ loginPage: "/login", resource: resourceURL, // OAuth configuration oidcConfig: { allowDynamicClientRegistration: true, loginPage: "/login", codeExpiresIn: 300, accessTokenExpiresIn: 3600, refreshTokenExpiresIn: 60 * 60 * 24 * 90, defaultScope: "openid profile email", scopes: [ "openid", "profile", "email", "claudeai", "offline_access", ], trustedClients: [ { clientId: "claude.ai", name: "Claude", type: "public", metadata: {}, disabled: false, redirectURLs: [ "https://claude.ai/api/mcp/auth_callback", "https://claude.com/api/mcp/auth_callback", ], skipConsent: true, }, { clientId: "chatgpt.com", name: "ChatGPT", type: "public", metadata: {}, disabled: false, redirectURLs: [ "https://chatgpt.com/connector_platform_oauth_redirect", "https://chat.openai.com/connector_platform_oauth_redirect", ], skipConsent: true, }, ], }, }), stripe({ stripeClient, stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, createCustomerOnSignUp: true, onEvent: async (event) => { console.log("[Stripe Webhook] Received event:", { type: event.type, id: event.id, created: new Date(event.created * 1000).toISOString(), }); if (event.type === "checkout.session.completed") { const session = event.data.object as any; console.log("[Stripe Webhook] Checkout session metadata:", { sessionId: session.id, customerId: session.customer, subscriptionId: session.subscription, metadata: session.metadata, }); } }, subscription: { enabled: true, plans: [ { name: "basic", priceId: process.env.STRIPE_BASIC_PRICE_ID || "", limits: { maxAccounts: 3, }, }, { name: "pro", priceId: process.env.STRIPE_PRO_PRICE_ID || "", limits: { maxAccounts: 10, }, freeTrial: { days: 14, onTrialStart: async (subscription: Subscription) => { console.log("[Stripe] Trial started", { referenceId: subscription.referenceId, }); }, onTrialEnd: async ( { subscription }: { subscription: Subscription } ) => { console.log("[Stripe] Trial ended", { referenceId: subscription.referenceId, }); }, onTrialExpired: async (subscription: Subscription) => { console.warn("[Stripe] Trial expired without conversion", { referenceId: subscription.referenceId, }); }, }, }, { name: "enterprise", priceId: process.env.STRIPE_ENTERPRISE_PRICE_ID || "", limits: { maxAccounts: Infinity, }, }, ], requireEmailVerification: false, onSubscriptionComplete: async ({ subscription, plan, }: { subscription: Subscription; plan: StripePlan; }) => { console.log("[Stripe] Subscription complete - HOOK CALLED", { subscriptionId: subscription.id, referenceId: subscription.referenceId, plan: plan.name, status: subscription.status, stripeSubscriptionId: subscription.stripeSubscriptionId, }); // Send subscription confirmation email try { const { EmailService } = await import("@/lib/services/email-service"); // Get user details from database const pool = auth.options.database as Pool; const client = await pool.connect(); try { const userResult = await client.query( 'SELECT email, name FROM "user" WHERE id = $1', [subscription.referenceId] ); const user = userResult.rows[0]; if (user?.email) { const userName = user.name || "there"; const planName = plan.name.charAt(0).toUpperCase() + plan.name.slice(1); await EmailService.sendSubscriptionConfirmation( user.email, userName, planName ); console.log("[Stripe] Subscription confirmation email sent to", user.email); } } finally { client.release(); } } catch (error) { console.error("[Stripe] Failed to send subscription confirmation email:", error); // Don't throw - email failure shouldn't block subscription } }, onSubscriptionUpdate: async ({ subscription, }: { subscription: Subscription; }) => { console.log("[Stripe] Subscription updated", { subscriptionId: subscription.id, }); }, onSubscriptionCancel: async ({ subscription, }: { subscription: Subscription; }) => { console.log("[Stripe] Subscription canceled", { subscriptionId: subscription.id, }); }, onSubscriptionDeleted: async ({ subscription, }: { subscription: Subscription; }) => { console.warn("[Stripe] Subscription deleted", { subscriptionId: subscription.id, }); }, }, }), ], // Rate limiting configuration (uses Redis) rateLimit: { enabled: true, window: 60, max: 100, storage: "secondary-storage", customRules: { "/api/auth/sign-in/*": { window: 60, max: 10, }, "/api/auth/oauth/token": { window: 60, max: 20, }, "/api/auth/oauth/authorize": { window: 60, max: 20, }, "/api/auth/.well-known/*": false, }, }, }); export type AuthInstance = typeof auth; ``` ### Additional context _No response_
GiteaMirror added the lockedbug labels 2026-04-15 17:47:46 -05:00
Author
Owner

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

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

This error is caused by a breaking change in the Stripe API: fields like subscriptionId, current_period_start, and current_period_end are no longer always present at the top level of the subscription object in webhook events. The Better Auth Stripe plugin expects these fields to be present, so when they're missing or null (especially with newer API versions like 2025-09-30.clover), the webhook handler fails when trying to fetch subscription data or update your database, resulting in errors like "subscription_exposed_id must be a string, but got: null" and missing subscription records [source] [source] [source].

Workaround:
Patch your webhook handler to support both old and new Stripe API versions. In your handler (usually in packages/stripe/src/hooks.ts or similar), update how you read the period fields and subscription ID:

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;
const subscriptionId = session.subscription ?? session.subscription_id ?? null;

Also, add an early return in your onCheckoutSessionCompleted hook for sessions with mode: "payment" or mode: "setup" (or when subscription management is disabled):

if (session.mode === "payment" || session.mode === "setup" || !options.subscription?.enabled) {
  return;
}

This prevents the handler from trying to fetch a subscription when none exists [source].

Why this happens:
Stripe changed the structure of webhook payloads in recent API versions, so the plugin needs to be updated to handle both the old and new formats. Downgrading the Stripe API version is not possible for new accounts, so patching locally is the only workaround until an official fix is released [source].

Next steps:

  • Patch your local plugin as above.
  • Monitor for updates or merged PRs (like PR #4892) that address this issue.
  • If you need to handle one-off payments, override your onEvent handler to detect payment mode and bypass subscription logic.

Let me know if you need a more detailed code snippet or help with patching your handler.

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:3530385802 --> @dosubot[bot] commented on GitHub (Nov 14, 2025): <!-- Greeting --> Hi @bradmcnew! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> This error is caused by a breaking change in the Stripe API: fields like `subscriptionId`, `current_period_start`, and `current_period_end` are no longer always present at the top level of the subscription object in webhook events. The Better Auth Stripe plugin expects these fields to be present, so when they're missing or null (especially with newer API versions like 2025-09-30.clover), the webhook handler fails when trying to fetch subscription data or update your database, resulting in errors like `"subscription_exposed_id must be a string, but got: null"` and missing subscription records [[source]](https://github.com/better-auth/better-auth/issues/4565) [[source]](https://github.com/better-auth/better-auth/issues/4957) [[source]](https://github.com/better-auth/better-auth/issues/4861). **Workaround:** Patch your webhook handler to support both old and new Stripe API versions. In your handler (usually in `packages/stripe/src/hooks.ts` or similar), update how you read the period fields and subscription ID: ```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; const subscriptionId = session.subscription ?? session.subscription_id ?? null; ``` Also, add an early return in your `onCheckoutSessionCompleted` hook for sessions with `mode: "payment"` or `mode: "setup"` (or when subscription management is disabled): ```js if (session.mode === "payment" || session.mode === "setup" || !options.subscription?.enabled) { return; } ``` This prevents the handler from trying to fetch a subscription when none exists [[source]](https://github.com/better-auth/better-auth/issues/4565). **Why this happens:** Stripe changed the structure of webhook payloads in recent API versions, so the plugin needs to be updated to handle both the old and new formats. Downgrading the Stripe API version is not possible for new accounts, so patching locally is the only workaround until an official fix is released [[source]](https://github.com/better-auth/better-auth/issues/4861). **Next steps:** - Patch your local plugin as above. - Monitor for updates or merged PRs (like [PR #4892](https://github.com/better-auth/better-auth/pull/4892)) that address this issue. - If you need to handle one-off payments, override your `onEvent` handler to detect `payment` mode and bypass subscription logic. Let me know if you need a more detailed code snippet or help with patching your handler. <!-- 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/e163548d-16b7-4bb7-8e0a-7da2e1fd7e4a?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/e163548d-16b7-4bb7-8e0a-7da2e1fd7e4a?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/e163548d-16b7-4bb7-8e0a-7da2e1fd7e4a?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/e163548d-16b7-4bb7-8e0a-7da2e1fd7e4a?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/e163548d-16b7-4bb7-8e0a-7da2e1fd7e4a?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/e163548d-16b7-4bb7-8e0a-7da2e1fd7e4a?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/e163548d-16b7-4bb7-8e0a-7da2e1fd7e4a?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/5976)
Author
Owner

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

@dosu is it becuase the stripe api endpoints have been changed.

<!-- gh-comment-id:3531262251 --> @rovertrack commented on GitHub (Nov 14, 2025): @dosu is it becuase the stripe api endpoints have been changed.
Author
Owner

@bytaesu commented on GitHub (Jan 17, 2026):

I will check if this issue persists

<!-- gh-comment-id:3762988876 --> @bytaesu commented on GitHub (Jan 17, 2026): I will check if this issue persists
Author
Owner

@bytaesu commented on GitHub (Jan 28, 2026):

The Stripe plugin has been improved overall in the past few months. The logic has changed a lot since then, so it’s hard to pinpoint issues. It’s much better now, so try using the latest version 😄

Feel free to reopen or mention me if you have further questions!

<!-- gh-comment-id:3808692115 --> @bytaesu commented on GitHub (Jan 28, 2026): The Stripe plugin has been improved overall in the past few months. The logic has changed a lot since then, so it’s hard to pinpoint issues. It’s much better now, so try using the latest version 😄 Feel free to reopen or mention me if you have further questions!
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#19023