reactjsnode.jsstripe-payments

Stripe ExpressCheckoutElement fails to confirm subscription payment due to setup_future_usage mismatch (off_session vs null)


I am trying to create a Stripe Subscription using the ExpressCheckoutElement for Google Pay and Apple Pay. My backend successfully creates the subscription and returns a clientSecret, but the frontend fails when calling stripe.confirmPayment with a specific error about setup_future_usage.

The same backend logic works perfectly for standard credit card payments using the CardElement.

My backend has an endpoint that creates a Stripe Subscription. To handle potential SCA/3D Secure requirements for card payments, I'm using payment_behavior: 'default_incomplete'.

app.post('/create-subscription-intent', async (req, res) => {
  const { customerId, priceId } = req.body;

  try {
    const subscription = await stripe.subscriptions.create({
      customer: customerId,
      items: [{ price: priceId }],

      // This parameter is crucial. It's used to handle SCA for card payments
      // by leaving the subscription incomplete until confirmed on the client.
      payment_behavior: 'default_incomplete',

      // We need to expand this to get the client_secret
      expand: ['latest_invoice.payment_intent'],
    });

    // Safely access the client_secret
    const clientSecret = 
      subscription.latest_invoice.payment_intent.client_secret;

    res.send({ clientSecret });

  } catch (error) {
    res.status(400).send({ error: { message: error.message } });
  }
});

The Frontend Flow (React / @stripe/react-stripe-js)

On the frontend, I use the ExpressCheckoutElement. When the user confirms their payment in the Google Pay/Apple Pay dialog, the onConfirm event fires. I then take the clientSecret from my backend and use it to confirm the payment.

import {
  Elements,
  ExpressCheckoutElement,
  useStripe,
  useElements,
} from '@stripe/react-stripe-js';

function CheckoutForm() {
  const stripe = useStripe();

  const handleExpressCheckoutConfirm = async () => {
    if (!stripe) {
      return;
    }

    // 1. Call backend to create the subscription and get the clientSecret
    const { error: backendError, clientSecret } = await fetch(
      '/create-subscription-intent',
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          /* customerId, priceId */
        }),
      }
    ).then((res) => res.json());

    if (backendError) {
      console.error(backendError.message);
      return;
    }

    // 2. Use the clientSecret to confirm the payment on the frontend
    const { error: stripeError } = await stripe.confirmPayment({
      clientSecret,
      confirmParams: {
        // The return URL is required for redirect-based payment methods
        return_url: `${window.location.origin}/checkout-success`,
      },
    });

    if (stripeError) {
      // This is where the error occurs
      console.error(stripeError.message);
    }
  };

  return (
    <ExpressCheckoutElement onConfirm={handleExpressCheckoutConfirm} />
  );
}

The Problem: The API Error

When stripe.confirmPayment is called in the handleExpressCheckoutConfirm function, the Stripe API returns a 400 Bad Request with the following JSON error payload:

{
  "error": {
    "message": "The provided setup_future_usage (off_session) does not match the expected setup_future_usage (null). Try confirming with a Payment Intent that is configured to use the same parameters as Stripe Elements.",
    "param": "setup_future_usage",
    "type": "invalid_request_error"
  }
}

My investigation suggests the following is happening:

  1. When I create a Stripe.Subscription with payment_behavior: 'default_incomplete', Stripe implicitly creates the underlying PaymentIntent with setup_future_usage: 'off_session'. This makes sense, as the goal of a subscription is to save the payment method for future, off-session charges.

  2. The ExpressCheckoutElement's confirmation flow, however, seems to require that the PaymentIntent it is confirming has setup_future_usage: null, as explicitly stated in the error message.

What is the correct, Stripe-recommended way to create an initial subscription payment with the ExpressCheckoutElement that also saves the payment method for future recurring (off-session) charges?


Solution

  • because this intent is coming from a subscription's payment intent, you will want to set mode: 'subscription' when setting up your elements instance on your page. That should ensure that the ECE uses the proper setup future usage setting when confirming the intent.

      const options = {
        mode: 'subscription',
        amount: 1099,
        currency: 'USD'
      };
    
      return (
        <Elements stripe={stripePromise} options={options}>
          <CheckoutForm />
        </Elements>
    

    For one-time payments, setup_future_usage can be directly when initiating your Elements instance via the setupFutureUsage property:

      const options = {
        mode: 'payment',
        amount: 1099,
        currency: 'USD',
        setupFutureUsage: 'off_session'
      };
    ...