Skip to content

Confirm payments on your server ​

Collect payment details with the Payment Element, turn them into a ConfirmationToken with useCreateConfirmationToken(), and create and confirm the PaymentIntent on your server. This follows Stripe's finalize payments on the server flow: the Payment Element runs without an Intent, and the Intent only exists once the customer clicks Pay. Payment objects explains how ConfirmationTokens relate to PaymentMethods and Tokens.

Before you begin ​

ConfirmationToken: confirm on your server ​

mermaid
sequenceDiagram
    accTitle: ConfirmationToken payment sequence
    accDescr: The browser creates a ConfirmationToken from the Payment Element and posts it to your server, the server creates and confirms a PaymentIntent with it and returns the status, and the browser calls handleNextAction when the status is requires_action.
    participant Browser
    participant Server
    participant Stripe
    Browser->>Stripe: createConfirmationToken() (submits the Payment Element)
    Stripe-->>Browser: ctoken_...
    Browser->>Server: POST /api/pay { confirmationToken }
    Server->>Stripe: paymentIntents.create({ confirm: true, confirmation_token })
    Stripe-->>Server: PaymentIntent (succeeded or requires_action)
    Server-->>Browser: { status, clientSecret }, or 402 { error } when declined
    Browser->>Stripe: handleNextAction({ clientSecret }) when requires_action

1. Render the Payment Element without an Intent ​

Give VueStripeElements a mode, amount and currency instead of a client secret (Collect payment details before creating an Intent covers this setup):

vue
<!-- CheckoutPage.vue -->
<script setup lang="ts">
import { VueStripeProvider, VueStripeElements, VueStripePaymentElement } from '@vue-stripe/vue-stripe'
import '@vue-stripe/vue-stripe/style.css' // once, e.g. in main.ts
import PayButton from './PayButton.vue'

const publishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY
</script>

<template>
  <VueStripeProvider :publishable-key="publishableKey">
    <VueStripeElements mode="payment" :amount="1099" currency="usd">
      <VueStripePaymentElement />
      <PayButton />
    </VueStripeElements>
  </VueStripeProvider>
</template>

Set amount from the cart before the customer starts typing: changing mode, amount or currency creates a new Elements group, so the Payment Element remounts empty.

2. Create the token and send it to your server ​

createConfirmationToken() runs elements.submit() first, so a validation error (an incomplete card number, say) resolves as { error } without creating a token. If the server's PaymentIntent needs 3D Secure, useHandleNextAction() finishes it in the browser:

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

type PayResult = { status?: string; clientSecret?: string; error?: string }

const { createConfirmationToken, loading } = useCreateConfirmationToken()
const { handleNextAction } = useHandleNextAction()
const status = ref('')
const message = ref('')

const pay = async () => {
  // Clear the previous attempt's outcome
  status.value = ''
  message.value = ''

  const { confirmationToken, error } = await createConfirmationToken({
    params: { return_url: `${window.location.origin}/complete` }
  })
  if (!confirmationToken) {
    message.value = error?.message ?? 'Check your payment details.'
    return
  }

  const response = await fetch('/api/pay', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ confirmationToken: confirmationToken.id })
  })
  // A proxy or crash error page is not JSON
  const result: PayResult = await response.json().catch(() => ({}))

  if (!response.ok || !result.status) {
    message.value = result.error ?? 'Payment failed. Please try again.' // declined (402) or server error
    return
  }
  if (result.status === 'requires_action' && result.clientSecret) {
    const { paymentIntent, error: actionError } = await handleNextAction({
      clientSecret: result.clientSecret
    })
    if (actionError) {
      message.value = actionError.message ?? 'Authentication failed.' // 3D Secure failed or was cancelled
      return
    }
    status.value = paymentIntent?.status ?? 'failed'
  } else {
    status.value = result.status
  }
}
</script>

<template>
  <button :disabled="loading" @click="pay">Pay</button>
  <p v-if="message">{{ message }}</p>
  <p v-if="status">Payment {{ status }}</p>
</template>

pay() clears message at the start of each attempt. Don't show the composables' error refs here instead: each one resets only when its own composable runs again, so a failed 3D Secure message would stay on the page after the next payment succeeds.

confirmationToken.payment_method_preview describes what the customer chose (type, card.brand, card.last4, ...) if you want to show it or check it before charging.

3. Create and confirm the PaymentIntent on your server ​

js
// Node.js (Express-style handler)
import Stripe from 'stripe'

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

app.post('/api/pay', async (req, res) => {
  try {
    const paymentIntent = await stripe.paymentIntents.create({
      amount: 1099, // compute from the cart on the server, never trust the browser
      currency: 'usd',
      automatic_payment_methods: { enabled: true },
      confirm: true,
      confirmation_token: req.body.confirmationToken,
      return_url: 'https://example.com/complete',
      metadata: { orderId: 'order_123' } // your own order ID, read back by the webhook
    })
    res.json({ status: paymentIntent.status, clientSecret: paymentIntent.client_secret })
  } catch (err) {
    if (err instanceof Stripe.errors.StripeCardError) {
      // A decline: the message is written for the customer
      res.status(402).json({ error: err.message })
    } else {
      console.error(err)
      res.status(500).json({ error: 'Payment failed. Please try again.' })
    }
  }
})

Because the server confirms, a decline does not come back as a PaymentIntent status: paymentIntents.create() throws a StripeCardError (HTTP 402, code: 'card_declined', with a decline_code). Its message ("Your card was declined.") is safe to show the customer; other errors are not, so the handler logs them and returns a generic message. Try it with the decline test cards.

The same token confirms a SetupIntent (stripe.setupIntents.create({ confirm: true, confirmation_token })) when you render the Elements group with mode="setup".

Create a PaymentMethod to charge later ​

useCreatePaymentMethod() creates a reusable PaymentMethod (pm_...) for your server to attach to a customer or charge later. Inside a Payment Element group (with :options="{ paymentMethodCreation: 'manual' }" on VueStripeElements), call it with no arguments; with the Card Element, pass the element. The Card Element shows validation errors (an incomplete card number, say) in its own #error slot, so the sample shows only the other errors:

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

const cardRef = ref<InstanceType<typeof VueStripeCardElement> | null>(null)
const { createPaymentMethod, loading } = useCreatePaymentMethod()
const message = ref('')

const save = async () => {
  const card = cardRef.value?.element
  if (!card) return
  message.value = ''
  const { paymentMethod, error } = await createPaymentMethod({ type: 'card', card })
  if (paymentMethod) {
    // Send paymentMethod.id to your server, e.g. to attach it to a customer
  } else if (error && !('type' in error && error.type === 'validation_error')) {
    message.value = error.message ?? 'The card could not be saved.'
  }
}
</script>

<template>
  <VueStripeCardElement ref="cardRef" />
  <button :disabled="loading" @click="save">Save card</button>
  <p v-if="message">{{ message }}</p>
</template>

To save a card with authentication up front, use a SetupIntent instead: see Save a payment method for later.

Tokens and Radar sessions ​

  • useCreateToken() wraps every stripe.createToken() overload: card tokens for the legacy Charges API, and account, person and PII tokens for Connect.
  • useRadarSession() gives Radar the browser signals it needs when card details reach Stripe without Stripe.js; pass the session ID to your server as radar_options.session.

See also ​