Skip to content

Start a subscription ​

Sign a customer up for a recurring price and collect the first payment. There are four ways to do it with Vue Stripe; pick one:

WayYou buildGuide
Stripe Checkout in subscription modeA button; Stripe hosts the pageOption A below
A pricing tableNothing but the embed; Stripe hosts the plans and the checkoutEmbed a pricing table
The Payment Element on your pageYour own page; your server creates the subscriptionOption B below
Save a card first, then subscribeYour own page; the subscription starts after the card is savedOption C below

For the trade-offs, see Choosing an integration.

Before you start, create a product with a recurring price in the Stripe Dashboard (or with the API) and note its price ID (price_...).

Option A: Stripe Checkout ​

1. Create a Checkout Session in subscription mode ​

js
// POST /api/subscription-checkout (Node, stripe-node)
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)

app.post('/api/subscription-checkout', async (req, res) => {
  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    line_items: [{ price: 'price_...', quantity: 1 }],
    customer_email: req.user.email, // or customer: 'cus_...' for an existing customer
    success_url: 'https://example.com/welcome?session_id={CHECKOUT_SESSION_ID}',
    cancel_url: 'https://example.com/pricing'
  })
  res.json({ url: session.url })
})

2. Redirect to Checkout ​

useStripeCheckout needs no provider, because hosted Checkout is a plain redirect to the session URL:

vue
<script setup lang="ts">
import { ref } from 'vue'
import { useStripeCheckout } from '@vue-stripe/vue-stripe'

const { redirectToUrl, loading } = useStripeCheckout()
const failed = ref(false)

const subscribe = async () => {
  failed.value = false
  const response = await fetch('/api/subscription-checkout', { method: 'POST' })
  if (!response.ok) {
    failed.value = true
    return
  }
  redirectToUrl((await response.json()).url)
}
</script>

<template>
  <button :disabled="loading" @click="subscribe">Subscribe</button>
  <p v-if="failed" class="error">Could not start checkout. Please try again.</p>
</template>

VueStripeCheckout does the same as a component: <VueStripeCheckout :session-url="url" button-text="Subscribe" />. To keep the customer on your site, embed the same session with VueStripeEmbeddedCheckout instead. Try it in the subscription Checkout demo.

Option B: the Payment Element ​

1. Create the subscription on the server ​

Create the subscription in an incomplete state, and return the client secret of its first invoice's payment:

js
// POST /api/subscription (Node, stripe-node)
app.post('/api/subscription', async (req, res) => {
  const customer = await stripe.customers.create({ email: req.body.email })

  const subscription = await stripe.subscriptions.create({
    customer: customer.id,
    items: [{ price: 'price_...' }],
    payment_behavior: 'default_incomplete',
    payment_settings: { save_default_payment_method: 'on_subscription' },
    expand: ['latest_invoice.confirmation_secret']
  })

  res.json({
    subscriptionId: subscription.id,
    clientSecret: subscription.latest_invoice.confirmation_secret.client_secret
  })
})

payment_behavior: 'default_incomplete' keeps the subscription incomplete until the first payment succeeds, and save_default_payment_method stores the payment method for renewals. Since Stripe API version 2025-03-31 (Basil), the first payment's client secret is on latest_invoice.confirmation_secret; older API versions used latest_invoice.payment_intent.

In a real app, look up the signed-in customer instead of creating one per request.

2. Collect payment details and confirm ​

Render the Payment Element with the client secret and confirm with usePaymentIntent, as for a one-time payment:

vue
<!-- SubscribePage.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import {
  VueStripeProvider,
  VueStripeElements,
  VueStripePaymentElement
} from '@vue-stripe/vue-stripe'
import ConfirmSubscription from './ConfirmSubscription.vue'

const publishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY
const email = ref('')
const clientSecret = ref('')
const failed = ref(false)

const start = async () => {
  failed.value = false
  const response = await fetch('/api/subscription', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email: email.value })
  })
  if (!response.ok) {
    failed.value = true
    return
  }
  clientSecret.value = (await response.json()).clientSecret
}
</script>

<template>
  <form v-if="!clientSecret" @submit.prevent="start">
    <input v-model="email" type="email" placeholder="Email" required />
    <button>Continue</button>
    <p v-if="failed" class="error">Could not start the subscription. Please try again.</p>
  </form>

  <VueStripeProvider v-else :publishable-key="publishableKey">
    <VueStripeElements :client-secret="clientSecret">
      <VueStripePaymentElement />
      <ConfirmSubscription :client-secret="clientSecret" />
    </VueStripeElements>
  </VueStripeProvider>
</template>
vue
<!-- ConfirmSubscription.vue: rendered inside <VueStripeElements> -->
<script setup lang="ts">
import { ref } from 'vue'
import { usePaymentIntent } from '@vue-stripe/vue-stripe'

const props = defineProps<{ clientSecret: string }>()
const { confirmPayment, loading, error } = usePaymentIntent()
const active = ref(false)

const subscribe = async () => {
  const { paymentIntent } = await confirmPayment({
    clientSecret: props.clientSecret,
    confirmParams: { return_url: `${window.location.origin}/welcome` },
    redirect: 'if_required'
  })
  active.value = paymentIntent?.status === 'succeeded'
}
</script>

