Skip to content
ExponaBLOG
PAYMENT ENGINEERING & STRIPE INTEGRATION · DAY 028 / 100

Saving cards for later: a practical guide for developers

SetupIntents and off-session payments.

Expona Admin8 min read

Saving cards for later: a practical guide for developers

Building a payment system that lets customers save their cards creates friction-free checkout experiences and higher conversion rates. The challenge isn't just collecting payment methods—it's doing it securely, handling authentication requirements that appear months later, and managing the flow when a saved card declines. This guide walks through SetupIntents, customer storage, off-session charging, and failure recovery so your team can implement card storage without the security or UX surprises.

Why SetupIntent exists

When a customer wants to save their card for future purchases, you're not immediately charging them. You're authenticating and storing the payment method for later use. Stripe's SetupIntent is the right tool for this phase. Unlike a PaymentIntent (which creates a charge), a SetupIntent confirms the card is real and the customer authorizes its storage—nothing more.

This distinction matters because:

  • You comply with PCI-DSS by never handling raw card data
  • You capture the customer's explicit consent to save the card
  • You trigger 3D Secure or other authentication at save time, not at charge time
  • You get a PaymentMethod ID to reuse for months

Without SetupIntent, you'd either store unconfirmed card tokens (risky) or rely on off-session authentication later (often fails). SetupIntent closes that gap.

Creating a SetupIntent on the frontend

Your frontend flow starts with a request to your backend to create the SetupIntent. Your backend then returns the client secret, which the frontend uses with Stripe.js to confirm the card and authenticate if needed.

typescript
// Backend: create SetupIntent
const setupIntent = await stripe.setupIntents.create({
  payment_method_types: ['card'],
  usage: 'off_session', // Signal that this card will be charged later without the customer present
});

// Return clientSecret to frontend
res.json({ clientSecret: setupIntent.client_secret });

On your frontend with Stripe Elements or Payment Element:

typescript
// Frontend: confirm the SetupIntent
const { setupIntent: result } = await stripe.confirmCardSetup(clientSecret, {
  payment_method: {
    card: cardElement,
    billing_details: {
      name: customer.name,
      email: customer.email,
    },
  },
});

if (result.status === 'succeeded') {
  // PaymentMethod created and verified
  const paymentMethodId = result.payment_method;
  // Send paymentMethodId to your backend
} else if (result.status === 'requires_action') {
  // Customer needs to complete 3D Secure or similar
  // Stripe handles this automatically with confirmCardSetup
}

The key: confirmCardSetup handles authentication flows transparently. If the card requires 3D Secure, the customer completes it in a modal. Your frontend waits and retries. The backend doesn't need to know about these details.

Attach the payment method to a customer

Once the SetupIntent succeeds and you have a PaymentMethod ID, you need to:

  1. Ensure the customer exists in Stripe (or create one)
  2. Attach the PaymentMethod to that customer
  3. Store the Stripe customer ID in your database
  4. Optionally mark it as the default payment method
typescript
// Backend: attach payment method to customer
let stripeCustomerId = customer.stripeCustomerId;

if (!stripeCustomerId) {
  // Create Stripe customer if we don't have one
  const stripeCustomer = await stripe.customers.create({
    email: customer.email,
    name: customer.name,
    metadata: {
      userId: customer.id, // Link back to your database
    },
  });
  stripeCustomerId = stripeCustomer.id;
  // Save to your database
  await db.customers.update(customer.id, { stripeCustomerId });
}

// Attach the payment method
await stripe.paymentMethods.attach(paymentMethodId, {
  customer: stripeCustomerId,
});

// Optionally set as default
await stripe.customers.update(stripeCustomerId, {
  invoice_settings: {
    default_payment_method: paymentMethodId,
  },
});

Why attach to a customer? Stripe uses this relationship to manage webhooks, billing history, and invoice payment attempts. Your database also benefits: you now have a Stripe customer ID to query saved cards later without hitting your database.

Charging a saved card off-session

When the customer makes a purchase later, you create a PaymentIntent with the saved PaymentMethod and mark it as off-session. "Off-session" tells Stripe (and card networks) that the customer is not present, so certain security requirements apply.

typescript
// Backend: charge the saved card
const paymentIntent = await stripe.paymentIntents.create({
  amount: 5999, // $59.99
  currency: 'usd',
  customer: stripeCustomerId,
  payment_method: paymentMethodId,
  off_session: true, // Mark as off-session
  confirm: true, // Confirm immediately
});

if (paymentIntent.status === 'succeeded') {
  // Charge succeeded
  console.log('Payment successful:', paymentIntent.id);
} else if (paymentIntent.status === 'requires_action') {
  // Authentication required—see next section
  console.log('Action needed:', paymentIntent.client_secret);
}

The off_session: true flag is crucial. Without it, Stripe may decline the charge because the card network expects to see evidence that the customer authorized the transaction. With it, Stripe applies the right exemptions and retries intelligently.

