You have built your pricing page, set up your backend endpoint, and executed stripe.subscriptions.create. You expect the subscription object to return with status: 'active'.
Instead, the API returns status: 'incomplete'.
This is one of the most common frustration points for engineers integrating Stripe Billing. It breaks your provisioning logic because your system assumes the user has paid, but Stripe says they haven't.
If you try to force the charge, it fails. If you ignore it, you have "zombie" subscriptions in your dashboard that never collect revenue.
This article explains strictly why this happens within the Stripe state machine and provides the modern, SCA-compliant architecture to handle it correctly.
The Root Cause: SCA and The First Invoice
To understand why your subscription is "incomplete," you must look at the underlying PaymentIntent.
When you create a subscription, Stripe immediately generates the first invoice. Attempting to pay that invoice triggers a PaymentIntent. In the modern era of online payments (specifically with PSD2/SCA regulations in Europe and similar rules globally), a payment often requires 3D Secure authentication (e.g., a fingerprint scan or bank app approval).
The API cannot perform a fingerprint scan. Only the frontend (the browser) can.
Therefore, the incomplete status is not an error—it is a waiting state.
The payment_behavior Parameter
Stripe determines the initial status based on the payment_behavior parameter passed during creation.
allow_incomplete(Default): If payment requires action (SCA) or fails, the subscription is created but staysincomplete.error_if_incomplete: The API call throws an exception if payment isn't instant. This is rarely what you want for a robust checkout flow.default_incomplete(Recommended): This explicitly tells Stripe, "I know payment requires a frontend step. Create the subscription in a waiting state so I can hand off theclient_secretto the UI."
If you are getting stuck in "incomplete" without knowing why, you are likely relying on the default behavior but failing to handle the subsequent requires_action state on the frontend.
The Solution: The "Default Incomplete" Pattern
To fix this, we must decouple the subscription creation from the payment confirmation.
We will use the Payment Element flow. This is the current gold standard for Stripe integration as of 2024.
Step 1: Backend Implementation (Node.js/TypeScript)
Update your subscription creation logic. We will enforce payment_behavior: 'default_incomplete'. This ensures a predictable state regardless of the payment method.
We must also expand the latest_invoice.payment_intent to retrieve the client_secret, which serves as the key for the frontend to finalize the transaction.
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2023-10-16', // Always pin your API version
});
export const createSubscription = async (
customerId: string,
priceId: string
) => {
try {
// 1. Create the subscription in incomplete state
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: priceId }],
payment_behavior: 'default_incomplete',
payment_settings: { save_default_payment_method: 'on_subscription' },
expand: ['latest_invoice.payment_intent'],
});
// 2. Extract the Client Secret safely
const invoice = subscription.latest_invoice as Stripe.Invoice;
const paymentIntent = invoice.payment_intent as Stripe.PaymentIntent;
if (!paymentIntent?.client_secret) {
throw new Error('Failed to generate payment intent');
}
// 3. Return IDs to frontend
return {
subscriptionId: subscription.id,
clientSecret: paymentIntent.client_secret,
};
} catch (error) {
console.error('Subscription creation failed:', error);
throw error;
}
};
Step 2: Frontend Implementation (React)
On the client side, we render the Payment Element. When the user clicks "Subscribe," we confirm the payment using the clientSecret generated in Step 1.
This code assumes you have wrapped your application in the <Elements> provider from @stripe/react-stripe-js.
import React, { useState } from 'react';
import {
useStripe,
useElements,
PaymentElement
} from '@stripe/react-stripe-js';
export const SubscriptionForm = () => {
const stripe = useStripe();
const elements = useElements();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!stripe || !elements) {
// Stripe.js hasn't loaded yet
return;
}
setIsProcessing(true);
// This triggers the SCA modal if required, or finalizes the charge.
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
// Redirect logic is handled here
return_url: `${window.location.origin}/dashboard/success`,
},
});
if (error) {
// Show error to your customer (e.g., insufficient funds)
setErrorMessage(error.message ?? "An unexpected error occurred.");
setIsProcessing(false);
} else {
// The UI will automatically redirect to the return_url.
}
};
return (
<form onSubmit={handleSubmit} className="p-4 border rounded shadow-sm">
<h2 className="text-xl font-bold mb-4">Confirm Subscription</h2>
<PaymentElement />
{errorMessage && (
<div className="text-red-500 mt-2 text-sm">{errorMessage}</div>
)}
<button
disabled={!stripe || isProcessing}
className="mt-4 bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
>
{isProcessing ? "Processing..." : "Subscribe"}
</button>
</form>
);
};
Deep Dive: Why This Fix Works
This architecture solves the "incomplete" problem by changing the lifecycle expectation.
- State Initialization: The backend creates the record. The status is
incomplete. This is now expected, not an error. - Handshake: The backend sends the
client_secretto the frontend. - Finalization: The function
stripe.confirmPaymentuses the secret to connect to the specific PaymentIntent attached to the subscription's first invoice. - State Transition: Once
confirmPaymentsucceeds (SCA passed, bank approved), Stripe automatically transitions the PaymentIntent tosucceededand the Subscription status toactive.
This shift moves the complexity of handling card errors, 3D Secure, and banking declines entirely to Stripe's hosted UI elements, keeping your backend logic clean.
Handling Provisioning (Webhooks)
You should never provision access (e.g., "Upgrade User to Pro") based solely on the frontend success response. Users can manipulate frontend code.
To securely handle the transition from incomplete to active, you must listen for webhooks.
Critical Webhook Events
invoice.payment_succeeded: This is the most reliable trigger for subscriptions. When the invoice is paid, the subscription is active. Provision access here.customer.subscription.updated: Listen to this to detect status changes (e.g.,incomplete->active).invoice.payment_failed: Reach out to the user or lock the account.
Edge Cases and Pitfalls
1. Trials
If your subscription has a trial period, the initial invoice amount is $0. In this case, the status will immediately be trialing (unless payment method validation fails). Your frontend code needs to handle cases where confirmPayment is technically not needed for a charge, but is needed to set up the card for future charges. The setup_intent logic handles this automatically when using payment_behavior: 'default_incomplete'.
2. "Incomplete Expired"
Subscriptions in the incomplete state expire after 23 hours. If the user closes the browser before finishing the SCA check, the subscription moves to incomplete_expired. You should have a cron job or webhook listener to clean up associated data if you store local references to these "ghost" subscriptions.
3. Legacy "Incomplete" Handling
If you see old tutorials mentioning stripe.handleCardPayment, ignore them. That is the legacy method. stripe.confirmPayment combined with the Payment Element is the current standard that properly handles the complex incomplete flows.
Conclusion
The status: 'incomplete' response is not a bug; it is a feature of the asynchronous nature of modern payments. By forcing default_incomplete and utilizing the Payment Element to finalize the intent on the client side, you align your integration with global banking standards.
Stop trying to force the status to "active" in one API call. Embrace the two-step flow, and your subscription success rates will improve significantly.