<template>
  <p v-if="active">Subscription started.</p>
  <template v-else>
    <button :disabled="loading" @click="subscribe">Start subscription</button>
    <p v-if="error">{{ error }}</p>
  </template>
</template>

When the first payment succeeds, Stripe activates the subscription. Try this flow in the embedded subscription demo.

To show the Payment Element before the subscription exists, render VueStripeElements with mode="subscription", amount and currency, and create the subscription when the customer clicks; see Collect payment details before creating an Intent.

Subscriptions with a free trial ​

With a trial, the first invoice is for $0 and has no payment to confirm. Add 'pending_setup_intent' to expand, return subscription.pending_setup_intent.client_secret, and confirm it with confirmSetup() from useSetupIntent instead.

Option C: save a card, then subscribe ​

Use this when the card is collected before the plan is final, for example in an onboarding flow where the customer picks the plan on a later step.

1. Create a SetupIntent for the customer ​

js
// POST /api/setup-intent (Node, stripe-node)
app.post('/api/setup-intent', async (req, res) => {
  const setupIntent = await stripe.setupIntents.create({
    customer: req.user.stripeCustomerId, // the signed-in customer
    automatic_payment_methods: { enabled: true },
    usage: 'off_session'
  })
  res.json({ clientSecret: setupIntent.client_secret })
})

Render the Payment Element with this client secret, as in Save a payment method for later.

2. Save the card, then start the subscription ​

vue
<!-- SubscribeWithSavedCard.vue: rendered inside <VueStripeElements> -->
<script setup lang="ts">
import { ref } from 'vue'
import { useSetupIntent, useHandleNextAction } from '@vue-stripe/vue-stripe'

const props = defineProps<{ clientSecret: string; priceId: string }>()
const { confirmSetup, loading } = useSetupIntent()
const { handleNextAction } = useHandleNextAction()
const message = ref('')

const subscribe = async () => {
  // 1. Validate the form and save the card
  const { setupIntent, error } = await confirmSetup({
    clientSecret: props.clientSecret,
    confirmParams: { return_url: `${window.location.origin}/welcome` },
    redirect: 'if_required'
  })
  if (error || !setupIntent) {
    message.value = error?.message ?? 'The card could not be saved.'
    return
  }

  // 2. Start the subscription with the saved payment method
  const response = await fetch('/api/create-subscription', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ paymentMethodId: setupIntent.payment_method, priceId: props.priceId })
  })
  if (!response.ok) {
    message.value = 'The subscription could not be started. Please try again.'
    return
  }
  const subscription: { status: string; paymentStatus?: string; clientSecret?: string } =
    await response.json()

  // 3. The bank asked for authentication on the first payment
  if (subscription.paymentStatus === 'requires_action' && subscription.clientSecret) {
    const { error: actionError } = await handleNextAction({ clientSecret: subscription.clientSecret })
    message.value = actionError ? (actionError.message ?? 'Authentication failed.') : 'Subscription started.'
    return
  }
  // 4. Otherwise the first payment succeeded (or there is a trial), or it was declined
  const started = subscription.status === 'active' || subscription.status === 'trialing'
  message.value = started ? 'Subscription started.' : 'The first payment failed.'
}
</script>

<template>
  <button :disabled="loading" @click="subscribe">Subscribe</button>
  <p v-if="message">{{ message }}</p>
</template>
js
// POST /api/create-subscription (Node, stripe-node)
app.post('/api/create-subscription', async (req, res) => {
  const subscription = await stripe.subscriptions.create({
    customer: req.user.stripeCustomerId,
    items: [{ price: req.body.priceId }], // check it is one of your plans
    default_payment_method: req.body.paymentMethodId,
    expand: ['latest_invoice.payments']
  })
  if (subscription.status !== 'incomplete') {
    return res.json({ status: subscription.status }) // active, or trialing
  }

  // The first payment did not go through: read its PaymentIntent
  const { payment } = subscription.latest_invoice.payments.data[0]
  const paymentIntent = await stripe.paymentIntents.retrieve(payment.payment_intent)
  res.json({
    status: subscription.status,
    paymentStatus: paymentIntent.status, // requires_action, or requires_payment_method when declined
    clientSecret: paymentIntent.client_secret
  })
})

The SetupIntent attaches the card to the customer because it was created with customer. The subscription charges the first invoice right away; it is active when that succeeds, and incomplete when the payment needs authentication or is declined. The first payment's PaymentIntent tells the two apart: requires_action means the bank wants 3D Secure, which useHandleNextAction runs in the browser; requires_payment_method means the card was declined, and the page shows "The first payment failed." Try both with 4000 0027 6000 3184 and 4000 0000 0000 0341 (Test your integration).

Keep access in sync with webhooks ​

The subscription renews, fails and ends on Stripe's side, so drive access from webhooks, not from the browser:

EventDo
invoice.paidGrant or extend access for the period paid.
invoice.payment_failedTell the customer to update their payment method.
customer.subscription.updatedApply plan changes, cancellations at period end and status changes.
customer.subscription.deletedRemove access.

Let customers manage their plan and payment method in the customer portal rather than building those screens yourself.

Test it ​

Pay with 4242 4242 4242 4242. To see renewals and failed renewals without waiting a month, use test clocks and the card 4000 0000 0000 0341; see Test your integration.

Result ​

The customer has an active subscription with a saved payment method, and your webhook handler grants access on every paid invoice.