Nurture TechnologiesNurture Tech
Integrations12 min read·July 24, 2026

Stripe Subscription Status Not Updating After Payment

StripeNode.jsTypeScriptPostgreSQL

Customers pay successfully, Stripe shows the subscription as active, but your database still shows past_due or canceled. The webhook handler exists but it is not running the right logic for the right events.

Problem Summary

Stripe subscription billing generates a cascade of webhook events that your system must handle in the correct order to stay in sync: invoice.created, invoice.finalized, invoice.paid, customer.subscription.updated, and payment_intent.succeeded all fire for a single successful renewal. If your handler processes only some of these events or processes them in the wrong order your internal subscription state diverges from Stripe's source of truth.

Symptoms

  • Stripe Dashboard shows subscription.status = 'active' but your database has 'past_due' or 'canceled'
  • User logs in and sees 'subscription expired' even though their card was charged successfully
  • invoice.paid event is received but customer.subscription.updated is not handled
  • Subscription upgrades/downgrades processed in Stripe but access level does not change in your app
  • Trial-to-paid conversion not detected user's trial ends and access is revoked despite successful payment
  • Subscription cancellation at period end not flagged user loses access immediately instead of at period end
  • customer.subscription.updated fires but your handler only checks for status changes, missing plan changes

Business Impact

A SaaS product that locks out paying customers is a churn machine. Every false lockout generates a support ticket, erodes trust, and risks a chargeback. Conversely, failing to lock out truly lapsed accounts is a revenue leak. Both directions of divergence phantom active subscriptions and false inactive subscriptions cause real business damage.

Root Cause

The most common root cause is trusting a single event type (like payment_intent.succeeded) to represent subscription state, when Stripe's subscription lifecycle produces a different event that should drive billing state: invoice.paid. Additionally, many teams handle subscription creation events but forget that upgrades, downgrades, trial conversions, and cancellations each produce customer.subscription.updated with different fields, all requiring different business logic.

Do not use payment_intent.succeeded to update subscription state. A PaymentIntent can exist independently of a subscription. Use customer.subscription.updated and invoice.paid as the authoritative events for subscription lifecycle management.

Diagnosis

Step 1 Map the Full Event Sequence for One Invoice Cycle

Use the Stripe Dashboard to find a recent successful invoice and trace all events generated by that invoice cycle. Confirm which events your webhook handler processes and which it silently ignores.

$stripe events list --type customer.subscription.updated --limit 10

Step 2 Implement a Complete Subscription Event Handler

src/webhooks/subscription-handler.ts
import Stripe from "stripe";

export async function handleStripeEvent(event: Stripe.Event): Promise<void> {
  switch (event.type) {
    case "customer.subscription.created":
    case "customer.subscription.updated": {
      const subscription = event.data.object as Stripe.Subscription;
      await syncSubscription(subscription);
      break;
    }

    case "customer.subscription.deleted": {
      const subscription = event.data.object as Stripe.Subscription;
      await db.subscriptions.update({
        where: { stripeSubscriptionId: subscription.id },
        data: {
          status: "canceled",
          canceledAt: new Date(),
          currentPeriodEnd: new Date(subscription.current_period_end * 1000),
        },
      });
      await revokeUserAccess(subscription.metadata.userId);
      break;
    }

    case "invoice.paid": {
      const invoice = event.data.object as Stripe.Invoice;
      if (invoice.subscription) {
        await db.subscriptions.update({
          where: { stripeSubscriptionId: invoice.subscription as string },
          data: {
            status: "active",
            lastPaymentAt: new Date(),
            lastInvoiceId: invoice.id,
          },
        });
      }
      break;
    }

    case "invoice.payment_failed": {
      const invoice = event.data.object as Stripe.Invoice;
      if (invoice.subscription) {
        const attemptCount = invoice.attempt_count;
        console.warn(
          `[Stripe] Invoice payment failed for ${invoice.subscription}, attempt ${attemptCount}`
        );
        await notifyPaymentFailed(invoice.customer as string, attemptCount);
      }
      break;
    }

    default:
      console.log(`[Stripe] Unhandled event type: ${event.type}`);
  }
}

