Skip to main content
This guide provides step-by-step instructions for implementing Flex as a Stripe custom payment method, with complete code examples based on a working implementation.

Overview

This implementation enables a dual-payment system where:
  • Stripe handles traditional payment methods (cards, Apple Pay, etc.) with embedded processing
  • Flex handles HSA/FSA payments with a hosted checkout redirect flow
Both payment options appear in a single Stripe Payment Element, providing a unified customer experience.

Implementation Steps

1

Create Server Actions

Server actions handle payment processing on the backend, ensuring API keys remain secure.
2

Build the Checkout Form Component

The checkout form integrates the Stripe Payment Element and handles payment routing.
3

Create Success Page

Display order confirmation after successful payment.
4

Configure Validation Schemas

Set up form validation using Zod for type safety and error handling.

1. Server Actions

Stripe Payment Intent Creation

Create a server action to generate Stripe PaymentIntents:
app/actions/stripe.ts
"use server";

import { stripe } from "@/lib/stripe";
import { getPlanDetails } from "@/lib/utils";
import { formatAmountToCents } from "@/lib/utils";
import { CURRENCY } from "@/lib/constants";
import type Stripe from "stripe";

export async function createPaymentIntent(
  plan: string
): Promise<{ client_secret: string }> {
  // Get plan details (price, product info)
  const planDetails = await getPlanDetails(plan);

  // Create PaymentIntent with automatic payment methods
  const paymentIntent: Stripe.PaymentIntent = await stripe.paymentIntents.create({
    amount: formatAmountToCents(Number(planDetails.price), CURRENCY),
    automatic_payment_methods: {
      enabled: true // Enables all configured payment methods
    },
    currency: CURRENCY,
  });

  return {
    client_secret: paymentIntent.client_secret as string
  };
}
The automatic_payment_methods setting allows Stripe to automatically display all enabled payment methods, including cards, digital wallets, and bank transfers.

Flex Checkout Session Creation

Create a server action to generate Flex checkout sessions:
app/actions/flex.ts
"use server";

import { redirect } from "next/navigation";
import { v4 as uuidv4 } from "uuid";
import { getPlanDetails } from "@/lib/utils";

const FLEX_BASE_URL = "https://api.withflex.com";

