Payment Flow
This document details the complete flow for users purchasing a pricing plan, including frontend interactions, API calls, payment processing, and result verification.
Flow Overview
User selects plan
↓
Clicks purchase button
↓
Create checkout session (POST /api/payment/checkout-session)
↓
Redirect to payment provider page
↓
User completes payment
↓
Redirect back to app (GET /payment/success)
↓
Verify payment status (GET /api/payment/verify-success)
↓
Webhook processing (POST /api/stripe/webhook or /api/creem/webhook)
↓
Order creation and credit allocationStep 1: User Selects Plan
Users see all available pricing plans on the pricing page. Each plan card displays:
- Plan title and description
- Pricing information
- Feature list
- Purchase button
Frontend Components
The PricingCardDisplay component handles displaying plan cards:
<PricingCardDisplay
plan={plan}
localizedPlan={localizedPlan}
/>The PricingCTA component handles purchase button click events:
<PricingCTA plan={plan} localizedPlan={localizedPlan} />Step 2: Create Checkout Session
When the user clicks the purchase button, the frontend calls the /api/payment/checkout-session API to create a checkout session.
API Request
Endpoint: POST /api/payment/checkout-session
Request Body (Stripe):
{
"provider": "stripe",
"stripePriceId": "price_xxx",
"couponCode": "DISCOUNT10" // Optional
}Request Body (Creem):
{
"provider": "creem",
"creemProductId": "prod_xxx",
"couponCode": "DISCOUNT10" // Optional
}API Processing Logic
Stripe Checkout Session Creation
// 1. Verify user identity
const session = await getSession();
const user = session?.user;
if (!user) return apiResponse.unauthorized();
// 2. Get plan information
const plan = await db
.select()
.from(pricingPlansSchema)
.where(eq(pricingPlansSchema.stripePriceId, stripePriceId))
.limit(1);
// 3. Create or get Stripe Customer
const customerId = await getOrCreateStripeCustomer(user.id);
// 4. Create Stripe Checkout Session
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
line_items: [{ price: stripePriceId, quantity: 1 }],
mode: isSubscription ? 'subscription' : 'payment',
success_url: getURL('payment/success?session_id={CHECKOUT_SESSION_ID}&provider=stripe'),
cancel_url: getURL(process.env.NEXT_PUBLIC_PRICING_PATH!),
metadata: {
userId: user.id,
planId: plan.id,
planName: plan.cardTitle,
priceId: stripePriceId
}
});
// 5. Return session URL
return apiResponse.success({
sessionId: checkoutSession.id,
url: checkoutSession.url
});Creem Checkout Session Creation
// 1. Verify user identity
const session = await getSession();
const user = session?.user;
if (!user) return apiResponse.unauthorized();
// 2. Get plan information
const plan = await db
.select()
.from(pricingPlansSchema)
.where(eq(pricingPlansSchema.creemProductId, creemProductId))
.limit(1);
// 3. Create Creem Checkout Session
const checkoutSession = await createCreemCheckoutSession({
product_id: creemProductId,
units: 1,
discount_code: couponCode,
customer: {
email: user.email
},
success_url: getURL('payment/success?provider=creem'),
metadata: {
userId: user.id,
planId: plan.id,
planName: plan.cardTitle,
productId: creemProductId
}
});
// 4. Return session URL
return apiResponse.success({
sessionId: checkoutSession.id,
url: checkoutSession.checkout_url
});Frontend Processing
const handleCheckout = async () => {
setIsLoading(true);
try {
const response = await fetch('/api/payment/checkout-session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept-Language': locale
},
body: JSON.stringify({
provider: plan.provider,
stripePriceId: plan.stripePriceId, // Stripe
creemProductId: plan.creemProductId, // Creem
couponCode: plan.stripeCouponId || plan.creemDiscountCode // At most one exists
})
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'Failed to create checkout session');
}
if (result.data.url) {
// Redirect to payment page
router.push(result.data.url);
}
} catch (error) {
toast.error(error.message);
} finally {
setIsLoading(false);
}
};Step 3: User Completes Payment
The user is redirected to the payment provider's payment page (Stripe Checkout or Creem Checkout) to complete the payment flow.
Stripe Checkout
- User enters payment information
- Stripe processes payment
- After successful payment, redirects to
success_url
Creem Checkout
- User enters payment information
- Creem processes payment
- After successful payment, redirects to
success_url
Step 4: Payment Success Callback
After successful payment, the user is redirected back to the application's payment/success page.
Page Route
import { redirect } from 'next/navigation';
import { Suspense } from 'react';
export default function PaymentSuccessPage({
searchParams
}: {
searchParams: { session_id?: string; checkout_id?: string; provider?: string }
}) {
const provider = searchParams.provider;
if (!provider) {
redirect('/pricing');
}
return (
<div className="container mx-auto py-12">
<Suspense fallback={<div>Verifying payment...</div>}>
<PaymentVerification
provider={provider}
sessionId={searchParams.session_id}
checkoutId={searchParams.checkout_id}
/>
</Suspense>
</div>
);
}Payment Verification Component
async function PaymentVerification({
provider,
sessionId,
checkoutId
}: {
provider: string;
sessionId?: string;
checkoutId?: string;
}) {
const session = await getSession();
if (!session?.user) {
redirect('/login');
}
// Call verification API
const verifyUrl = new URL('/api/payment/verify-success', process.env.NEXT_PUBLIC_SITE_URL);
verifyUrl.searchParams.set('provider', provider);
if (provider === 'stripe' && sessionId) {
verifyUrl.searchParams.set('session_id', sessionId);
}
if (provider === 'creem' && checkoutId) {
verifyUrl.searchParams.set('checkout_id', checkoutId);
}
const response = await fetch(verifyUrl.toString(), {
headers: {
Cookie: cookies().toString()
}
});
const result = await response.json();
if (!result.success) {
return (
<div className="text-center">
<h1 className="text-2xl font-bold text-red-600">Payment Verification Failed</h1>
<p>{result.error}</p>
</div>
);
}
// Display success message
return (
<div className="text-center">
<h1 className="text-2xl font-bold text-green-600">Payment Successful!</h1>
<p>Your order has been processed successfully.</p>
{result.data.order && (
<div className="mt-4">
<p>Order ID: {result.data.order.id}</p>
</div>
)}
</div>
);
}Step 5: Verify Payment Status
The /api/payment/verify-success API is responsible for verifying payment status and returning order information.
API Processing Flow
Stripe Verification
export async function verifyStripePayment(req: NextRequest, userId: string) {
const sessionId = req.nextUrl.searchParams.get('session_id');
// 1. Retrieve Checkout Session
const session = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ['line_items', 'payment_intent', 'subscription']
});
// 2. Verify user ID
if (session.metadata?.userId !== userId) {
return apiResponse.unauthorized('User ID mismatch');
}
// 3. Verify session status
if (session.status !== 'complete') {
return apiResponse.badRequest(`Session status is not complete: ${session.status}`);
}
// 4. Handle subscription or one-time payment
if (session.mode === 'subscription') {
return handleStripeSubscription(session, userId, sessionId);
} else if (session.mode === 'payment') {
return handleStripePayment(session, userId);
}
}Creem Verification
export async function verifyCreemPayment(req: NextRequest, userId: string) {
const checkoutId = req.nextUrl.searchParams.get('checkout_id');
// 1. Retrieve Checkout Session
const session = await retrieveCreemCheckoutSession(checkoutId);
// 2. Verify user ID
if (session.metadata?.userId !== userId) {
return apiResponse.unauthorized('User ID mismatch');
}
// 3. Verify payment status
if (session.status !== 'completed') {
return apiResponse.badRequest(`Checkout status is not completed: ${session.status}`);
}
// 4. Handle subscription or one-time payment
if (session.order.type === 'recurring') {
return handleCreemSubscription(session, userId, checkoutId);
} else {
return handleCreemPayment(session, userId);
}
}Response Data Structure
One-time Payment:
{
"success": true,
"data": {
"order": {
"id": "order-id",
"status": "succeeded",
"amountTotal": "29.00",
"currency": "USD"
}
}
}Subscription Payment:
{
"success": true,
"data": {
"subscription": {
"id": "subscription-id",
"status": "active",
"currentPeriodEnd": "2024-02-01T00:00:00Z"
}
}
}Step 6: Webhook Processing
Payment providers send webhook events after payment completion. Webhook processing is a critical part of the payment flow, responsible for:
- Creating order records
- Granting user credits
- Synchronizing subscription status
Webhook Events
Stripe Webhook Events
checkout.session.completed- Checkout completed (one-time payment)invoice.paid- Invoice payment successful (subscription renewal)customer.subscription.created- Subscription createdcustomer.subscription.updated- Subscription updatedcustomer.subscription.deleted- Subscription canceledcharge.refunded- Refund
Creem Webhook Events
checkout.completed- Checkout completed (one-time payment)subscription.paid- Subscription payment successfulsubscription.active- Subscription activatedsubscription.update- Subscription updatedsubscription.canceled- Subscription canceledsubscription.expired- Subscription expiredrefund.created- Refund
For detailed webhook processing logic, please refer to Webhook Handling Mechanism.
Payment Flow Sequence Diagram
User Browser Frontend App Backend API Payment Provider
| | | |
|--Click Buy---->| | |
| |--Create Sess-->| |
| | |--Create Sess---->|
| | |<--Return URL-----|
| |<--Return URL---| |
|<--Redirect-----| | |
| | | |
|--Go to Payment->| | |
| | | |
|--Complete Pay-->| | |
| | | |
|<--Redirect Back-| | |
| | | |
|--Verify Pay--->|--Verify Pay--->| |
| | |--Query Order---->|
| | |<--Return Order---|
| |<--Return Result-| |
|<--Show Result--| | |
| | | |
| | |<--Webhook Event--|
| | |--Process Event-->|
| | |--Create Order----|
| | |--Grant Credits---|Security Considerations
1. User Authentication
All payment-related APIs require user login:
const session = await getSession();
if (!session?.user) {
return apiResponse.unauthorized();
}2. Webhook Signature Verification
All webhook requests must verify signatures:
Stripe:
const sig = headers.get('stripe-signature');
const event = stripe.webhooks.constructEvent(body, sig, webhookSecret);Creem:
const signature = headers.get('creem-signature');
if (!verifySignature(body, signature, webhookSecret)) {
return apiResponse.badRequest('Signature verification failed');
}3. Idempotency Handling
Order creation uses idempotency checks to avoid duplicate creation:
const { order, existed } = await createOrderWithIdempotency(
provider,
orderData,
providerOrderId
);
if (existed) {
return; // Order already exists, skip processing
}4. User ID Verification
Verify that the user ID in the payment session matches the currently logged-in user:
if (session.metadata?.userId !== userId) {
return apiResponse.unauthorized('User ID mismatch');
}Testing Payment Flow
Using Stripe Test Mode
- Get test API Key from Stripe Dashboard
- Create test products and prices
- Use test card number:
4242 4242 4242 4242 - Configure Webhook URL to point to your application or use Stripe CLI to forward webhooks:
stripe listen --forward-to localhost:3000/api/stripe/webhook
Using Creem Test Mode
- Get test API Key from Creem Dashboard
- Create test products
- Use test card number:
4242 4242 4242 4242 - Configure Webhook URL to point to your application