async function syncSubscription(subscription: Stripe.Subscription) {
  const priceId = subscription.items.data[0]?.price.id;

  await db.subscriptions.upsert({
    where: { stripeSubscriptionId: subscription.id },
    create: {
      stripeSubscriptionId: subscription.id,
      stripeCustomerId: subscription.customer as string,
      status: subscription.status,
      priceId: priceId ?? null,
      currentPeriodStart: new Date(subscription.current_period_start * 1000),
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
      cancelAtPeriodEnd: subscription.cancel_at_period_end,
      trialEnd: subscription.trial_end
        ? new Date(subscription.trial_end * 1000)
        : null,
    },
    update: {
      status: subscription.status,
      priceId: priceId ?? null,
      currentPeriodStart: new Date(subscription.current_period_start * 1000),
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
      cancelAtPeriodEnd: subscription.cancel_at_period_end,
      trialEnd: subscription.trial_end
        ? new Date(subscription.trial_end * 1000)
        : null,
    },
  });

  await updateUserAccessLevel(subscription);
}

Step 3 Handle cancel_at_period_end Correctly

When a user cancels, Stripe sets cancel_at_period_end = true but the subscription status remains 'active' until the period ends. If your code only checks status = 'active', you will never know a cancellation is pending and when the subscription finally moves to 'canceled', it appears to be an abrupt deletion rather than a scheduled cancellation.

src/utils/subscription-access.ts
import Stripe from "stripe";

export function hasActiveAccess(subscription: {
  status: string;
  cancelAtPeriodEnd: boolean;
  currentPeriodEnd: Date;
}): boolean {
  // User still has access if active, even if cancellation is scheduled
  if (subscription.status === "active") return true;

  // Trialing users have access
  if (subscription.status === "trialing") return true;

  // past_due: grace period  still allow access for a few days
  if (subscription.status === "past_due") {
    const gracePeriodDays = 3;
    const gracePeriodEnd = new Date(
      subscription.currentPeriodEnd.getTime() +
        gracePeriodDays * 24 * 60 * 60 * 1000
    );
    return new Date() < gracePeriodEnd;
  }

  return false;
}

export function getCancellationLabel(subscription: {
  status: string;
  cancelAtPeriodEnd: boolean;
  currentPeriodEnd: Date;
}): string | null {
  if (subscription.cancelAtPeriodEnd) {
    return `Access ends ${subscription.currentPeriodEnd.toLocaleDateString()}`;
  }
  return null;
}

Step 4 Build a Reconciliation Job

Webhooks can be missed, delayed, or processed out of order. Run a daily reconciliation job that lists all active Stripe subscriptions and compares them against your database, correcting any divergence.

src/jobs/subscription-reconcile.ts
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-06-20",
});

export async function reconcileSubscriptions(): Promise<void> {
  console.log("[Reconcile] Starting subscription reconciliation...");
  let reconciled = 0;
  let mismatches = 0;

  for await (const subscription of stripe.subscriptions.list({
    limit: 100,
    status: "all",
    expand: ["data.customer"],
  })) {
    const local = await db.subscriptions.findUnique({
      where: { stripeSubscriptionId: subscription.id },
    });

    if (!local) {
      console.warn(`[Reconcile] Missing subscription in DB: ${subscription.id}`);
      await syncSubscription(subscription);
      mismatches++;
      continue;
    }

    if (local.status !== subscription.status) {
      console.warn(
        `[Reconcile] Status mismatch for ${subscription.id}: local=${local.status} stripe=${subscription.status}`
      );
      await syncSubscription(subscription);
      mismatches++;
    }

    reconciled++;
  }

  console.log(
    `[Reconcile] Complete. Checked: ${reconciled}, Fixed: ${mismatches}`
  );
}

Step 5 Verify Subscription State After Plan Changes

When a user upgrades or downgrades, Stripe issues customer.subscription.updated with items.data changed. Your handler must compare the old price ID (event.data.previous_attributes.items) with the new one to determine which tier to provision.

$stripe trigger customer.subscription.updated

Production Failure Scenarios

Scenario 1 Trial End Not Processed Correctly

When a trial ends and converts to paid, Stripe fires customer.subscription.updated with status changing from 'trialing' to 'active'. If the webhook handler only handles 'active' → 'past_due' transitions, the trial conversion is ignored and the user's access tier is never updated to reflect they are now a paying customer.

Scenario 2 Duplicate invoice.paid Events Causing Double Credit

Stripe's at-least-once delivery can send invoice.paid twice. If the handler credits the user's account for each event (adding usage credits, for example), users receive double credits. Implement idempotency by checking if the invoice ID has already been processed before applying credits.

Scenario 3 Subscription Paused Without Platform Awareness

Stripe supports subscription pausing (status = 'paused'). If your code only handles 'active', 'past_due', and 'canceled', a paused subscription is treated as active indefinitely. Always handle all possible subscription statuses: active, past_due, canceled, trialing, paused, incomplete, incomplete_expired, unpaid.