Important: Never set off_session: true on a PaymentIntent created on the frontend. That flag is for backend-initiated charges only. If the customer is present, use a standard PaymentIntent without the flag.

Handle authentication failures and recovery

Even with a saved card, authentication can still be required. This happens when:

  • Card networks require step-up authentication for high-value transactions
  • The card issuer flags unusual activity
  • Regional regulations (like PSD2 in Europe) mandate re-authentication

When status === 'requires_action', you need to bring the customer back into the payment flow. Stripe provides a client secret; send it to your frontend and use Stripe.js to handle the challenge:

typescript
// Backend: send client_secret to frontend
if (paymentIntent.status === 'requires_action') {
  res.json({
    status: 'requires_action',
    clientSecret: paymentIntent.client_secret,
  });
}
typescript
// Frontend: handle the action
const { paymentIntent: result } = await stripe.confirmCardPayment(clientSecret);

if (result.status === 'succeeded') {
  // Payment now complete
} else if (result.status === 'requires_action') {
  // Rare: another action needed (shouldn't happen for cards)
}

The flow is smooth if you design it into your order confirmation or checkout page. Show a modal or inline message asking the customer to authenticate. Many will complete it immediately. If they don't, you have the charge ID for retries.

Webhook verification and idempotency

Always listen for payment_intent.succeeded and payment_intent.payment_failed webhooks. Your database is the source of truth for order status, but webhooks tell you when Stripe's state changes—especially useful if the customer closes the browser during a challenge.

typescript
// Backend: handle webhook
app.post('/webhook', async (req, res) => {
  const event = req.body;

  if (event.type === 'payment_intent.succeeded') {
    const paymentIntent = event.data.object;
    // Update order status to paid
    await db.orders.update(paymentIntent.metadata.orderId, {
      status: 'paid',
      stripePiId: paymentIntent.id,
    });
  }

  if (event.type === 'payment_intent.payment_failed') {
    const paymentIntent = event.data.object;
    // Update order status to failed, consider retrying
    await db.orders.update(paymentIntent.metadata.orderId, {
      status: 'failed',
      failureMessage: paymentIntent.last_payment_error.message,
    });
  }

  res.json({ received: true });
});

Use the metadata field on PaymentIntent to tie it to your order ID. Always include an idempotency key when creating PaymentIntent to prevent duplicate charges if your request is retried:

typescript
const paymentIntent = await stripe.paymentIntents.create(
  {
    amount: 5999,
    currency: 'usd',
    customer: stripeCustomerId,
    payment_method: paymentMethodId,
    off_session: true,
    confirm: true,
  },
  {
    idempotencyKey: `order_${orderId}`, // Unique per order
  }
);

Listing and updating saved payment methods

Let customers see and manage their saved cards. Query Stripe for the PaymentMethods attached to a customer:

typescript
const paymentMethods = await stripe.paymentMethods.list({
  customer: stripeCustomerId,
  type: 'card',
});

paymentMethods.data.forEach((pm) => {
  console.log(`Card: ${pm.card.brand} ${pm.card.last4}`);
  console.log(`Expires: ${pm.card.exp_month}/${pm.card.exp_year}`);
});

To delete a saved card:

typescript
await stripe.paymentMethods.detach(paymentMethodId);

To update the default payment method:

typescript
await stripe.customers.update(stripeCustomerId, {
  invoice_settings: {
    default_payment_method: newPaymentMethodId,
  },
});

Common mistakes

  • Forgetting off_session: true: Your charges will decline frequently because Stripe doesn't know you're charging without the customer present. Always include this flag for saved-card charges initiated by your backend.
  • Using SetupIntent for authenticated charges: If the customer is on your checkout page right now, use PaymentIntent without off_session. SetupIntent is only for saving; it doesn't create a charge.
  • Ignoring webhook failures: Webhook delivery isn't guaranteed on first try. Implement exponential backoff retries and log failures. A missed payment_intent.succeeded event means your order status never updates in the database.
  • No idempotency key: Network errors can cause your request to retry silently. Without an idempotency key, you risk charging the customer twice.
  • Storing raw card data in your database: Never. Always use PaymentMethod IDs. Your PCI compliance depends on it.
  • Not handling requires_action on off-session charges: You'll lose customers because the payment silently fails. Build recovery flow into your order confirmation page.

Key takeaways

  • Use SetupIntent to save a card without charging; it handles authentication at save time and returns a PaymentMethod ID.
  • Attach the PaymentMethod to a Stripe Customer and store the customer ID in your database for future lookups.
  • Charge saved cards with off_session: true to signal that the customer is not present and let Stripe apply the right exemptions.
  • Always handle the requires_action status by bringing the customer back to a verification modal; don't assume all off-session charges succeed on the first attempt.
  • Listen for PaymentIntent webhooks to keep your order status in sync with Stripe, and use idempotency keys to prevent duplicate charges from retries.
  • Let customers view, update, and delete saved payment methods by querying and managing PaymentMethods linked to their Stripe Customer.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles