Custom Payment Methods Implementation - Flex Documentation
Documentation Index
Fetch the complete documentation index at: /llms.txt
Use this file to discover all available pages before exploring further.
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 }> {
const planDetails = await getPlanDetails(plan);
const paymentIntent: Stripe.PaymentIntent = await stripe.paymentIntents.create({
amount: formatAmountToCents(Number(planDetails.price), CURRENCY),
automatic_payment_methods: {
enabled: true
},
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 {
const planDetails = await getPlanDetails(plan);
const orderId = uuidv4();
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000";
const body = {
line_items: [
{
price: planDetails.flex_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,
},
};
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(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
"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",
},
},
],
currency: CURRENCY,
mode: "payment",
amount: Number(price),
}}
>
<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
"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 {
const { error: submitError, selectedPaymentMethod } = await elements.submit();
if (submitError) {
setErrorMessage(submitError.message);
setIsProcessing(false);
return;
}
if (selectedPaymentMethod === FLEX_CUSTOM_PAYMENT_METHOD_ID) {
const result = await createCheckoutSession({
firstName: values.firstName,
lastName: values.lastName,
email: values.email,
plan,
});
if (result?.error) {
setErrorMessage(result.error.message);
setIsProcessing(false);
}
} else {
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);
}
}
} catch (error) {
setErrorMessage("An unexpected error occurred");
setIsProcessing(false);
}
}
return (
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<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>
<div>
<PaymentElement />
</div>
{errorMessage && (
<div className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded">
{errorMessage}
</div>
)}
<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.
3. Validation Schemas
Use Zod for type-safe form validation:
import { z } from "zod";
export const planSchema = z.enum(["monthly", "annual"]);
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"),
});
export const flexCustomerDefaultSchema = checkoutFormSchema.extend({
plan: planSchema,
});
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:
export function formatAmountToCents(amount: number, currency: string): number {
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;
}
}
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 };
}) {
const result = CheckoutQueryParamsSchema.safeParse(searchParams);
if (!result.success) {
redirect("/");
}
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>
);
}
Additional Resources
- Stripe Custom Payment Methods Documentation
- Stripe Payment Element Documentation
- Flex API Documentation
- Flex Checkout Sessions
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.