Prevention Checklist

  • Handle all subscription status values: active, trialing, past_due, canceled, paused, incomplete, unpaid
  • Use invoice.paid (not payment_intent.succeeded) as the trigger for subscription renewal logic
  • Store and check cancel_at_period_end in your access control logic
  • Implement a daily reconciliation job to catch missed or failed webhooks
  • Use idempotency keys on database writes keyed to Stripe event IDs
  • Test trial-to-paid conversion, upgrade, downgrade, and pause flows explicitly
  • Store the Stripe subscription object's current_period_end to implement grace periods
  • Log every customer.subscription.updated event with previous and new status
  • Never rely on a single event type to represent the full subscription lifecycle

Resolution Summary

Handle all Stripe subscription lifecycle events (customer.subscription.created, updated, deleted; invoice.paid, invoice.payment_failed). Use invoice.paid not payment_intent.succeeded as the trigger for renewal state. Implement a daily reconciliation job as a safety net. Store cancel_at_period_end and handle it explicitly in access control logic.

Lessons Learned

  • Subscription state is a cascade of events handling only one or two event types always creates edge cases
  • A reconciliation job is not optional; webhooks fail and state diverges in production
  • cancel_at_period_end is invisible if you only read the status field always read both
  • customer.subscription.updated fires for every change: plan changes, trial conversions, pause/resume, quantity changes
  • Past_due is a temporary state with a grace period, not immediately 'canceled' design your UX around this

Conclusion

Stripe subscription management requires handling a full event lifecycle, not just the happy path. Build handlers for all status transitions, implement idempotency, run periodic reconciliation, and treat Stripe's subscription object as the single source of truth querying it directly when in doubt rather than relying solely on cached local state.

Nurture Technologies

NEED HELP WITH YOUR STACK?

Nurture Technologies builds and maintains production-quality software for startups and businesses. If engineering problems are slowing you down, our team can help.

Talk to our team →
FAQ

FREQUENTLY ASKED QUESTIONS

Which Stripe event should I use to detect a successful subscription renewal?+

Use invoice.paid. This event fires when Stripe successfully collects payment for a subscription renewal. Do not use payment_intent.succeeded for this purpose a PaymentIntent can exist independently of a subscription.

What is the difference between customer.subscription.deleted and cancel_at_period_end?+

cancel_at_period_end = true means cancellation is scheduled but the subscription is still active. customer.subscription.deleted fires when the subscription actually terminates either at the period end (if cancel_at_period_end was set) or immediately if canceled without a grace period.

How do I handle subscription upgrades and downgrades?+

Listen to customer.subscription.updated and compare event.data.object.items.data[0].price.id to the previous price ID. Update the user's access tier in your database based on which plan they now hold.

Should I allow access during the past_due grace period?+

Generally yes implement a 3–7 day grace period during which users retain access while Stripe retries payment. Stripe's Smart Retries handles the retry schedule. Display a prominent banner prompting the user to update their payment method.

How do I handle a subscription that moves from trialing to active?+

Stripe fires customer.subscription.updated with the previous_attributes.status = 'trialing' and new status = 'active'. Your handler must check for this transition and provision the appropriate paid access tier.

Can I rely entirely on webhooks for subscription sync, or do I need a fallback?+

You should never rely entirely on webhooks. Implement a daily reconciliation job that lists all Stripe subscriptions and compares them against your database. This catches missed webhooks, processing failures, and any edge cases your event handler does not cover.

What does subscription status 'incomplete' mean?+

A subscription enters 'incomplete' status when the first invoice payment fails or requires 3D Secure authentication. The subscription remains incomplete for 23 hours; if payment is not completed in that time, it moves to 'incomplete_expired' and is effectively canceled.

How do I prevent processing duplicate invoice.paid events?+

Store the invoice ID (invoice.id) in your database when processing an invoice.paid event. Before processing, check if this invoice ID already exists. If it does, acknowledge the event with HTTP 200 and return without reprocessing.

How do subscription quantities work in webhooks?+

Per-seat or per-unit subscriptions use subscription.quantity. When a team changes seat count, customer.subscription.updated fires with the new quantity. Your handler must read subscription.items.data[0].quantity and update access accordingly.

What is the 'unpaid' subscription status?+

Stripe moves a subscription to 'unpaid' after all retry attempts in the dunning cycle are exhausted (typically after 4 attempts over several days). This is the final failure state before cancellation. You should revoke access and send a final notification to the user.