export async function createCheckoutSession({
  firstName,
  lastName,
  email,
  plan,
}: {
  firstName: string;
  lastName: string;
  email: string;
  plan: string;
}): Promise<void | { error: Error }> {
  try {
    // Get plan details including Flex price ID
    const planDetails = await getPlanDetails(plan);

    // Generate unique order reference
    const orderId = uuidv4();

    // Get base URL from environment
    const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000";

    // Build Flex checkout session body
    const body = {
      line_items: [
        {
          price: planDetails.flex_price_id, // Flex-specific price ID
          quantity: 1,
        },
      ],
      mode: "payment",
      success_url: `${baseUrl}/success?client-reference-id=${orderId}&plan=${plan}&price=${planDetails.price}`,
      cancel_url: baseUrl,
      client_reference_id: orderId,
      defaults: {
        first_name: firstName,
        last_name: lastName,
        email: email,
      },
    };

    // Create checkout session via Flex API
    const res = await fetch(`${FLEX_BASE_URL}/v1/checkout/sessions`, {
      method: "POST",
      body: JSON.stringify({ checkout_session: body }),
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.FLEX_SECRET_KEY}`,
      },
    });

    if (!res.ok) {
      throw new Error(`Failed to create Flex checkout session: ${res.statusText}`);
    }

    const { checkout_session } = await res.json();

    // Redirect to Flex hosted checkout
    redirect(checkout_session.redirect_url);
  } catch (error) {
    console.error("Error creating Flex checkout session:", error);
    return { error: error as Error };
  }
}
The Flex integration uses a hosted checkout model, meaning customers are redirected to Flex’s domain to complete payment. Ensure your success_url and cancel_url are properly configured.

2. Checkout Form Component

The checkout form is the heart of the integration, rendering the Payment Element and routing payments appropriately.

Form Wrapper Component

components/checkout-form.tsx
"use client";

import { Elements } from "@stripe/react-stripe-js";
import getStripe from "@/lib/get-stripejs";
import { CURRENCY, FLEX_CUSTOM_PAYMENT_METHOD_ID } from "@/lib/constants";
import CheckoutForm from "./checkout-form-inner";

export default function ElementsForm({ plan, price }: { plan: string; price: string }) {
  return (
    <Elements
      stripe={getStripe()}
      options={{
        appearance: {
          variables: {
            colorIcon: "#6772e5",
            fontFamily: "Roboto, Open Sans, Segoe UI, sans-serif",
          },
        },
        customPaymentMethods: [
          {
            id: FLEX_CUSTOM_PAYMENT_METHOD_ID,
            options: {
              type: "static", // Non-interactive UI element
            },
          },
        ],
        currency: CURRENCY,
        mode: "payment",
        amount: Number(price), // Amount in cents
      }}
    >
      <CheckoutForm plan={plan} price={price} />
    </Elements>
  );
}
The customPaymentMethods configuration registers Flex as a payment option. The type: "static" setting displays Flex as a selectable option without embedded interactive elements.

Main Checkout Form

components/checkout-form-inner.tsx
"use client";

import { useState } from "react";
import { useStripe, useElements, PaymentElement } from "@stripe/react-stripe-js";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createPaymentIntent } from "@/app/actions/stripe";
import { createCheckoutSession } from "@/app/actions/flex";
import { FLEX_CUSTOM_PAYMENT_METHOD_ID } from "@/lib/constants";
import { checkoutFormSchema } from "@/lib/schemas";

export default function CheckoutForm({
  plan,
  price
}: {
  plan: string;
  price: string;
}) {
  const stripe = useStripe();
  const elements = useElements();
  const [errorMessage, setErrorMessage] = useState<string>();
  const [isProcessing, setIsProcessing] = useState(false);

  const form = useForm<z.infer<typeof checkoutFormSchema>>({
    resolver: zodResolver(checkoutFormSchema),
    defaultValues: {
      firstName: "",
      lastName: "",
      email: "",
    },
  });

  async function onSubmit(values: z.infer<typeof checkoutFormSchema>) {
    if (!stripe || !elements) {
      return;
    }

    setIsProcessing(true);
    setErrorMessage(undefined);

    try {
      // Submit the form and detect selected payment method
      const { error: submitError, selectedPaymentMethod } = await elements.submit();

      if (submitError) {
        setErrorMessage(submitError.message);
        setIsProcessing(false);
        return;
      }

      // Route to appropriate payment processor
      if (selectedPaymentMethod === FLEX_CUSTOM_PAYMENT_METHOD_ID) {
        // FLEX PATH: Redirect to hosted checkout
        const result = await createCheckoutSession({
          firstName: values.firstName,
          lastName: values.lastName,
          email: values.email,
          plan,
        });

        if (result?.error) {
          setErrorMessage(result.error.message);
          setIsProcessing(false);
        }
        // If successful, user will be redirected
      } else {
        // STRIPE PATH: Embedded payment processing
        const { client_secret: clientSecret } = await createPaymentIntent(plan);

        const { error: confirmError } = await stripe.confirmPayment({
          elements,
          clientSecret,
          confirmParams: {
            return_url: `${window.location.origin}/success`,
            payment_method_data: {
              billing_details: {
                name: `${values.firstName} ${values.lastName}`,
                email: values.email,
              },
            },
          },
        });

        if (confirmError) {
          setErrorMessage(confirmError.message);
          setIsProcessing(false);
        }
        // If successful, user will be redirected
      }
    } catch (error) {
      setErrorMessage("An unexpected error occurred");
      setIsProcessing(false);
    }
  }

  return (
    <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
      {/* Customer Information Fields */}
      <div className="grid gap-4">
        <div>
          <label htmlFor="firstName" className="block text-sm font-medium mb-2">
            First Name
          </label>
          <input
            id="firstName"
            {...form.register("firstName")}
            className="w-full px-3 py-2 border rounded-md"
          />
          {form.formState.errors.firstName && (
            <p className="text-red-600 text-sm mt-1">
              {form.formState.errors.firstName.message}
            </p>
          )}
        </div>

        <div>
          <label htmlFor="lastName" className="block text-sm font-medium mb-2">
            Last Name
          </label>
          <input
            id="lastName"
            {...form.register("lastName")}
            className="w-full px-3 py-2 border rounded-md"
          />
          {form.formState.errors.lastName && (
            <p className="text-red-600 text-sm mt-1">
              {form.formState.errors.lastName.message}
            </p>
          )}
        </div>

        <div>
          <label htmlFor="email" className="block text-sm font-medium mb-2">
            Email
          </label>
          <input
            id="email"
            type="email"
            {...form.register("email")}
            className="w-full px-3 py-2 border rounded-md"
          />
          {form.formState.errors.email && (
            <p className="text-red-600 text-sm mt-1">
              {form.formState.errors.email.message}
            </p>
          )}
        </div>
      </div>

      {/* Stripe Payment Element with Flex as Custom Payment Method */}
      <div>
        <PaymentElement />
      </div>

      {/* Error Message Display */}
      {errorMessage && (
        <div className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded">
          {errorMessage}
        </div>
      )}

      {/* Submit Button */}
      <button
        type="submit"
        disabled={!stripe || isProcessing}
        className="w-full bg-blue-600 text-white py-3 rounded-md hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed"
      >
        {isProcessing ? "Processing..." : "Pay Now"}
      </button>
    </form>
  );
}

Key Implementation Details

Payment Method Detection:
const { selectedPaymentMethod } = await elements.submit();
The elements.submit() method returns the selected payment method ID, allowing you to route to the appropriate processor. Conditional Routing:
if (selectedPaymentMethod === FLEX_CUSTOM_PAYMENT_METHOD_ID) {
  // Create Flex checkout session and redirect
} else {
  // Create Stripe payment intent and confirm inline
}
This pattern keeps the payment logic clean and maintainable. The form doesn’t need to know about the specifics of each payment processor—it simply routes based on selection.

3. Validation Schemas

Use Zod for type-safe form validation:
lib/schemas.ts
import { z } from "zod";

// Plan validation
export const planSchema = z.enum(["monthly", "annual"]);

// Checkout form validation
export const checkoutFormSchema = z.object({
  firstName: z.string().min(1, "First name is required"),
  lastName: z.string().min(1, "Last name is required"),
  email: z.string().email("Please enter a valid email address"),
});

// Flex customer defaults (includes plan)
export const flexCustomerDefaultSchema = checkoutFormSchema.extend({
  plan: planSchema,
});

// Type exports for TypeScript
export type CheckoutFormData = z.infer<typeof checkoutFormSchema>;
export type FlexCustomerDefaults = z.infer<typeof flexCustomerDefaultSchema>;

4. Utility Functions

Amount Formatting

Convert amounts to cents for Stripe:
lib/utils.ts
export function formatAmountToCents(amount: number, currency: string): number {
  // Check if currency uses decimal places
  const numberFormat = new Intl.NumberFormat(["en-US"], {
    style: "currency",
    currency: currency,
    currencyDisplay: "symbol",
  });

  const parts = numberFormat.formatToParts(amount);
  let zeroDecimalCurrency = true;

  for (const part of parts) {
    if (part.type === "decimal") {
      zeroDecimalCurrency = false;
    }
  }

  // Zero-decimal currencies (like JPY) don't need conversion
  return zeroDecimalCurrency ? amount : Math.round(amount * 100);
}

Plan Details Retrieval

Fetch plan information from your data source:
lib/utils.ts
import data from "@/data.json";

export async function getPlanDetails(plan: string) {
  const planDetails = data.plans.find((p) => p.name === plan);

  if (!planDetails) {
    throw new Error(`Plan "${plan}" not found`);
  }

  return planDetails;
}
In production, replace this with a database query or API call to fetch plan details dynamically.

5. Success Page

Display order confirmation after payment:
app/success/page.tsx
import { Suspense } from "react";

export default function SuccessPage({
  searchParams,
}: {
  searchParams: { [key: string]: string | string[] | undefined };
}) {
  const orderId = searchParams["client-reference-id"];
  const plan = searchParams["plan"];
  const price = searchParams["price"];

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8">
        <div className="text-center">
          <div className="mb-4">
            <svg
              className="mx-auto h-12 w-12 text-green-600"
              fill="none"
              stroke="currentColor"
              viewBox="0 0 24 24"
            >
              <path
                strokeLinecap="round"
                strokeLinejoin="round"
                strokeWidth={2}
                d="M5 13l4 4L19 7"
              />
            </svg>
          </div>

          <h1 className="text-2xl font-bold text-gray-900 mb-2">
            Payment Successful!
          </h1>

          <p className="text-gray-600 mb-6">
            Thank you for your purchase. Your order has been confirmed.
          </p>

          <div className="bg-gray-50 rounded-md p-4 mb-6">
            <dl className="space-y-2 text-sm">
              <div className="flex justify-between">
                <dt className="text-gray-600">Order ID:</dt>
                <dd className="font-medium text-gray-900">{orderId}</dd>
              </div>
              <div className="flex justify-between">
                <dt className="text-gray-600">Plan:</dt>
                <dd className="font-medium text-gray-900 capitalize">{plan}</dd>
              </div>
              <div className="flex justify-between">
                <dt className="text-gray-600">Amount:</dt>
                <dd className="font-medium text-gray-900">
                  ${Number(price) / 100}
                </dd>
              </div>
            </dl>
          </div>

          <p className="text-sm text-gray-500">
            A confirmation email has been sent to your email address.
          </p>
        </div>
      </div>
    </div>
  );
}
Security Note: The success page currently displays order details from URL parameters. In production, you should verify the payment status by querying the appropriate payment provider’s API before displaying the confirmation.
// TODO: Verify payment status
const isValid = await verifyPaymentStatus(orderId);
if (!isValid) {
  redirect("/checkout");
}

6. Checkout Page Integration

Create your checkout page that uses the form:
app/checkout/page.tsx
import { redirect } from "next/navigation";
import { z } from "zod";
import ElementsForm from "@/components/checkout-form";

const CheckoutQueryParamsSchema = z.object({
  plan: z.enum(["monthly", "annual"]),
  price: z.string(),
});

export default function CheckoutPage({
  searchParams,
}: {
  searchParams: { [key: string]: string | string[] | undefined };
}) {
  // Validate query parameters
  const result = CheckoutQueryParamsSchema.safeParse(searchParams);

  if (!result.success) {
    redirect("/"); // Redirect to home if invalid params
  }

  const { plan, price } = result.data;

  return (
    <div className="min-h-screen bg-gray-50 py-12">
      <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
        <div className="max-w-3xl mx-auto">
          <h1 className="text-3xl font-bold text-gray-900 mb-8">
            Complete Your Purchase
          </h1>

          <div className="bg-white rounded-lg shadow p-6">
            <ElementsForm plan={plan} price={price} />
          </div>
        </div>
      </div>
    </div>
  );
}

Payment Flow Diagram

Testing Your Integration

Test Mode Setup

  1. Stripe Test Mode: Use test API keys (starting with sk_test_ and pk_test_)
  2. Flex Test Mode: Ensure you’re using Flex test credentials
  3. Test Cards: Use Stripe test cards

Testing Checklist

  • Flex appears as a payment option in the Payment Element
  • Selecting Flex redirects to Flex hosted checkout
  • Selecting card payment processes inline with Stripe
  • Success page displays correct order information
  • Error messages display appropriately
  • Form validation works as expected
  • Payment confirmation emails are sent (if configured)

Common Test Scenarios

Test Stripe Payment Flow:
1. Navigate to checkout with a plan
2. Select "Card" payment method
3. Enter test card: 4242 4242 4242 4242
4. Fill in customer information
5. Click "Pay Now"
6. Verify redirect to success page
Test Flex Payment Flow:
1. Navigate to checkout with a plan
2. Select "Flex" payment method
3. Fill in customer information
4. Click "Pay Now"
5. Verify redirect to Flex hosted checkout
6. Complete payment on Flex
7. Verify redirect back to success page

Production Considerations

Security

Critical Security Practices:
  1. Never expose secret keys to the client side
  2. Always validate user input on the server
  3. Verify payment status on the success page via API calls
  4. Use HTTPS for all production traffic
  5. Implement rate limiting on payment endpoints
  6. Log security events for audit trails

Error Handling

Implement comprehensive error handling:
try {
  // Payment processing
} catch (error) {
  // Log error for debugging
  console.error("Payment error:", error);

  // Display user-friendly message
  if (error instanceof StripeError) {
    setErrorMessage(error.message);
  } else {
    setErrorMessage("An unexpected error occurred. Please try again.");
  }

  // Report to error tracking service
  // errorTracker.capture(error);
}

Webhook Integration

For production deployments, implement webhooks to handle payment events: Stripe Webhooks:
  • payment_intent.succeeded
  • payment_intent.payment_failed
  • charge.refunded
Flex Webhooks:
  • Configure in your Flex Dashboard
  • Handle success/failure events
  • Update order status in your database

Performance Optimization

  1. Lazy load Stripe.js to improve initial page load
  2. Cache plan details to reduce database queries
  3. Implement loading states for better UX
  4. Monitor payment processing times

Troubleshooting

Flex Not Appearing in Payment Element

Problem: Custom payment method doesn’t show up. Solutions:
  • Verify FLEX_CUSTOM_PAYMENT_METHOD_ID is correct
  • Check that the custom payment method is enabled in Stripe Dashboard
  • Ensure the ID is properly passed to Elements configuration
  • Clear browser cache and reload

Payment Intent Creation Fails

Problem: Stripe returns an error when creating payment intent. Solutions:
  • Verify Stripe secret key is correct and not expired
  • Check that the amount is in the correct format (cents)
  • Ensure currency is supported
  • Review Stripe Dashboard for API errors

Redirect Not Working After Flex Payment

Problem: User isn’t redirected back after Flex checkout. Solutions:
  • Verify success_url and cancel_url are correct
  • Check that URLs are publicly accessible (not localhost in production)
  • Ensure Flex API credentials are valid
  • Review Flex Dashboard for session details

Form Validation Errors

Problem: Form submission fails with validation errors. Solutions:
  • Check Zod schema matches form fields
  • Verify all required fields are included
  • Review browser console for validation errors
  • Test with minimal valid data first

Next Steps

  • Implement webhook handlers for payment events
  • Add payment status verification on the success page
  • Set up error tracking and monitoring
  • Configure production environment variables
  • Test thoroughly in production mode before going live
  • Review Stripe and Flex production checklists

Additional Resources

Support

If you encounter issues:
  1. Review this documentation thoroughly
  2. Check Stripe and Flex Dashboard logs
  3. Review browser console for errors
  4. Contact Flex support with specific error messages and request IDs