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
Create Server Actions
Server actions handle payment processing on the backend, ensuring API keys remain secure.
Build the Checkout Form Component
The checkout form integrates the Stripe Payment Element and handles payment routing.
Create Success Page
Display order confirmation after successful payment.
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:
"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:
"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.
The checkout form is the heart of the integration, rendering the Payment Element and routing payments appropriately.
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:
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
Convert amounts to cents for Stripe:
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:
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:
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:
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
- Stripe Test Mode: Use test API keys (starting with
sk_test_ and pk_test_)
- Flex Test Mode: Ensure you’re using Flex test credentials
- Test Cards: Use Stripe test cards
Testing Checklist
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:
- Never expose secret keys to the client side
- Always validate user input on the server
- Verify payment status on the success page via API calls
- Use HTTPS for all production traffic
- Implement rate limiting on payment endpoints
- 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
- Lazy load Stripe.js to improve initial page load
- Cache plan details to reduce database queries
- Implement loading states for better UX
- 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
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:
- Review this documentation thoroughly
- Check Stripe and Flex Dashboard logs
- Review browser console for errors
- Contact Flex support with specific error messages and request IDs